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.
@@ -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.13";
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.13";
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.13";
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.13",
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,