balladeer 1.0.14 → 1.0.15
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/cli.js +22 -2
- package/dist/commands/guidance.d.ts +1 -1
- package/dist/commands/guidance.js +6 -0
- package/dist/commands/mcp.d.ts +6 -0
- package/dist/commands/mcp.js +53 -0
- package/dist/guidance-hook.mjs +160 -74
- package/dist/guidance.d.ts +2 -0
- package/dist/guidance.js +7 -1
- package/dist/hook-trust.d.ts +31 -0
- package/dist/hook-trust.js +63 -0
- package/dist/self-update.d.ts +29 -2
- package/dist/self-update.js +96 -11
- package/dist/user-scope.d.ts +2 -0
- package/dist/user-scope.js +26 -2
- package/dist/wire.d.ts +6 -2
- package/dist/wire.js +5 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -21,9 +21,11 @@ import { runWhoami } from "./commands/whoami.js";
|
|
|
21
21
|
import { runInstall } from "./install.js";
|
|
22
22
|
import { openInBrowser } from "./open-browser.js";
|
|
23
23
|
import { PUBLISHED_SPECIFIER } from "./release.js";
|
|
24
|
-
import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
|
|
24
|
+
import { CODEX_HOOK_APPROVAL, installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
|
|
25
25
|
import { updateNotice } from "./currency.js";
|
|
26
26
|
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
|
|
27
|
+
import { recordSelfUpdateState, SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
|
|
28
|
+
import { hooksAwaitingTrust } from "./hook-trust.js";
|
|
27
29
|
import { describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
|
|
28
30
|
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
29
31
|
import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
@@ -472,6 +474,19 @@ function installEverything(options) {
|
|
|
472
474
|
for (const w of writes ?? [])
|
|
473
475
|
if (!options.json)
|
|
474
476
|
options.write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
|
|
477
|
+
// Codex runs a hook only once the person has trusted it, and trusts it by
|
|
478
|
+
// the hash of its definition, so a hook this command just wrote or changed
|
|
479
|
+
// is skipped until they do. Found 29 September 2026: Codex was the most
|
|
480
|
+
// common host at Didero and not one of its sessions had run the hook.
|
|
481
|
+
const codex = (writes ?? []).find((w) => w.host === "codex" && w.status !== "refused");
|
|
482
|
+
// Said whenever it is true, which is after any install that wrote or changed
|
|
483
|
+
// the hooks and until Codex has run one of them; silent once it has.
|
|
484
|
+
if (codex !== undefined && hooksAwaitingTrust(process.env, "codex")) {
|
|
485
|
+
if (options.json)
|
|
486
|
+
options.write(`${JSON.stringify({ step: "codex_hooks", approval: codex.status === "written" ? "required" : "check", command: "/hooks" })}\n`);
|
|
487
|
+
else
|
|
488
|
+
options.write(`${CODEX_HOOK_APPROVAL}\n`);
|
|
489
|
+
}
|
|
475
490
|
// With the user-scope form in place, the older per-repository form in this
|
|
476
491
|
// checkout only makes the session hear the guidance twice. Out it goes, with
|
|
477
492
|
// a copy beside any file git does not already keep.
|
|
@@ -493,7 +508,12 @@ function installEverything(options) {
|
|
|
493
508
|
options.write("Balladeer now runs in every Claude Code" +
|
|
494
509
|
((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
|
|
495
510
|
" session on this machine. In a folder of a connected repository it works as before; anywhere else it stays quiet, and `balladeer status` there says why.\n");
|
|
496
|
-
|
|
511
|
+
const outcome = (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
|
|
512
|
+
// Started by the session hook's background update: leave how it ended where
|
|
513
|
+
// the next hook fire will find it and tell the server.
|
|
514
|
+
if (process.env[SELF_UPDATE_ENVIRONMENT] === "1")
|
|
515
|
+
recordSelfUpdateState(process.env, outcome === 0 ? `installed:${CLI_VERSION}` : `failed:exit-${outcome}`, Date.now());
|
|
516
|
+
return outcome;
|
|
497
517
|
}
|
|
498
518
|
async function dispatch(parsed, write) {
|
|
499
519
|
switch (parsed.command) {
|
|
@@ -15,7 +15,7 @@ export type GuidanceOptions = Readonly<{
|
|
|
15
15
|
fetchImpl?: typeof fetch;
|
|
16
16
|
now?: () => number;
|
|
17
17
|
/** Runs the background update; injected so tests never reach npm. */
|
|
18
|
-
startUpdate?: (command: string, args: readonly string[]) => void;
|
|
18
|
+
startUpdate?: (command: string, args: readonly string[], environment: NodeJS.ProcessEnv) => boolean | void;
|
|
19
19
|
}>;
|
|
20
20
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
21
21
|
export declare function runGuidance(options: GuidanceOptions): Promise<number>;
|
|
@@ -5,6 +5,7 @@ import { selectAgent } from "../agent.js";
|
|
|
5
5
|
import { repositoryHints } from "../repository.js";
|
|
6
6
|
import { newerVersionPublished } from "../currency.js";
|
|
7
7
|
import { startSelfUpdateIfDue } from "../self-update.js";
|
|
8
|
+
import { noteHookFired } from "../hook-trust.js";
|
|
8
9
|
/** A repository-specific setup step for explicit status and empty-catalog responses. */
|
|
9
10
|
export function untrackedLine(remote) {
|
|
10
11
|
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
@@ -98,10 +99,15 @@ export async function runGuidance(options) {
|
|
|
98
99
|
// Unconnected folders stay silent. An explicit status request explains setup.
|
|
99
100
|
return 0;
|
|
100
101
|
}
|
|
102
|
+
// A hook that runs has been trusted by its host; hosts that ask for that
|
|
103
|
+
// trust are the ones this matters to.
|
|
104
|
+
if (options.hook === "codex")
|
|
105
|
+
noteHookFired(options.environment, "codex");
|
|
101
106
|
const loaded = await loadGuidance({
|
|
102
107
|
agent: agents[0],
|
|
103
108
|
environment: options.environment,
|
|
104
109
|
timeoutMs: 750,
|
|
110
|
+
hook: options.hook,
|
|
105
111
|
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
106
112
|
});
|
|
107
113
|
// Behind? Start the install in the background, once a day at most, and
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -63,3 +63,9 @@ export declare function staleClientFrame(id: unknown, update: string): string;
|
|
|
63
63
|
*/
|
|
64
64
|
export declare function revokedConnectionFrame(id: unknown, controlPlane: string): string;
|
|
65
65
|
export declare function runMcp(options: McpOptions): Promise<number>;
|
|
66
|
+
/**
|
|
67
|
+
* The tool's answer with one more text block at its end, or nothing when the
|
|
68
|
+
* answer is not a single tool result this can add to: a batch, an error frame,
|
|
69
|
+
* or anything it cannot parse is forwarded exactly as it came.
|
|
70
|
+
*/
|
|
71
|
+
export declare function withNotice(text: string, notice: string): string | undefined;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -5,6 +5,7 @@ import { noteServerVersion, updateNotice } from "../currency.js";
|
|
|
5
5
|
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
6
6
|
import { CLI_VERSION } from "../wire.js";
|
|
7
7
|
import { untrackedLine } from "./guidance.js";
|
|
8
|
+
import { HOOKS_AWAITING_TRUST_NOTICE, hooksAwaitingTrust, trustingHost } from "../hook-trust.js";
|
|
8
9
|
import { StoreError, readCredentials } from "../store.js";
|
|
9
10
|
// The three commands that use an agent connection select and address it through
|
|
10
11
|
// one module. These are re-exported because this is where the forwarder's
|
|
@@ -198,6 +199,11 @@ export async function runMcp(options) {
|
|
|
198
199
|
if (guidanceInstall.status !== "not_applicable")
|
|
199
200
|
options.error(`Balladeer guidance loader: ${guidanceInstall.status}${guidanceInstall.reason ? ` (${guidanceInstall.reason})` : ""}. New project hooks may need host trust; no trust is assumed.\n`);
|
|
200
201
|
let saidUpdate = false;
|
|
202
|
+
// Which host this session belongs to is known from its hello; whether its
|
|
203
|
+
// hooks are waiting for the person's trust is asked at the first tool call,
|
|
204
|
+
// by which time a trusted session-start hook has certainly run.
|
|
205
|
+
let host;
|
|
206
|
+
let saidTrust = false;
|
|
201
207
|
const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
|
|
202
208
|
for await (const line of lines) {
|
|
203
209
|
if (line.trim().length === 0)
|
|
@@ -247,7 +253,54 @@ export async function runMcp(options) {
|
|
|
247
253
|
options.error("Balladeer refused a response frame larger than 4 MiB.\n");
|
|
248
254
|
return 5;
|
|
249
255
|
}
|
|
256
|
+
const request = frameOf(line);
|
|
257
|
+
if (request?.method === "initialize")
|
|
258
|
+
host = trustingHost(request.params?.clientInfo?.name);
|
|
259
|
+
if (!saidTrust &&
|
|
260
|
+
host !== undefined &&
|
|
261
|
+
request?.method === "tools/call" &&
|
|
262
|
+
hooksAwaitingTrust(options.environment, host)) {
|
|
263
|
+
const noted = withNotice(text, HOOKS_AWAITING_TRUST_NOTICE);
|
|
264
|
+
if (noted !== undefined) {
|
|
265
|
+
saidTrust = true;
|
|
266
|
+
options.write(`${noted}\n`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
250
270
|
options.write(`${text.replace(/\n+$/, "")}\n`);
|
|
251
271
|
}
|
|
252
272
|
return 0;
|
|
253
273
|
}
|
|
274
|
+
function frameOf(line) {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(line);
|
|
277
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
278
|
+
? parsed
|
|
279
|
+
: undefined;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The tool's answer with one more text block at its end, or nothing when the
|
|
287
|
+
* answer is not a single tool result this can add to: a batch, an error frame,
|
|
288
|
+
* or anything it cannot parse is forwarded exactly as it came.
|
|
289
|
+
*/
|
|
290
|
+
export function withNotice(text, notice) {
|
|
291
|
+
try {
|
|
292
|
+
const frame = JSON.parse(text);
|
|
293
|
+
if (frame === null || typeof frame !== "object" || Array.isArray(frame))
|
|
294
|
+
return undefined;
|
|
295
|
+
const content = frame.result?.content;
|
|
296
|
+
if (!Array.isArray(content))
|
|
297
|
+
return undefined;
|
|
298
|
+
return JSON.stringify({
|
|
299
|
+
...frame,
|
|
300
|
+
result: { ...frame.result, content: [...content, { type: "text", text: notice }] },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
}
|
package/dist/guidance-hook.mjs
CHANGED
|
@@ -2027,8 +2027,10 @@ var require_toml = __commonJS({
|
|
|
2027
2027
|
});
|
|
2028
2028
|
|
|
2029
2029
|
// src/wire.ts
|
|
2030
|
-
var CLI_VERSION = "1.0.
|
|
2030
|
+
var CLI_VERSION = "1.0.15";
|
|
2031
2031
|
var CLIENT_HEADER = "x-balladeer-client";
|
|
2032
|
+
var UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
2033
|
+
var HOOK_HOST_HEADER = "x-balladeer-hook";
|
|
2032
2034
|
var CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|
|
2033
2035
|
var DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
2034
2036
|
|
|
@@ -2150,6 +2152,102 @@ function newerVersionPublished() {
|
|
|
2150
2152
|
return newestSeen;
|
|
2151
2153
|
}
|
|
2152
2154
|
|
|
2155
|
+
// src/self-update.ts
|
|
2156
|
+
import { spawn } from "node:child_process";
|
|
2157
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
2158
|
+
import { delimiter, dirname as dirname2, join as join2 } from "node:path";
|
|
2159
|
+
|
|
2160
|
+
// src/release.ts
|
|
2161
|
+
var HOOK_SPECIFIER = "balladeer@1";
|
|
2162
|
+
|
|
2163
|
+
// src/self-update.ts
|
|
2164
|
+
var STAMP = "self-update.json";
|
|
2165
|
+
var STATE = "self-update-state.json";
|
|
2166
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2167
|
+
var SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
|
|
2168
|
+
var STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
|
|
2169
|
+
function stampPath(environment) {
|
|
2170
|
+
return join2(configHome(environment), STAMP);
|
|
2171
|
+
}
|
|
2172
|
+
function statePath(environment) {
|
|
2173
|
+
return join2(configHome(environment), STATE);
|
|
2174
|
+
}
|
|
2175
|
+
function lastStarted(environment) {
|
|
2176
|
+
const path = stampPath(environment);
|
|
2177
|
+
if (!existsSync(path)) return 0;
|
|
2178
|
+
try {
|
|
2179
|
+
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
2180
|
+
return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
|
|
2181
|
+
} catch {
|
|
2182
|
+
return 0;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
function writePrivate(path, value, environment) {
|
|
2186
|
+
mkdirSync2(configHome(environment), { recursive: true, mode: 448 });
|
|
2187
|
+
writeFileSync(path, JSON.stringify(value) + "\n", { mode: 384 });
|
|
2188
|
+
}
|
|
2189
|
+
function recordSelfUpdateState(environment, state, at) {
|
|
2190
|
+
if (!STATE_PATTERN.test(state)) return;
|
|
2191
|
+
try {
|
|
2192
|
+
writePrivate(statePath(environment), { state, at }, environment);
|
|
2193
|
+
} catch {
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
function selfUpdateState(environment) {
|
|
2197
|
+
try {
|
|
2198
|
+
const parsed = JSON.parse(readFileSync2(statePath(environment), "utf8"));
|
|
2199
|
+
return typeof parsed.state === "string" && STATE_PATTERN.test(parsed.state) ? parsed.state : void 0;
|
|
2200
|
+
} catch {
|
|
2201
|
+
return void 0;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
function resolveLaunch(specifier, environment, execPath = process.execPath, exists = existsSync) {
|
|
2205
|
+
const bin = dirname2(execPath);
|
|
2206
|
+
const tail = ["-y", specifier, "install", "--json"];
|
|
2207
|
+
const child = {
|
|
2208
|
+
...environment,
|
|
2209
|
+
PATH: [bin, environment.PATH ?? ""].filter((part) => part.length > 0).join(delimiter),
|
|
2210
|
+
[SELF_UPDATE_ENVIRONMENT]: "1"
|
|
2211
|
+
};
|
|
2212
|
+
const script = join2(bin, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js");
|
|
2213
|
+
if (exists(script)) return { command: execPath, args: [script, ...tail], environment: child };
|
|
2214
|
+
const shim = join2(bin, process.platform === "win32" ? "npx.cmd" : "npx");
|
|
2215
|
+
if (exists(shim)) return { command: shim, args: tail, environment: child };
|
|
2216
|
+
return { command: "npx", args: tail, environment: child };
|
|
2217
|
+
}
|
|
2218
|
+
function detached(command, args, environment) {
|
|
2219
|
+
const child = spawn(command, [...args], { detached: true, stdio: "ignore", env: environment });
|
|
2220
|
+
child.on("error", () => void 0);
|
|
2221
|
+
child.unref();
|
|
2222
|
+
return child.pid !== void 0;
|
|
2223
|
+
}
|
|
2224
|
+
function startSelfUpdateIfDue(newer, deps) {
|
|
2225
|
+
if (newer === void 0) return false;
|
|
2226
|
+
const now = deps.now ?? Date.now;
|
|
2227
|
+
if (now() - lastStarted(deps.environment) < DAY_MS) return false;
|
|
2228
|
+
const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
|
|
2229
|
+
let started;
|
|
2230
|
+
try {
|
|
2231
|
+
started = (deps.run ?? detached)(launch.command, launch.args, launch.environment) !== false;
|
|
2232
|
+
} catch {
|
|
2233
|
+
started = false;
|
|
2234
|
+
}
|
|
2235
|
+
if (!started) {
|
|
2236
|
+
recordSelfUpdateState(deps.environment, "unstartable:launcher", now());
|
|
2237
|
+
return false;
|
|
2238
|
+
}
|
|
2239
|
+
try {
|
|
2240
|
+
writePrivate(
|
|
2241
|
+
stampPath(deps.environment),
|
|
2242
|
+
{ startedAt: now(), toward: newer },
|
|
2243
|
+
deps.environment
|
|
2244
|
+
);
|
|
2245
|
+
} catch {
|
|
2246
|
+
}
|
|
2247
|
+
recordSelfUpdateState(deps.environment, `started:${newer}`, now());
|
|
2248
|
+
return true;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2153
2251
|
// src/guidance.ts
|
|
2154
2252
|
import { createHash, randomBytes } from "node:crypto";
|
|
2155
2253
|
import {
|
|
@@ -2157,14 +2255,14 @@ import {
|
|
|
2157
2255
|
closeSync as closeSync2,
|
|
2158
2256
|
fstatSync,
|
|
2159
2257
|
lstatSync as lstatSync2,
|
|
2160
|
-
mkdirSync as
|
|
2258
|
+
mkdirSync as mkdirSync3,
|
|
2161
2259
|
openSync as openSync2,
|
|
2162
|
-
readFileSync as
|
|
2260
|
+
readFileSync as readFileSync3,
|
|
2163
2261
|
renameSync as renameSync2,
|
|
2164
2262
|
unlinkSync as unlinkSync2,
|
|
2165
|
-
writeFileSync
|
|
2263
|
+
writeFileSync as writeFileSync2
|
|
2166
2264
|
} from "node:fs";
|
|
2167
|
-
import { join as
|
|
2265
|
+
import { join as join3 } from "node:path";
|
|
2168
2266
|
var 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.";
|
|
2169
2267
|
var HEX = /^[a-f0-9]{64}$/;
|
|
2170
2268
|
var 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;
|
|
@@ -2201,7 +2299,7 @@ function readJson(path) {
|
|
|
2201
2299
|
fd = openSync2(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
2202
2300
|
const stat = fstatSync(fd);
|
|
2203
2301
|
if (!stat.isFile() || stat.size > MAX_BYTES || (stat.mode & 63) !== 0) return void 0;
|
|
2204
|
-
return JSON.parse(
|
|
2302
|
+
return JSON.parse(readFileSync3(fd, "utf8"));
|
|
2205
2303
|
} catch {
|
|
2206
2304
|
return void 0;
|
|
2207
2305
|
} finally {
|
|
@@ -2210,7 +2308,7 @@ function readJson(path) {
|
|
|
2210
2308
|
}
|
|
2211
2309
|
function privateDirectory(path) {
|
|
2212
2310
|
try {
|
|
2213
|
-
|
|
2311
|
+
mkdirSync3(path, { mode: 448 });
|
|
2214
2312
|
} catch {
|
|
2215
2313
|
}
|
|
2216
2314
|
const stat = lstatSync2(path);
|
|
@@ -2220,7 +2318,7 @@ function privateDirectory(path) {
|
|
|
2220
2318
|
function atomicJson(path, value) {
|
|
2221
2319
|
const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
2222
2320
|
try {
|
|
2223
|
-
|
|
2321
|
+
writeFileSync2(temporary, `${JSON.stringify(value)}
|
|
2224
2322
|
`, { flag: "wx", mode: 384 });
|
|
2225
2323
|
renameSync2(temporary, path);
|
|
2226
2324
|
} finally {
|
|
@@ -2244,16 +2342,16 @@ function guidanceScopeKey(agent) {
|
|
|
2244
2342
|
function cacheDirectory(environment) {
|
|
2245
2343
|
const home = configHome(environment);
|
|
2246
2344
|
privateDirectory(home);
|
|
2247
|
-
const directory =
|
|
2345
|
+
const directory = join3(home, "guidance-v1");
|
|
2248
2346
|
privateDirectory(directory);
|
|
2249
2347
|
return directory;
|
|
2250
2348
|
}
|
|
2251
2349
|
function cached(directory, scopeKey, agent) {
|
|
2252
2350
|
try {
|
|
2253
|
-
const pointer = readJson(
|
|
2351
|
+
const pointer = readJson(join3(directory, `${scopeKey}.current.json`));
|
|
2254
2352
|
if (!object(pointer) || typeof pointer.key !== "string" || !HEX.test(pointer.key))
|
|
2255
2353
|
return void 0;
|
|
2256
|
-
const body = readJson(
|
|
2354
|
+
const body = readJson(join3(directory, `${pointer.key}.body.json`));
|
|
2257
2355
|
if (!object(body) || typeof body.etag !== "string" || !/^"[a-f0-9]{64}"$/.test(body.etag))
|
|
2258
2356
|
return void 0;
|
|
2259
2357
|
const document = parseGuidance(body.document, agent);
|
|
@@ -2291,6 +2389,7 @@ async function loadGuidance(input) {
|
|
|
2291
2389
|
} catch {
|
|
2292
2390
|
}
|
|
2293
2391
|
const previous = directory ? cached(directory, scopeKey, input.agent) : void 0;
|
|
2392
|
+
const updateState = selfUpdateState(input.environment);
|
|
2294
2393
|
const abort = new AbortController();
|
|
2295
2394
|
let timer;
|
|
2296
2395
|
try {
|
|
@@ -2302,6 +2401,10 @@ async function loadGuidance(input) {
|
|
|
2302
2401
|
headers: {
|
|
2303
2402
|
authorization: `Bearer ${input.agent.token}`,
|
|
2304
2403
|
[CLIENT_HEADER]: CLIENT_HEADER_VALUE,
|
|
2404
|
+
// How the last background update ended, so a laptop that cannot
|
|
2405
|
+
// update itself is seen on the scorecard rather than guessed at.
|
|
2406
|
+
...updateState === void 0 ? {} : { [UPDATE_STATE_HEADER]: updateState },
|
|
2407
|
+
...input.hook === void 0 ? {} : { [HOOK_HOST_HEADER]: input.hook },
|
|
2305
2408
|
accept: "application/json",
|
|
2306
2409
|
...previous ? { "if-none-match": previous.etag } : {}
|
|
2307
2410
|
}
|
|
@@ -2325,11 +2428,11 @@ async function loadGuidance(input) {
|
|
|
2325
2428
|
if (directory) {
|
|
2326
2429
|
try {
|
|
2327
2430
|
const key = sha(JSON.stringify([scopeKey, document.revision]));
|
|
2328
|
-
atomicJson(
|
|
2329
|
-
atomicJson(
|
|
2431
|
+
atomicJson(join3(directory, `${key}.body.json`), { document, etag });
|
|
2432
|
+
atomicJson(join3(directory, `${scopeKey}.current.json`), { key });
|
|
2330
2433
|
if (previous && previous.key !== key) {
|
|
2331
2434
|
try {
|
|
2332
|
-
unlinkSync2(
|
|
2435
|
+
unlinkSync2(join3(directory, `${previous.key}.body.json`));
|
|
2333
2436
|
} catch {
|
|
2334
2437
|
}
|
|
2335
2438
|
}
|
|
@@ -2357,7 +2460,7 @@ async function loadGuidance(input) {
|
|
|
2357
2460
|
if (timer) clearTimeout(timer);
|
|
2358
2461
|
}
|
|
2359
2462
|
}
|
|
2360
|
-
function recordGuidanceContext(input,
|
|
2463
|
+
function recordGuidanceContext(input, write2) {
|
|
2361
2464
|
const always = input.event !== "UserPromptSubmit" || input.load.status === "unavailable" || !input.sessionId;
|
|
2362
2465
|
const contextKey = sha(
|
|
2363
2466
|
JSON.stringify([
|
|
@@ -2371,13 +2474,13 @@ function recordGuidanceContext(input, write) {
|
|
|
2371
2474
|
let path;
|
|
2372
2475
|
let emitted = true;
|
|
2373
2476
|
try {
|
|
2374
|
-
path =
|
|
2477
|
+
path = join3(cacheDirectory(input.environment), `${contextKey}.context.json`);
|
|
2375
2478
|
const previous = readJson(path);
|
|
2376
2479
|
emitted = always || !object(previous) || previous.status === "unavailable" || previous.digest !== document?.digest || previous.revision !== document?.revision || (previous.runtimeGeneration ?? null) !== (input.runtimeGeneration ?? null);
|
|
2377
2480
|
} catch {
|
|
2378
2481
|
}
|
|
2379
2482
|
if (!emitted) return false;
|
|
2380
|
-
|
|
2483
|
+
write2();
|
|
2381
2484
|
if (path) {
|
|
2382
2485
|
try {
|
|
2383
2486
|
atomicJson(path, {
|
|
@@ -2404,50 +2507,45 @@ import {
|
|
|
2404
2507
|
closeSync as closeSync5,
|
|
2405
2508
|
fstatSync as fstatSync2,
|
|
2406
2509
|
openSync as openSync5,
|
|
2407
|
-
existsSync as
|
|
2510
|
+
existsSync as existsSync3,
|
|
2408
2511
|
lstatSync as lstatSync4,
|
|
2409
2512
|
linkSync,
|
|
2410
|
-
mkdirSync as
|
|
2411
|
-
readFileSync as
|
|
2513
|
+
mkdirSync as mkdirSync5,
|
|
2514
|
+
readFileSync as readFileSync6,
|
|
2412
2515
|
renameSync as renameSync5,
|
|
2413
2516
|
unlinkSync as unlinkSync5,
|
|
2414
|
-
writeFileSync as
|
|
2517
|
+
writeFileSync as writeFileSync3
|
|
2415
2518
|
} from "node:fs";
|
|
2416
2519
|
var import_toml2 = __toESM(require_toml(), 1);
|
|
2417
|
-
import { dirname as
|
|
2520
|
+
import { dirname as dirname4, join as join6, relative } from "node:path";
|
|
2418
2521
|
|
|
2419
2522
|
// src/codex-config.ts
|
|
2420
2523
|
var import_toml = __toESM(require_toml(), 1);
|
|
2421
2524
|
import {
|
|
2422
2525
|
closeSync as closeSync4,
|
|
2423
|
-
existsSync,
|
|
2526
|
+
existsSync as existsSync2,
|
|
2424
2527
|
fsyncSync as fsyncSync3,
|
|
2425
2528
|
lstatSync as lstatSync3,
|
|
2426
|
-
mkdirSync as
|
|
2529
|
+
mkdirSync as mkdirSync4,
|
|
2427
2530
|
openSync as openSync4,
|
|
2428
|
-
readFileSync as
|
|
2531
|
+
readFileSync as readFileSync5,
|
|
2429
2532
|
renameSync as renameSync4,
|
|
2430
2533
|
unlinkSync as unlinkSync4,
|
|
2431
2534
|
writeSync as writeSync3
|
|
2432
2535
|
} from "node:fs";
|
|
2433
|
-
import { join as
|
|
2536
|
+
import { join as join5 } from "node:path";
|
|
2434
2537
|
|
|
2435
2538
|
// src/mcp-config.ts
|
|
2436
2539
|
import {
|
|
2437
2540
|
closeSync as closeSync3,
|
|
2438
2541
|
fsyncSync as fsyncSync2,
|
|
2439
2542
|
openSync as openSync3,
|
|
2440
|
-
readFileSync as
|
|
2543
|
+
readFileSync as readFileSync4,
|
|
2441
2544
|
renameSync as renameSync3,
|
|
2442
2545
|
unlinkSync as unlinkSync3,
|
|
2443
2546
|
writeSync as writeSync2
|
|
2444
2547
|
} from "node:fs";
|
|
2445
|
-
import { basename, dirname as
|
|
2446
|
-
|
|
2447
|
-
// src/release.ts
|
|
2448
|
-
var HOOK_SPECIFIER = "balladeer@1";
|
|
2449
|
-
|
|
2450
|
-
// src/mcp-config.ts
|
|
2548
|
+
import { basename, dirname as dirname3, isAbsolute, join as join4 } from "node:path";
|
|
2451
2549
|
var MCP_CONFIG_FILE = ".mcp.json";
|
|
2452
2550
|
function sameOrigin(left, right) {
|
|
2453
2551
|
try {
|
|
@@ -2490,7 +2588,7 @@ function currentEntry(parsed) {
|
|
|
2490
2588
|
}
|
|
2491
2589
|
function readMcpConfig(repositoryRoot) {
|
|
2492
2590
|
try {
|
|
2493
|
-
return JSON.parse(
|
|
2591
|
+
return JSON.parse(readFileSync4(join4(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
|
|
2494
2592
|
} catch {
|
|
2495
2593
|
return void 0;
|
|
2496
2594
|
}
|
|
@@ -2500,7 +2598,7 @@ function readMcpConfig(repositoryRoot) {
|
|
|
2500
2598
|
var CODEX_CONFIG_FILE = ".codex/config.toml";
|
|
2501
2599
|
function readCodexEntry(root) {
|
|
2502
2600
|
try {
|
|
2503
|
-
const config = (0, import_toml.parse)(
|
|
2601
|
+
const config = (0, import_toml.parse)(readFileSync5(join5(root, CODEX_CONFIG_FILE), "utf8"));
|
|
2504
2602
|
const servers = config.mcp_servers;
|
|
2505
2603
|
return servers && typeof servers === "object" && !Array.isArray(servers) && !(servers instanceof Date) ? servers.balladeer : void 0;
|
|
2506
2604
|
} catch {
|
|
@@ -2509,7 +2607,7 @@ function readCodexEntry(root) {
|
|
|
2509
2607
|
}
|
|
2510
2608
|
function hasManagedCodexEntry(root, controlPlane) {
|
|
2511
2609
|
try {
|
|
2512
|
-
const text =
|
|
2610
|
+
const text = readFileSync5(join5(root, CODEX_CONFIG_FILE), "utf8");
|
|
2513
2611
|
const starts = [...text.matchAll(/^# balladeer:mcp:start\r?$/gm)];
|
|
2514
2612
|
const ends = [...text.matchAll(/^# balladeer:mcp:end\r?$/gm)];
|
|
2515
2613
|
if (starts.length !== 1 || ends.length !== 1 || !starts[0] || !ends[0] || starts[0].index >= ends[0].index)
|
|
@@ -2853,7 +2951,7 @@ in for them to read. The tool sends nothing itself: an administrator presses Sen
|
|
|
2853
2951
|
// src/guidance-install.ts
|
|
2854
2952
|
var MAX_FILE = 262144;
|
|
2855
2953
|
function readRegular(path, allowMissing = false, writable = true) {
|
|
2856
|
-
if (!
|
|
2954
|
+
if (!existsSync3(path)) {
|
|
2857
2955
|
try {
|
|
2858
2956
|
lstatSync4(path);
|
|
2859
2957
|
} catch (e) {
|
|
@@ -2864,10 +2962,10 @@ function readRegular(path, allowMissing = false, writable = true) {
|
|
|
2864
2962
|
const stat = lstatSync4(path);
|
|
2865
2963
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE || writable && (stat.mode & 146) === 0)
|
|
2866
2964
|
throw new Error("file_not_safely_writable");
|
|
2867
|
-
return
|
|
2965
|
+
return readFileSync6(path, "utf8");
|
|
2868
2966
|
}
|
|
2869
2967
|
function safeParent(root, path) {
|
|
2870
|
-
let at =
|
|
2968
|
+
let at = dirname4(path);
|
|
2871
2969
|
while (at !== root) {
|
|
2872
2970
|
if (relative(root, at).startsWith("..")) throw new Error("outside_repository");
|
|
2873
2971
|
try {
|
|
@@ -2876,7 +2974,7 @@ function safeParent(root, path) {
|
|
|
2876
2974
|
} catch (e) {
|
|
2877
2975
|
if (e.code !== "ENOENT") throw e;
|
|
2878
2976
|
}
|
|
2879
|
-
at =
|
|
2977
|
+
at = dirname4(at);
|
|
2880
2978
|
}
|
|
2881
2979
|
}
|
|
2882
2980
|
function validateProjectGuidanceScope(options) {
|
|
@@ -2886,7 +2984,7 @@ function validateProjectGuidanceScope(options) {
|
|
|
2886
2984
|
stdio: ["ignore", "pipe", "ignore"],
|
|
2887
2985
|
timeout: 1e3
|
|
2888
2986
|
}).trim();
|
|
2889
|
-
const path =
|
|
2987
|
+
const path = join6(root, options.hook === "codex" ? ".codex/config.toml" : ".mcp.json");
|
|
2890
2988
|
safeParent(root, path);
|
|
2891
2989
|
readRegular(path, false, false);
|
|
2892
2990
|
const entry = options.hook === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
|
|
@@ -2999,47 +3097,33 @@ function repositoryHints(cwd = process.cwd()) {
|
|
|
2999
3097
|
return hints;
|
|
3000
3098
|
}
|
|
3001
3099
|
|
|
3002
|
-
// src/
|
|
3003
|
-
import {
|
|
3004
|
-
import {
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3008
|
-
function stampPath(environment) {
|
|
3009
|
-
return join6(configHome(environment), STAMP);
|
|
3010
|
-
}
|
|
3011
|
-
function lastStarted(environment) {
|
|
3012
|
-
const path = stampPath(environment);
|
|
3013
|
-
if (!existsSync3(path)) return 0;
|
|
3100
|
+
// src/hook-trust.ts
|
|
3101
|
+
import { mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3102
|
+
import { join as join7 } from "node:path";
|
|
3103
|
+
var FILE = "hook-trust.json";
|
|
3104
|
+
function read(environment) {
|
|
3014
3105
|
try {
|
|
3015
|
-
const parsed = JSON.parse(
|
|
3016
|
-
return typeof parsed
|
|
3106
|
+
const parsed = JSON.parse(readFileSync7(join7(configHome(environment), FILE), "utf8"));
|
|
3107
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3017
3108
|
} catch {
|
|
3018
|
-
return
|
|
3109
|
+
return {};
|
|
3019
3110
|
}
|
|
3020
3111
|
}
|
|
3021
|
-
function
|
|
3022
|
-
const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
|
|
3023
|
-
child.on("error", () => void 0);
|
|
3024
|
-
child.unref();
|
|
3025
|
-
}
|
|
3026
|
-
function startSelfUpdateIfDue(newer, deps) {
|
|
3027
|
-
if (newer === void 0) return false;
|
|
3028
|
-
const now = deps.now ?? Date.now;
|
|
3029
|
-
if (now() - lastStarted(deps.environment) < DAY_MS) return false;
|
|
3112
|
+
function write(environment, marks) {
|
|
3030
3113
|
try {
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
{ mode: 384 }
|
|
3036
|
-
);
|
|
3037
|
-
(deps.run ?? detached)("npx", ["-y", HOOK_SPECIFIER, "install", "--json"]);
|
|
3038
|
-
return true;
|
|
3114
|
+
mkdirSync6(configHome(environment), { recursive: true, mode: 448 });
|
|
3115
|
+
writeFileSync4(join7(configHome(environment), FILE), JSON.stringify(marks) + "\n", {
|
|
3116
|
+
mode: 384
|
|
3117
|
+
});
|
|
3039
3118
|
} catch {
|
|
3040
|
-
return false;
|
|
3041
3119
|
}
|
|
3042
3120
|
}
|
|
3121
|
+
function noteHookFired(environment, host, now = Date.now()) {
|
|
3122
|
+
const marks = read(environment);
|
|
3123
|
+
const mark = marks[host];
|
|
3124
|
+
if (mark?.firedAt !== void 0 && mark.firedAt >= (mark.writtenAt ?? 0)) return;
|
|
3125
|
+
write(environment, { ...marks, [host]: { ...mark, firedAt: now } });
|
|
3126
|
+
}
|
|
3043
3127
|
|
|
3044
3128
|
// src/commands/guidance.ts
|
|
3045
3129
|
function readInput(stream) {
|
|
@@ -3118,10 +3202,12 @@ async function runGuidance(options) {
|
|
|
3118
3202
|
if (projectScoped) throw new Error("guidance_connection_unavailable");
|
|
3119
3203
|
return 0;
|
|
3120
3204
|
}
|
|
3205
|
+
if (options.hook === "codex") noteHookFired(options.environment, "codex");
|
|
3121
3206
|
const loaded = await loadGuidance({
|
|
3122
3207
|
agent: agents[0],
|
|
3123
3208
|
environment: options.environment,
|
|
3124
3209
|
timeoutMs: 750,
|
|
3210
|
+
hook: options.hook,
|
|
3125
3211
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
3126
3212
|
});
|
|
3127
3213
|
if (event === "SessionStart")
|
package/dist/guidance.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export declare function loadGuidance(input: {
|
|
|
24
24
|
environment: NodeJS.ProcessEnv;
|
|
25
25
|
fetchImpl?: typeof fetch;
|
|
26
26
|
timeoutMs?: number;
|
|
27
|
+
/** The host whose hook is asking, so the server can tell a laptop where one host never asks. */
|
|
28
|
+
hook?: "codex" | "claude";
|
|
27
29
|
}): Promise<GuidanceLoad>;
|
|
28
30
|
/** Writes context first, then records emission metadata; this never claims model receipt. */
|
|
29
31
|
export declare function recordGuidanceContext(input: {
|
package/dist/guidance.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { noteServerVersion } from "./currency.js";
|
|
2
|
-
import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
|
|
2
|
+
import { CLIENT_HEADER, CLIENT_HEADER_VALUE, HOOK_HOST_HEADER, UPDATE_STATE_HEADER, } from "./wire.js";
|
|
3
|
+
import { selfUpdateState } from "./self-update.js";
|
|
3
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
5
|
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
5
6
|
import { join } from "node:path";
|
|
@@ -168,6 +169,7 @@ export async function loadGuidance(input) {
|
|
|
168
169
|
/* Cache is optional. */
|
|
169
170
|
}
|
|
170
171
|
const previous = directory ? cached(directory, scopeKey, input.agent) : undefined;
|
|
172
|
+
const updateState = selfUpdateState(input.environment);
|
|
171
173
|
const abort = new AbortController();
|
|
172
174
|
let timer;
|
|
173
175
|
try {
|
|
@@ -179,6 +181,10 @@ export async function loadGuidance(input) {
|
|
|
179
181
|
headers: {
|
|
180
182
|
authorization: `Bearer ${input.agent.token}`,
|
|
181
183
|
[CLIENT_HEADER]: CLIENT_HEADER_VALUE,
|
|
184
|
+
// How the last background update ended, so a laptop that cannot
|
|
185
|
+
// update itself is seen on the scorecard rather than guessed at.
|
|
186
|
+
...(updateState === undefined ? {} : { [UPDATE_STATE_HEADER]: updateState }),
|
|
187
|
+
...(input.hook === undefined ? {} : { [HOOK_HOST_HEADER]: input.hook }),
|
|
182
188
|
accept: "application/json",
|
|
183
189
|
...(previous ? { "if-none-match": previous.etag } : {}),
|
|
184
190
|
},
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowing when a host has not run the hooks this command last wrote, so the
|
|
3
|
+
* person is told to approve them whenever that is true, not only at setup.
|
|
4
|
+
*
|
|
5
|
+
* Robert, 29 September 2026: hooks will change from release to release, and
|
|
6
|
+
* most releases arrive through the background update, which says nothing to
|
|
7
|
+
* anybody. Codex trusts a hook by the hash of its definition and skips a new
|
|
8
|
+
* or changed one until the person trusts it under /hooks, so a laptop can
|
|
9
|
+
* update itself into silence. Two small marks settle it without reading
|
|
10
|
+
* anything of the host's: when this command last wrote a host's hooks, and
|
|
11
|
+
* when one of that host's hooks last ran. Written after fired means the
|
|
12
|
+
* current hooks have never run, and the host's own session is where the
|
|
13
|
+
* person hears it.
|
|
14
|
+
*/
|
|
15
|
+
export type TrustingHost = "codex";
|
|
16
|
+
/** Hosts that run a hook only after the person has approved it. */
|
|
17
|
+
export declare const HOSTS_REQUIRING_TRUST: readonly TrustingHost[];
|
|
18
|
+
/**
|
|
19
|
+
* This command wrote or changed the host's hooks, or found them in place with
|
|
20
|
+
* no record that it ever had: either way, from now the host has to have run
|
|
21
|
+
* them once before anybody assumes it does.
|
|
22
|
+
*/
|
|
23
|
+
export declare function noteHooksWritten(environment: NodeJS.ProcessEnv, host: TrustingHost, changed: boolean, now?: number): void;
|
|
24
|
+
/** One of the host's hooks ran, which it does only once they are trusted. */
|
|
25
|
+
export declare function noteHookFired(environment: NodeJS.ProcessEnv, host: TrustingHost, now?: number): void;
|
|
26
|
+
/** True while the hooks last written for this host have never run in it. */
|
|
27
|
+
export declare function hooksAwaitingTrust(environment: NodeJS.ProcessEnv, host: TrustingHost): boolean;
|
|
28
|
+
/** The host a session belongs to, from the name it gave when it said hello. */
|
|
29
|
+
export declare function trustingHost(clientName: unknown): TrustingHost | undefined;
|
|
30
|
+
/** What the agent is asked to pass on, once, in the host where the approval happens. */
|
|
31
|
+
export declare const HOOKS_AWAITING_TRUST_NOTICE = "Note from Balladeer on this laptop, to pass on to the person once: Balladeer's hooks here were added or changed and Codex has not run them. Codex runs a hook only after the person trusts it. Ask them to type /hooks in Codex and trust Balladeer's three hooks; until then Codex has Balladeer's tools without its session-start guidance or its updates.";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { configHome } from "./store.js";
|
|
4
|
+
/** Hosts that run a hook only after the person has approved it. */
|
|
5
|
+
export const HOSTS_REQUIRING_TRUST = ["codex"];
|
|
6
|
+
const FILE = "hook-trust.json";
|
|
7
|
+
function read(environment) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(readFileSync(join(configHome(environment), FILE), "utf8"));
|
|
10
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
11
|
+
? parsed
|
|
12
|
+
: {};
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function write(environment, marks) {
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
|
|
21
|
+
writeFileSync(join(configHome(environment), FILE), JSON.stringify(marks) + "\n", {
|
|
22
|
+
mode: 0o600,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* A mark that cannot be written costs a notice, never a session. */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* This command wrote or changed the host's hooks, or found them in place with
|
|
31
|
+
* no record that it ever had: either way, from now the host has to have run
|
|
32
|
+
* them once before anybody assumes it does.
|
|
33
|
+
*/
|
|
34
|
+
export function noteHooksWritten(environment, host, changed, now = Date.now()) {
|
|
35
|
+
const marks = read(environment);
|
|
36
|
+
if (!changed && marks[host]?.writtenAt !== undefined)
|
|
37
|
+
return;
|
|
38
|
+
write(environment, { ...marks, [host]: { ...marks[host], writtenAt: now } });
|
|
39
|
+
}
|
|
40
|
+
/** One of the host's hooks ran, which it does only once they are trusted. */
|
|
41
|
+
export function noteHookFired(environment, host, now = Date.now()) {
|
|
42
|
+
const marks = read(environment);
|
|
43
|
+
const mark = marks[host];
|
|
44
|
+
// Nothing to settle, or already settled: no write on the session's path.
|
|
45
|
+
if (mark?.firedAt !== undefined && mark.firedAt >= (mark.writtenAt ?? 0))
|
|
46
|
+
return;
|
|
47
|
+
write(environment, { ...marks, [host]: { ...mark, firedAt: now } });
|
|
48
|
+
}
|
|
49
|
+
/** True while the hooks last written for this host have never run in it. */
|
|
50
|
+
export function hooksAwaitingTrust(environment, host) {
|
|
51
|
+
const mark = read(environment)[host];
|
|
52
|
+
if (mark?.writtenAt === undefined)
|
|
53
|
+
return false;
|
|
54
|
+
return mark.firedAt === undefined || mark.firedAt < mark.writtenAt;
|
|
55
|
+
}
|
|
56
|
+
/** The host a session belongs to, from the name it gave when it said hello. */
|
|
57
|
+
export function trustingHost(clientName) {
|
|
58
|
+
return typeof clientName === "string" && clientName.toLowerCase().startsWith("codex")
|
|
59
|
+
? "codex"
|
|
60
|
+
: undefined;
|
|
61
|
+
}
|
|
62
|
+
/** What the agent is asked to pass on, once, in the host where the approval happens. */
|
|
63
|
+
export const HOOKS_AWAITING_TRUST_NOTICE = "Note from Balladeer on this laptop, to pass on to the person once: Balladeer's hooks here were added or changed and Codex has not run them. Codex runs a hook only after the person trusts it. Ask them to type /hooks in Codex and trust Balladeer's three hooks; until then Codex has Balladeer's tools without its session-start guidance or its updates.";
|
package/dist/self-update.d.ts
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
|
+
/** Set on the child so the install it runs can record how it ended. */
|
|
2
|
+
export declare const SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
|
|
3
|
+
export type Launch = Readonly<{
|
|
4
|
+
command: string;
|
|
5
|
+
args: readonly string[];
|
|
6
|
+
environment: NodeJS.ProcessEnv;
|
|
7
|
+
}>;
|
|
1
8
|
export type SelfUpdateDeps = Readonly<{
|
|
2
9
|
environment: NodeJS.ProcessEnv;
|
|
3
10
|
now?: () => number;
|
|
4
|
-
|
|
11
|
+
/** Starts the child and answers whether one exists. Absent a return value, it started. */
|
|
12
|
+
run?: (command: string, args: readonly string[], environment: NodeJS.ProcessEnv) => boolean | void;
|
|
13
|
+
/** The node binary this process runs under; the launcher is found beside it. */
|
|
14
|
+
execPath?: string;
|
|
15
|
+
exists?: (path: string) => boolean;
|
|
5
16
|
}>;
|
|
17
|
+
/** A short, closed vocabulary: it travels in a header and lands in a column. */
|
|
18
|
+
export type SelfUpdateState = `started:${string}` | `unstartable:${string}` | `installed:${string}` | `failed:${string}`;
|
|
19
|
+
/** What the last attempt came to, for the next hook fire to report. Never throws. */
|
|
20
|
+
export declare function recordSelfUpdateState(environment: NodeJS.ProcessEnv, state: SelfUpdateState, at: number): void;
|
|
21
|
+
/** The recorded state, or nothing when none was written or it is not one of ours. */
|
|
22
|
+
export declare function selfUpdateState(environment: NodeJS.ProcessEnv): SelfUpdateState | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* How to start the installer without trusting PATH. npm ships npx as a script
|
|
25
|
+
* beside the node it belongs to, so the node this hook runs under can run it
|
|
26
|
+
* directly; that node certainly exists, which is what makes the launch
|
|
27
|
+
* dependable. The shim beside node is second, and PATH is last, for the
|
|
28
|
+
* layouts that keep npm somewhere else.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveLaunch(specifier: string, environment: NodeJS.ProcessEnv, execPath?: string, exists?: (path: string) => boolean): Launch;
|
|
6
31
|
/**
|
|
7
32
|
* Start the update when the server named a newer release and none was
|
|
8
33
|
* started today. Answers whether one was started, for the caller's own
|
|
9
|
-
* record; the session hears nothing either way.
|
|
34
|
+
* record; the session hears nothing either way. A launch that could not start
|
|
35
|
+
* leaves no stamp, so the next session start tries again rather than waiting
|
|
36
|
+
* a day for the same failure.
|
|
10
37
|
*/
|
|
11
38
|
export declare function startSelfUpdateIfDue(newer: string | undefined, deps: SelfUpdateDeps): boolean;
|
package/dist/self-update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
4
|
import { HOOK_SPECIFIER } from "./release.js";
|
|
5
5
|
import { configHome } from "./store.js";
|
|
6
6
|
/**
|
|
@@ -10,15 +10,33 @@ import { configHome } from "./store.js";
|
|
|
10
10
|
* update must never hold the agent. So the hook, having served this session
|
|
11
11
|
* from the copy it has, only starts the install: a detached child on the
|
|
12
12
|
* current major, output discarded, at most once a day per laptop whatever it
|
|
13
|
-
* reports. The next session runs whatever that child installed.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* reports. The next session runs whatever that child installed. `balladeer
|
|
14
|
+
* update` is the manual path. Nothing here reads the session or writes to it.
|
|
15
|
+
*
|
|
16
|
+
* What changed on 29 September 2026, from what Didero's laptops showed: four
|
|
17
|
+
* of nine stayed on 1.0.12 through two releases while active every day. The
|
|
18
|
+
* hook reached for `npx` on whatever PATH its host gave it, wrote "tried
|
|
19
|
+
* today" before it knew whether anything had started, and swallowed the
|
|
20
|
+
* failure, so a laptop whose editor launches hooks without npm on PATH failed
|
|
21
|
+
* silently once a day, for ever. Now the installer is launched through the
|
|
22
|
+
* node binary this hook is already running under, with npm's own npx script
|
|
23
|
+
* beside it; the day's stamp is written only once a child actually exists;
|
|
24
|
+
* and what happened is kept in a small state file the next hook fire reports
|
|
25
|
+
* to the server, so a laptop that cannot update is visible on the scorecard
|
|
26
|
+
* instead of looking merely behind.
|
|
16
27
|
*/
|
|
17
28
|
const STAMP = "self-update.json";
|
|
29
|
+
const STATE = "self-update-state.json";
|
|
18
30
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
/** Set on the child so the install it runs can record how it ended. */
|
|
32
|
+
export const SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
|
|
33
|
+
const STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
|
|
19
34
|
function stampPath(environment) {
|
|
20
35
|
return join(configHome(environment), STAMP);
|
|
21
36
|
}
|
|
37
|
+
function statePath(environment) {
|
|
38
|
+
return join(configHome(environment), STATE);
|
|
39
|
+
}
|
|
22
40
|
function lastStarted(environment) {
|
|
23
41
|
const path = stampPath(environment);
|
|
24
42
|
if (!existsSync(path))
|
|
@@ -31,15 +49,71 @@ function lastStarted(environment) {
|
|
|
31
49
|
return 0;
|
|
32
50
|
}
|
|
33
51
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
52
|
+
function writePrivate(path, value, environment) {
|
|
53
|
+
mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
|
|
54
|
+
writeFileSync(path, JSON.stringify(value) + "\n", { mode: 0o600 });
|
|
55
|
+
}
|
|
56
|
+
/** What the last attempt came to, for the next hook fire to report. Never throws. */
|
|
57
|
+
export function recordSelfUpdateState(environment, state, at) {
|
|
58
|
+
if (!STATE_PATTERN.test(state))
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
writePrivate(statePath(environment), { state, at }, environment);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* A state that cannot be written is only a state nobody hears about. */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The recorded state, or nothing when none was written or it is not one of ours. */
|
|
68
|
+
export function selfUpdateState(environment) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(statePath(environment), "utf8"));
|
|
71
|
+
return typeof parsed.state === "string" && STATE_PATTERN.test(parsed.state)
|
|
72
|
+
? parsed.state
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* How to start the installer without trusting PATH. npm ships npx as a script
|
|
81
|
+
* beside the node it belongs to, so the node this hook runs under can run it
|
|
82
|
+
* directly; that node certainly exists, which is what makes the launch
|
|
83
|
+
* dependable. The shim beside node is second, and PATH is last, for the
|
|
84
|
+
* layouts that keep npm somewhere else.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveLaunch(specifier, environment, execPath = process.execPath, exists = existsSync) {
|
|
87
|
+
const bin = dirname(execPath);
|
|
88
|
+
const tail = ["-y", specifier, "install", "--json"];
|
|
89
|
+
// The child's own children (npm's scripts) need to find node too.
|
|
90
|
+
const child = {
|
|
91
|
+
...environment,
|
|
92
|
+
PATH: [bin, environment.PATH ?? ""].filter((part) => part.length > 0).join(delimiter),
|
|
93
|
+
[SELF_UPDATE_ENVIRONMENT]: "1",
|
|
94
|
+
};
|
|
95
|
+
const script = join(bin, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js");
|
|
96
|
+
if (exists(script))
|
|
97
|
+
return { command: execPath, args: [script, ...tail], environment: child };
|
|
98
|
+
const shim = join(bin, process.platform === "win32" ? "npx.cmd" : "npx");
|
|
99
|
+
if (exists(shim))
|
|
100
|
+
return { command: shim, args: tail, environment: child };
|
|
101
|
+
return { command: "npx", args: tail, environment: child };
|
|
102
|
+
}
|
|
103
|
+
function detached(command, args, environment) {
|
|
104
|
+
const child = spawn(command, [...args], { detached: true, stdio: "ignore", env: environment });
|
|
36
105
|
child.on("error", () => undefined);
|
|
37
106
|
child.unref();
|
|
107
|
+
// A command that could not be found has no process id, and that is known
|
|
108
|
+
// before this function returns.
|
|
109
|
+
return child.pid !== undefined;
|
|
38
110
|
}
|
|
39
111
|
/**
|
|
40
112
|
* Start the update when the server named a newer release and none was
|
|
41
113
|
* started today. Answers whether one was started, for the caller's own
|
|
42
|
-
* record; the session hears nothing either way.
|
|
114
|
+
* record; the session hears nothing either way. A launch that could not start
|
|
115
|
+
* leaves no stamp, so the next session start tries again rather than waiting
|
|
116
|
+
* a day for the same failure.
|
|
43
117
|
*/
|
|
44
118
|
export function startSelfUpdateIfDue(newer, deps) {
|
|
45
119
|
if (newer === undefined)
|
|
@@ -47,13 +121,24 @@ export function startSelfUpdateIfDue(newer, deps) {
|
|
|
47
121
|
const now = deps.now ?? Date.now;
|
|
48
122
|
if (now() - lastStarted(deps.environment) < DAY_MS)
|
|
49
123
|
return false;
|
|
124
|
+
const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
|
|
125
|
+
let started;
|
|
50
126
|
try {
|
|
51
|
-
|
|
52
|
-
writeFileSync(stampPath(deps.environment), JSON.stringify({ startedAt: now(), toward: newer }) + "\n", { mode: 0o600 });
|
|
53
|
-
(deps.run ?? detached)("npx", ["-y", HOOK_SPECIFIER, "install", "--json"]);
|
|
54
|
-
return true;
|
|
127
|
+
started = (deps.run ?? detached)(launch.command, launch.args, launch.environment) !== false;
|
|
55
128
|
}
|
|
56
129
|
catch {
|
|
130
|
+
started = false;
|
|
131
|
+
}
|
|
132
|
+
if (!started) {
|
|
133
|
+
recordSelfUpdateState(deps.environment, "unstartable:launcher", now());
|
|
57
134
|
return false;
|
|
58
135
|
}
|
|
136
|
+
try {
|
|
137
|
+
writePrivate(stampPath(deps.environment), { startedAt: now(), toward: newer }, deps.environment);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* The child is running; an unwritten stamp costs one more attempt, not a missed one. */
|
|
141
|
+
}
|
|
142
|
+
recordSelfUpdateState(deps.environment, `started:${newer}`, now());
|
|
143
|
+
return true;
|
|
59
144
|
}
|
package/dist/user-scope.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export declare function isOurUserEntry(entry: unknown): boolean;
|
|
|
53
53
|
export declare function isInstalledCommandPath(command: string): boolean;
|
|
54
54
|
/** `~/.claude/settings.json` carries user-scope hooks. */
|
|
55
55
|
export declare function mergeClaudeUserHooks(home: string, published: boolean, installed?: string): UserScopeWrite;
|
|
56
|
+
/** What a person has to do once in Codex, which no command can do for them. */
|
|
57
|
+
export declare const CODEX_HOOK_APPROVAL = "Codex: type /hooks in a Codex session and trust Balladeer's three hooks. Codex skips a hook until you have trusted it, so until then Codex has Balladeer's tools and none of its session-start guidance or updates.";
|
|
56
58
|
/** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
|
|
57
59
|
* one fenced block this command owns end to end. */
|
|
58
60
|
export declare function mergeCodexUserConfig(home: string, published: boolean, installed?: string): UserScopeWrite;
|
package/dist/user-scope.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { noteHooksWritten } from "./hook-trust.js";
|
|
1
2
|
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -207,6 +208,21 @@ export function mergeClaudeUserHooks(home, published, installed) {
|
|
|
207
208
|
return { host, path, status: "refused", reason: "permissions.allow is not a list" };
|
|
208
209
|
const deny = Array.isArray(permissions.deny) ? permissions.deny : [];
|
|
209
210
|
const denied = deny.some((rule) => typeof rule === "string" && /^mcp__balladeer(__|$)/.test(rule));
|
|
211
|
+
// An event this release no longer hooks: its own entry there is deprecated
|
|
212
|
+
// and comes out. Robert, 29 September 2026: a hook that should no longer
|
|
213
|
+
// exist is removed, a new one is added, one that still belongs persists.
|
|
214
|
+
for (const [event, rows] of Object.entries(hooks)) {
|
|
215
|
+
if (CLAUDE_EVENTS.includes(event) || !Array.isArray(rows))
|
|
216
|
+
continue;
|
|
217
|
+
const kept = rows.filter((row) => !row?.hooks?.some((h) => h.statusMessage === USER_SCOPE_OWNER));
|
|
218
|
+
if (kept.length === rows.length)
|
|
219
|
+
continue;
|
|
220
|
+
if (kept.length === 0)
|
|
221
|
+
delete hooks[event];
|
|
222
|
+
else
|
|
223
|
+
hooks[event] = kept;
|
|
224
|
+
changed = true;
|
|
225
|
+
}
|
|
210
226
|
if (!denied && !allow.some((rule) => rule === USER_SCOPE_ALLOW_RULE)) {
|
|
211
227
|
permissions.allow = [...allow, USER_SCOPE_ALLOW_RULE];
|
|
212
228
|
root.permissions = permissions;
|
|
@@ -219,6 +235,8 @@ export function mergeClaudeUserHooks(home, published, installed) {
|
|
|
219
235
|
writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
|
|
220
236
|
return { host, path, status: "written" };
|
|
221
237
|
}
|
|
238
|
+
/** What a person has to do once in Codex, which no command can do for them. */
|
|
239
|
+
export const CODEX_HOOK_APPROVAL = "Codex: type /hooks in a Codex session and trust Balladeer's three hooks. Codex skips a hook until you have trusted it, so until then Codex has Balladeer's tools and none of its session-start guidance or updates.";
|
|
222
240
|
const CODEX_START = "# balladeer:user:start";
|
|
223
241
|
const CODEX_END = "# balladeer:user:end";
|
|
224
242
|
/** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
|
|
@@ -302,7 +320,13 @@ export function installUserScope(options) {
|
|
|
302
320
|
mergeClaudeUserMcp(home, options.published, options.installed),
|
|
303
321
|
mergeClaudeUserHooks(home, options.published, options.installed),
|
|
304
322
|
];
|
|
305
|
-
if (existsSync(join(home, ".codex")))
|
|
306
|
-
|
|
323
|
+
if (existsSync(join(home, ".codex"))) {
|
|
324
|
+
const codex = mergeCodexUserConfig(home, options.published, options.installed);
|
|
325
|
+
writes.push(codex);
|
|
326
|
+
// Codex has to run these once before anybody assumes it does; the mark is
|
|
327
|
+
// what lets its own session say so after a silent background update.
|
|
328
|
+
if (codex.status !== "refused")
|
|
329
|
+
noteHooksWritten(options.environment, "codex", codex.status === "written");
|
|
330
|
+
}
|
|
307
331
|
return writes;
|
|
308
332
|
}
|
package/dist/wire.d.ts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
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.
|
|
8
|
+
export declare const CLI_VERSION = "1.0.15";
|
|
9
9
|
export declare const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export declare const CLIENT_HEADER = "x-balladeer-client";
|
|
11
|
-
|
|
11
|
+
/** What the last background update came to: `started:1.0.15`, `installed:1.0.15`, `failed:exit-1`, `unstartable:launcher`. */
|
|
12
|
+
export declare const UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
13
|
+
/** Which host's hook made this guidance fetch: `claude` or `codex`. */
|
|
14
|
+
export declare const HOOK_HOST_HEADER = "x-balladeer-hook";
|
|
15
|
+
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.15";
|
|
12
16
|
export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
13
17
|
export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
|
|
14
18
|
export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
|
package/dist/wire.js
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
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.
|
|
8
|
+
export const CLI_VERSION = "1.0.15";
|
|
9
9
|
export const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export const CLIENT_HEADER = "x-balladeer-client";
|
|
11
|
+
/** What the last background update came to: `started:1.0.15`, `installed:1.0.15`, `failed:exit-1`, `unstartable:launcher`. */
|
|
12
|
+
export const UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
13
|
+
/** Which host's hook made this guidance fetch: `claude` or `codex`. */
|
|
14
|
+
export const HOOK_HOST_HEADER = "x-balladeer-hook";
|
|
11
15
|
export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|
|
12
16
|
export const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
13
17
|
export const DELEGATED_SCOPES = [
|