balladeer 1.0.6 → 1.0.8

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.d.ts CHANGED
@@ -19,5 +19,7 @@ export declare function runInstall(options: InstallOptions & {
19
19
  json: boolean;
20
20
  write: (text: string) => void;
21
21
  allowPartial?: boolean;
22
+ /** Carried into the one JSON object this prints, never as a second line. */
23
+ extra?: Record<string, unknown>;
22
24
  }): number;
23
25
  export {};
package/dist/install.js CHANGED
@@ -344,6 +344,7 @@ export function runInstall(options) {
344
344
  ...result,
345
345
  status: result.manualPathRequired ? "path_pending" : result.status,
346
346
  message,
347
+ ...(options.extra ?? {}),
347
348
  }) + "\n"
348
349
  : message + "\n");
349
350
  return result.manualPathRequired && !options.allowPartial ? 4 : 0;
@@ -353,7 +354,12 @@ export function runInstall(options) {
353
354
  ? error.message
354
355
  : "Installation could not access a required local file or command. Allow the reported installer operation and retry; no pairing has started.";
355
356
  options.write(options.json
356
- ? JSON.stringify({ step: "install", status: "blocked", message }) + "\n"
357
+ ? JSON.stringify({
358
+ step: "install",
359
+ status: "blocked",
360
+ message,
361
+ ...(options.extra ?? {}),
362
+ }) + "\n"
357
363
  : message + "\n");
358
364
  return 4;
359
365
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where a person said "not here".
3
+ *
4
+ * With Balladeer registered once per laptop, every session in every folder
5
+ * asks whether this folder is tracked. In one that is not, the hook says so
6
+ * once and names the command that would track it; a person who does not want
7
+ * to hear that again in a scratch clone silences the folder, and one who does
8
+ * not want it for a whole product silences the remote. The default is the
9
+ * folder, because "not here" usually means this checkout and not the repository.
10
+ */
11
+ export type QuietList = Readonly<{
12
+ schemaVersion: 1;
13
+ folders: string[];
14
+ remotes: string[];
15
+ }>;
16
+ export declare function quietPath(environment?: NodeJS.ProcessEnv): string;
17
+ export declare function readQuiet(environment?: NodeJS.ProcessEnv): QuietList;
18
+ /** The folder a session is in, as git names it, so a worktree and a clone are
19
+ * each their own folder while the remote stays one thing. */
20
+ export declare function folderKey(cwd: string): string;
21
+ export declare function isQuiet(cwd: string, environment?: NodeJS.ProcessEnv): boolean;
22
+ export declare function setQuiet(cwd: string, scope: "folder" | "repo", quiet: boolean, environment?: NodeJS.ProcessEnv): {
23
+ key: string;
24
+ changed: boolean;
25
+ };
package/dist/quiet.js ADDED
@@ -0,0 +1,73 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeJsonAtomically } from "./mcp-config.js";
5
+ import { repositoryHint } from "./repository.js";
6
+ import { configHome } from "./store.js";
7
+ const EMPTY = { schemaVersion: 1, folders: [], remotes: [] };
8
+ export function quietPath(environment = process.env) {
9
+ return join(configHome(environment), "quiet.json");
10
+ }
11
+ export function readQuiet(environment = process.env) {
12
+ const path = quietPath(environment);
13
+ if (!existsSync(path))
14
+ return EMPTY;
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
17
+ if (parsed.schemaVersion !== 1)
18
+ return EMPTY;
19
+ return {
20
+ schemaVersion: 1,
21
+ folders: Array.isArray(parsed.folders) ? parsed.folders.filter(isString) : [],
22
+ remotes: Array.isArray(parsed.remotes) ? parsed.remotes.filter(isString) : [],
23
+ };
24
+ }
25
+ catch {
26
+ return EMPTY;
27
+ }
28
+ }
29
+ const isString = (value) => typeof value === "string";
30
+ function writeQuiet(list, environment) {
31
+ mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
32
+ writeJsonAtomically(quietPath(environment), JSON.stringify(list, null, 2) + "\n");
33
+ }
34
+ /** The folder a session is in, as git names it, so a worktree and a clone are
35
+ * each their own folder while the remote stays one thing. */
36
+ export function folderKey(cwd) {
37
+ try {
38
+ return realpathSync(execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "ignore"],
41
+ timeout: 1000,
42
+ }).trim());
43
+ }
44
+ catch {
45
+ return realpathSync(cwd);
46
+ }
47
+ }
48
+ export function isQuiet(cwd, environment = process.env) {
49
+ const list = readQuiet(environment);
50
+ const folder = folderKey(cwd);
51
+ const remote = repositoryHint(cwd);
52
+ return (list.folders.includes(folder) ||
53
+ (remote !== "unknown/unknown" &&
54
+ list.remotes.some((r) => r.toLowerCase() === remote.toLowerCase())));
55
+ }
56
+ export function setQuiet(cwd, scope, quiet, environment = process.env) {
57
+ const list = readQuiet(environment);
58
+ const key = scope === "folder" ? folderKey(cwd) : repositoryHint(cwd);
59
+ if (scope === "repo" && key === "unknown/unknown")
60
+ throw new Error("This folder has no GitHub origin remote, so there is no repository to name.");
61
+ const field = scope === "folder" ? "folders" : "remotes";
62
+ const has = list[field].some((x) => x.toLowerCase() === key.toLowerCase());
63
+ if (has === quiet)
64
+ return { key, changed: false };
65
+ const next = {
66
+ ...list,
67
+ [field]: quiet
68
+ ? [...list[field], key]
69
+ : list[field].filter((x) => x.toLowerCase() !== key.toLowerCase()),
70
+ };
71
+ writeQuiet(next, environment);
72
+ return { key, changed: true };
73
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Install Balladeer once per laptop.
3
+ *
4
+ * Setup used to write two files into one repository checkout, uncommitted: an
5
+ * `.mcp.json` entry and a hook in `.claude/settings.json`. A coding host lists
6
+ * a project server only when that exact folder is open, so every other clone,
7
+ * every worktree and every teammate saw nothing, and `/mcp` on two laptops
8
+ * showed only the legacy server. This registers the same server and the same
9
+ * hook at the host's user scope instead: one entry that runs in every session,
10
+ * and decides at run time, from the folder's git remote, which connection it
11
+ * is for. Nothing to commit and nothing per folder.
12
+ *
13
+ * Every write here is an additive merge that refuses to touch an entry it does
14
+ * not own, exactly as the project writers do. What it owns is recognised by
15
+ * shape, never by trust in the name.
16
+ */
17
+ export declare const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
18
+ export type UserScopeHost = "claude" | "codex";
19
+ export type UserScopeWrite = Readonly<{
20
+ host: UserScopeHost;
21
+ path: string;
22
+ status: "current" | "written" | "refused";
23
+ reason?: string;
24
+ }>;
25
+ /** The command a host runs to reach Balladeer, with no repository named: the
26
+ * directory decides. The published form is what a laptop gets; the checkout
27
+ * form exists so this repository's own tests can point a host at source. */
28
+ export declare function userScopeCommand(published: boolean): {
29
+ command: string;
30
+ args: string[];
31
+ };
32
+ /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
33
+ export declare function mergeClaudeUserMcp(home: string, published: boolean): UserScopeWrite;
34
+ /** Ours when it runs `npx` on our published specifier, or `node` on this
35
+ * checkout's entry file, with `mcp` as the subcommand and no repository named:
36
+ * a repository argument at user scope would pin every folder to one connection. */
37
+ export declare function isOurUserEntry(entry: unknown): boolean;
38
+ /** `~/.claude/settings.json` carries user-scope hooks. */
39
+ export declare function mergeClaudeUserHooks(home: string, published: boolean): UserScopeWrite;
40
+ /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
41
+ * one fenced block this command owns end to end. */
42
+ export declare function mergeCodexUserConfig(home: string, published: boolean): UserScopeWrite;
43
+ /** A copy of this command running out of a source checkout registers the
44
+ * checkout; any installed or npx copy registers the published package. */
45
+ export declare function runningFromCheckout(entry?: string): boolean;
46
+ /** The home whose host files get the entries. `BALLADEER_USER_HOME` exists for
47
+ * this repository's own tests, which cannot move HOME under a Volta-managed
48
+ * Node without losing Node. */
49
+ export declare function userHome(environment?: NodeJS.ProcessEnv): string;
50
+ /** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
51
+ export declare function installUserScope(options: {
52
+ environment: NodeJS.ProcessEnv;
53
+ published: boolean;
54
+ home?: string;
55
+ }): UserScopeWrite[];