balladeer 1.0.5 → 1.0.7

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.
@@ -0,0 +1,293 @@
1
+ import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { configHome } from "./store.js";
6
+ export const GUIDANCE_UNAVAILABLE = "Balladeer could not verify the current workspace guidance and capture mode. Continue the user's authorized work, but do not make unsolicited capture offers or file inferred promises. Do not reuse earlier Quiet/Thorough permission or cached instructions as current. An explicit request to record still requires current Balladeer tool checks and named-human agreement to meaning; never invent approval. Retry current guidance at the next hook boundary.";
7
+ const HEX = /^[a-f0-9]{64}$/;
8
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
9
+ const MAX_BYTES = 40_000;
10
+ const sha = (value) => createHash("sha256").update(value).digest("hex");
11
+ function object(value) {
12
+ return value !== null && typeof value === "object" && !Array.isArray(value);
13
+ }
14
+ export function parseGuidance(value, agent) {
15
+ if (!object(value) ||
16
+ Object.keys(value).sort().join(",") !==
17
+ "captureStyle,digest,instructions,repositoryId,revision,schemaVersion,workspaceId" ||
18
+ value.schemaVersion !== 1 ||
19
+ value.workspaceId !== agent.workspaceId ||
20
+ value.repositoryId !== agent.repositoryId ||
21
+ typeof value.revision !== "string" ||
22
+ !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(value.revision) ||
23
+ typeof value.digest !== "string" ||
24
+ !HEX.test(value.digest) ||
25
+ !["quiet", "thorough", "off"].includes(String(value.captureStyle)) ||
26
+ typeof value.instructions !== "string" ||
27
+ value.instructions.length === 0 ||
28
+ value.instructions.length > 8_000 ||
29
+ sha(value.instructions) !== value.digest) {
30
+ throw new Error("invalid_guidance");
31
+ }
32
+ return value;
33
+ }
34
+ export function guidanceEndpoint(agent) {
35
+ const url = new URL(agent.mcpUrl);
36
+ const expected = new URL(agent.controlPlane);
37
+ if (url.origin !== expected.origin ||
38
+ url.protocol !== "https:" ||
39
+ url.pathname !== "/api/mcp" ||
40
+ url.username ||
41
+ url.password ||
42
+ url.hash ||
43
+ !UUID.test(agent.workspaceId) ||
44
+ !UUID.test(agent.repositoryId))
45
+ throw new Error("invalid_guidance_scope");
46
+ for (const [key, value] of [
47
+ ["workspaceId", agent.workspaceId],
48
+ ["repositoryId", agent.repositoryId],
49
+ ]) {
50
+ if (url.searchParams.getAll(key).length > 1 ||
51
+ (url.searchParams.has(key) && url.searchParams.get(key) !== value))
52
+ throw new Error("invalid_guidance_scope");
53
+ url.searchParams.set(key, value);
54
+ }
55
+ url.pathname = "/api/agent-guidance";
56
+ return url;
57
+ }
58
+ function readJson(path) {
59
+ let fd;
60
+ try {
61
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
62
+ const stat = fstatSync(fd);
63
+ if (!stat.isFile() || stat.size > MAX_BYTES || (stat.mode & 0o077) !== 0)
64
+ return undefined;
65
+ return JSON.parse(readFileSync(fd, "utf8"));
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ finally {
71
+ if (fd !== undefined)
72
+ closeSync(fd);
73
+ }
74
+ }
75
+ function privateDirectory(path) {
76
+ try {
77
+ mkdirSync(path, { mode: 0o700 });
78
+ }
79
+ catch {
80
+ /* Existing private directory is checked below. */
81
+ }
82
+ const stat = lstatSync(path);
83
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0)
84
+ throw new Error("cache_unavailable");
85
+ }
86
+ function atomicJson(path, value) {
87
+ const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
88
+ try {
89
+ writeFileSync(temporary, `${JSON.stringify(value)}\n`, { flag: "wx", mode: 0o600 });
90
+ renameSync(temporary, path);
91
+ }
92
+ finally {
93
+ try {
94
+ unlinkSync(temporary);
95
+ }
96
+ catch {
97
+ /* Renamed or never created. */
98
+ }
99
+ }
100
+ }
101
+ export function guidanceScopeKey(agent) {
102
+ return sha(JSON.stringify([
103
+ new URL(agent.controlPlane).origin,
104
+ sha(agent.token),
105
+ agent.workspaceId,
106
+ agent.repositoryId,
107
+ 1,
108
+ ]));
109
+ }
110
+ function cacheDirectory(environment) {
111
+ const home = configHome(environment);
112
+ privateDirectory(home);
113
+ const directory = join(home, "guidance-v1");
114
+ privateDirectory(directory);
115
+ return directory;
116
+ }
117
+ function cached(directory, scopeKey, agent) {
118
+ try {
119
+ const pointer = readJson(join(directory, `${scopeKey}.current.json`));
120
+ if (!object(pointer) || typeof pointer.key !== "string" || !HEX.test(pointer.key))
121
+ return undefined;
122
+ const body = readJson(join(directory, `${pointer.key}.body.json`));
123
+ if (!object(body) || typeof body.etag !== "string" || !/^"[a-f0-9]{64}"$/.test(body.etag))
124
+ return undefined;
125
+ const document = parseGuidance(body.document, agent);
126
+ if (pointer.key !== sha(JSON.stringify([scopeKey, document.revision])))
127
+ return undefined;
128
+ return { document, etag: body.etag, key: pointer.key };
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ async function boundedBody(response) {
135
+ const declared = Number(response.headers.get("content-length") ?? 0);
136
+ if (declared > MAX_BYTES)
137
+ throw new Error("guidance_oversized");
138
+ const reader = response.body?.getReader();
139
+ if (!reader)
140
+ throw new Error("guidance_empty");
141
+ const buffers = [];
142
+ let bytes = 0;
143
+ try {
144
+ while (true) {
145
+ const part = await reader.read();
146
+ if (part.done)
147
+ break;
148
+ bytes += part.value.byteLength;
149
+ if (bytes > MAX_BYTES)
150
+ throw new Error("guidance_oversized");
151
+ buffers.push(part.value);
152
+ }
153
+ return JSON.parse(Buffer.concat(buffers).toString("utf8"));
154
+ }
155
+ finally {
156
+ await reader.cancel().catch(() => undefined);
157
+ }
158
+ }
159
+ /** Never returns stale instructions: even a cache hit requires a fresh scoped server response. */
160
+ export async function loadGuidance(input) {
161
+ const scopeKey = guidanceScopeKey(input.agent);
162
+ let directory;
163
+ try {
164
+ directory = cacheDirectory(input.environment);
165
+ }
166
+ catch {
167
+ /* Cache is optional. */
168
+ }
169
+ const previous = directory ? cached(directory, scopeKey, input.agent) : undefined;
170
+ const abort = new AbortController();
171
+ let timer;
172
+ try {
173
+ const task = async () => {
174
+ const response = await (input.fetchImpl ?? fetch)(guidanceEndpoint(input.agent), {
175
+ method: "GET",
176
+ redirect: "manual",
177
+ signal: abort.signal,
178
+ headers: {
179
+ authorization: `Bearer ${input.agent.token}`,
180
+ [CLIENT_HEADER]: CLIENT_HEADER_VALUE,
181
+ accept: "application/json",
182
+ ...(previous ? { "if-none-match": previous.etag } : {}),
183
+ },
184
+ });
185
+ if (response.status === 304) {
186
+ if (!previous ||
187
+ response.headers.get("etag") !== previous.etag ||
188
+ response.headers.get("x-balladeer-guidance-revision") !== previous.document.revision) {
189
+ throw new Error("invalid_guidance_revalidation");
190
+ }
191
+ return { status: "revalidated", document: previous.document, scopeKey };
192
+ }
193
+ if (response.status !== 200) {
194
+ await response.body?.cancel();
195
+ throw new Error("guidance_unavailable");
196
+ }
197
+ const document = parseGuidance(await boundedBody(response), input.agent);
198
+ const etag = response.headers.get("etag");
199
+ if (!etag ||
200
+ !/^"[a-f0-9]{64}"$/.test(etag) ||
201
+ response.headers.get("x-balladeer-guidance-revision") !== document.revision)
202
+ throw new Error("invalid_guidance_headers");
203
+ // Do not let a late, nonconforming transport populate cache after the deadline.
204
+ if (abort.signal.aborted)
205
+ throw new Error("guidance_deadline");
206
+ if (directory) {
207
+ try {
208
+ const key = sha(JSON.stringify([scopeKey, document.revision]));
209
+ atomicJson(join(directory, `${key}.body.json`), { document, etag });
210
+ atomicJson(join(directory, `${scopeKey}.current.json`), { key });
211
+ if (previous && previous.key !== key) {
212
+ try {
213
+ unlinkSync(join(directory, `${previous.key}.body.json`));
214
+ }
215
+ catch {
216
+ /* Concurrent cache is optional. */
217
+ }
218
+ }
219
+ }
220
+ catch {
221
+ /* Fresh guidance remains usable without disk caching. */
222
+ }
223
+ }
224
+ return { status: "fresh", document, scopeKey };
225
+ };
226
+ return await Promise.race([
227
+ task(),
228
+ new Promise((_, reject) => {
229
+ timer = setTimeout(() => {
230
+ abort.abort();
231
+ reject(new Error("guidance_deadline"));
232
+ }, Math.min(input.timeoutMs ?? 1000, 1000));
233
+ }),
234
+ ]);
235
+ }
236
+ catch {
237
+ abort.abort();
238
+ return { status: "unavailable", scopeKey };
239
+ }
240
+ finally {
241
+ if (timer)
242
+ clearTimeout(timer);
243
+ }
244
+ }
245
+ /** Writes context first, then records emission metadata; this never claims model receipt. */
246
+ export function recordGuidanceContext(input, write) {
247
+ const always = input.event !== "UserPromptSubmit" || input.load.status === "unavailable" || !input.sessionId;
248
+ const contextKey = sha(JSON.stringify([
249
+ input.scopeKey,
250
+ input.hook,
251
+ input.sessionId ?? randomBytes(16).toString("hex"),
252
+ input.agentId ?? "root",
253
+ ]));
254
+ const document = input.load.document;
255
+ let path;
256
+ let emitted = true;
257
+ try {
258
+ path = join(cacheDirectory(input.environment), `${contextKey}.context.json`);
259
+ const previous = readJson(path);
260
+ emitted =
261
+ always ||
262
+ !object(previous) ||
263
+ previous.status === "unavailable" ||
264
+ previous.digest !== document?.digest ||
265
+ previous.revision !== document?.revision ||
266
+ (previous.runtimeGeneration ?? null) !== (input.runtimeGeneration ?? null);
267
+ }
268
+ catch {
269
+ /* Cache failure requires emission. */
270
+ }
271
+ if (!emitted)
272
+ return false;
273
+ write();
274
+ if (path) {
275
+ try {
276
+ atomicJson(path, {
277
+ schemaVersion: 1,
278
+ contextKey,
279
+ event: input.event,
280
+ status: input.load.status,
281
+ digest: document?.digest ?? null,
282
+ revision: document?.revision ?? null,
283
+ runtimeGeneration: input.runtimeGeneration ?? null,
284
+ emitted: true,
285
+ emittedAt: new Date(input.now ?? Date.now()).toISOString(),
286
+ });
287
+ }
288
+ catch {
289
+ /* An unwritten receipt causes another emission, never a skipped one. */
290
+ }
291
+ }
292
+ return true;
293
+ }
package/dist/install.js CHANGED
@@ -23,9 +23,10 @@ function commandOnPath(name, env) {
23
23
  }
24
24
  return undefined;
25
25
  }
26
- function execute(command, args, env) {
26
+ function execute(command, args, env, cwd) {
27
27
  return execFileSync(command, args, {
28
28
  env,
29
+ ...(cwd ? { cwd } : {}),
29
30
  encoding: "utf8",
30
31
  stdio: ["ignore", "pipe", "pipe"],
31
32
  timeout: 120_000,
@@ -33,14 +34,14 @@ function execute(command, args, env) {
33
34
  }).trim();
34
35
  }
35
36
  /** Inspect package ownership before executing a command from PATH. */
36
- function packageVersion(command, env) {
37
+ function packageVersion(command, env, cwd) {
37
38
  let entry = realpathSync(command);
38
39
  if (basename(entry) === "volta-shim") {
39
40
  const volta = commandOnPath("volta", env);
40
41
  if (!volta)
41
42
  return undefined;
42
43
  try {
43
- entry = realpathSync(execute(volta, ["which", "balladeer"], env));
44
+ entry = realpathSync(execute(volta, ["which", "balladeer"], env, cwd));
44
45
  }
45
46
  catch {
46
47
  return undefined;
@@ -121,25 +122,114 @@ function shellProfiles(env, bin) {
121
122
  return { path, text, previous: old };
122
123
  });
123
124
  }
125
+ function atLeastCurrent(version) {
126
+ if (!/^\d+\.\d+\.\d+$/.test(version))
127
+ return false;
128
+ const actual = version.split(".").map(Number), expected = CLI_VERSION.split(".").map(Number);
129
+ for (let i = 0; i < 3; i++) {
130
+ if (actual[i] !== expected[i])
131
+ return actual[i] > expected[i];
132
+ }
133
+ return true;
134
+ }
135
+ /** npm exec may put the actual Node-image bin ahead of Volta's registered shim. */
136
+ function installWithVolta(env) {
137
+ const home = env.VOLTA_HOME ?? (env.HOME ? join(env.HOME, ".volta") : undefined);
138
+ const volta = commandOnPath("volta", env);
139
+ if (!home || !isAbsolute(home) || !volta)
140
+ return undefined;
141
+ const bin = join(home, "bin"), shim = join(bin, "balladeer");
142
+ const nodeShim = join(bin, "node");
143
+ // Merely having Volta somewhere on PATH must not select it for another toolchain.
144
+ const onPath = (env.PATH ?? "")
145
+ .split(delimiter)
146
+ .some((part) => resolve(part || ".") === resolve(bin));
147
+ const managedNode = existsSync(nodeShim) && basename(realpathSync(nodeShim)) === "volta-shim";
148
+ const imageDirectory = join(home, "tools/image/node");
149
+ const imageRoot = existsSync(imageDirectory)
150
+ ? realpathSync(imageDirectory)
151
+ : resolve(imageDirectory);
152
+ const selectedNode = commandOnPath("node", env);
153
+ const actualNode = selectedNode ? realpathSync(selectedNode) : undefined;
154
+ const activeToolchain = actualNode !== undefined &&
155
+ ((managedNode && actualNode === realpathSync(nodeShim)) ||
156
+ actualNode.startsWith(imageRoot + "/"));
157
+ if (!onPath || !managedNode || !activeToolchain)
158
+ return undefined;
159
+ const bootstrap = env.npm_command === "exec";
160
+ const parentEnv = {
161
+ ...env,
162
+ PATH: (env.PATH ?? "")
163
+ .split(delimiter)
164
+ .filter((part) => {
165
+ const full = resolve(part || ".");
166
+ if (full.split(/[\\/]/).includes("node_modules"))
167
+ return false;
168
+ return !(bootstrap &&
169
+ (existsSync(full) ? realpathSync(full) : full).startsWith(imageRoot + "/") &&
170
+ basename(full) === "bin");
171
+ })
172
+ .join(delimiter),
173
+ };
174
+ const selected = commandOnPath("balladeer", parentEnv);
175
+ if (selected && resolve(selected) !== resolve(shim))
176
+ throw new InstallError(`Another command precedes Volta at ${selected}. Resolve that PATH conflict before installing; nothing was overwritten.`);
177
+ if (existsSync(shim) && basename(realpathSync(shim)) !== "volta-shim")
178
+ throw new InstallError("Another command owns Volta's balladeer path. Nothing was overwritten.");
179
+ const neutral = mkdtempSync(join(tmpdir(), "balladeer-volta-install-"));
180
+ const installEnv = { ...parentEnv, npm_config_cache: join(neutral, "npm-cache") };
181
+ try {
182
+ let version = existsSync(shim) ? packageVersion(shim, installEnv, neutral) : undefined;
183
+ if (existsSync(shim) && version === undefined)
184
+ throw new InstallError("Volta's registered Balladeer package cannot be verified. Repair the toolchain before pairing.");
185
+ const alreadyCurrent = version !== undefined && atLeastCurrent(version);
186
+ if (!alreadyCurrent) {
187
+ try {
188
+ execute(volta, ["install", `balladeer@${CLI_VERSION}`], installEnv, neutral);
189
+ }
190
+ catch {
191
+ throw new InstallError("Volta could not install the exact Balladeer release. Check network and toolchain write access, then repeat npx -y balladeer@latest install. No pairing has started.");
192
+ }
193
+ version = existsSync(shim) ? packageVersion(shim, installEnv, neutral) : undefined;
194
+ if (version !== CLI_VERSION)
195
+ throw new InstallError("Volta finished, but its registered default is not the expected Balladeer release. No pairing has started.");
196
+ }
197
+ // Verify both the global default and what an ordinary fresh child in the
198
+ // caller's directory sees. A project override is never mistaken for a default.
199
+ if (!version ||
200
+ execute(shim, ["--version"], installEnv, neutral) !== version ||
201
+ commandOnPath("balladeer", parentEnv) !== shim ||
202
+ execute(shim, ["--version"], parentEnv) !== version)
203
+ throw new InstallError("Volta's default was checked, but this terminal or project selects a different Balladeer. Resolve its PATH or project tool pin before pairing.");
204
+ const shell = execute("/bin/sh", ["-c", "command -v balladeer; balladeer --version"], parentEnv).split("\n");
205
+ if (shell[0] !== shim || shell[1] !== version)
206
+ throw new InstallError("The fresh terminal PATH does not select the verified Volta command. No pairing has started.");
207
+ env.PATH = parentEnv.PATH;
208
+ return {
209
+ status: alreadyCurrent ? "current" : "installed",
210
+ command: shim,
211
+ version,
212
+ restartRequired: false,
213
+ };
214
+ }
215
+ finally {
216
+ rmSync(neutral, { recursive: true, force: true });
217
+ }
218
+ }
124
219
  export function ensureInstalled(options) {
125
220
  const env = options.environment;
126
221
  const platform = options.platform ?? process.platform;
127
222
  if (platform === "win32")
128
223
  throw new InstallError("Automatic command installation currently supports macOS and Linux. On Windows, use npm install --global balladeer@latest, then verify balladeer --version. No files or pairing were changed.");
224
+ const managed = installWithVolta(env);
225
+ if (managed)
226
+ return managed;
129
227
  const current = commandOnPath("balladeer", env);
130
228
  if (current) {
131
229
  const version = packageVersion(current, env);
132
230
  if (version === undefined)
133
231
  throw new InstallError(`Another command already owns ${current}. Keep it intact and resolve that command conflict before installing Balladeer.`);
134
- const newer = /^\d+\.\d+\.\d+$/.test(version) &&
135
- version
136
- .split(".")
137
- .map(Number)
138
- .some((part, index, parts) => part > Number(CLI_VERSION.split(".")[index]) &&
139
- parts
140
- .slice(0, index)
141
- .every((value, before) => value === Number(CLI_VERSION.split(".")[before])));
142
- if (version === CLI_VERSION || newer) {
232
+ if (atLeastCurrent(version)) {
143
233
  if (execute(current, ["--version"], env) !== version)
144
234
  throw new InstallError("The installed Balladeer command does not run the expected version. Repair the installation before pairing.");
145
235
  return { status: "current", command: current, restartRequired: false, version };
package/dist/wire.d.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export declare const CLI_VERSION = "1.0.5";
8
+ export declare const CLI_VERSION = "1.0.7";
9
9
  export declare const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export declare const CLIENT_HEADER = "x-balladeer-client";
11
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.5";
11
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.7";
12
12
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
13
13
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
14
14
  export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
@@ -222,6 +222,12 @@ export type ClaudeDesktopStep = Readonly<{
222
222
  reason?: string;
223
223
  }>;
224
224
  export type JsonStep = Readonly<{
225
+ step: "guidance_loader";
226
+ status: "installed_needs_host_trust" | "installed_trust_unverified" | "unavailable" | "not_applicable";
227
+ changed: boolean;
228
+ hosts: readonly ("codex" | "claude")[];
229
+ reason?: string;
230
+ }> | Readonly<{
225
231
  step: "explain";
226
232
  version: string;
227
233
  }> | Readonly<{
package/dist/wire.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export const CLI_VERSION = "1.0.5";
8
+ export const CLI_VERSION = "1.0.7";
9
9
  export const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export const CLIENT_HEADER = "x-balladeer-client";
11
11
  export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -21,11 +21,12 @@
21
21
  "node": ">=22.0.0"
22
22
  },
23
23
  "scripts": {
24
- "build": "tsc -p tsconfig.json",
24
+ "build": "tsc -p tsconfig.json && node build-guidance.mjs",
25
25
  "typecheck": "tsc -p tsconfig.json --noEmit"
26
26
  },
27
27
  "devDependencies": {
28
- "typescript": "5.9.3"
28
+ "typescript": "5.9.3",
29
+ "esbuild": "0.25.12"
29
30
  },
30
31
  "dependencies": {
31
32
  "@iarna/toml": "2.2.5"