@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.
- package/dist/agents/codex.js +7 -1
- package/dist/client.js +2 -1
- package/dist/commands/hook.js +13 -8
- package/dist/commands/report.js +2 -3
- package/dist/commands/upgrade.js +8 -2
- package/dist/output.js +5 -3
- package/opencode/berliner.js +13 -4
- package/package.json +1 -1
package/dist/agents/codex.js
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
|
32
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/report.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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}`);
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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);
|
package/opencode/berliner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|