@paxnax/berliner 0.5.1 → 0.5.2

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,6 +9,7 @@ export async function readCodex(rolloutPath) {
9
9
  let taskUrl = null;
10
10
  let latest = zero;
11
11
  let latestTotal = 0;
12
+ let latestLast = 0;
12
13
  const byModel = {};
13
14
  for await (const e of jsonl(rolloutPath)) {
14
15
  span.add(e.timestamp);
@@ -28,8 +29,12 @@ export async function readCodex(rolloutPath) {
28
29
  if (!u)
29
30
  continue;
30
31
  const total = num(u.total_tokens);
32
+ const last = num(p?.info?.last_token_usage?.total_tokens);
31
33
  // 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));
34
+ // A repeat of the previous event, last request included, is a duplicate notification.
35
+ // shortcut: a restart after a one-request run of the same size reads as a duplicate; use a run marker if Codex adds one.
36
+ const dup = total === latestTotal && last === latestLast;
37
+ const restart = total < latestTotal || (latestTotal > 0 && total === last && !dup);
33
38
  const cur = {
34
39
  input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
35
40
  output: num(u.output_tokens),
@@ -42,6 +47,7 @@ export async function readCodex(rolloutPath) {
42
47
  if (model)
43
48
  byModel[model] = addTokens(byModel[model] ?? zero, delta);
44
49
  latestTotal = total;
50
+ latestLast = last;
45
51
  latest = cur;
46
52
  }
47
53
  return { model, tokensByModel: byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
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),
@@ -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")
@@ -230,10 +231,14 @@ async function send(tool, file, env) {
230
231
  catch (err) {
231
232
  if (!(err instanceof CliError && err.status === 404))
232
233
  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
234
  // 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 });
235
+ // session needs a cost: 0 until prices load. Create-only: when an agent report created the session since the 404 (412),
236
+ // only the machine's fields are sent. A server that ignores the header overwrites the title, as before.
237
+ 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) => {
238
+ if (err instanceof CliError && err.status === 412)
239
+ return request(cfg, "PATCH", path, body);
240
+ throw err;
241
+ });
237
242
  try {
238
243
  saved = await create(cfg.plan ?? undefined);
239
244
  }
@@ -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}`);
@@ -86,7 +86,7 @@ export async function upgrade(env, values) {
86
86
  const targets = values["no-skill"] ? [] : skillTargets(tools, env);
87
87
  const skill = targets.length ? await skillOf(target, !installing, env) : "";
88
88
  let stale = targets.filter((t) => !sameAs(t.path, skill));
89
- // Computed now, so what is written is what the plan showed.
89
+ // Computed now, for the plan.
90
90
  const hooks = tools.flatMap((tool) => {
91
91
  const path = hookPath(tool.id, env);
92
92
  const text = updatedHooks(tool.id, path);
@@ -153,9 +153,15 @@ export async function upgrade(env, values) {
153
153
  }
154
154
  }
155
155
  for (const h of hooks) {
156
+ // 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 one
157
+ // the plan showed: the install may have replaced this package's copy.
158
+ // shortcut: an edit between this read and write is still lost; lock if that shows up.
159
+ const text = h.id === "opencode" ? h.text : updatedHooks(h.id, h.path);
160
+ if (text === null)
161
+ continue;
156
162
  try {
157
163
  // In place, so a settings file that is a symlink stays one.
158
- writeFileSync(h.path, h.text);
164
+ writeFileSync(h.path, text);
159
165
  console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
160
166
  }
161
167
  catch (err) {
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);
@@ -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) {
@@ -77,14 +80,16 @@ async function save(client, sessionID) {
77
80
  return session;
78
81
  }
79
82
 
80
- // One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one.
83
+ // 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
84
+ // hangs holds the next one up for 10 seconds at most.
81
85
  // shortcut: only within this OpenCode process; two `berliner hook` processes can still write out of order, lock there if that shows up.
86
+ // shortcut: a save that resumes after its deadline can write older totals; the next save rewrites them.
82
87
  const queues = new Map();
83
88
  const serial = (id, fn) => {
84
89
  const p = (queues.get(id) ?? Promise.resolve()).then(fn, fn);
85
90
  queues.set(
86
91
  id,
87
- p.catch(() => {}),
92
+ within(p, 10_000).catch(() => {}),
88
93
  );
89
94
  return p;
90
95
  };
@@ -111,11 +116,15 @@ export const BerlinerPlugin = async ({ client }) => ({
111
116
  if (!input?.sessionID) return;
112
117
  output.env.OPENCODE_SESSION_ID = input.sessionID;
113
118
  // 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);
119
+ // A second each at most: past it the shell keeps the id it was given, or runs before the save is done.
120
+ const top = await within(topLevelId(client, input.sessionID), 1000);
115
121
  if (!top) return;
116
122
  output.env.OPENCODE_SESSION_ID = top;
117
123
  // 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));
124
+ await within(
125
+ serial(top, () => save(client, top)),
126
+ 1000,
127
+ );
119
128
  } catch {
120
129
  // Never fail the agent's shell.
121
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paxnax/berliner",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "type": "module",
6
6
  "engines": {