@paxnax/berliner 0.5.1 → 0.5.3

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,5 +1,12 @@
1
1
  import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
2
2
  const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
3
+ const tokensOf = (u) => ({
4
+ input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
5
+ output: num(u.output_tokens),
6
+ cacheRead: num(u.cached_input_tokens),
7
+ cacheWrite: num(u.cache_write_input_tokens),
8
+ cacheWrite1h: 0,
9
+ });
3
10
  // Line shapes as Codex 0.162.1 writes them (codex-rs/protocol). input_tokens includes both cached reads and cache writes,
4
11
  // as in Codex's own parsing test (100 input = 40 cached + 60 written); reasoning_output_tokens is part of output_tokens.
5
12
  export async function readCodex(rolloutPath) {
@@ -9,12 +16,25 @@ export async function readCodex(rolloutPath) {
9
16
  let taskUrl = null;
10
17
  let latest = zero;
11
18
  let latestTotal = 0;
19
+ let latestLast = 0;
12
20
  const byModel = {};
21
+ // 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();
13
25
  for await (const e of jsonl(rolloutPath)) {
14
26
  span.add(e.timestamp);
15
27
  const p = e.payload;
16
28
  if (e.type === "turn_context" && typeof p?.model === "string")
17
29
  model = p.model;
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
+ }
37
+ }
18
38
  if (e.type !== "event_msg")
19
39
  continue;
20
40
  if (p?.type === "item_completed" && p.item?.type === "UserMessage" && Array.isArray(p.item.content)) {
@@ -28,21 +48,23 @@ export async function readCodex(rolloutPath) {
28
48
  if (!u)
29
49
  continue;
30
50
  const total = num(u.total_tokens);
51
+ const last = num(p?.info?.last_token_usage?.total_tokens);
31
52
  // 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 = {
34
- input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
35
- output: num(u.output_tokens),
36
- cacheRead: num(u.cached_input_tokens),
37
- cacheWrite: num(u.cache_write_input_tokens),
38
- cacheWrite1h: 0,
39
- };
53
+ // A repeat of the previous event, last request included, is a duplicate notification.
54
+ // shortcut: without usage records, a restart after a one-request run of the same size reads as a duplicate; drop this path once pre-0.148 rollouts no longer matter.
55
+ const dup = total === latestTotal && last === latestLast;
56
+ const restart = total < latestTotal || (latestTotal > 0 && total === last && !dup);
57
+ const cur = tokensOf(u);
40
58
  // Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
41
59
  const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
42
60
  if (model)
43
61
  byModel[model] = addTokens(byModel[model] ?? zero, delta);
44
62
  latestTotal = total;
63
+ latestLast = last;
45
64
  latest = cur;
46
65
  }
47
- return { model, tokensByModel: byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
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 };
48
70
  }
package/dist/client.js CHANGED
@@ -11,7 +11,7 @@ export class CliError extends Error {
11
11
  }
12
12
  export const absolute = (cfg, path) => cfg.url.replace(/\/+$/, "") + path;
13
13
  export async function request(cfg, method, path, body, opts = {}) {
14
- const { timeoutMs = 10_000, retries = 3, deadlineMs = 15_000, auth = true } = opts;
14
+ const { timeoutMs = 10_000, retries = 3, deadlineMs = 15_000, auth = true, headers = {} } = opts;
15
15
  if (auth && !cfg.token)
16
16
  throw new CliError("No API token: run `npx @paxnax/berliner@latest setup`, or set BERLINER_TOKEN.", "usage");
17
17
  // One budget for all attempts, so a hung server can't hold the agent's pipeline for long (anti-goal 1).
@@ -28,6 +28,7 @@ export async function request(cfg, method, path, body, opts = {}) {
28
28
  headers: {
29
29
  ...(auth ? { Authorization: `Bearer ${cfg.token}` } : {}),
30
30
  ...(body === undefined ? {} : { "Content-Type": raw ? "application/octet-stream" : "application/json" }),
31
+ ...headers,
31
32
  },
32
33
  // A Uint8Array is sent as-is (evidence bytes); fetch sets its Content-Length.
33
34
  body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
@@ -50,15 +50,24 @@ export async function fromTool(tool, values, env, command) {
50
50
  }
51
51
  // Inside a tool: the same fields, minus a derived title, for a session that exists; on a 404 (the first turn, before the hook
52
52
  // has reported), the PUT that creates it, at cost 0 while the cost is unknown and on BERLINER_PLAN when no plan is given.
53
+ // Create-only: when another report created the session since the 404 (412), the PATCH after all, so its title and cost stay.
53
54
  export async function patchFirst(cfg, path, body, titled, guess) {
54
- try {
55
+ const patch = async () => {
55
56
  const res = await saveSession(cfg, "PATCH", path, titled ? body : { ...body, title: undefined }, guess);
56
57
  return { ...res, json: { created: false, ...res.json } };
58
+ };
59
+ try {
60
+ return await patch();
57
61
  }
58
62
  catch (err) {
59
63
  if (!(err instanceof CliError && err.status === 404))
60
64
  throw err;
61
- return saveSession(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined }, guess);
65
+ const create = { ...body, cost_usd: body.cost_usd ?? 0, plan: body.plan ?? cfg.plan ?? undefined };
66
+ return saveSession(cfg, "PUT", path, create, guess, { "If-None-Match": "*" }).catch((err) => {
67
+ if (err instanceof CliError && err.status === 412)
68
+ return patch();
69
+ throw err;
70
+ });
62
71
  }
63
72
  }
64
73
  function number(values, flag) {
@@ -127,7 +136,7 @@ function prefilterPrs(body) {
127
136
  // `guess`, the agent's --plan, in that same retry; without --plan it is left out, so a new session gets the server's pick.
128
137
  // Anything else is thrown as before. Warnings are printed only once the save succeeded, so a failed save never claims
129
138
  // fields were dropped.
130
- export async function saveSession(cfg, method, path, input, guess) {
139
+ export async function saveSession(cfg, method, path, input, guess, headers) {
131
140
  const first = prefilterPrs(input);
132
141
  const dropped = first.dropped;
133
142
  const fromEnv = cfg.plan !== null && input.plan === cfg.plan && guess !== cfg.plan;
@@ -142,7 +151,7 @@ export async function saveSession(cfg, method, path, input, guess) {
142
151
  return { ...res, dropped };
143
152
  };
144
153
  try {
145
- return finish(await request(cfg, method, path, first.body));
154
+ return finish(await request(cfg, method, path, first.body, { headers }));
146
155
  }
147
156
  catch (err) {
148
157
  const fieldErrors = err instanceof CliError && err.status === 422 ? err.details?.fieldErrors : undefined;
@@ -155,6 +164,6 @@ export async function saveSession(cfg, method, path, input, guess) {
155
164
  delete retry[field];
156
165
  dropped.push({ field, reason: field === "prs" ? `all PRs dropped: ${[fieldErrors[field]].flat().join(", ")}` : [fieldErrors[field]].flat().join(", ") });
157
166
  }
158
- return finish(await request(cfg, method, path, retry));
167
+ return finish(await request(cfg, method, path, retry, { headers }));
159
168
  }
160
169
  }
@@ -28,15 +28,16 @@ export function inTool(env) {
28
28
  return null;
29
29
  }
30
30
  // The session's facts from the tool's own record, with a cost estimated wherever the tool stores none; the cost stays null when
31
- // no prices load. Claude Code and Codex name the record in the hook payload, and OpenCode's plugin sends the session itself;
32
- // otherwise it is looked up. Null without a record.
31
+ // no prices load. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
32
+ // the session itself too, used only without a saved record. Null without a record.
33
33
  export async function sessionFacts(tool, sessionId, env, payload = {}) {
34
34
  let facts;
35
- if (tool === "opencode" && payload.session)
35
+ const named = typeof payload.transcript_path === "string" && payload.transcript_path ? payload.transcript_path : null;
36
+ const file = named ?? (await findRecord(tool, sessionId, env));
37
+ // The plugin saves the record, in order, before it spawns each reporter, so a reporter that runs late still finds the newest.
38
+ if (tool === "opencode" && !file && payload.session)
36
39
  facts = readOpenCode(payload.session);
37
40
  else {
38
- const named = typeof payload.transcript_path === "string" && payload.transcript_path ? payload.transcript_path : null;
39
- const file = named ?? (await findRecord(tool, sessionId, env));
40
41
  if (!file)
41
42
  return null;
42
43
  if (tool === "claude-code")
@@ -104,8 +105,10 @@ async function start(tool, env) {
104
105
  // shortcut: payload files are kept, one small file per session, as a child can't tell whether a newer one is on its way.
105
106
  writeFileSync(`${file}.${process.pid}`, text);
106
107
  renameSync(`${file}.${process.pid}`, file);
107
- // No stdio and its own process group: the tool waits on this process only, and the child outlives it.
108
- spawn(process.execPath, [process.argv[1], "hook", tool, "--run", file], { detached: true, stdio: "ignore" })
108
+ // No stdio and its own process group: the tool waits on this process only, and the child outlives it. Bun gets the
109
+ // launcher's flags, so the cwd's .env and bunfig.toml stay out of it (see index.ts).
110
+ const bun = process.versions.bun ? ["--config=/dev/null", "--env-file=/dev/null"] : [];
111
+ spawn(process.execPath, [...bun, process.argv[1], "hook", tool, "--run", file], { detached: true, stdio: "ignore" })
109
112
  .on("error", (err) => log(env, `${tool} ${id}: ${err.message}`))
110
113
  .unref();
111
114
  }
@@ -230,10 +233,14 @@ async function send(tool, file, env) {
230
233
  catch (err) {
231
234
  if (!(err instanceof CliError && err.status === 404))
232
235
  throw err;
233
- // shortcut: an agent report landing between the 404 and this PUT gets the derived title; a create-only call would close that.
234
236
  // The plan is BERLINER_PLAN from the tool's environment, else the server's pick from the user's subscriptions. A new
235
- // session needs a cost: 0 until prices load.
236
- const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan });
237
+ // session needs a cost: 0 until prices load. Create-only: when an agent report created the session since the 404 (412),
238
+ // only the machine's fields are sent. A server that ignores the header overwrites the title, as before.
239
+ const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan }, { headers: { "If-None-Match": "*" } }).catch((err) => {
240
+ if (err instanceof CliError && err.status === 412)
241
+ return request(cfg, "PATCH", path, body);
242
+ throw err;
243
+ });
237
244
  try {
238
245
  saved = await create(cfg.plan ?? undefined);
239
246
  }
@@ -43,9 +43,8 @@ export async function report(cfg, values, positionals, env) {
43
43
  if (json_)
44
44
  print({ data: { ...json, evidence: result }, text }, true);
45
45
  else {
46
- console.log(`Evidence: ${result.uploaded} uploaded, ${result.existing} already uploaded, ${result.skipped.length} skipped.`);
47
- for (const p of result.published ?? [])
48
- console.log(p.markdown);
46
+ const summary = `Evidence: ${result.uploaded} uploaded, ${result.existing} already uploaded, ${result.skipped.length} skipped.`;
47
+ print({ data: result, text: [summary, ...(result.published ?? []).map((p) => p.markdown)].join("\n") }, false);
49
48
  }
50
49
  for (const s of result.skipped)
51
50
  warn(`${s.path}: ${s.reason}`);
@@ -23,11 +23,13 @@ async function latestVersion(env) {
23
23
  throw new CliError(`Could not look up the latest berliner on ${registry} (${err.message}). Nothing was changed.`, "network");
24
24
  }
25
25
  }
26
- // The skill text comes from the release being installed, so the plan's "already up to date" and what is written match it.
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) {
26
+ // The skill and the OpenCode plugin come from the release being installed, so the plan's "already up to date" and what is
27
+ // written match it. They are read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc
28
+ // and node_modules.
29
+ async function releaseFiles(version, bundled, env) {
30
+ const local = (file) => readFileSync(new URL(`../../${file}`, import.meta.url), "utf8");
29
31
  if (bundled)
30
- return readFileSync(new URL("../../skill/SKILL.md", import.meta.url), "utf8");
32
+ return { skill: local("skill/SKILL.md"), plugin: local("opencode/berliner.js") };
31
33
  try {
32
34
  const meta = await fetch(`${registryOf(env)}/@paxnax/berliner/${version}`);
33
35
  if (!meta.ok)
@@ -41,17 +43,23 @@ async function skillOf(version, bundled, env) {
41
43
  const tar = gunzipSync(Buffer.from(await res.arrayBuffer()));
42
44
  // 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
45
  const field = (at, end) => tar.toString("utf8", at, end).split("\0")[0];
46
+ const files = new Map();
44
47
  for (let at = 0; at + 512 <= tar.length && tar[at];) {
45
48
  const prefix = field(at + 345, at + 500);
46
49
  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);
50
+ files.set(`${prefix && `${prefix}/`}${field(at, at + 100)}`, tar.toString("utf8", at + 512, at + 512 + size));
49
51
  at += 512 + Math.ceil(size / 512) * 512;
50
52
  }
51
- throw new Error("no skill/SKILL.md in the package");
53
+ const read = (file) => {
54
+ const text = files.get(`package/${file}`);
55
+ if (text === undefined)
56
+ throw new Error(`no ${file} in the package`);
57
+ return text;
58
+ };
59
+ return { skill: read("skill/SKILL.md"), plugin: read("opencode/berliner.js") };
52
60
  }
53
61
  catch (err) {
54
- throw new CliError(`Could not read the skill of berliner ${version} (${err.message}). Nothing was changed.`, "network");
62
+ throw new CliError(`Could not read the skill and OpenCode plugin of berliner ${version} (${err.message}). Nothing was changed.`, "network");
55
63
  }
56
64
  }
57
65
  // Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
@@ -84,12 +92,14 @@ export async function upgrade(env, values) {
84
92
  const install = installCommand(latest);
85
93
  const tools = detectTools(env);
86
94
  const targets = values["no-skill"] ? [] : skillTargets(tools, env);
87
- const skill = targets.length ? await skillOf(target, !installing, env) : "";
95
+ // The plugin too, so --no-skill still brings it up to date.
96
+ const release = targets.length || tools.some((t) => t.id === "opencode" && t.found) ? await releaseFiles(target, !installing, env) : null;
97
+ const skill = release?.skill ?? "";
88
98
  let stale = targets.filter((t) => !sameAs(t.path, skill));
89
- // Computed now, so what is written is what the plan showed.
99
+ // Computed now, for the plan.
90
100
  const hooks = tools.flatMap((tool) => {
91
101
  const path = hookPath(tool.id, env);
92
- const text = updatedHooks(tool.id, path);
102
+ const text = updatedHooks(tool.id, path, release?.plugin);
93
103
  return text === null ? [] : [{ ...tool, path, text }];
94
104
  });
95
105
  if (!installing && !stale.length && !hooks.length) {
@@ -153,9 +163,15 @@ export async function upgrade(env, values) {
153
163
  }
154
164
  }
155
165
  for (const h of hooks) {
166
+ // Merged again into the file as it is now, so an edit made during the prompt or the install is kept. The plugin is the
167
+ // release's, as the plan showed.
168
+ // shortcut: an edit between this read and write is still lost; lock if that shows up.
169
+ const text = h.id === "opencode" ? h.text : updatedHooks(h.id, h.path);
170
+ if (text === null)
171
+ continue;
156
172
  try {
157
173
  // In place, so a settings file that is a symlink stays one.
158
- writeFileSync(h.path, h.text);
174
+ writeFileSync(h.path, text);
159
175
  console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
160
176
  }
161
177
  catch (err) {
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/bin/sh
2
- //usr/bin/true; exec "$(command -v node || command -v bun)" "$0" "$@"
2
+ //usr/bin/true; command -v node >/dev/null && exec node "$0" "$@"; exec bun --config=/dev/null --env-file=/dev/null "$0" "$@"
3
+ // Bun reads the cwd's .env and bunfig.toml (whose preload runs code), so a checkout could redirect the token or run its code.
3
4
  import { parse } from "./args.js";
4
5
  import { CliError } from "./client.js";
5
6
  import { del } from "./commands/delete.js";
package/dist/output.js CHANGED
@@ -1,12 +1,14 @@
1
+ // Stored text must not drive the terminal (OSC 52, cursor moves); one char for one keeps table widths.
2
+ const safe = (s) => s.replace(/(?![\t\n])\p{Cc}/gu, "\ufffd");
1
3
  export function print(result, json) {
2
- console.log(json ? JSON.stringify(result.data, null, 2) : result.text);
4
+ console.log(json ? JSON.stringify(result.data, null, 2) : safe(result.text));
3
5
  }
4
6
  // Always a single line, so agents and CI logs see exactly one warning.
5
7
  export function warn(msg) {
6
- console.error(`berliner: ${msg.replace(/\s*\n\s*/g, " ")}`);
8
+ console.error(`berliner: ${safe(msg.replace(/\s*\n\s*/g, " "))}`);
7
9
  }
8
10
  // GitHub renders an image from Markdown but not a video from another host, so a video is its bare URL.
9
- export const embed = (e, url) => e.kind === "video" ? url : `![${(e.caption || e.filename).replace(/[[\]]/g, "\\$&")}](${url})`;
11
+ export const embed = (e, url) => e.kind === "video" ? url : `![${(e.caption || e.filename).replace(/\s*\n\s*/g, " ").replace(/[\\[\]]/g, "\\$&")}](${url})`;
10
12
  // Same wording as the web's mergeLabel (app/src/web/format.ts).
11
13
  const MERGE = { open: "Open", merged: "Merged", closed: "Closed", none: "No PRs" };
12
14
  export const mergeLabel = (s) => s.pr_count > 1 ? `${s.merged_count} of ${s.pr_count} merged` : (MERGE[s.merge] ?? s.merge);
@@ -78,18 +78,20 @@ export const hookPath = (tool, env) => tool === "claude-code"
78
78
  ? join(codexDir(env), "hooks.json")
79
79
  : join(opencodeDir(env), "plugins", "berliner.js");
80
80
  // For upgrade: the hook file's new text when a Berliner hook is installed but older than this release, else null. Hooks that
81
- // are missing stay missing: adding them is setup's.
82
- export function updatedHooks(tool, path) {
83
- if (tool === "opencode")
84
- return existsSync(path) && !current(path) ? plugin() : null;
81
+ // are missing stay missing: adding them is setup's. `next` is the plugin of the release being installed, else this one's.
82
+ export function updatedHooks(tool, path, next) {
83
+ if (tool === "opencode") {
84
+ const text = next ?? plugin();
85
+ return existsSync(path) && !current(path, text) ? text : null;
86
+ }
85
87
  const result = merged(path, entries(tool, tool === "claude-code"), false);
86
88
  return result === "unreadable" ? null : (result?.text ?? null);
87
89
  }
88
90
  // dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
89
91
  const plugin = () => readFileSync(new URL("../../opencode/berliner.js", import.meta.url), "utf8");
90
- const current = (pluginPath) => {
92
+ const current = (pluginPath, text = plugin()) => {
91
93
  try {
92
- return readFileSync(pluginPath, "utf8") === plugin();
94
+ return readFileSync(pluginPath, "utf8") === text;
93
95
  }
94
96
  catch {
95
97
  return false;
@@ -26,6 +26,9 @@ const MAX_DEPTH = 10;
26
26
 
27
27
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
28
28
  const get = async (client, id) => (await client.session.get({ path: { id } }))?.data;
29
+ // Resolves with p, or with undefined after ms: an SDK call that never answers must not hold up OpenCode. Unreferenced, so a
30
+ // pending deadline never keeps a process alive.
31
+ const within = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms).unref?.())]);
29
32
 
30
33
  // One agent session is one Berliner session: a subagent's child session belongs to the top-level session above it.
31
34
  async function topLevelId(client, id) {
@@ -60,14 +63,23 @@ async function withChildren(client, session, depth = 0) {
60
63
  return { ...session, cost, tokens };
61
64
  }
62
65
 
66
+ // Each save's place in the order saves started, and per session the latest one written: a save that finishes after a later
67
+ // one (past its 10-second deadline in serial) never writes, nor reports, its older totals.
68
+ let seq = 0;
69
+ const committed = new Map();
70
+
63
71
  // Saves a top-level session with its subagents' totals where `berliner report` run by the agent finds them: its shell can't
64
- // reach this client. Returns what it saved, or null for a child session, which the top-level session's totals count.
72
+ // reach this client. Returns what it saved, or null for a child session, which the top-level session's totals count, and for
73
+ // a save a later one overtook.
65
74
  async function save(client, sessionID) {
75
+ const mine = ++seq;
66
76
  if (typeof sessionID !== "string" || !SAFE_ID.test(sessionID)) return null;
67
77
  const found = await get(client, sessionID);
68
78
  if (found?.id !== sessionID || found.parentID) return null;
69
79
  // OpenCode moves time.updated only when a prompt arrives, so the session's last activity is now.
70
80
  const session = { ...(await withChildren(client, found)), time: { ...found.time, updated: Date.now() } };
81
+ if ((committed.get(sessionID) ?? 0) > mine) return null;
82
+ committed.set(sessionID, mine);
71
83
  const dir = join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner", "opencode");
72
84
  mkdirSync(dir, { recursive: true, mode: 0o700 });
73
85
  const file = join(dir, `${sessionID}.json`);
@@ -77,14 +89,15 @@ async function save(client, sessionID) {
77
89
  return session;
78
90
  }
79
91
 
80
- // One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one.
92
+ // One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one. A save that
93
+ // hangs holds the next one up for 10 seconds at most.
81
94
  // shortcut: only within this OpenCode process; two `berliner hook` processes can still write out of order, lock there if that shows up.
82
95
  const queues = new Map();
83
96
  const serial = (id, fn) => {
84
97
  const p = (queues.get(id) ?? Promise.resolve()).then(fn, fn);
85
98
  queues.set(
86
99
  id,
87
- p.catch(() => {}),
100
+ within(p, 10_000).catch(() => {}),
88
101
  );
89
102
  return p;
90
103
  };
@@ -111,11 +124,15 @@ export const BerlinerPlugin = async ({ client }) => ({
111
124
  if (!input?.sessionID) return;
112
125
  output.env.OPENCODE_SESSION_ID = input.sessionID;
113
126
  // A subagent's shell gets the top-level session, so a `berliner report` the agent runs there doesn't split it off.
114
- const top = await topLevelId(client, input.sessionID);
127
+ // A second each at most: past it the shell keeps the id it was given, or runs before the save is done.
128
+ const top = await within(topLevelId(client, input.sessionID), 1000);
115
129
  if (!top) return;
116
130
  output.env.OPENCODE_SESSION_ID = top;
117
131
  // So that report finds the totals before the session's first idle. Not reported from here: that waits for idle.
118
- await serial(top, () => save(client, top));
132
+ await within(
133
+ serial(top, () => save(client, top)),
134
+ 1000,
135
+ );
119
136
  } catch {
120
137
  // Never fail the agent's shell.
121
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paxnax/berliner",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "type": "module",
6
6
  "engines": {