@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.
- package/dist/agents/codex.js +11 -9
- package/dist/commands/delete.js +7 -5
- package/dist/commands/hook.js +28 -7
- package/dist/commands/setup.js +2 -1
- package/dist/commands/upgrade.js +35 -11
- package/opencode/berliner.js +28 -15
- package/package.json +1 -1
package/dist/agents/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/delete.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
|
156
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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) {
|
package/dist/commands/setup.js
CHANGED
|
@@ -114,7 +114,8 @@ export async function setup(env, values) {
|
|
|
114
114
|
}
|
|
115
115
|
let installFailed = false;
|
|
116
116
|
if (!values["no-install"]) {
|
|
117
|
-
|
|
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}`);
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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");
|
package/opencode/berliner.js
CHANGED
|
@@ -77,20 +77,33 @@ async function save(client, sessionID) {
|
|
|
77
77
|
return session;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
}
|