balladeer 1.0.20 → 1.0.21

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,3 +1,4 @@
1
+ import { type SelfUpdateDeps } from "../self-update.js";
1
2
  import { type StoredAgent } from "../store.js";
2
3
  export { forwarderHeaders, selectAgent, type AgentSelection } from "../agent.js";
3
4
  export type McpOptions = Readonly<{
@@ -15,6 +16,9 @@ export type McpOptions = Readonly<{
15
16
  stdin: NodeJS.ReadableStream;
16
17
  write: (text: string) => void;
17
18
  error: (text: string) => void;
19
+ now?: () => number;
20
+ /** Runs the background update; injected so tests never reach npm. */
21
+ startUpdate?: SelfUpdateDeps["run"];
18
22
  }>;
19
23
  export type ProbeResult = Readonly<{
20
24
  kind: "answered";
@@ -1,10 +1,11 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
3
- import { noteServerVersion, updateNotice } from "../currency.js";
3
+ import { newerVersionPublished, noteServerVersion, updateNotice } from "../currency.js";
4
4
  import { repositoryHint, repositoryHints } from "../repository.js";
5
5
  import { CLI_VERSION } from "../wire.js";
6
6
  import { untrackedLine } from "./guidance.js";
7
7
  import { hooksAwaitingTrust, hooksAwaitingTrustNotice, trustingHost } from "../hook-trust.js";
8
+ import { startSelfUpdateIfDue } from "../self-update.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
@@ -12,6 +13,8 @@ import { StoreError, readCredentials } from "../store.js";
12
13
  export { forwarderHeaders, selectAgent } from "../agent.js";
13
14
  const MAX_REQUEST_BYTES = 1024 * 1024;
14
15
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
16
+ /** How often one long-running forwarder looks at whether an update is due. */
17
+ const UPDATE_CHECK_MS = 60 * 60 * 1000;
15
18
  /**
16
19
  * One real call over the connection just issued, before anything claims it
17
20
  * works.
@@ -195,6 +198,8 @@ export async function runMcp(options) {
195
198
  // user-scope hook had just switched off. It installs nothing now.
196
199
  const { agent } = selection;
197
200
  let saidUpdate = false;
201
+ let updateCheckedAt = Number.NEGATIVE_INFINITY;
202
+ const clock = options.now ?? Date.now;
198
203
  // Which host this session belongs to is known from its hello; whether its
199
204
  // hooks are waiting for the person's trust is asked at the first tool call,
200
205
  // by which time a trusted session-start hook has certainly run.
@@ -230,6 +235,24 @@ export async function runMcp(options) {
230
235
  saidUpdate = true;
231
236
  options.error(`${notice}\n`);
232
237
  }
238
+ // The guidance hook is not the only way in. Codex skips a hook nobody has
239
+ // trusted, and a Codex-only laptop (Luke's, at Didero) then never started
240
+ // an update at all, while this forwarder ran every session. So the
241
+ // forwarder starts the same background install, under the same stamp,
242
+ // looking at most once an hour however many frames pass.
243
+ if (newerVersionPublished() !== undefined && clock() - updateCheckedAt >= UPDATE_CHECK_MS) {
244
+ updateCheckedAt = clock();
245
+ try {
246
+ startSelfUpdateIfDue(newerVersionPublished(), {
247
+ environment: options.environment,
248
+ now: clock,
249
+ ...(options.startUpdate ? { run: options.startUpdate } : {}),
250
+ });
251
+ }
252
+ catch {
253
+ /* An update that cannot even be considered never costs the session its tools. */
254
+ }
255
+ }
233
256
  if (response.status === 401) {
234
257
  options.write(`${revokedConnectionFrame(frameId(line), options.controlPlane)}\n`);
235
258
  options.error("Balladeer refused this agent connection. Rotate it in Balladeer.\n");
package/dist/currency.js CHANGED
@@ -60,10 +60,16 @@ export function noteServerVersion(headers) {
60
60
  newestSeen = newest;
61
61
  const advice = headers.get(CLI_ADVICE_HEADER);
62
62
  const line = advice === null ? "" : bounded(advice);
63
+ // On this copy's own major the background update installs it (it lands
64
+ // from 1.0.21), so there is nothing to tell anyone to run. Another major
65
+ // is a choice a person makes.
66
+ const sameMajor = newest.split(".")[0] === CLI_VERSION.split(".")[0];
63
67
  notice =
64
- line === ""
65
- ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run npx -y balladeer@latest setup --refresh to update the command and instructions, then restart this session.`
66
- : line;
68
+ line !== ""
69
+ ? line
70
+ : sameMajor
71
+ ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). It installs in the background; nothing needs to be run.`
72
+ : `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run npx -y balladeer@latest setup --refresh to update the command and instructions, then restart this session.`;
67
73
  }
68
74
  /** The one line this run prints about itself, or nothing when it is current. */
69
75
  export function updateNotice() {
@@ -2152,7 +2152,7 @@ function relayHook(input, directory) {
2152
2152
  }
2153
2153
 
2154
2154
  // src/wire.ts
2155
- var CLI_VERSION = "1.0.20";
2155
+ var CLI_VERSION = "1.0.21";
2156
2156
  var CLIENT_HEADER = "x-balladeer-client";
2157
2157
  var UPDATE_STATE_HEADER = "x-balladeer-update-state";
2158
2158
  var HOOK_HOST_HEADER = "x-balladeer-hook";
@@ -2289,7 +2289,8 @@ function noteServerVersion(headers) {
2289
2289
  newestSeen = newest;
2290
2290
  const advice = headers.get(CLI_ADVICE_HEADER);
2291
2291
  const line = advice === null ? "" : bounded(advice);
2292
- notice = line === "" ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run npx -y balladeer@latest setup --refresh to update the command and instructions, then restart this session.` : line;
2292
+ const sameMajor = newest.split(".")[0] === CLI_VERSION.split(".")[0];
2293
+ notice = line !== "" ? line : sameMajor ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). It installs in the background; nothing needs to be run.` : `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run npx -y balladeer@latest setup --refresh to update the command and instructions, then restart this session.`;
2293
2294
  }
2294
2295
  function newerVersionPublished() {
2295
2296
  return newestSeen;
@@ -2334,6 +2335,13 @@ function due(newer, last, now) {
2334
2335
  const newerTarget = last.toward === void 0 || older(last.toward, newer);
2335
2336
  return newerTarget && since >= HOUR_MS;
2336
2337
  }
2338
+ function updateSpecifier(newer) {
2339
+ if (!VERSION2.test(newer)) return void 0;
2340
+ const [name, major] = HOOK_SPECIFIER.split("@");
2341
+ if (newer.split(".")[0] !== major) return void 0;
2342
+ if (!older(CLI_VERSION, newer)) return void 0;
2343
+ return `${name}@${newer}`;
2344
+ }
2337
2345
  function writePrivate(path, value, environment) {
2338
2346
  mkdirSync3(configHome(environment), { recursive: true, mode: 448 });
2339
2347
  writeFileSync2(path, JSON.stringify(value) + "\n", { mode: 384 });
@@ -2375,9 +2383,11 @@ function detached(command, args, environment) {
2375
2383
  }
2376
2384
  function startSelfUpdateIfDue(newer, deps) {
2377
2385
  if (newer === void 0) return false;
2386
+ const specifier = updateSpecifier(newer);
2387
+ if (specifier === void 0) return false;
2378
2388
  const now = deps.now ?? Date.now;
2379
2389
  if (!due(newer, lastAttempt(deps.environment), now())) return false;
2380
- const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
2390
+ const launch = resolveLaunch(specifier, deps.environment, deps.execPath, deps.exists);
2381
2391
  let started;
2382
2392
  try {
2383
2393
  started = (deps.run ?? detached)(launch.command, launch.args, launch.environment) !== false;
@@ -16,6 +16,15 @@ export type SelfUpdateDeps = Readonly<{
16
16
  }>;
17
17
  /** A short, closed vocabulary: it travels in a header and lands in a column. */
18
18
  export type SelfUpdateState = `started:${string}` | `unstartable:${string}` | `installed:${string}` | `failed:${string}`;
19
+ /**
20
+ * The exact release to ask npx for, or nothing when there is none to ask for.
21
+ *
22
+ * Exact, because a range is answered from whatever copy is already on the
23
+ * laptop (see above). Only a release on this copy's own major, so a 2.x stays a
24
+ * choice a person makes, and only one newer than this copy, so a server that
25
+ * names an older number cannot walk a laptop backwards.
26
+ */
27
+ export declare function updateSpecifier(newer: string): string | undefined;
19
28
  /** What the last attempt came to, for the next hook fire to report. Never throws. */
20
29
  export declare function recordSelfUpdateState(environment: NodeJS.ProcessEnv, state: SelfUpdateState, at: number): void;
21
30
  /** The recorded state, or nothing when none was written or it is not one of ours. */
@@ -4,6 +4,7 @@ import { delimiter, dirname, join } from "node:path";
4
4
  import { older } from "./currency.js";
5
5
  import { HOOK_SPECIFIER } from "./release.js";
6
6
  import { configHome } from "./store.js";
7
+ import { CLI_VERSION } from "./wire.js";
7
8
  /**
8
9
  * Bringing the durable command current without the session ever waiting.
9
10
  *
@@ -35,6 +36,18 @@ import { configHome } from "./store.js";
35
36
  * 1.0.15. Now any guidance hook fire can begin the install, and a release
36
37
  * newer than the one the last attempt aimed at may begin one an hour after
37
38
  * it, rather than a day.
39
+ *
40
+ * What changed on 14 October 2026, from Didero's laptops and Robert's again:
41
+ * seven laptops sat on 1.0.12 to 1.0.14 for two weeks, and Robert's on 1.0.19
42
+ * with an attempt toward 1.0.20 "started" that morning. Every attempt had
43
+ * asked npx for `balladeer@1`, a range. npx runs any copy already on the
44
+ * laptop that satisfies a range without asking the registry: the durable
45
+ * command itself when it sits in npm's global folder, or whatever the first
46
+ * attempt left in npx's own cache. That copy's installer installs its own
47
+ * version and refuses to go down, so every attempt after the first changed
48
+ * nothing, silently, once a day. Now the attempt names the exact release the
49
+ * server announced, which npx cannot satisfy from an older copy, and the
50
+ * installer that runs is the one being installed.
38
51
  */
39
52
  const STAMP = "self-update.json";
40
53
  const STATE = "self-update-state.json";
@@ -79,6 +92,24 @@ function due(newer, last, now) {
79
92
  const newerTarget = last.toward === undefined || older(last.toward, newer);
80
93
  return newerTarget && since >= HOUR_MS;
81
94
  }
95
+ /**
96
+ * The exact release to ask npx for, or nothing when there is none to ask for.
97
+ *
98
+ * Exact, because a range is answered from whatever copy is already on the
99
+ * laptop (see above). Only a release on this copy's own major, so a 2.x stays a
100
+ * choice a person makes, and only one newer than this copy, so a server that
101
+ * names an older number cannot walk a laptop backwards.
102
+ */
103
+ export function updateSpecifier(newer) {
104
+ if (!VERSION.test(newer))
105
+ return undefined;
106
+ const [name, major] = HOOK_SPECIFIER.split("@");
107
+ if (newer.split(".")[0] !== major)
108
+ return undefined;
109
+ if (!older(CLI_VERSION, newer))
110
+ return undefined;
111
+ return `${name}@${newer}`;
112
+ }
82
113
  function writePrivate(path, value, environment) {
83
114
  mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
84
115
  writeFileSync(path, JSON.stringify(value) + "\n", { mode: 0o600 });
@@ -148,10 +179,13 @@ function detached(command, args, environment) {
148
179
  export function startSelfUpdateIfDue(newer, deps) {
149
180
  if (newer === undefined)
150
181
  return false;
182
+ const specifier = updateSpecifier(newer);
183
+ if (specifier === undefined)
184
+ return false;
151
185
  const now = deps.now ?? Date.now;
152
186
  if (!due(newer, lastAttempt(deps.environment), now()))
153
187
  return false;
154
- const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
188
+ const launch = resolveLaunch(specifier, deps.environment, deps.execPath, deps.exists);
155
189
  let started;
156
190
  try {
157
191
  started = (deps.run ?? detached)(launch.command, launch.args, launch.environment) !== false;
package/dist/wire.d.ts CHANGED
@@ -5,14 +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.20";
8
+ export declare const CLI_VERSION = "1.0.21";
9
9
  export declare const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export declare const CLIENT_HEADER = "x-balladeer-client";
11
11
  /** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
12
12
  export declare const UPDATE_STATE_HEADER = "x-balladeer-update-state";
13
13
  /** Which host's hook made this guidance fetch: `claude` or `codex`. */
14
14
  export declare const HOOK_HOST_HEADER = "x-balladeer-hook";
15
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.20";
15
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.21";
16
16
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
17
17
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
18
18
  export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
package/dist/wire.js CHANGED
@@ -5,7 +5,7 @@
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.20";
8
+ export const CLI_VERSION = "1.0.21";
9
9
  export const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export const CLIENT_HEADER = "x-balladeer-client";
11
11
  /** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
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,