@paxnax/berliner 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/codex.js +17 -9
- package/dist/client.js +2 -1
- package/dist/commands/delete.js +7 -5
- package/dist/commands/hook.js +41 -15
- package/dist/commands/report.js +2 -3
- package/dist/commands/setup.js +2 -1
- package/dist/commands/upgrade.js +43 -13
- package/dist/output.js +5 -3
- package/opencode/berliner.js +38 -16
- package/package.json +1 -1
package/dist/agents/codex.js
CHANGED
|
@@ -9,8 +9,8 @@ export async function readCodex(rolloutPath) {
|
|
|
9
9
|
let taskUrl = null;
|
|
10
10
|
let latest = zero;
|
|
11
11
|
let latestTotal = 0;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
let latestLast = 0;
|
|
13
|
+
const byModel = {};
|
|
14
14
|
for await (const e of jsonl(rolloutPath)) {
|
|
15
15
|
span.add(e.timestamp);
|
|
16
16
|
const p = e.payload;
|
|
@@ -29,18 +29,26 @@ export async function readCodex(rolloutPath) {
|
|
|
29
29
|
if (!u)
|
|
30
30
|
continue;
|
|
31
31
|
const total = num(u.total_tokens);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
const last = num(p?.info?.last_token_usage?.total_tokens);
|
|
33
|
+
// A resumed session restarts the running total in the same rollout: it drops, or its first count is just its own last request.
|
|
34
|
+
// A repeat of the previous event, last request included, is a duplicate notification.
|
|
35
|
+
// shortcut: a restart after a one-request run of the same size reads as a duplicate; use a run marker if Codex adds one.
|
|
36
|
+
const dup = total === latestTotal && last === latestLast;
|
|
37
|
+
const restart = total < latestTotal || (latestTotal > 0 && total === last && !dup);
|
|
38
|
+
const cur = {
|
|
37
39
|
input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
|
|
38
40
|
output: num(u.output_tokens),
|
|
39
41
|
cacheRead: num(u.cached_input_tokens),
|
|
40
42
|
cacheWrite: num(u.cache_write_input_tokens),
|
|
41
43
|
cacheWrite1h: 0,
|
|
42
44
|
};
|
|
45
|
+
// Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
|
|
46
|
+
const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
|
|
47
|
+
if (model)
|
|
48
|
+
byModel[model] = addTokens(byModel[model] ?? zero, delta);
|
|
49
|
+
latestTotal = total;
|
|
50
|
+
latestLast = last;
|
|
51
|
+
latest = cur;
|
|
43
52
|
}
|
|
44
|
-
|
|
45
|
-
return { model, tokensByModel: model ? { [model]: tokens } : {}, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
53
|
+
return { model, tokensByModel: byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
|
|
46
54
|
}
|
package/dist/client.js
CHANGED
|
@@ -11,7 +11,7 @@ export class CliError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
export const absolute = (cfg, path) => cfg.url.replace(/\/+$/, "") + path;
|
|
13
13
|
export async function request(cfg, method, path, body, opts = {}) {
|
|
14
|
-
const { timeoutMs = 10_000, retries = 3, deadlineMs = 15_000, auth = true } = opts;
|
|
14
|
+
const { timeoutMs = 10_000, retries = 3, deadlineMs = 15_000, auth = true, headers = {} } = opts;
|
|
15
15
|
if (auth && !cfg.token)
|
|
16
16
|
throw new CliError("No API token: run `npx @paxnax/berliner@latest setup`, or set BERLINER_TOKEN.", "usage");
|
|
17
17
|
// One budget for all attempts, so a hung server can't hold the agent's pipeline for long (anti-goal 1).
|
|
@@ -28,6 +28,7 @@ export async function request(cfg, method, path, body, opts = {}) {
|
|
|
28
28
|
headers: {
|
|
29
29
|
...(auth ? { Authorization: `Bearer ${cfg.token}` } : {}),
|
|
30
30
|
...(body === undefined ? {} : { "Content-Type": raw ? "application/octet-stream" : "application/json" }),
|
|
31
|
+
...headers,
|
|
31
32
|
},
|
|
32
33
|
// A Uint8Array is sent as-is (evidence bytes); fetch sets its Content-Length.
|
|
33
34
|
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
|
package/dist/commands/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";
|
|
@@ -28,15 +28,16 @@ export function inTool(env) {
|
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
// The session's facts from the tool's own record, with a cost estimated wherever the tool stores none; the cost stays null when
|
|
31
|
-
// no prices load. Claude Code and Codex name the record in the hook payload
|
|
32
|
-
//
|
|
31
|
+
// no prices load. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
|
|
32
|
+
// the session itself too, used only without a saved record. Null without a record.
|
|
33
33
|
export async function sessionFacts(tool, sessionId, env, payload = {}) {
|
|
34
34
|
let facts;
|
|
35
|
-
|
|
35
|
+
const named = typeof payload.transcript_path === "string" && payload.transcript_path ? payload.transcript_path : null;
|
|
36
|
+
const file = named ?? (await findRecord(tool, sessionId, env));
|
|
37
|
+
// The plugin saves the record, in order, before it spawns each reporter, so a reporter that runs late still finds the newest.
|
|
38
|
+
if (tool === "opencode" && !file && payload.session)
|
|
36
39
|
facts = readOpenCode(payload.session);
|
|
37
40
|
else {
|
|
38
|
-
const named = typeof payload.transcript_path === "string" && payload.transcript_path ? payload.transcript_path : null;
|
|
39
|
-
const file = named ?? (await findRecord(tool, sessionId, env));
|
|
40
41
|
if (!file)
|
|
41
42
|
return null;
|
|
42
43
|
if (tool === "claude-code")
|
|
@@ -152,15 +153,36 @@ async function child(tool, file, env) {
|
|
|
152
153
|
clearTimeout(timer);
|
|
153
154
|
}
|
|
154
155
|
}
|
|
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) {
|
|
156
|
+
// 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
|
|
157
|
+
// removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
|
|
158
|
+
export function takeLock(lock) {
|
|
158
159
|
if (createLock(lock))
|
|
159
160
|
return true;
|
|
160
|
-
|
|
161
|
+
const seen = statSync(lock, { throwIfNoEntry: false });
|
|
162
|
+
// Gone since: its child just unlocked, so it is free, not stale.
|
|
163
|
+
if (!seen)
|
|
164
|
+
return createLock(lock);
|
|
165
|
+
if (Date.now() - seen.mtimeMs < LOCK_MS)
|
|
161
166
|
return false;
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
const aside = `${lock}.${process.pid}`;
|
|
168
|
+
try {
|
|
169
|
+
renameSync(lock, aside);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (err.code === "ENOENT")
|
|
173
|
+
return false;
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
if (statSync(aside).ino !== seen.ino) {
|
|
177
|
+
// shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
|
|
178
|
+
try {
|
|
179
|
+
linkSync(aside, lock);
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
rmSync(aside, { force: true });
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
rmSync(aside, { force: true });
|
|
164
186
|
return createLock(lock);
|
|
165
187
|
}
|
|
166
188
|
function createLock(lock) {
|
|
@@ -209,10 +231,14 @@ async function send(tool, file, env) {
|
|
|
209
231
|
catch (err) {
|
|
210
232
|
if (!(err instanceof CliError && err.status === 404))
|
|
211
233
|
throw err;
|
|
212
|
-
// shortcut: an agent report landing between the 404 and this PUT gets the derived title; a create-only call would close that.
|
|
213
234
|
// The plan is BERLINER_PLAN from the tool's environment, else the server's pick from the user's subscriptions. A new
|
|
214
|
-
// session needs a cost: 0 until prices load.
|
|
215
|
-
|
|
235
|
+
// session needs a cost: 0 until prices load. Create-only: when an agent report created the session since the 404 (412),
|
|
236
|
+
// only the machine's fields are sent. A server that ignores the header overwrites the title, as before.
|
|
237
|
+
const create = (plan) => request(cfg, "PUT", path, { ...body, cost_usd: body.cost_usd ?? 0, title: derivedTitle(facts.firstPrompt, git), plan }, { headers: { "If-None-Match": "*" } }).catch((err) => {
|
|
238
|
+
if (err instanceof CliError && err.status === 412)
|
|
239
|
+
return request(cfg, "PATCH", path, body);
|
|
240
|
+
throw err;
|
|
241
|
+
});
|
|
216
242
|
try {
|
|
217
243
|
saved = await create(cfg.plan ?? undefined);
|
|
218
244
|
}
|
package/dist/commands/report.js
CHANGED
|
@@ -43,9 +43,8 @@ export async function report(cfg, values, positionals, env) {
|
|
|
43
43
|
if (json_)
|
|
44
44
|
print({ data: { ...json, evidence: result }, text }, true);
|
|
45
45
|
else {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.log(p.markdown);
|
|
46
|
+
const summary = `Evidence: ${result.uploaded} uploaded, ${result.existing} already uploaded, ${result.skipped.length} skipped.`;
|
|
47
|
+
print({ data: result, text: [summary, ...(result.published ?? []).map((p) => p.markdown)].join("\n") }, false);
|
|
49
48
|
}
|
|
50
49
|
for (const s of result.skipped)
|
|
51
50
|
warn(`${s.path}: ${s.reason}`);
|
package/dist/commands/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,9 +84,9 @@ 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
|
-
// Computed now,
|
|
89
|
+
// Computed now, for the plan.
|
|
67
90
|
const hooks = tools.flatMap((tool) => {
|
|
68
91
|
const path = hookPath(tool.id, env);
|
|
69
92
|
const text = updatedHooks(tool.id, path);
|
|
@@ -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");
|
|
@@ -129,9 +153,15 @@ export async function upgrade(env, values) {
|
|
|
129
153
|
}
|
|
130
154
|
}
|
|
131
155
|
for (const h of hooks) {
|
|
156
|
+
// Merged again into the file as it is now, so an edit made during the prompt or the install is kept. The plugin is the one
|
|
157
|
+
// the plan showed: the install may have replaced this package's copy.
|
|
158
|
+
// shortcut: an edit between this read and write is still lost; lock if that shows up.
|
|
159
|
+
const text = h.id === "opencode" ? h.text : updatedHooks(h.id, h.path);
|
|
160
|
+
if (text === null)
|
|
161
|
+
continue;
|
|
132
162
|
try {
|
|
133
163
|
// In place, so a settings file that is a symlink stays one.
|
|
134
|
-
writeFileSync(h.path,
|
|
164
|
+
writeFileSync(h.path, text);
|
|
135
165
|
console.log(`✓ ${h.name} hook: ${tilde(h.path)}`);
|
|
136
166
|
}
|
|
137
167
|
catch (err) {
|
package/dist/output.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
// Stored text must not drive the terminal (OSC 52, cursor moves); one char for one keeps table widths.
|
|
2
|
+
const safe = (s) => s.replace(/(?![\t\n])\p{Cc}/gu, "\ufffd");
|
|
1
3
|
export function print(result, json) {
|
|
2
|
-
console.log(json ? JSON.stringify(result.data, null, 2) : result.text);
|
|
4
|
+
console.log(json ? JSON.stringify(result.data, null, 2) : safe(result.text));
|
|
3
5
|
}
|
|
4
6
|
// Always a single line, so agents and CI logs see exactly one warning.
|
|
5
7
|
export function warn(msg) {
|
|
6
|
-
console.error(`berliner: ${msg.replace(/\s*\n\s*/g, " ")}`);
|
|
8
|
+
console.error(`berliner: ${safe(msg.replace(/\s*\n\s*/g, " "))}`);
|
|
7
9
|
}
|
|
8
10
|
// GitHub renders an image from Markdown but not a video from another host, so a video is its bare URL.
|
|
9
|
-
export const embed = (e, url) => e.kind === "video" ? url : `![${(e.caption || e.filename).replace(/[[\]]/g, "\\$&")}](${url})`;
|
|
11
|
+
export const embed = (e, url) => e.kind === "video" ? url : `![${(e.caption || e.filename).replace(/\s*\n\s*/g, " ").replace(/[\\[\]]/g, "\\$&")}](${url})`;
|
|
10
12
|
// Same wording as the web's mergeLabel (app/src/web/format.ts).
|
|
11
13
|
const MERGE = { open: "Open", merged: "Merged", closed: "Closed", none: "No PRs" };
|
|
12
14
|
export const mergeLabel = (s) => s.pr_count > 1 ? `${s.merged_count} of ${s.pr_count} merged` : (MERGE[s.merge] ?? s.merge);
|
package/opencode/berliner.js
CHANGED
|
@@ -26,6 +26,9 @@ const MAX_DEPTH = 10;
|
|
|
26
26
|
|
|
27
27
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
28
28
|
const get = async (client, id) => (await client.session.get({ path: { id } }))?.data;
|
|
29
|
+
// Resolves with p, or with undefined after ms: an SDK call that never answers must not hold up OpenCode. Unreferenced, so a
|
|
30
|
+
// pending deadline never keeps a process alive.
|
|
31
|
+
const within = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms).unref?.())]);
|
|
29
32
|
|
|
30
33
|
// One agent session is one Berliner session: a subagent's child session belongs to the top-level session above it.
|
|
31
34
|
async function topLevelId(client, id) {
|
|
@@ -77,20 +80,35 @@ async function save(client, sessionID) {
|
|
|
77
80
|
return session;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
// One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one. A save that
|
|
84
|
+
// hangs holds the next one up for 10 seconds at most.
|
|
85
|
+
// shortcut: only within this OpenCode process; two `berliner hook` processes can still write out of order, lock there if that shows up.
|
|
86
|
+
// shortcut: a save that resumes after its deadline can write older totals; the next save rewrites them.
|
|
87
|
+
const queues = new Map();
|
|
88
|
+
const serial = (id, fn) => {
|
|
89
|
+
const p = (queues.get(id) ?? Promise.resolve()).then(fn, fn);
|
|
90
|
+
queues.set(
|
|
91
|
+
id,
|
|
92
|
+
within(p, 10_000).catch(() => {}),
|
|
93
|
+
);
|
|
94
|
+
return p;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const report = (client, event, sessionID) =>
|
|
98
|
+
serial(sessionID, async () => {
|
|
99
|
+
try {
|
|
100
|
+
const session = await save(client, sessionID);
|
|
101
|
+
if (!session) return;
|
|
102
|
+
// Detached and unreferenced: OpenCode never waits on it.
|
|
103
|
+
const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
|
|
104
|
+
child.on("error", () => {});
|
|
105
|
+
child.stdin.on("error", () => {});
|
|
106
|
+
child.stdin.end(JSON.stringify({ event, session }));
|
|
107
|
+
child.unref();
|
|
108
|
+
} catch {
|
|
109
|
+
// A report that can't be sent is skipped; the next idle sends the latest totals.
|
|
110
|
+
}
|
|
111
|
+
});
|
|
94
112
|
|
|
95
113
|
export const BerlinerPlugin = async ({ client }) => ({
|
|
96
114
|
"shell.env": async (input, output) => {
|
|
@@ -98,11 +116,15 @@ export const BerlinerPlugin = async ({ client }) => ({
|
|
|
98
116
|
if (!input?.sessionID) return;
|
|
99
117
|
output.env.OPENCODE_SESSION_ID = input.sessionID;
|
|
100
118
|
// A subagent's shell gets the top-level session, so a `berliner report` the agent runs there doesn't split it off.
|
|
101
|
-
|
|
119
|
+
// A second each at most: past it the shell keeps the id it was given, or runs before the save is done.
|
|
120
|
+
const top = await within(topLevelId(client, input.sessionID), 1000);
|
|
102
121
|
if (!top) return;
|
|
103
122
|
output.env.OPENCODE_SESSION_ID = top;
|
|
104
123
|
// So that report finds the totals before the session's first idle. Not reported from here: that waits for idle.
|
|
105
|
-
await
|
|
124
|
+
await within(
|
|
125
|
+
serial(top, () => save(client, top)),
|
|
126
|
+
1000,
|
|
127
|
+
);
|
|
106
128
|
} catch {
|
|
107
129
|
// Never fail the agent's shell.
|
|
108
130
|
}
|