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 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
- return (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
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
@@ -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;
@@ -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
+ }
@@ -2027,8 +2027,10 @@ var require_toml = __commonJS({
2027
2027
  });
2028
2028
 
2029
2029
  // src/wire.ts
2030
- var CLI_VERSION = "1.0.14";
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) {
@@ -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")
@@ -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;
@@ -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. A child that
14
- * fails changes nothing and is tried again the next day; `balladeer update`
15
- * is the manual path. Nothing here reads the session or writes to it.
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 detached(command, args) {
35
- const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
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
- mkdirSync(configHome(deps.environment), { recursive: true, mode: 0o700 });
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
  }
@@ -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;
@@ -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
- writes.push(mergeCodexUserConfig(home, options.published, options.installed));
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.14";
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
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.14";
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.14";
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 = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,