@paxnax/berliner 0.1.0 → 0.2.0
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/README.md +120 -0
- package/dist/agents/claude.js +61 -0
- package/dist/agents/codex.js +43 -0
- package/dist/agents/index.js +35 -0
- package/dist/agents/opencode.js +27 -0
- package/dist/agents/read.js +37 -0
- package/dist/agents/types.js +1 -0
- package/dist/args.js +3 -0
- package/dist/client.js +4 -4
- package/dist/commands/body.js +42 -6
- package/dist/commands/hook.js +228 -0
- package/dist/commands/report.js +30 -5
- package/dist/commands/setup.js +174 -0
- package/dist/commands/summary.js +11 -1
- package/dist/commands/update.js +13 -4
- package/dist/config.js +30 -1
- package/dist/git.js +42 -0
- package/dist/index.js +18 -6
- package/dist/output.js +18 -0
- package/dist/prices.js +98 -0
- package/dist/setup/connect.js +42 -0
- package/dist/setup/hooks.js +84 -0
- package/dist/setup/targets.js +41 -0
- package/opencode/berliner.js +124 -0
- package/package.json +2 -1
- package/skill/SKILL.md +46 -9
package/dist/output.js
CHANGED
|
@@ -8,3 +8,21 @@ export function warn(msg) {
|
|
|
8
8
|
// Same wording as the web's mergeLabel (app/src/web/format.ts).
|
|
9
9
|
const MERGE = { open: "Open", merged: "Merged", closed: "Closed", none: "No PRs" };
|
|
10
10
|
export const mergeLabel = (s) => s.pr_count > 1 ? `${s.merged_count} of ${s.pr_count} merged` : (MERGE[s.merge] ?? s.merge);
|
|
11
|
+
// Same wording as the web's subscriptionLabel (app/src/web/format.ts): "Claude Max x20".
|
|
12
|
+
const VENDOR = { anthropic: "Claude", openai: "OpenAI" };
|
|
13
|
+
const PLAN = {
|
|
14
|
+
free: "Free",
|
|
15
|
+
pro: "Pro",
|
|
16
|
+
max_x5: "Max x5",
|
|
17
|
+
max_x20: "Max x20",
|
|
18
|
+
team: "Team",
|
|
19
|
+
enterprise: "Enterprise",
|
|
20
|
+
go: "Go",
|
|
21
|
+
plus: "Plus",
|
|
22
|
+
pro_more: "Pro More",
|
|
23
|
+
pro_max: "Pro Max",
|
|
24
|
+
business: "Business",
|
|
25
|
+
business_premium: "Business Premium",
|
|
26
|
+
api: "API",
|
|
27
|
+
};
|
|
28
|
+
export const subscriptionLabel = (s) => `${VENDOR[s.vendor] ?? s.vendor} ${PLAN[s.plan] ?? s.plan}`;
|
package/dist/prices.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cacheDir } from "./config.js";
|
|
4
|
+
const SOURCE = "https://models.dev/api.json";
|
|
5
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
async function fetchSource(env) {
|
|
7
|
+
// BERLINER_PRICES_FILE stands in for models.dev so tests never touch the network. Deliberately undocumented: it's a test seam, not a setting.
|
|
8
|
+
if (env.BERLINER_PRICES_FILE)
|
|
9
|
+
return JSON.parse(await readFile(env.BERLINER_PRICES_FILE, "utf8"));
|
|
10
|
+
// Well inside the hook child's 15-second deadline, so a slow models.dev never costs the report.
|
|
11
|
+
const res = await fetch(SOURCE, { signal: AbortSignal.timeout(4_000) });
|
|
12
|
+
if (!res.ok)
|
|
13
|
+
throw new Error(`${SOURCE} answered ${res.status}`);
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
// Resellers repeat the first-party ids, so a model's price comes from anthropic, then openai, then the first provider listing it.
|
|
17
|
+
function slim(data) {
|
|
18
|
+
if (typeof data !== "object" || data === null)
|
|
19
|
+
throw new Error("unexpected price list");
|
|
20
|
+
const providers = data;
|
|
21
|
+
const order = ["anthropic", "openai", ...Object.keys(providers).filter((p) => p !== "anthropic" && p !== "openai")];
|
|
22
|
+
const prices = {};
|
|
23
|
+
for (const provider of order) {
|
|
24
|
+
for (const [id, model] of Object.entries(providers[provider]?.models ?? {})) {
|
|
25
|
+
const c = model?.cost;
|
|
26
|
+
if (Object.hasOwn(prices, id) || typeof c?.input !== "number" || typeof c.output !== "number")
|
|
27
|
+
continue;
|
|
28
|
+
// shortcut: tiered prices by context size are ignored; upgrade if estimates drift from the tools' own figures.
|
|
29
|
+
const price = { input: c.input, output: c.output };
|
|
30
|
+
if (typeof c.cache_read === "number")
|
|
31
|
+
price.cache_read = c.cache_read;
|
|
32
|
+
if (typeof c.cache_write === "number")
|
|
33
|
+
price.cache_write = c.cache_write;
|
|
34
|
+
prices[id] = price;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return prices;
|
|
38
|
+
}
|
|
39
|
+
async function readCache(file) {
|
|
40
|
+
try {
|
|
41
|
+
const [text, info] = await Promise.all([readFile(file, "utf8"), stat(file)]);
|
|
42
|
+
const prices = JSON.parse(text);
|
|
43
|
+
if (typeof prices !== "object" || prices === null || !Object.keys(prices).length)
|
|
44
|
+
return null;
|
|
45
|
+
return { prices: prices, fresh: Date.now() - info.mtimeMs < DAY_MS };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Prices cached for a day. A failed fetch falls back to the cache at any age; null when there is neither.
|
|
52
|
+
export async function loadPrices(env) {
|
|
53
|
+
const file = join(cacheDir(env), "prices.json");
|
|
54
|
+
const cached = await readCache(file);
|
|
55
|
+
if (cached?.fresh)
|
|
56
|
+
return cached.prices;
|
|
57
|
+
let prices;
|
|
58
|
+
try {
|
|
59
|
+
prices = slim(await fetchSource(env));
|
|
60
|
+
// A list without a single price is a broken answer: never cached, never used.
|
|
61
|
+
if (!Object.keys(prices).length)
|
|
62
|
+
throw new Error("no prices");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Touched, so the calls of the next day use the stale cache instead of waiting on the source again.
|
|
66
|
+
if (cached)
|
|
67
|
+
await utimes(file, new Date(), new Date()).catch(() => { });
|
|
68
|
+
return cached?.prices ?? null;
|
|
69
|
+
}
|
|
70
|
+
// The cache only saves a download; failing to write it is not an error. Renamed into place, so a reader never sees half of it.
|
|
71
|
+
const temp = `${file}.${process.pid}`;
|
|
72
|
+
await mkdir(cacheDir(env), { recursive: true })
|
|
73
|
+
.then(() => writeFile(temp, JSON.stringify(prices)))
|
|
74
|
+
.then(() => rename(temp, file))
|
|
75
|
+
.catch(() => rm(temp, { force: true }).catch(() => { }));
|
|
76
|
+
return prices;
|
|
77
|
+
}
|
|
78
|
+
// A missing cache price is charged at the input price. models.dev's cache_write is the 5-minute rate; Anthropic bills 1-hour
|
|
79
|
+
// cache writes at twice the input price.
|
|
80
|
+
export function estimateCost(tokensByModel, prices) {
|
|
81
|
+
let costUsd = 0;
|
|
82
|
+
const unpriced = [];
|
|
83
|
+
for (const [model, t] of Object.entries(tokensByModel)) {
|
|
84
|
+
const p = Object.hasOwn(prices, model) ? prices[model] : undefined;
|
|
85
|
+
if (!p) {
|
|
86
|
+
unpriced.push(model);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
costUsd +=
|
|
90
|
+
(t.input * p.input +
|
|
91
|
+
t.output * p.output +
|
|
92
|
+
t.cacheRead * (p.cache_read ?? p.input) +
|
|
93
|
+
t.cacheWrite * (p.cache_write ?? p.input) +
|
|
94
|
+
t.cacheWrite1h * 2 * p.input) /
|
|
95
|
+
1e6;
|
|
96
|
+
}
|
|
97
|
+
return { costUsd, unpriced };
|
|
98
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { CliError, request } from "../client.js";
|
|
5
|
+
// The name the approval page and Settings show for this machine.
|
|
6
|
+
export const machineName = () => hostname()
|
|
7
|
+
.replace(/\.local$/, "")
|
|
8
|
+
.slice(0, 64);
|
|
9
|
+
// The token is made here, like Settings' tokens; the server only ever sees its hash and prefix.
|
|
10
|
+
export async function startConnect(cfg, clientName) {
|
|
11
|
+
const token = `brl_${randomBytes(32).toString("base64url")}`;
|
|
12
|
+
const body = { client_name: clientName, token_hash: createHash("sha256").update(token).digest("hex"), token_prefix: token.slice(0, 11) };
|
|
13
|
+
const { json } = await request(cfg, "POST", "/v1/connect", body, { auth: false });
|
|
14
|
+
return { ...json, token };
|
|
15
|
+
}
|
|
16
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
export async function waitForApproval(cfg, id, interval, expiresIn) {
|
|
18
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
19
|
+
for (;;) {
|
|
20
|
+
await sleep(interval * 1000);
|
|
21
|
+
try {
|
|
22
|
+
const { json } = await request(cfg, "GET", `/v1/connect/${id}`, undefined, { auth: false, deadlineMs: 15_000 });
|
|
23
|
+
if (json.status !== "pending")
|
|
24
|
+
return json.status;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
// A network blip, a rate limit or a server error is no answer: the request stays pending until it expires. A 404 or
|
|
28
|
+
// another refusal ends the wait.
|
|
29
|
+
const unanswered = err instanceof CliError && (err.kind === "network" || err.status === 429 || (err.status ?? 0) >= 500);
|
|
30
|
+
if (!unanswered)
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (Date.now() >= deadline)
|
|
34
|
+
return "expired";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Best effort: when no browser opens, the printed URL is enough.
|
|
38
|
+
export function openBrowser(url) {
|
|
39
|
+
const child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
40
|
+
child.on("error", () => { }); // a missing opener is reported here, not thrown
|
|
41
|
+
child.unref();
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
const entries = (tool, asyncStop) => {
|
|
4
|
+
const hooks = (extra = {}) => [{ type: "command", command: `berliner hook ${tool}`, ...extra }];
|
|
5
|
+
return {
|
|
6
|
+
// Claude Code runs Stop after every reply; async keeps the agent from waiting on it.
|
|
7
|
+
Stop: { hooks: hooks(asyncStop ? { async: true } : {}) },
|
|
8
|
+
PreCompact: { hooks: hooks() },
|
|
9
|
+
SessionEnd: { hooks: hooks() },
|
|
10
|
+
// Not async: what it prints is the reminder, which the tool adds to the agent's context.
|
|
11
|
+
SessionStart: { matcher: "compact|clear", hooks: hooks() },
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15
|
+
const ours = (entry) => isObject(entry) &&
|
|
16
|
+
Array.isArray(entry.hooks) &&
|
|
17
|
+
entry.hooks.some((h) => isObject(h) && typeof h.command === "string" && h.command.startsWith("berliner hook "));
|
|
18
|
+
// The file with our entries added to the events that lack one, or null when nothing is missing. "unreadable" when it
|
|
19
|
+
// isn't a JSON object whose hooks we can add to: then it is the user's to fix, and is never rewritten.
|
|
20
|
+
function merged(file, add) {
|
|
21
|
+
let text = null;
|
|
22
|
+
let json = {};
|
|
23
|
+
try {
|
|
24
|
+
text = readFileSync(file, "utf8");
|
|
25
|
+
json = JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (text !== null || err.code !== "ENOENT")
|
|
29
|
+
return "unreadable";
|
|
30
|
+
}
|
|
31
|
+
if (!isObject(json))
|
|
32
|
+
return "unreadable";
|
|
33
|
+
const hooks = json.hooks ?? {};
|
|
34
|
+
if (!isObject(hooks))
|
|
35
|
+
return "unreadable";
|
|
36
|
+
let changed = false;
|
|
37
|
+
for (const [event, entry] of Object.entries(add)) {
|
|
38
|
+
const list = hooks[event] ?? [];
|
|
39
|
+
if (!Array.isArray(list))
|
|
40
|
+
return "unreadable";
|
|
41
|
+
if (list.some(ours))
|
|
42
|
+
continue;
|
|
43
|
+
hooks[event] = [...list, entry];
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
if (!changed)
|
|
47
|
+
return null;
|
|
48
|
+
const indent = text?.match(/^[ \t]+(?=\S)/m)?.[0] ?? " ";
|
|
49
|
+
return `${JSON.stringify({ ...json, hooks }, null, indent)}\n`;
|
|
50
|
+
}
|
|
51
|
+
function install(file, add) {
|
|
52
|
+
const text = merged(file, add);
|
|
53
|
+
if (text === "unreadable")
|
|
54
|
+
return text;
|
|
55
|
+
if (text === null)
|
|
56
|
+
return "present";
|
|
57
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
58
|
+
// In place, so a settings file that is a symlink stays one.
|
|
59
|
+
writeFileSync(file, text);
|
|
60
|
+
return "added";
|
|
61
|
+
}
|
|
62
|
+
const describe = (text) => (text === "unreadable" ? "can't be read, left unchanged" : text === null ? "already installed" : "adds a hook");
|
|
63
|
+
export const installClaudeHooks = (settingsPath) => install(settingsPath, entries("claude-code", true));
|
|
64
|
+
export const describeClaudeHooks = (settingsPath) => describe(merged(settingsPath, entries("claude-code", true)));
|
|
65
|
+
export const installCodexHooks = (hooksPath) => install(hooksPath, entries("codex", false));
|
|
66
|
+
export const describeCodexHooks = (hooksPath) => describe(merged(hooksPath, entries("codex", false)));
|
|
67
|
+
// dist/setup/hooks.js -> ../../opencode/berliner.js (the package's opencode/ directory).
|
|
68
|
+
const plugin = () => readFileSync(new URL("../../opencode/berliner.js", import.meta.url), "utf8");
|
|
69
|
+
const current = (pluginPath) => {
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(pluginPath, "utf8") === plugin();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
export function installOpenCodePlugin(pluginPath) {
|
|
78
|
+
if (current(pluginPath))
|
|
79
|
+
return "present";
|
|
80
|
+
mkdirSync(dirname(pluginPath), { recursive: true });
|
|
81
|
+
writeFileSync(pluginPath, plugin());
|
|
82
|
+
return "added";
|
|
83
|
+
}
|
|
84
|
+
export const describeOpenCodePlugin = (pluginPath) => (current(pluginPath) ? "already installed" : "adds a hook");
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const claudeDir = (env) => env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
5
|
+
export const codexDir = (env) => env.CODEX_HOME || join(homedir(), ".codex");
|
|
6
|
+
export const opencodeDir = (env) => join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
|
|
7
|
+
// The spec's table (docs/superpowers/specs/2026-10-20-onboarding-design.md, "Where each tool looks for skills").
|
|
8
|
+
const TOOLS = [
|
|
9
|
+
{ id: "claude-code", name: "Claude Code", bin: "claude", dir: claudeDir },
|
|
10
|
+
{ id: "codex", name: "Codex", bin: "codex", dir: codexDir },
|
|
11
|
+
{ id: "opencode", name: "OpenCode", bin: "opencode", dir: opencodeDir },
|
|
12
|
+
];
|
|
13
|
+
function isExecutable(file) {
|
|
14
|
+
try {
|
|
15
|
+
accessSync(file, constants.X_OK);
|
|
16
|
+
return statSync(file).isFile();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const onPath = (bin, env) => (env.PATH ?? "").split(":").some((dir) => dir !== "" && isExecutable(join(dir, bin)));
|
|
23
|
+
export function detectTools(env) {
|
|
24
|
+
return TOOLS.map((t) => ({ id: t.id, name: t.name, found: onPath(t.bin, env) || existsSync(t.dir(env)) }));
|
|
25
|
+
}
|
|
26
|
+
const skillFile = (dir) => join(dir, "skills", "berliner", "SKILL.md");
|
|
27
|
+
// One target per tool found. OpenCode reads ~/.claude/skills and ~/.agents/skills too, so it gets its own copy only when neither is written.
|
|
28
|
+
export function skillTargets(tools, env) {
|
|
29
|
+
const found = (id) => tools.some((t) => t.id === id && t.found);
|
|
30
|
+
const targets = [];
|
|
31
|
+
if (found("claude-code"))
|
|
32
|
+
targets.push({ tool: "claude-code", path: skillFile(claudeDir(env)) });
|
|
33
|
+
if (found("codex"))
|
|
34
|
+
targets.push({ tool: "codex", path: skillFile(join(homedir(), ".agents")) });
|
|
35
|
+
if (found("opencode")) {
|
|
36
|
+
const read = [".claude", ".agents"].map((d) => skillFile(join(homedir(), d)));
|
|
37
|
+
const covering = targets.find((t) => read.includes(t.path));
|
|
38
|
+
targets.push({ tool: "opencode", path: covering?.path ?? skillFile(opencodeDir(env)) });
|
|
39
|
+
}
|
|
40
|
+
return targets.map((t) => ({ ...t, exists: existsSync(t.path) }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Berliner's OpenCode plugin, installed by `berliner setup` to ${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/berliner.js.
|
|
2
|
+
// It reports each top-level session to Berliner through `berliner hook opencode`, and must never throw into OpenCode or make it wait.
|
|
3
|
+
//
|
|
4
|
+
// Checked against OpenCode 1.18.30 (https://opencode.ai/docs/plugins/ and @opencode-ai/plugin 1.18.30's Hooks type):
|
|
5
|
+
// - a plugin is an exported async function of { client, directory, ... } that returns its hooks;
|
|
6
|
+
// - `event` receives every bus event; session.idle carries properties.sessionID;
|
|
7
|
+
// - `shell.env` runs before the agent's shell tool (with sessionID) and before user terminals (without one);
|
|
8
|
+
// - `experimental.session.compacting` runs before every compaction, manual (/compact) and automatic alike, and adds
|
|
9
|
+
// output.context to the summary prompt;
|
|
10
|
+
// - client.session.get({ path: { id } }) returns { data } with the server's Session, which holds cost, tokens and model,
|
|
11
|
+
// so the messages are not needed;
|
|
12
|
+
// - subagents run in child sessions (parentID set), each with its own cost and tokens, and
|
|
13
|
+
// client.session.children({ path: { id } }) returns { data } with a session's direct children.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const REMINDER = "This session reports to Berliner: run `berliner report` with the task and PRs before your final message.";
|
|
20
|
+
// The id names a file, so only the characters OpenCode's own ids use.
|
|
21
|
+
const SAFE_ID = /^[\w-]+$/;
|
|
22
|
+
// Bounds a walk up or down the session tree, which subagents keep shallow.
|
|
23
|
+
const MAX_DEPTH = 10;
|
|
24
|
+
|
|
25
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
26
|
+
const get = async (client, id) => (await client.session.get({ path: { id } }))?.data;
|
|
27
|
+
|
|
28
|
+
// One agent session is one Berliner session: a subagent's child session belongs to the top-level session above it.
|
|
29
|
+
async function topLevelId(client, id) {
|
|
30
|
+
let session = await get(client, id);
|
|
31
|
+
for (let i = 0; session?.parentID && i < MAX_DEPTH; i++) session = await get(client, session.parentID);
|
|
32
|
+
return session && !session.parentID ? session.id : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The session with its subagents' cost and tokens added in, in the shape the CLI reads. OpenCode 1.18.30 keeps them apart:
|
|
36
|
+
// a session's cost and tokens grow only from its own step-finish parts (packages/core/src/session/projector.ts:310-326,
|
|
37
|
+
// applyUsage on the part's own sessionID, lines 89-109), and a live run with one task-tool subagent gave the parent 0.215
|
|
38
|
+
// and the child 1.05.
|
|
39
|
+
async function withChildren(client, session, depth = 0) {
|
|
40
|
+
const children = depth < MAX_DEPTH ? (await client.session.children({ path: { id: session.id } }))?.data : null;
|
|
41
|
+
if (!Array.isArray(children) || children.length === 0) return session;
|
|
42
|
+
const t = session.tokens;
|
|
43
|
+
let cost = num(session.cost);
|
|
44
|
+
const tokens = {
|
|
45
|
+
input: num(t?.input),
|
|
46
|
+
output: num(t?.output),
|
|
47
|
+
reasoning: num(t?.reasoning),
|
|
48
|
+
cache: { read: num(t?.cache?.read), write: num(t?.cache?.write) },
|
|
49
|
+
};
|
|
50
|
+
for (const c of await Promise.all(children.map((child) => withChildren(client, child, depth + 1)))) {
|
|
51
|
+
cost += num(c.cost);
|
|
52
|
+
tokens.input += num(c.tokens?.input);
|
|
53
|
+
tokens.output += num(c.tokens?.output);
|
|
54
|
+
tokens.reasoning += num(c.tokens?.reasoning);
|
|
55
|
+
tokens.cache.read += num(c.tokens?.cache?.read);
|
|
56
|
+
tokens.cache.write += num(c.tokens?.cache?.write);
|
|
57
|
+
}
|
|
58
|
+
return { ...session, cost, tokens };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Saves a top-level session with its subagents' totals where `berliner report` run by the agent finds them: its shell can't
|
|
62
|
+
// reach this client. Returns what it saved, or null for a child session, which the top-level session's totals count.
|
|
63
|
+
async function save(client, sessionID) {
|
|
64
|
+
if (typeof sessionID !== "string" || !SAFE_ID.test(sessionID)) return null;
|
|
65
|
+
const found = await get(client, sessionID);
|
|
66
|
+
if (found?.id !== sessionID || found.parentID) return null;
|
|
67
|
+
// OpenCode moves time.updated only when a prompt arrives, so the session's last activity is now.
|
|
68
|
+
const session = { ...(await withChildren(client, found)), time: { ...found.time, updated: Date.now() } };
|
|
69
|
+
const dir = join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner", "opencode");
|
|
70
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
71
|
+
const file = join(dir, `${sessionID}.json`);
|
|
72
|
+
// Renamed into place, so a reader never sees half of it.
|
|
73
|
+
writeFileSync(`${file}.${process.pid}`, JSON.stringify(session));
|
|
74
|
+
renameSync(`${file}.${process.pid}`, file);
|
|
75
|
+
return session;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function report(client, event, sessionID) {
|
|
79
|
+
try {
|
|
80
|
+
const session = await save(client, sessionID);
|
|
81
|
+
if (!session) return;
|
|
82
|
+
// Detached and unreferenced: OpenCode never waits on it.
|
|
83
|
+
const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
|
|
84
|
+
child.on("error", () => {});
|
|
85
|
+
child.stdin.on("error", () => {});
|
|
86
|
+
child.stdin.end(JSON.stringify({ event, session }));
|
|
87
|
+
child.unref();
|
|
88
|
+
} catch {
|
|
89
|
+
// A report that can't be sent is skipped; the next idle sends the latest totals.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const BerlinerPlugin = async ({ client }) => ({
|
|
94
|
+
"shell.env": async (input, output) => {
|
|
95
|
+
try {
|
|
96
|
+
if (!input?.sessionID) return;
|
|
97
|
+
output.env.OPENCODE_SESSION_ID = input.sessionID;
|
|
98
|
+
// A subagent's shell gets the top-level session, so a `berliner report` the agent runs there doesn't split it off.
|
|
99
|
+
const top = await topLevelId(client, input.sessionID);
|
|
100
|
+
if (!top) return;
|
|
101
|
+
output.env.OPENCODE_SESSION_ID = top;
|
|
102
|
+
// So that report finds the totals before the session's first idle. Not reported from here: that waits for idle.
|
|
103
|
+
await save(client, top);
|
|
104
|
+
} catch {
|
|
105
|
+
// Never fail the agent's shell.
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
event: async (input) => {
|
|
109
|
+
try {
|
|
110
|
+
if (input?.event?.type === "session.idle") void report(client, "idle", input.event.properties?.sessionID);
|
|
111
|
+
} catch {
|
|
112
|
+
// Never fail OpenCode's event bus.
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"experimental.session.compacting": async (input, output) => {
|
|
116
|
+
try {
|
|
117
|
+
// The summary carries it, so the agent still reports the task and PRs after compaction.
|
|
118
|
+
output.context.push(REMINDER);
|
|
119
|
+
} catch {
|
|
120
|
+
// Never fail the compaction.
|
|
121
|
+
}
|
|
122
|
+
void report(client, "compacting", input?.sessionID);
|
|
123
|
+
},
|
|
124
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paxnax/berliner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Report coding-agent sessions to Berliner",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"opencode",
|
|
17
18
|
"skill"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: berliner
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use when you commit, open or update a pull request, or finish a task, and whenever the user asks you to report to Berliner. Reports the task, pull requests, test coverage and screenshots or videos with the berliner CLI.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Reporting to Berliner
|
|
@@ -9,10 +9,29 @@ Berliner records what an autonomous session cost and produced. You report with o
|
|
|
9
9
|
|
|
10
10
|
## When to report
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
- Again
|
|
12
|
+
- Before your final message, whenever you committed, opened or updated a pull request, or finished a task.
|
|
13
|
+
- Again whenever a PR changes (opened, merged, CI finishes) or you add evidence.
|
|
14
|
+
- Right away when the user asks you to report to Berliner.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
Reporting again is safe. The new values replace the old ones, PR URLs are added to the list, and evidence already uploaded is skipped. It never double-counts.
|
|
17
|
+
|
|
18
|
+
## Inside Claude Code, Codex or OpenCode
|
|
19
|
+
|
|
20
|
+
Hooks installed by `berliner setup` already report the session's cost, model, repository and commits after every reply. Send only what you know and the hooks don't:
|
|
21
|
+
|
|
22
|
+
| Flag | What to send |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `--title` | A short line naming the task. |
|
|
25
|
+
| `--task`, `--task-url` | The ticket ID and its URL, if there is one. |
|
|
26
|
+
| `--pr` | Every pull request, one flag each. |
|
|
27
|
+
| `--coverage` | Percent, from the test run's coverage summary. |
|
|
28
|
+
| `--evidence` | Each screenshot or video. |
|
|
29
|
+
|
|
30
|
+
Do not pass `--session-id`, `--cost` or `--model`: the CLI fills them in from the tool's own records, and an explicit value is ignored with a warning. `--repo` and `--plan` are not needed either: the plan comes from the user's `BERLINER_PLAN`, or from their subscriptions in Settings. A report without `--title` keeps the session's existing title. `berliner update` ignores a session ID here.
|
|
31
|
+
|
|
32
|
+
## Outside those tools
|
|
33
|
+
|
|
34
|
+
Without a hook, `report` needs these flags:
|
|
16
35
|
|
|
17
36
|
| Flag | Source |
|
|
18
37
|
|---|---|
|
|
@@ -21,11 +40,13 @@ Berliner records what an autonomous session cost and produced. You report with o
|
|
|
21
40
|
| `--repo` | `owner/name`, from `git remote get-url origin`. |
|
|
22
41
|
| `--cost` | Estimated USD. Your harness's reported session cost if it shows one, otherwise estimate from token usage at the model's API prices. |
|
|
23
42
|
| `--model` | The primary model ID you ran on. |
|
|
24
|
-
| `--plan` |
|
|
43
|
+
| `--plan` | Your best guess at the plan you run on. Claude: `free`, `pro`, `max_x5`, `max_x20`, `team`, `enterprise`. OpenAI (Codex): `free`, `go`, `plus`, `pro`, `pro_more`, `pro_max`, `business`, `business_premium`. Billed per token: `api`. Always pass it: when the user has set `BERLINER_PLAN`, that value replaces yours. |
|
|
44
|
+
|
|
45
|
+
Report again with the same `--session-id` whenever something changes.
|
|
25
46
|
|
|
26
47
|
## Optional values
|
|
27
48
|
|
|
28
|
-
- `--commits <n
|
|
49
|
+
- `--commits <n>` (outside the tools; the hooks count them): `git rev-list --count <base>..HEAD`
|
|
29
50
|
- `--coverage <percent>`: from the test run's coverage summary.
|
|
30
51
|
- `--pr <url>[,merge=<open|merged|closed>][,ci=<pending|running|passed|failed|none>]`: repeat per PR. URL from `gh pr view --json url`.
|
|
31
52
|
- `--evidence <path>[::caption]`: repeat per screenshot or video.
|
|
@@ -35,7 +56,9 @@ Quote any value that contains spaces.
|
|
|
35
56
|
|
|
36
57
|
## Environment
|
|
37
58
|
|
|
38
|
-
`BERLINER_TOKEN`
|
|
59
|
+
`berliner setup` stores the API token in a config file, so `BERLINER_TOKEN` is only needed to override it (a token from the Settings page at https://berliner.app). `BERLINER_URL` overrides the server address. `BERLINER_PLAN`, when the user sets it, replaces any `--plan`.
|
|
60
|
+
|
|
61
|
+
If the `berliner` command is not found, ask the user to run `npx @paxnax/berliner@latest setup`. Do not try to install it yourself.
|
|
39
62
|
|
|
40
63
|
## Timeouts
|
|
41
64
|
|
|
@@ -46,9 +69,23 @@ The session is saved first, then evidence is uploaded. Allow at least 10 minutes
|
|
|
46
69
|
- Success prints what was saved, then an evidence line such as `Evidence: 1 uploaded, 0 already uploaded, 0 skipped.`
|
|
47
70
|
- A line starting `berliner:` on stderr is a warning: the report may be incomplete (server unreachable, a file skipped). The command still exits 0, so it never fails your work (`--strict` makes failures exit 1). Re-run the same command; it is safe to repeat.
|
|
48
71
|
- `--json` prints the saved session as JSON, plus an `evidence` field with `uploaded`, `existing` and `skipped` results when `--evidence` was given.
|
|
49
|
-
- To change a field later without resending everything, use `berliner update <session-id> --commits 5
|
|
72
|
+
- To change a field later without resending everything, use `berliner update --commits 5` inside the tools, or `berliner update <session-id> --commits 5` outside them.
|
|
73
|
+
|
|
74
|
+
## Examples
|
|
75
|
+
|
|
76
|
+
Inside Claude Code, Codex or OpenCode:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
berliner report \
|
|
80
|
+
--title "Fix recurring event off-by-one" \
|
|
81
|
+
--task TEC-5521 \
|
|
82
|
+
--task-url https://linear.app/octo/issue/TEC-5521 \
|
|
83
|
+
--coverage 87.5 \
|
|
84
|
+
--pr "https://github.com/octo/app/pull/42,ci=running" \
|
|
85
|
+
--evidence "./shots/after.png::Calendar after the fix"
|
|
86
|
+
```
|
|
50
87
|
|
|
51
|
-
|
|
88
|
+
Anywhere else:
|
|
52
89
|
|
|
53
90
|
```sh
|
|
54
91
|
berliner report \
|