balladeer 1.0.13 → 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.
@@ -2027,8 +2027,10 @@ var require_toml = __commonJS({
2027
2027
  });
2028
2028
 
2029
2029
  // src/wire.ts
2030
- var CLI_VERSION = "1.0.13";
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 mkdirSync2,
2258
+ mkdirSync as mkdirSync3,
2161
2259
  openSync as openSync2,
2162
- readFileSync as readFileSync2,
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 join2 } from "node:path";
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(readFileSync2(fd, "utf8"));
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
- mkdirSync2(path, { mode: 448 });
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
- writeFileSync(temporary, `${JSON.stringify(value)}
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 = join2(home, "guidance-v1");
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(join2(directory, `${scopeKey}.current.json`));
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(join2(directory, `${pointer.key}.body.json`));
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(join2(directory, `${key}.body.json`), { document, etag });
2329
- atomicJson(join2(directory, `${scopeKey}.current.json`), { key });
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(join2(directory, `${previous.key}.body.json`));
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, write) {
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 = join2(cacheDirectory(input.environment), `${contextKey}.context.json`);
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
- write();
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 existsSync2,
2510
+ existsSync as existsSync3,
2408
2511
  lstatSync as lstatSync4,
2409
2512
  linkSync,
2410
- mkdirSync as mkdirSync4,
2411
- readFileSync as readFileSync5,
2513
+ mkdirSync as mkdirSync5,
2514
+ readFileSync as readFileSync6,
2412
2515
  renameSync as renameSync5,
2413
2516
  unlinkSync as unlinkSync5,
2414
- writeFileSync as writeFileSync2
2517
+ writeFileSync as writeFileSync3
2415
2518
  } from "node:fs";
2416
2519
  var import_toml2 = __toESM(require_toml(), 1);
2417
- import { dirname as dirname3, join as join5, relative } from "node:path";
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 mkdirSync3,
2529
+ mkdirSync as mkdirSync4,
2427
2530
  openSync as openSync4,
2428
- readFileSync as readFileSync4,
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 join4 } from "node:path";
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 readFileSync3,
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 dirname2, isAbsolute, join as join3 } from "node:path";
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(readFileSync3(join3(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
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)(readFileSync4(join4(root, CODEX_CONFIG_FILE), "utf8"));
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 = readFileSync4(join4(root, CODEX_CONFIG_FILE), "utf8");
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 (!existsSync2(path)) {
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 readFileSync5(path, "utf8");
2965
+ return readFileSync6(path, "utf8");
2868
2966
  }
2869
2967
  function safeParent(root, path) {
2870
- let at = dirname3(path);
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 = dirname3(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 = join5(root, options.hook === "codex" ? ".codex/config.toml" : ".mcp.json");
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/self-update.ts
3003
- import { spawn } from "node:child_process";
3004
- import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
3005
- import { join as join6 } from "node:path";
3006
- var STAMP = "self-update.json";
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(readFileSync6(path, "utf8"));
3016
- return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
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 0;
3109
+ return {};
3019
3110
  }
3020
3111
  }
3021
- function detached(command, args) {
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
- mkdirSync5(configHome(deps.environment), { recursive: true, mode: 448 });
3032
- writeFileSync3(
3033
- stampPath(deps.environment),
3034
- JSON.stringify({ startedAt: now(), toward: newer }) + "\n",
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) {
@@ -3107,25 +3191,23 @@ async function runGuidance(options) {
3107
3191
  event = parsedEvent;
3108
3192
  const record = input;
3109
3193
  const credentials = readCredentials(options.environment).agents;
3194
+ const repositories = projectScoped ? [] : repositoryHints(options.cwd);
3110
3195
  const agents = projectScoped ? credentials.filter(
3111
3196
  (agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === options.repositoryId
3112
3197
  ) : (() => {
3113
- const selection = selectAgent(
3114
- credentials,
3115
- options.controlPlane,
3116
- void 0,
3117
- repositoryHints(options.cwd)
3118
- );
3198
+ const selection = selectAgent(credentials, options.controlPlane, void 0, repositories);
3119
3199
  return selection.kind === "refused" ? [] : [selection.agent];
3120
3200
  })();
3121
3201
  if (agents.length !== 1 || !agents[0]) {
3122
3202
  if (projectScoped) throw new Error("guidance_connection_unavailable");
3123
3203
  return 0;
3124
3204
  }
3205
+ if (options.hook === "codex") noteHookFired(options.environment, "codex");
3125
3206
  const loaded = await loadGuidance({
3126
3207
  agent: agents[0],
3127
3208
  environment: options.environment,
3128
3209
  timeoutMs: 750,
3210
+ hook: options.hook,
3129
3211
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
3130
3212
  });
3131
3213
  if (event === "SessionStart")
@@ -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.";
@@ -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
- run?: (command: string, args: readonly string[]) => void;
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;