@paxnax/berliner 0.6.0 → 0.8.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 +2 -2
- package/dist/agents/claude.js +10 -7
- package/dist/agents/codex.js +14 -17
- package/dist/agents/opencode.js +6 -2
- package/dist/agents/read.js +6 -0
- package/dist/args.js +3 -0
- package/dist/commands/body.js +2 -0
- package/dist/commands/hook.js +27 -53
- package/dist/commands/report.js +14 -3
- package/dist/commands/session.js +2 -2
- package/dist/commands/sessions.js +2 -3
- package/dist/commands/summary.js +6 -5
- package/dist/commands/upgrade.js +21 -31
- package/dist/index.js +8 -1
- package/dist/ledger.js +202 -0
- package/dist/lock.js +45 -0
- package/dist/output.js +5 -0
- package/dist/upload.js +1 -0
- package/dist/version.js +54 -0
- package/opencode/berliner.js +45 -3
- package/package.json +1 -1
- package/skill/SKILL.md +18 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ With Bun, run `bunx --bun @paxnax/berliner@latest setup`. To install only the co
|
|
|
21
21
|
npx @paxnax/berliner@latest setup
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner upgrade` to update the CLI, the skill and any Berliner hook an older setup installed to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is.
|
|
24
|
+
It shows what it will do and asks once, then opens your browser so you can approve the connection. After you approve, it saves the token, installs the CLI globally, and installs the agent skill and a reporting hook for each of Claude Code, Codex and OpenCode that it finds. `--yes` skips the question, `--no-install` skips the global install, and `--no-browser` only prints the URL (over SSH, for example). Run `berliner upgrade` to update the CLI, the skill and any Berliner hook an older setup installed to the latest release: it shows its plan and asks first, and a second question (or `--no-skill`) keeps your skill as it is. Once a day, the first `berliner` command or agent session start checks for a newer release and says so.
|
|
25
25
|
|
|
26
26
|
3. From then on, the hooks report each session's cost, model, repository, commits and start and end times after every reply. They also fill in a ticket from a Linear or Jira URL in your prompts while the session has none, and refresh up to 5 open pull requests the Berliner GitHub App doesn't track with your `gh` (skipped when `gh` is missing or signed out). When a session starts, the hook reminds your agent to name the session and report its ticket. Your agent adds the title, ticket, pull requests, coverage and evidence with `berliner report` and `berliner update`, as the skill tells it to. Codex runs a new hook only once you trust it: open `codex` once in a terminal and trust the Berliner hook.
|
|
27
27
|
4. On **Settings**, list the subscriptions you pay for. Overview then compares each one with the sessions that ran on it.
|
|
@@ -121,7 +121,7 @@ Other commands:
|
|
|
121
121
|
- `berliner delete <session-id>`: delete a session, or one item with `--evidence <id>`. Asks to confirm unless `--yes`.
|
|
122
122
|
- `berliner evidence public <id>`: let anyone with the link view one file, and print its public URL and Markdown line. `berliner evidence private <id>` makes it private again. `berliner session` shows the ids, and the URL of each public file.
|
|
123
123
|
- `berliner skill`: print the agent skill (`SKILL.md`) to install in your agent.
|
|
124
|
-
- `berliner upgrade [--yes] [--no-skill]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command, each skill file and each Berliner hook it updates) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is. It brings a Berliner hook an older setup installed up to date, but never adds one; your token and config are never touched.
|
|
124
|
+
- `berliner upgrade [--yes] [--no-skill] [--no-hooks]`: install the latest CLI and skill. It shows its plan (the versions, the exact install command, each skill file and each Berliner hook it updates) and asks before changing anything; a second question, or `--no-skill`, leaves your skill as it is, and `--no-hooks` leaves your hooks as they are. It brings a Berliner hook an older setup installed up to date, but never adds one; your token and config are never touched. When it updated a skill or hook, its last line names the tools to start a new session in so they load it.
|
|
125
125
|
- `berliner hook <tool>`: run by the hooks setup installs, with the tool's payload on stdin. It never fails and never makes the agent wait, and prints only the reminder when a session starts; errors go to `${XDG_STATE_HOME:-~/.local/state}/berliner/hook.log`.
|
|
126
126
|
|
|
127
127
|
`report` and `update` exit 0 even when something fails, printing a warning that starts with `berliner:` to stderr, so reporting never breaks an agent's pipeline. Pass `--strict` to make them exit 1 on failure, including a file that could not be uploaded or made public; the session is still saved. The other commands exit 1 on failure.
|
package/dist/agents/claude.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { jsonl, num, Span, sumByModel, ticketUrl } from "./read.js";
|
|
4
4
|
// Subagent transcripts sit next to the session's: <project>/<session-id>/subagents/agent-*.jsonl.
|
|
5
5
|
export async function subagentFiles(transcriptPath) {
|
|
6
6
|
const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
|
|
@@ -26,7 +26,9 @@ export async function readClaudeCode(transcriptPath) {
|
|
|
26
26
|
let firstPrompt = null;
|
|
27
27
|
let taskUrl = null;
|
|
28
28
|
for (const file of [transcriptPath, ...(await subagentFiles(transcriptPath))]) {
|
|
29
|
+
let line = -1;
|
|
29
30
|
for await (const e of jsonl(file)) {
|
|
31
|
+
line++;
|
|
30
32
|
span.add(e.timestamp);
|
|
31
33
|
if (file === transcriptPath) {
|
|
32
34
|
const prompt = promptText(e);
|
|
@@ -40,7 +42,10 @@ export async function readClaudeCode(transcriptPath) {
|
|
|
40
42
|
const u = m.usage;
|
|
41
43
|
// cache_creation_input_tokens counts both kinds of write; the 1-hour ones cost more.
|
|
42
44
|
const writes1h = num(u.cache_creation?.ephemeral_1h_input_tokens);
|
|
43
|
-
|
|
45
|
+
// The line index counts parsed lines only, which is stable as the file is only appended to.
|
|
46
|
+
const id = typeof m.id === "string" ? m.id : typeof e.uuid === "string" ? e.uuid : `${file}:${line}`;
|
|
47
|
+
responses.set(id, {
|
|
48
|
+
id,
|
|
44
49
|
model: m.model,
|
|
45
50
|
tokens: {
|
|
46
51
|
input: num(u.input_tokens),
|
|
@@ -52,14 +57,12 @@ export async function readClaudeCode(transcriptPath) {
|
|
|
52
57
|
});
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
tokensByModel[model] = tokensByModel[model] ? addTokens(tokensByModel[model], tokens) : tokens;
|
|
58
|
-
}
|
|
60
|
+
const replies = [...responses.values()];
|
|
61
|
+
const tokensByModel = sumByModel(replies);
|
|
59
62
|
// The session's model is the one that wrote the most.
|
|
60
63
|
let model = null;
|
|
61
64
|
for (const [id, t] of Object.entries(tokensByModel))
|
|
62
65
|
if (model === null || t.output > tokensByModel[model].output)
|
|
63
66
|
model = id;
|
|
64
|
-
return { model, tokensByModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
67
|
+
return { model, tokensByModel, replies, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
65
68
|
}
|
package/dist/agents/codex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsonl, num, Span, sumByModel, ticketUrl } from "./read.js";
|
|
2
2
|
const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
|
|
3
3
|
const tokensOf = (u) => ({
|
|
4
4
|
input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
|
|
@@ -17,23 +17,22 @@ export async function readCodex(rolloutPath) {
|
|
|
17
17
|
let latest = zero;
|
|
18
18
|
let latestTotal = 0;
|
|
19
19
|
let latestLast = 0;
|
|
20
|
-
const byModel = {};
|
|
21
20
|
// Codex 0.148+ writes a token_usage_record per model response, before its token_count; where present they are the usage.
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
21
|
+
// Replies from token_count growth come first; a rollout begun on an older Codex and resumed on a newer one keeps those before its first record.
|
|
22
|
+
const counted = [];
|
|
23
|
+
let countedBeforeRecords = null;
|
|
24
|
+
const records = new Map();
|
|
25
25
|
for await (const e of jsonl(rolloutPath)) {
|
|
26
26
|
span.add(e.timestamp);
|
|
27
27
|
const p = e.payload;
|
|
28
28
|
if (e.type === "turn_context" && typeof p?.model === "string")
|
|
29
29
|
model = p.model;
|
|
30
30
|
if (e.type === "token_usage_record" && p?.usage) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
records
|
|
36
|
-
}
|
|
31
|
+
countedBeforeRecords ??= counted.length;
|
|
32
|
+
const id = typeof p.response_id === "string" ? p.response_id : `tr:${e.timestamp}:${num(p.usage.total_tokens)}`;
|
|
33
|
+
// A response id repeated in the rollout counts once, whatever model is in effect.
|
|
34
|
+
if (model && !(typeof p.response_id === "string" && records.has(id)))
|
|
35
|
+
records.set(id, { id, model, tokens: tokensOf(p.usage) });
|
|
37
36
|
}
|
|
38
37
|
if (e.type !== "event_msg")
|
|
39
38
|
continue;
|
|
@@ -57,14 +56,12 @@ export async function readCodex(rolloutPath) {
|
|
|
57
56
|
const cur = tokensOf(u);
|
|
58
57
|
// Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
|
|
59
58
|
const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
|
|
60
|
-
if (model)
|
|
61
|
-
|
|
59
|
+
if (model && Object.values(delta).some((v) => v > 0))
|
|
60
|
+
counted.push({ id: `tc:${e.timestamp}:${total}`, model, tokens: delta });
|
|
62
61
|
latestTotal = total;
|
|
63
62
|
latestLast = last;
|
|
64
63
|
latest = cur;
|
|
65
64
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
beforeRecords[m] = addTokens(beforeRecords[m] ?? zero, t);
|
|
69
|
-
return { model, tokensByModel: beforeRecords ?? byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
65
|
+
const replies = countedBeforeRecords === null ? counted : [...counted.slice(0, countedBeforeRecords), ...records.values()];
|
|
66
|
+
return { model, tokensByModel: sumByModel(replies), replies, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
70
67
|
}
|
package/dist/agents/opencode.js
CHANGED
|
@@ -7,7 +7,9 @@ const iso = (ms) => {
|
|
|
7
7
|
export function readOpenCode(json) {
|
|
8
8
|
const s = (typeof json === "object" && json !== null ? json : {});
|
|
9
9
|
const model = typeof s.model?.id === "string" ? s.model.id : null;
|
|
10
|
-
const
|
|
10
|
+
const own = s.own && typeof s.own.cost === "number" && Number.isFinite(s.own.cost) && s.own.tokens ? s.own : null;
|
|
11
|
+
const t = own ? own.tokens : s.tokens;
|
|
12
|
+
const cost = own ? own.cost : s.cost;
|
|
11
13
|
const tokens = {
|
|
12
14
|
input: num(t?.input),
|
|
13
15
|
// OpenCode counts reasoning apart from output and bills it at the output price.
|
|
@@ -19,7 +21,9 @@ export function readOpenCode(json) {
|
|
|
19
21
|
return {
|
|
20
22
|
model,
|
|
21
23
|
tokensByModel: model && t ? { [model]: tokens } : {},
|
|
22
|
-
|
|
24
|
+
// A session row has no per-reply records.
|
|
25
|
+
replies: [],
|
|
26
|
+
costUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
|
|
23
27
|
startedAt: iso(s.time?.created),
|
|
24
28
|
endedAt: iso(s.time?.updated),
|
|
25
29
|
firstPrompt: null,
|
package/dist/agents/read.js
CHANGED
package/dist/args.js
CHANGED
|
@@ -5,6 +5,8 @@ const config = {
|
|
|
5
5
|
title: { type: "string" },
|
|
6
6
|
repo: { type: "string" },
|
|
7
7
|
cost: { type: "string" },
|
|
8
|
+
// Set only by fromTool, from the ledger; not a documented flag.
|
|
9
|
+
"cost-status": { type: "string" },
|
|
8
10
|
model: { type: "string" },
|
|
9
11
|
plan: { type: "string" },
|
|
10
12
|
task: { type: "string" },
|
|
@@ -26,6 +28,7 @@ const config = {
|
|
|
26
28
|
"no-install": { type: "boolean" },
|
|
27
29
|
"no-browser": { type: "boolean" },
|
|
28
30
|
"no-skill": { type: "boolean" },
|
|
31
|
+
"no-hooks": { type: "boolean" },
|
|
29
32
|
none: { type: "boolean" },
|
|
30
33
|
all: { type: "boolean" },
|
|
31
34
|
run: { type: "string" },
|
package/dist/commands/body.js
CHANGED
|
@@ -18,6 +18,7 @@ export function sessionBody(values) {
|
|
|
18
18
|
title: values.title,
|
|
19
19
|
repository: values.repo,
|
|
20
20
|
cost_usd: number(values, "cost"),
|
|
21
|
+
cost_status: values["cost-status"],
|
|
21
22
|
model: values.model,
|
|
22
23
|
plan: values.plan,
|
|
23
24
|
task_id: values.task,
|
|
@@ -42,6 +43,7 @@ export async function fromTool(tool, values, env, command) {
|
|
|
42
43
|
if (facts) {
|
|
43
44
|
// Unknown without prices: left out, so a cost already saved stays (report creates a new session at 0).
|
|
44
45
|
filled.cost = facts.costUsd === null ? undefined : String(facts.costUsd);
|
|
46
|
+
filled["cost-status"] = facts.costStatus ?? undefined;
|
|
45
47
|
filled.model = facts.model ?? values.model;
|
|
46
48
|
}
|
|
47
49
|
const ignored = ["session-id", "cost", "model"].filter((f) => values[f] !== undefined && values[f] !== filled[f]);
|
package/dist/commands/hook.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { appendFileSync, existsSync,
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, 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";
|
|
@@ -7,10 +7,13 @@ import { CliError, request } from "../client.js";
|
|
|
7
7
|
import { readConfig, stateDir } from "../config.js";
|
|
8
8
|
import { viewPr } from "../gh.js";
|
|
9
9
|
import { gitFacts } from "../git.js";
|
|
10
|
+
import { priceSession, sessionTime } from "../ledger.js";
|
|
11
|
+
import { takeLock } from "../lock.js";
|
|
10
12
|
import { estimateCost, loadPrices } from "../prices.js";
|
|
11
13
|
import { noteRefusal, resolveTeam, teamWarning } from "../team.js";
|
|
12
14
|
import { checkTests } from "../tests/check.js";
|
|
13
15
|
import { nudge } from "../tests/nudge.js";
|
|
16
|
+
import { upgradeNotice } from "../version.js";
|
|
14
17
|
// Same text in the OpenCode plugin (cli/opencode/berliner.js).
|
|
15
18
|
const REMINDER = 'This session reports to Berliner. Once you know the task, name it: berliner update --title "<short title>". When a Linear or Jira ticket comes up, report it: berliner update --task <ID> --task-url <URL>. Before your final message, run berliner report with the PRs.';
|
|
16
19
|
const TOOLS = ["claude-code", "codex", "opencode"];
|
|
@@ -18,7 +21,6 @@ const REPORT_EVENTS = ["Stop", "PreCompact", "SessionEnd"];
|
|
|
18
21
|
const OPENCODE_EVENTS = ["idle", "compacting"];
|
|
19
22
|
const SESSION_STARTS = ["startup", "resume", "compact", "clear"];
|
|
20
23
|
const MAX_PAYLOAD = 1024 * 1024;
|
|
21
|
-
const LOCK_MS = 60_000;
|
|
22
24
|
const DEADLINE_MS = 15_000;
|
|
23
25
|
const MAX_GH = 5;
|
|
24
26
|
export function inTool(env) {
|
|
@@ -30,8 +32,8 @@ export function inTool(env) {
|
|
|
30
32
|
return { tool: "opencode", sessionId: env.OPENCODE_SESSION_ID };
|
|
31
33
|
return null;
|
|
32
34
|
}
|
|
33
|
-
// The session's facts from the tool's own record, with a cost
|
|
34
|
-
// no prices load. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
|
|
35
|
+
// The session's facts from the tool's own record, with a cost priced once per reply in the ledger wherever the tool stores none.
|
|
36
|
+
// The cost and its status stay null when no prices load, the ledger is busy or its backfill is unfinished. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
|
|
35
37
|
// the session itself too, used only without a saved record. Null without a record.
|
|
36
38
|
export async function sessionFacts(tool, sessionId, env, payload = {}) {
|
|
37
39
|
let facts;
|
|
@@ -53,10 +55,22 @@ export async function sessionFacts(tool, sessionId, env, payload = {}) {
|
|
|
53
55
|
// Codex's payload names the model the session runs on now.
|
|
54
56
|
if (tool === "codex" && typeof payload.model === "string" && payload.model)
|
|
55
57
|
facts = { ...facts, model: payload.model };
|
|
56
|
-
if (facts.costUsd !== null)
|
|
57
|
-
return facts;
|
|
58
58
|
const prices = await loadPrices(env);
|
|
59
|
-
|
|
59
|
+
// OpenCode stays out of the ledger: its own figure, else an estimate. A 0 is a session nobody priced.
|
|
60
|
+
if (tool === "opencode") {
|
|
61
|
+
if (facts.costUsd !== null)
|
|
62
|
+
return { ...facts, costStatus: facts.costUsd > 0 ? "exact" : "unknown" };
|
|
63
|
+
if (!prices)
|
|
64
|
+
return { ...facts, costStatus: null };
|
|
65
|
+
const { costUsd, unpriced } = estimateCost(facts.tokensByModel, prices);
|
|
66
|
+
// As in the ledger: partial while some model has no price.
|
|
67
|
+
return { ...facts, costUsd, costStatus: costUsd === 0 ? "unknown" : unpriced.length ? "partial" : "exact" };
|
|
68
|
+
}
|
|
69
|
+
if (!prices || !file)
|
|
70
|
+
return { ...facts, costStatus: null };
|
|
71
|
+
// The record file's creation time, not the first entry's: the backfill orders sessions by it too.
|
|
72
|
+
const priced = await priceSession(env, { id: sessionId, startedAt: await sessionTime(file, facts.startedAt) }, facts.replies, prices);
|
|
73
|
+
return { ...facts, costUsd: priced?.costUsd ?? null, costStatus: priced?.status ?? null };
|
|
60
74
|
}
|
|
61
75
|
// The first prompt's first line cut to 80 characters, else the branch. The repository is the last resort: a new session needs a title.
|
|
62
76
|
export function derivedTitle(firstPrompt, git) {
|
|
@@ -90,9 +104,11 @@ async function start(tool, env) {
|
|
|
90
104
|
}
|
|
91
105
|
else {
|
|
92
106
|
// In the agent's context from the start, whether or not the skill triggers; after a compaction or /clear it may have lost
|
|
93
|
-
// the task and PRs it hasn't reported yet.
|
|
94
|
-
if (p.hook_event_name === "SessionStart" && SESSION_STARTS.includes(p.source))
|
|
95
|
-
|
|
107
|
+
// the task and PRs it hasn't reported yet. The day's upgrade check goes with it, so the agent can tell the user.
|
|
108
|
+
if (p.hook_event_name === "SessionStart" && SESSION_STARTS.includes(p.source)) {
|
|
109
|
+
const notice = await upgradeNotice(env);
|
|
110
|
+
return console.log(notice ? `${REMINDER}\n${notice}` : REMINDER);
|
|
111
|
+
}
|
|
96
112
|
if (!REPORT_EVENTS.includes(p.hook_event_name))
|
|
97
113
|
return;
|
|
98
114
|
id = p.session_id;
|
|
@@ -169,49 +185,6 @@ async function child(tool, file, env) {
|
|
|
169
185
|
clearTimeout(timer);
|
|
170
186
|
}
|
|
171
187
|
}
|
|
172
|
-
// 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
|
|
173
|
-
// removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
|
|
174
|
-
export function takeLock(lock) {
|
|
175
|
-
if (createLock(lock))
|
|
176
|
-
return true;
|
|
177
|
-
const seen = statSync(lock, { throwIfNoEntry: false });
|
|
178
|
-
// Gone since: its child just unlocked, so it is free, not stale.
|
|
179
|
-
if (!seen)
|
|
180
|
-
return createLock(lock);
|
|
181
|
-
if (Date.now() - seen.mtimeMs < LOCK_MS)
|
|
182
|
-
return false;
|
|
183
|
-
const aside = `${lock}.${process.pid}`;
|
|
184
|
-
try {
|
|
185
|
-
renameSync(lock, aside);
|
|
186
|
-
}
|
|
187
|
-
catch (err) {
|
|
188
|
-
if (err.code === "ENOENT")
|
|
189
|
-
return false;
|
|
190
|
-
throw err;
|
|
191
|
-
}
|
|
192
|
-
if (statSync(aside).ino !== seen.ino) {
|
|
193
|
-
// shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
|
|
194
|
-
try {
|
|
195
|
-
linkSync(aside, lock);
|
|
196
|
-
}
|
|
197
|
-
catch { }
|
|
198
|
-
rmSync(aside, { force: true });
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
rmSync(aside, { force: true });
|
|
202
|
-
return createLock(lock);
|
|
203
|
-
}
|
|
204
|
-
function createLock(lock) {
|
|
205
|
-
try {
|
|
206
|
-
writeFileSync(lock, String(process.pid), { flag: "wx" });
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
catch (err) {
|
|
210
|
-
if (err.code === "EEXIST")
|
|
211
|
-
return false;
|
|
212
|
-
throw err;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
188
|
// Only the fields the machine knows. The task, PRs, coverage, evidence, project, an existing title and the plan are the agent's.
|
|
216
189
|
async function send(tool, file, env) {
|
|
217
190
|
const payload = JSON.parse(await readFile(file, "utf8"));
|
|
@@ -249,6 +222,7 @@ async function send(tool, file, env) {
|
|
|
249
222
|
const body = {
|
|
250
223
|
// Unknown without prices: left out, so a cost already saved stays.
|
|
251
224
|
cost_usd: facts.costUsd ?? undefined,
|
|
225
|
+
cost_status: facts.costStatus ?? undefined,
|
|
252
226
|
model: facts.model ?? undefined,
|
|
253
227
|
commits: git.commits ?? undefined,
|
|
254
228
|
started_at: facts.startedAt ?? undefined,
|
package/dist/commands/report.js
CHANGED
|
@@ -21,6 +21,8 @@ export async function report(cfg, values, positionals, env) {
|
|
|
21
21
|
throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
|
|
22
22
|
if (values.public && !values.evidence?.length)
|
|
23
23
|
throw new CliError("--public needs --evidence", "usage");
|
|
24
|
+
// Parsed before anything is saved, so a bad label saves nothing.
|
|
25
|
+
const evidence = (values.evidence ?? []).map(parseEvidence);
|
|
24
26
|
const team = await resolveTeam(values, process.cwd(), env);
|
|
25
27
|
const body = { ...sessionBody(values), plan: cfg.plan ?? values.plan };
|
|
26
28
|
const id = values["session-id"];
|
|
@@ -43,7 +45,7 @@ export async function report(cfg, values, positionals, env) {
|
|
|
43
45
|
print({ data: json, text }, true);
|
|
44
46
|
return;
|
|
45
47
|
}
|
|
46
|
-
const result = await uploadEvidence(cfg, id,
|
|
48
|
+
const result = await uploadEvidence(cfg, id, evidence, undefined, values.public === true);
|
|
47
49
|
if (json_)
|
|
48
50
|
print({ data: { ...json, evidence: result }, text }, true);
|
|
49
51
|
else {
|
|
@@ -56,8 +58,17 @@ export async function report(cfg, values, positionals, env) {
|
|
|
56
58
|
if (values.strict && result.skipped.length)
|
|
57
59
|
process.exitCode = 1;
|
|
58
60
|
}
|
|
59
|
-
// <path>[::<caption>]
|
|
61
|
+
// <path>[::<caption>[::happy|unhappy|edge]]
|
|
60
62
|
function parseEvidence(spec) {
|
|
61
63
|
const i = spec.indexOf("::");
|
|
62
|
-
|
|
64
|
+
if (i < 0)
|
|
65
|
+
return { path: spec };
|
|
66
|
+
const rest = spec.slice(i + 2);
|
|
67
|
+
const j = rest.lastIndexOf("::");
|
|
68
|
+
if (j < 0)
|
|
69
|
+
return { path: spec.slice(0, i), caption: rest || undefined };
|
|
70
|
+
const label = rest.slice(j + 2);
|
|
71
|
+
if (!["happy", "unhappy", "edge"].includes(label))
|
|
72
|
+
throw new CliError(`Evidence label must be happy, unhappy or edge: "${label}"`, "usage");
|
|
73
|
+
return { path: spec.slice(0, i), caption: rest.slice(0, j) || undefined, path_label: label };
|
|
63
74
|
}
|
package/dist/commands/session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { absolute, CliError, request } from "../client.js";
|
|
2
|
-
import { mergeLabel, print } from "../output.js";
|
|
2
|
+
import { costLabel, mergeLabel, print } from "../output.js";
|
|
3
3
|
import { announceRefusal } from "../team.js";
|
|
4
4
|
export async function session(cfg, values, positionals) {
|
|
5
5
|
await announceRefusal(process.env);
|
|
@@ -13,7 +13,7 @@ export async function session(cfg, values, positionals) {
|
|
|
13
13
|
["Repository", s.repository],
|
|
14
14
|
["Task", [s.task_id, s.task_url].filter(Boolean).join(" ")],
|
|
15
15
|
["Project", s.project],
|
|
16
|
-
["Estimated cost",
|
|
16
|
+
["Estimated cost", costLabel(s.cost_usd, s.cost_status)],
|
|
17
17
|
["Model", s.model],
|
|
18
18
|
["Plan", s.plan],
|
|
19
19
|
["Started", s.started_at],
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { request } from "../client.js";
|
|
2
|
-
import { mergeLabel, print } from "../output.js";
|
|
2
|
+
import { costLabel, mergeLabel, print } from "../output.js";
|
|
3
3
|
import { announceRefusal } from "../team.js";
|
|
4
4
|
import { filterQuery } from "./query.js";
|
|
5
|
-
const usd = (n) => `$${n.toFixed(2)}`;
|
|
6
5
|
export async function sessions(cfg, values) {
|
|
7
6
|
await announceRefusal(process.env);
|
|
8
7
|
const { json } = await request(cfg, "GET", `/v1/sessions${filterQuery(values, { limit: values.limit })}`);
|
|
9
8
|
const rows = [["TASK", "TITLE", "REPO", "COST (est.)", "PRS", "CI", "MERGE"]];
|
|
10
9
|
for (const s of json.sessions)
|
|
11
|
-
rows.push([s.task_id ?? s.id, s.title, s.repository,
|
|
10
|
+
rows.push([s.task_id ?? s.id, s.title, s.repository, costLabel(s.cost_usd, s.cost_status), String(s.pr_count), s.ci, mergeLabel(s)]);
|
|
12
11
|
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
|
|
13
12
|
const lines = rows.map((r) => r
|
|
14
13
|
.map((c, i) => c.padEnd(widths[i]))
|
package/dist/commands/summary.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { request } from "../client.js";
|
|
2
|
-
import { print, subscriptionLabel } from "../output.js";
|
|
2
|
+
import { print, subscriptionLabel, totalLabel, unpricedNote } from "../output.js";
|
|
3
3
|
import { filterQuery } from "./query.js";
|
|
4
4
|
export async function summary(cfg, values) {
|
|
5
5
|
const { json } = await request(cfg, "GET", `/v1/summary${filterQuery(values)}`);
|
|
6
6
|
const s = json.summary;
|
|
7
|
+
const unpriced = s.unpriced_sessions ?? 0;
|
|
7
8
|
const text = [
|
|
8
|
-
`
|
|
9
|
+
`Spend: ${totalLabel(s.spend_usd, unpriced)}${unpricedNote(unpriced)}`,
|
|
9
10
|
`Sessions: ${s.sessions}`,
|
|
10
11
|
`PRs merged: ${s.prs_merged} of ${s.prs_opened} opened`,
|
|
11
|
-
`Cost per merged PR: ${s.cost_per_merged_pr === null ? "n/a" :
|
|
12
|
+
`Cost per merged PR: ${s.cost_per_merged_pr === null ? "n/a" : totalLabel(s.cost_per_merged_pr, unpriced)}`,
|
|
12
13
|
`Commits: ${s.commits}`,
|
|
13
14
|
...subscriptionLines(json.subscriptions),
|
|
14
15
|
].join("\n");
|
|
@@ -18,8 +19,8 @@ export async function summary(cfg, values) {
|
|
|
18
19
|
function subscriptionLines(c) {
|
|
19
20
|
if (!c)
|
|
20
21
|
return [];
|
|
21
|
-
const lines = c.rows.map((r) => `${subscriptionLabel(r)}:
|
|
22
|
+
const lines = c.rows.map((r) => `${subscriptionLabel(r)}: ${totalLabel(r.spend_usd, r.unpriced_sessions ?? 0)} used of $${r.prorated_usd.toFixed(2)} paid (${r.multiple.toFixed(1)}×), ${r.sessions} session(s)`);
|
|
22
23
|
if (c.unmatched.sessions > 0)
|
|
23
|
-
lines.push(`On no listed plan:
|
|
24
|
+
lines.push(`On no listed plan: ${totalLabel(c.unmatched.spend_usd, c.unmatched.unpriced_sessions ?? 0)}, ${c.unmatched.sessions} session(s)`);
|
|
24
25
|
return lines;
|
|
25
26
|
}
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -6,24 +6,9 @@ import { gunzipSync } from "node:zlib";
|
|
|
6
6
|
import { CliError } from "../client.js";
|
|
7
7
|
import { hookPath, updatedHooks } from "../setup/hooks.js";
|
|
8
8
|
import { detectTools, skillTargets } from "../setup/targets.js";
|
|
9
|
+
import { currentVersion, latestVersion, newer, registryOf } from "../version.js";
|
|
9
10
|
import { askNudge } from "./nudge.js";
|
|
10
11
|
import { confirm, installCommand, showPlan, tilde } from "./setup.js";
|
|
11
|
-
const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
12
|
-
async function latestVersion(env) {
|
|
13
|
-
const registry = registryOf(env);
|
|
14
|
-
try {
|
|
15
|
-
const res = await fetch(`${registry}/@paxnax/berliner/latest`);
|
|
16
|
-
if (!res.ok)
|
|
17
|
-
throw new Error(`HTTP ${res.status}`);
|
|
18
|
-
const { version } = (await res.json());
|
|
19
|
-
if (typeof version !== "string")
|
|
20
|
-
throw new Error("no version in the reply");
|
|
21
|
-
return version;
|
|
22
|
-
}
|
|
23
|
-
catch (err) {
|
|
24
|
-
throw new CliError(`Could not look up the latest berliner on ${registry} (${err.message}). Nothing was changed.`, "network");
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
12
|
// The skill and the OpenCode plugin come from the release being installed, so the plan's "already up to date" and what is
|
|
28
13
|
// written match it. They are read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc
|
|
29
14
|
// and node_modules.
|
|
@@ -63,12 +48,6 @@ async function releaseFiles(version, bundled, env) {
|
|
|
63
48
|
throw new CliError(`Could not read the skill and OpenCode plugin of berliner ${version} (${err.message}). Nothing was changed.`, "network");
|
|
64
49
|
}
|
|
65
50
|
}
|
|
66
|
-
// Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
|
|
67
|
-
const newer = (a, b) => {
|
|
68
|
-
const [x, y] = [a, b].map((v) => v.split("-")[0].split(".").map(Number));
|
|
69
|
-
const i = x.findIndex((n, j) => n !== y[j]);
|
|
70
|
-
return i >= 0 && x[i] > y[i];
|
|
71
|
-
};
|
|
72
51
|
const sameAs = (path, text) => {
|
|
73
52
|
try {
|
|
74
53
|
return readFileSync(path, "utf8") === text;
|
|
@@ -81,11 +60,11 @@ const sameAs = (path, text) => {
|
|
|
81
60
|
const HOOK_CHANGE = {
|
|
82
61
|
"claude-code": "Stop hook no longer async, so Berliner can ask the agent to run the tests",
|
|
83
62
|
codex: "the reminder also runs on startup and resume",
|
|
84
|
-
opencode: "the new reminder,
|
|
63
|
+
opencode: "the new reminder, totals saved in order, and a fork counts only its own messages",
|
|
85
64
|
};
|
|
86
65
|
// The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
|
|
87
66
|
export async function upgrade(env, values) {
|
|
88
|
-
const current =
|
|
67
|
+
const current = currentVersion();
|
|
89
68
|
const latest = await latestVersion(env);
|
|
90
69
|
// A CLI ahead of the registry (a local build, a release still propagating) is never taken backwards, nor is its skill.
|
|
91
70
|
const installing = newer(latest, current);
|
|
@@ -94,11 +73,12 @@ export async function upgrade(env, values) {
|
|
|
94
73
|
const tools = detectTools(env);
|
|
95
74
|
const targets = values["no-skill"] ? [] : skillTargets(tools, env);
|
|
96
75
|
// The plugin too, so --no-skill still brings it up to date.
|
|
97
|
-
const
|
|
76
|
+
const plugin = !values["no-hooks"] && tools.some((t) => t.id === "opencode" && t.found);
|
|
77
|
+
const release = targets.length || plugin ? await releaseFiles(target, !installing, env) : null;
|
|
98
78
|
const skill = release?.skill ?? "";
|
|
99
79
|
let stale = targets.filter((t) => !sameAs(t.path, skill));
|
|
100
80
|
// Computed now, for the plan.
|
|
101
|
-
const hooks = tools.flatMap((tool) => {
|
|
81
|
+
const hooks = (values["no-hooks"] ? [] : tools).flatMap((tool) => {
|
|
102
82
|
const path = hookPath(tool.id, env);
|
|
103
83
|
const text = updatedHooks(tool.id, path, release?.plugin);
|
|
104
84
|
return text === null ? [] : [{ ...tool, path, text }];
|
|
@@ -126,13 +106,15 @@ export async function upgrade(env, values) {
|
|
|
126
106
|
return `${tool.name.padEnd(14)}${where}`;
|
|
127
107
|
}),
|
|
128
108
|
},
|
|
129
|
-
...(hooks
|
|
130
|
-
? [{ label: "
|
|
131
|
-
:
|
|
109
|
+
...(values["no-hooks"]
|
|
110
|
+
? [{ label: "Leave the Berliner hooks as they are (--no-hooks)", detail: [] }]
|
|
111
|
+
: hooks.length
|
|
112
|
+
? [{ label: "Update the Berliner hook for:", detail: hooks.map((h) => `${h.name.padEnd(14)}${tilde(h.path)} (${HOOK_CHANGE[h.id]})`) }]
|
|
113
|
+
: []),
|
|
132
114
|
]);
|
|
133
115
|
if (!values.yes) {
|
|
134
116
|
if (!process.stdin.isTTY)
|
|
135
|
-
throw new CliError("There is no terminal to ask on.
|
|
117
|
+
throw new CliError("There is no terminal to ask on, so nothing was changed. To go ahead without asking, run upgrade again with --yes. If you are an AI agent, show the user this plan and get their approval first.", "usage");
|
|
136
118
|
if (!(await confirm(process.stdin, process.stdout)))
|
|
137
119
|
throw new CliError("Cancelled. Nothing was changed.", "usage");
|
|
138
120
|
// Without an install, the skill is all Continue covered.
|
|
@@ -152,12 +134,15 @@ export async function upgrade(env, values) {
|
|
|
152
134
|
}
|
|
153
135
|
// A file that can't be written (read-only, say) is reported and skipped, so the other tools still get theirs.
|
|
154
136
|
let unwritten = false;
|
|
137
|
+
// The tools that load what was written, for the reload line.
|
|
138
|
+
const reload = { skill: new Set(), hooks: new Set() };
|
|
155
139
|
for (const t of stale) {
|
|
156
140
|
const what = `${tools.find((tool) => tool.id === t.tool).name} skill`;
|
|
157
141
|
try {
|
|
158
142
|
mkdirSync(dirname(t.path), { recursive: true });
|
|
159
143
|
writeFileSync(t.path, skill);
|
|
160
144
|
console.log(`✓ ${what}: ${tilde(t.path)}`);
|
|
145
|
+
reload.skill.add(t.tool);
|
|
161
146
|
}
|
|
162
147
|
catch (err) {
|
|
163
148
|
unwritten = true;
|
|
@@ -175,11 +160,16 @@ export async function upgrade(env, values) {
|
|
|
175
160
|
// In place, so a settings file that is a symlink stays one.
|
|
176
161
|
writeFileSync(h.path, text);
|
|
177
162
|
console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
|
|
163
|
+
reload.hooks.add(h.id);
|
|
178
164
|
}
|
|
179
165
|
catch (err) {
|
|
180
166
|
unwritten = true;
|
|
181
167
|
console.error(`✗ ${h.name} hook: ${tilde(h.path)} can't be written (${err.message.split(",")[0]})`);
|
|
182
168
|
}
|
|
183
169
|
}
|
|
184
|
-
|
|
170
|
+
// A running agent keeps the skill and hooks it started with; the command itself is new from the next call.
|
|
171
|
+
const names = tools.filter((t) => reload.skill.has(t.id) || reload.hooks.has(t.id)).map((t) => t.name);
|
|
172
|
+
const what = [reload.skill.size && "skill", reload.hooks.size && "hooks"].filter(Boolean).join(" and ");
|
|
173
|
+
const again = names.length ? ` Start a new session in ${names.join(", ").replace(/, ([^,]*)$/, " and $1")} to load the updated ${what}.` : "";
|
|
174
|
+
console.log(unwritten ? `\nSome files can't be written. Fix the ones marked ✗ above, then run upgrade again.${again}` : `\nDone.${again}`);
|
|
185
175
|
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { update } from "./commands/update.js";
|
|
|
19
19
|
import { upgrade } from "./commands/upgrade.js";
|
|
20
20
|
import { readConfig } from "./config.js";
|
|
21
21
|
import { warn } from "./output.js";
|
|
22
|
+
import { upgradeNotice } from "./version.js";
|
|
22
23
|
const usage = `Usage: berliner <command> [flags]
|
|
23
24
|
|
|
24
25
|
Commands:
|
|
@@ -43,7 +44,7 @@ Commands:
|
|
|
43
44
|
delete <session-id> [--evidence <id>] [--yes]
|
|
44
45
|
evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
|
|
45
46
|
evidence private <id> make it viewable only by you again [--json]
|
|
46
|
-
upgrade install the latest CLI and
|
|
47
|
+
upgrade install the latest CLI, skill and hooks [--yes --no-skill --no-hooks]
|
|
47
48
|
skill print the bundled SKILL.md
|
|
48
49
|
hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
|
|
49
50
|
|
|
@@ -114,3 +115,9 @@ catch (err) {
|
|
|
114
115
|
console.error(usage);
|
|
115
116
|
process.exitCode = softFail ? 0 : 1;
|
|
116
117
|
}
|
|
118
|
+
// The hook carries it in the session-start reminder instead, and upgrade looks for itself.
|
|
119
|
+
if (args.command !== "hook" && args.command !== "upgrade") {
|
|
120
|
+
const notice = await upgradeNotice(process.env);
|
|
121
|
+
if (notice)
|
|
122
|
+
console.error(notice);
|
|
123
|
+
}
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { readClaudeCode, readCodex } from "./agents/index.js";
|
|
5
|
+
import { stateDir } from "./config.js";
|
|
6
|
+
import { takeLock } from "./lock.js";
|
|
7
|
+
import { estimateCost } from "./prices.js";
|
|
8
|
+
import { claudeDir, codexDir } from "./setup/targets.js";
|
|
9
|
+
// For the lock wait and the backfill together.
|
|
10
|
+
const BUDGET_MS = 3_000;
|
|
11
|
+
const PRUNE_BYTES = 20 * 1024 * 1024;
|
|
12
|
+
const KEEP_MS = 60 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const ROLLOUT = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
|
|
14
|
+
// In stateDir, not cacheDir: a cache is safe to delete, and losing this reprices every reply.
|
|
15
|
+
const paths = (env) => {
|
|
16
|
+
const dir = stateDir(env);
|
|
17
|
+
return { dir, ledger: join(dir, "replies.jsonl"), lock: join(dir, "replies.lock"), progress: join(dir, "replies-backfill.json") };
|
|
18
|
+
};
|
|
19
|
+
const entries = (dir, recursive = false) => readdir(dir, { recursive }).catch(() => []);
|
|
20
|
+
const toText = (lines) => lines.map((l) => `${JSON.stringify(l)}\n`).join("");
|
|
21
|
+
// Each batch starts on a line of its own, so a line cut off by a crash can't swallow the batch's first line; empty lines are skipped.
|
|
22
|
+
const append = (file, lines) => appendFile(file, `\n${toText(lines)}`);
|
|
23
|
+
// The earliest-started session owns a reply; a null start sorts last and a tie goes to the smaller id, so the owner is the
|
|
24
|
+
// same whatever order sessions report in. Starts are UTC ISO strings, which sort as text.
|
|
25
|
+
const before = (a, b) => (a.t === b.t ? a.s < b.s : b.t === null || (a.t !== null && a.t < b.t));
|
|
26
|
+
async function withLock(env, deadline, run) {
|
|
27
|
+
const { dir, lock } = paths(env);
|
|
28
|
+
await mkdir(dir, { recursive: true });
|
|
29
|
+
while (!takeLock(lock)) {
|
|
30
|
+
if (Date.now() >= deadline)
|
|
31
|
+
return null;
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
33
|
+
}
|
|
34
|
+
// The hook child's deadline ends it with process.exit, which skips the finally below; only a lock this process wrote is removed.
|
|
35
|
+
const onExit = () => {
|
|
36
|
+
try {
|
|
37
|
+
if (readFileSync(lock, "utf8") === String(process.pid))
|
|
38
|
+
rmSync(lock, { force: true });
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
};
|
|
42
|
+
process.on("exit", onExit);
|
|
43
|
+
try {
|
|
44
|
+
return await run();
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
process.off("exit", onExit);
|
|
48
|
+
rmSync(lock, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// A missing ledger is an empty one; any other read error is thrown, since treating it as empty would reprice every reply.
|
|
52
|
+
async function readText(file) {
|
|
53
|
+
return readFile(file, "utf8").catch((err) => {
|
|
54
|
+
if (err.code === "ENOENT")
|
|
55
|
+
return "";
|
|
56
|
+
throw err;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// A line cut off by a crash, or otherwise unparseable, is skipped.
|
|
60
|
+
function parse(text) {
|
|
61
|
+
const lines = [];
|
|
62
|
+
for (const raw of text.split("\n")) {
|
|
63
|
+
try {
|
|
64
|
+
const l = JSON.parse(raw);
|
|
65
|
+
if (typeof l?.r !== "string" || typeof l.s !== "string")
|
|
66
|
+
continue;
|
|
67
|
+
lines.push({ r: l.r, s: l.s, t: typeof l.t === "string" ? l.t : null, c: typeof l.c === "number" ? l.c : null });
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
function priceOf(reply, prices) {
|
|
74
|
+
if (!prices)
|
|
75
|
+
return null;
|
|
76
|
+
const { costUsd, unpriced } = estimateCost({ [reply.model]: reply.tokens }, prices);
|
|
77
|
+
return unpriced.length ? null : Math.round(costUsd * 1e6);
|
|
78
|
+
}
|
|
79
|
+
// A session's place in the owner order is when its record file was made: a fork's copied entries keep their parent's timestamps,
|
|
80
|
+
// its file does not. startedAt stands in where the file system keeps no creation time.
|
|
81
|
+
export async function sessionTime(file, startedAt) {
|
|
82
|
+
const birth = (await stat(file).catch(() => null))?.birthtimeMs ?? 0;
|
|
83
|
+
return birth > 0 ? new Date(birth).toISOString() : startedAt;
|
|
84
|
+
}
|
|
85
|
+
// Every local Claude Code and Codex transcript, with the session id its file name gives.
|
|
86
|
+
async function transcripts(env) {
|
|
87
|
+
const found = [];
|
|
88
|
+
const projects = join(claudeDir(env), "projects");
|
|
89
|
+
for (const project of await entries(projects)) {
|
|
90
|
+
for (const name of await entries(join(projects, project))) {
|
|
91
|
+
if (name.endsWith(".jsonl"))
|
|
92
|
+
found.push({ file: join(projects, project, name), id: name.slice(0, -".jsonl".length), read: readClaudeCode });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const sessions = join(codexDir(env), "sessions");
|
|
96
|
+
for (const f of await entries(sessions, true)) {
|
|
97
|
+
const id = ROLLOUT.exec(basename(f))?.[1];
|
|
98
|
+
if (id)
|
|
99
|
+
found.push({ file: join(sessions, f), id, read: readCodex });
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
// Run under the lock. Claims are written unpriced, so a reply's price is still set by the first report that owns it.
|
|
104
|
+
async function claimTranscripts(env, deadline) {
|
|
105
|
+
const { ledger, progress } = paths(env);
|
|
106
|
+
let saved = {};
|
|
107
|
+
try {
|
|
108
|
+
saved = JSON.parse(await readFile(progress, "utf8")) ?? {};
|
|
109
|
+
}
|
|
110
|
+
catch { }
|
|
111
|
+
if (saved.complete === true)
|
|
112
|
+
return true;
|
|
113
|
+
const done = new Set(Array.isArray(saved.done) ? saved.done.filter((f) => typeof f === "string") : []);
|
|
114
|
+
const lines = [];
|
|
115
|
+
let complete = true;
|
|
116
|
+
for (const { file, id, read } of await transcripts(env)) {
|
|
117
|
+
if (done.has(file))
|
|
118
|
+
continue;
|
|
119
|
+
if (Date.now() >= deadline) {
|
|
120
|
+
complete = false;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
// A file removed or unreadable since the listing has nothing left to claim.
|
|
124
|
+
const facts = await read(file).catch(() => null);
|
|
125
|
+
const t = facts && (await sessionTime(file, facts.startedAt));
|
|
126
|
+
for (const r of facts?.replies ?? [])
|
|
127
|
+
lines.push({ r: r.id, s: id, t, c: null });
|
|
128
|
+
done.add(file);
|
|
129
|
+
}
|
|
130
|
+
if (lines.length)
|
|
131
|
+
await append(ledger, lines);
|
|
132
|
+
await writeFile(progress, JSON.stringify({ done: [...done], complete }));
|
|
133
|
+
return complete;
|
|
134
|
+
}
|
|
135
|
+
// Returns true once every local transcript has been claimed, false when the deadline or the lock stops it first.
|
|
136
|
+
export async function backfill(env, deadline) {
|
|
137
|
+
return (await withLock(env, deadline, () => claimTranscripts(env, deadline))) ?? false;
|
|
138
|
+
}
|
|
139
|
+
// The cost of the replies this session owns. Null when the lock and the backfill don't both finish within 3 seconds.
|
|
140
|
+
export async function priceSession(env, session, replies, prices) {
|
|
141
|
+
const deadline = Date.now() + BUDGET_MS;
|
|
142
|
+
return withLock(env, deadline, async () => {
|
|
143
|
+
// Until every transcript is claimed, a fork reported first would own the replies it copied from a parent not yet claimed.
|
|
144
|
+
if (!(await claimTranscripts(env, deadline)))
|
|
145
|
+
return null;
|
|
146
|
+
const { ledger } = paths(env);
|
|
147
|
+
// shortcut: the whole ledger is read on each report, not only the new replies; upgrade to an index if it slows the hook.
|
|
148
|
+
const text = await readText(ledger);
|
|
149
|
+
let lines = parse(text);
|
|
150
|
+
// shortcut: past 20 MB, sessions created over 60 days ago are dropped, as Claude Code deletes transcripts after 30; one still
|
|
151
|
+
// resumed after that has its replies priced again. Upgrade to a last-seen time per session if that happens in practice.
|
|
152
|
+
if (Buffer.byteLength(text) > PRUNE_BYTES) {
|
|
153
|
+
const cutoff = new Date(Date.now() - KEEP_MS).toISOString();
|
|
154
|
+
const kept = lines.filter((l) => l.t === null || l.t >= cutoff);
|
|
155
|
+
// Nothing old to drop: a rewrite would cost every report a 20 MB write for nothing.
|
|
156
|
+
if (kept.length < lines.length) {
|
|
157
|
+
lines = kept;
|
|
158
|
+
await writeFile(`${ledger}.${process.pid}`, toText(lines));
|
|
159
|
+
await rename(`${ledger}.${process.pid}`, ledger);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const wanted = new Map(replies.map((r) => [r.id, r]));
|
|
163
|
+
const known = new Map();
|
|
164
|
+
for (const l of lines) {
|
|
165
|
+
if (!wanted.has(l.r))
|
|
166
|
+
continue;
|
|
167
|
+
const k = known.get(l.r);
|
|
168
|
+
if (!k)
|
|
169
|
+
known.set(l.r, { owner: l, price: l.c, mine: l.s === session.id });
|
|
170
|
+
else {
|
|
171
|
+
if (before(l, k.owner))
|
|
172
|
+
k.owner = l;
|
|
173
|
+
k.price ??= l.c;
|
|
174
|
+
k.mine ||= l.s === session.id;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const add = [];
|
|
178
|
+
let micros = 0;
|
|
179
|
+
let owned = 0;
|
|
180
|
+
let priced = 0;
|
|
181
|
+
for (const reply of wanted.values()) {
|
|
182
|
+
const k = known.get(reply.id);
|
|
183
|
+
const claim = { r: reply.id, s: session.id, t: session.startedAt, c: null };
|
|
184
|
+
const owner = k && !before(claim, k.owner) ? k.owner : claim;
|
|
185
|
+
let price = k?.price ?? null;
|
|
186
|
+
if (owner.s === session.id && price === null)
|
|
187
|
+
price = claim.c = priceOf(reply, prices);
|
|
188
|
+
if (!k?.mine || claim.c !== null)
|
|
189
|
+
add.push(claim);
|
|
190
|
+
if (owner.s !== session.id)
|
|
191
|
+
continue;
|
|
192
|
+
owned++;
|
|
193
|
+
if (price !== null) {
|
|
194
|
+
priced++;
|
|
195
|
+
micros += price;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (add.length)
|
|
199
|
+
await append(ledger, add);
|
|
200
|
+
return { costUsd: micros / 1e6, status: owned && priced === owned ? "exact" : priced ? "partial" : "unknown" };
|
|
201
|
+
});
|
|
202
|
+
}
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { linkSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
const LOCK_MS = 60_000;
|
|
3
|
+
// 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
|
|
4
|
+
// removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
|
|
5
|
+
export function takeLock(lock) {
|
|
6
|
+
if (createLock(lock))
|
|
7
|
+
return true;
|
|
8
|
+
const seen = statSync(lock, { throwIfNoEntry: false });
|
|
9
|
+
// Gone since: its child just unlocked, so it is free, not stale.
|
|
10
|
+
if (!seen)
|
|
11
|
+
return createLock(lock);
|
|
12
|
+
if (Date.now() - seen.mtimeMs < LOCK_MS)
|
|
13
|
+
return false;
|
|
14
|
+
const aside = `${lock}.${process.pid}`;
|
|
15
|
+
try {
|
|
16
|
+
renameSync(lock, aside);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err.code === "ENOENT")
|
|
20
|
+
return false;
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
if (statSync(aside).ino !== seen.ino) {
|
|
24
|
+
// shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
|
|
25
|
+
try {
|
|
26
|
+
linkSync(aside, lock);
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
rmSync(aside, { force: true });
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
rmSync(aside, { force: true });
|
|
33
|
+
return createLock(lock);
|
|
34
|
+
}
|
|
35
|
+
function createLock(lock) {
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(lock, String(process.pid), { flag: "wx" });
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err.code === "EEXIST")
|
|
42
|
+
return false;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/output.js
CHANGED
|
@@ -30,3 +30,8 @@ const PLAN = {
|
|
|
30
30
|
api: "API",
|
|
31
31
|
};
|
|
32
32
|
export const subscriptionLabel = (s) => `${VENDOR[s.vendor] ?? s.vendor} ${PLAN[s.plan] ?? s.plan}`;
|
|
33
|
+
// A session's cost in its three forms: every model priced, some priced (a floor), none priced.
|
|
34
|
+
export const costLabel = (usd, status) => status === "unknown" ? "Unknown" : `${status === "partial" ? "≥ " : ""}$${usd.toFixed(2)}`;
|
|
35
|
+
// A total of sessions, a floor whenever some of them are not fully priced. The count is for the line that names it.
|
|
36
|
+
export const totalLabel = (usd, unpriced) => `${unpriced > 0 ? "≥ " : ""}$${usd.toFixed(2)} est.`;
|
|
37
|
+
export const unpricedNote = (unpriced) => (unpriced > 0 ? ` · ${unpriced} ${unpriced === 1 ? "session" : "sessions"} not fully priced` : "");
|
package/dist/upload.js
CHANGED
package/dist/version.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CliError } from "./client.js";
|
|
4
|
+
import { stateDir } from "./config.js";
|
|
5
|
+
export const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
6
|
+
export const currentVersion = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
7
|
+
export async function latestVersion(env, timeoutMs) {
|
|
8
|
+
const registry = registryOf(env);
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`${registry}/@paxnax/berliner/latest`, { signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined });
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
throw new Error(`HTTP ${res.status}`);
|
|
13
|
+
const { version } = (await res.json());
|
|
14
|
+
if (typeof version !== "string")
|
|
15
|
+
throw new Error("no version in the reply");
|
|
16
|
+
return version;
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
throw new CliError(`Could not look up the latest berliner on ${registry} (${err.message}). Nothing was changed.`, "network");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
|
|
23
|
+
export const newer = (a, b) => {
|
|
24
|
+
const [x, y] = [a, b].map((v) => v.split("-")[0].split(".").map(Number));
|
|
25
|
+
const i = x.findIndex((n, j) => n !== y[j]);
|
|
26
|
+
return i >= 0 && x[i] > y[i];
|
|
27
|
+
};
|
|
28
|
+
const DAY_MS = 24 * 3600_000;
|
|
29
|
+
// Short, as it runs inline: the first command or session start of the day waits for it.
|
|
30
|
+
const CHECK_TIMEOUT_MS = 1500;
|
|
31
|
+
// Once a day per machine: the check file's mtime is when the last check started. A failed check counts too, so an
|
|
32
|
+
// offline machine doesn't wait on every run. Never throws; null unless the registry has a newer release.
|
|
33
|
+
export async function upgradeNotice(env) {
|
|
34
|
+
const stamp = join(stateDir(env), "version-check");
|
|
35
|
+
try {
|
|
36
|
+
if (Date.now() - statSync(stamp).mtimeMs < DAY_MS)
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Never checked on this machine.
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
// Before the lookup, so runs started meanwhile don't check again.
|
|
44
|
+
mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileSync(stamp, "");
|
|
46
|
+
const latest = await latestVersion(env, CHECK_TIMEOUT_MS);
|
|
47
|
+
if (!newer(latest, currentVersion()))
|
|
48
|
+
return null;
|
|
49
|
+
return `New berliner CLI version ${latest} is out, run berliner upgrade to update it. If you are an AI agent, bring this to the user for approval instead of taking action yourself.`;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/opencode/berliner.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// - client.session.get({ path: { id } }) returns { data } with the server's Session, which holds cost, tokens and model,
|
|
11
11
|
// so the messages are not needed;
|
|
12
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
|
|
13
|
+
// client.session.children({ path: { id } }) returns { data } with a session's direct children;
|
|
14
|
+
// - client.session.messages({ path: { id } }) returns { data }, an array of { info, parts } (sdk/js/src/gen/types.gen.ts at
|
|
15
|
+
// v1.18.30: SessionMessagesData; an assistant info has role, time.created, cost and tokens). A fork's session row counts
|
|
16
|
+
// its parent's usage too, and its copied messages keep the parent's time.created, so `own` counts only newer ones.
|
|
14
17
|
import { spawn } from "node:child_process";
|
|
15
18
|
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
16
19
|
import { homedir } from "node:os";
|
|
@@ -37,13 +40,50 @@ async function topLevelId(client, id) {
|
|
|
37
40
|
return session && !session.parentID ? session.id : null;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
const sum = (a, b) => ({
|
|
44
|
+
tokens: {
|
|
45
|
+
input: a.tokens.input + b.tokens.input,
|
|
46
|
+
output: a.tokens.output + b.tokens.output,
|
|
47
|
+
reasoning: a.tokens.reasoning + b.tokens.reasoning,
|
|
48
|
+
cache: { read: a.tokens.cache.read + b.tokens.cache.read, write: a.tokens.cache.write + b.tokens.cache.write },
|
|
49
|
+
},
|
|
50
|
+
cost: a.cost + b.cost,
|
|
51
|
+
});
|
|
52
|
+
const zero = () => ({ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 });
|
|
53
|
+
|
|
54
|
+
// What the session itself used: its assistant messages created at or after the session was. null when they can't be listed.
|
|
55
|
+
async function ownUsage(client, session) {
|
|
56
|
+
try {
|
|
57
|
+
const messages = (await within(client.session.messages({ path: { id: session.id } }), 5000))?.data;
|
|
58
|
+
if (!Array.isArray(messages)) return null;
|
|
59
|
+
let own = zero();
|
|
60
|
+
for (const { info: m } of messages) {
|
|
61
|
+
if (m?.role !== "assistant" || num(m.time?.created) < num(session.time?.created)) continue;
|
|
62
|
+
const t = m.tokens;
|
|
63
|
+
own = sum(own, {
|
|
64
|
+
tokens: {
|
|
65
|
+
input: num(t?.input),
|
|
66
|
+
output: num(t?.output),
|
|
67
|
+
reasoning: num(t?.reasoning),
|
|
68
|
+
cache: { read: num(t?.cache?.read), write: num(t?.cache?.write) },
|
|
69
|
+
},
|
|
70
|
+
cost: num(m.cost),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return own;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
// The session with its subagents' cost and tokens added in, in the shape the CLI reads. OpenCode 1.18.30 keeps them apart:
|
|
41
80
|
// a session's cost and tokens grow only from its own step-finish parts (packages/core/src/session/projector.ts:310-326,
|
|
42
81
|
// applyUsage on the part's own sessionID, lines 89-109), and a live run with one task-tool subagent gave the parent 0.215
|
|
43
82
|
// and the child 1.05.
|
|
44
83
|
async function withChildren(client, session, depth = 0) {
|
|
45
84
|
const children = depth < MAX_DEPTH ? (await client.session.children({ path: { id: session.id } }))?.data : null;
|
|
46
|
-
|
|
85
|
+
const mine = await ownUsage(client, session);
|
|
86
|
+
if (!Array.isArray(children) || children.length === 0) return mine ? { ...session, own: mine } : session;
|
|
47
87
|
const t = session.tokens;
|
|
48
88
|
let cost = num(session.cost);
|
|
49
89
|
const tokens = {
|
|
@@ -52,7 +92,9 @@ async function withChildren(client, session, depth = 0) {
|
|
|
52
92
|
reasoning: num(t?.reasoning),
|
|
53
93
|
cache: { read: num(t?.cache?.read), write: num(t?.cache?.write) },
|
|
54
94
|
};
|
|
95
|
+
let own = mine;
|
|
55
96
|
for (const c of await Promise.all(children.map((child) => withChildren(client, child, depth + 1)))) {
|
|
97
|
+
own = own && c.own ? sum(own, c.own) : null;
|
|
56
98
|
cost += num(c.cost);
|
|
57
99
|
tokens.input += num(c.tokens?.input);
|
|
58
100
|
tokens.output += num(c.tokens?.output);
|
|
@@ -60,7 +102,7 @@ async function withChildren(client, session, depth = 0) {
|
|
|
60
102
|
tokens.cache.read += num(c.tokens?.cache?.read);
|
|
61
103
|
tokens.cache.write += num(c.tokens?.cache?.write);
|
|
62
104
|
}
|
|
63
|
-
return { ...session, cost, tokens };
|
|
105
|
+
return { ...session, cost, tokens, ...(own && { own }) };
|
|
64
106
|
}
|
|
65
107
|
|
|
66
108
|
// Each save's place in the order saves started, and per session the latest one written: a save that finishes after a later
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -146,7 +146,7 @@ Report again with the same `--session-id` whenever something changes.
|
|
|
146
146
|
- `--commits <n>` (outside the tools; the hooks count them): `git rev-list --count <base>..HEAD`
|
|
147
147
|
- `--coverage <percent>`: from the test run's coverage summary.
|
|
148
148
|
- `--pr <url>[,merge=<open|merged|closed>][,ci=<pending|running|passed|failed|none>]`: repeat per PR. URL from `gh pr view --json url`.
|
|
149
|
-
- `--evidence <path>[::caption]`: repeat per screenshot or video.
|
|
149
|
+
- `--evidence <path>[::caption[::happy|unhappy|edge]]`: repeat per screenshot or video; the optional last part says whether it shows the happy path, an unhappy path or an edge case. A caption can't contain `::`.
|
|
150
150
|
- `--public`: with `--evidence`, also make those files viewable without sign-in. See below.
|
|
151
151
|
- `--task <id>`, `--task-url <url>`: the ticket. See [Tickets](#tickets).
|
|
152
152
|
- `--project <name>`: only the ticket's tracker project or a project the user named. See [Projects](#projects).
|
|
@@ -171,6 +171,23 @@ After the evidence line, it prints one line per file: `![Login page after the fi
|
|
|
171
171
|
|
|
172
172
|
If the `berliner` command is not found, carry on with your work and say in your final message that `npx @paxnax/berliner@latest setup` needs to run. Do not try to install it yourself.
|
|
173
173
|
|
|
174
|
+
## When a newer berliner is out
|
|
175
|
+
|
|
176
|
+
Once a day the CLI checks for a newer release. When there is one, the session-start reminder or a `berliner` command's output says: `New berliner CLI version X.Y.Z is out, run berliner upgrade to update it. If you are an AI agent, bring this to the user for approval instead of taking action yourself.` Never upgrade on your own:
|
|
177
|
+
|
|
178
|
+
1. Run `berliner upgrade` with no flags. Without a terminal it prints its plan and stops, changing nothing.
|
|
179
|
+
2. Show the user the plan, then ask them to approve each part separately: the berliner command, the skill and the hooks.
|
|
180
|
+
3. If they decline the command, run nothing. Otherwise run the command that matches their answers. It never stops to ask:
|
|
181
|
+
|
|
182
|
+
| Skill | Hooks | Command |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| yes | yes | `berliner upgrade --yes` |
|
|
185
|
+
| no | yes | `berliner upgrade --yes --no-skill` |
|
|
186
|
+
| yes | no | `berliner upgrade --yes --no-hooks` |
|
|
187
|
+
| no | no | `berliner upgrade --yes --no-skill --no-hooks` |
|
|
188
|
+
|
|
189
|
+
4. Pass on the upgrade's last line. When it names tools to start a new session in, tell the user the updated skill or hooks load only in a new session there.
|
|
190
|
+
|
|
174
191
|
## Timeouts
|
|
175
192
|
|
|
176
193
|
The session is saved first, then evidence is uploaded. Allow at least 10 minutes for the command when attaching videos.
|