@botbuddy/cli 1.64.16 → 1.64.18

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,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.16",
3
- "source_commit": "6690f94e4c3d385159ca315713a6eab3560289fb",
4
- "source_sha256": "81838b2e5024a22f62c4b55a75c3113c26cfe88f0047f0048f28f5800d0693d3",
2
+ "release_version": "1.64.18",
3
+ "source_commit": "ce75c0db8999addc150c16458ebeace234a11ac7",
4
+ "source_sha256": "524adf0ce3779f1b458c5aaea9e6af52269423cecb0abec9a4fbf73b7da0f4ad",
5
5
  "target": "darwin-arm64",
6
- "sha256": "9ca9db33cdee03687c26d5e162f4d936d44b7c1c23028c78566a61b90f7c40ad"
6
+ "sha256": "4f30f4a1f5be06733dc65e8930a13f66b5cd766cb7808b34a60ae63f9b1d8f25"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.16",
3
- "source_commit": "6690f94e4c3d385159ca315713a6eab3560289fb",
4
- "source_sha256": "81838b2e5024a22f62c4b55a75c3113c26cfe88f0047f0048f28f5800d0693d3",
2
+ "release_version": "1.64.18",
3
+ "source_commit": "ce75c0db8999addc150c16458ebeace234a11ac7",
4
+ "source_sha256": "524adf0ce3779f1b458c5aaea9e6af52269423cecb0abec9a4fbf73b7da0f4ad",
5
5
  "target": "linux-x64",
6
- "sha256": "b1a73bbd5bc7d78a13a3ec6f547436cc780163687bb5162e230d5796291e7689"
6
+ "sha256": "5b07c264400be4a1790ddb3f8a0b0880465deff8eaccbb0cf18a7e734385326d"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.16",
4
- "source_commit": "6690f94e4c3d385159ca315713a6eab3560289fb",
5
- "source_sha256": "81838b2e5024a22f62c4b55a75c3113c26cfe88f0047f0048f28f5800d0693d3",
3
+ "release_version": "1.64.18",
4
+ "source_commit": "ce75c0db8999addc150c16458ebeace234a11ac7",
5
+ "source_sha256": "524adf0ce3779f1b458c5aaea9e6af52269423cecb0abec9a4fbf73b7da0f4ad",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "9ca9db33cdee03687c26d5e162f4d936d44b7c1c23028c78566a61b90f7c40ad",
11
+ "sha256": "4f30f4a1f5be06733dc65e8930a13f66b5cd766cb7808b34a60ae63f9b1d8f25",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "b1a73bbd5bc7d78a13a3ec6f547436cc780163687bb5162e230d5796291e7689",
18
+ "sha256": "5b07c264400be4a1790ddb3f8a0b0880465deff8eaccbb0cf18a7e734385326d",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.64.16",
3
+ "version": "1.64.18",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -147,7 +147,7 @@ export async function run(argv, {
147
147
  case "wait": return runWait(args);
148
148
  case "pw": return runPw(args);
149
149
  // BOT-2084: the human-installed staging session pool broker.
150
- case "staging-pool": return (await import("./staging-pool/cli.mjs")).runStagingPool(args);
150
+ case "staging-pool": return (await import("./staging-pool/cli.mjs")).runStagingPool(args, { cliVersion: VERSION });
151
151
  case "mcp": return cmdMcp(args);
152
152
  case "resources": return callTool("list_resources");
153
153
  case "agents": return callTool("list_agents");
@@ -185,6 +185,9 @@ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = ran
185
185
  const signal = pick(fields.stop_signal?.signal, SIGNAL_RE);
186
186
  // BOT-2880: sent only when present, because an older server refuses an unknown key.
187
187
  const callbackAuthorizationReason = pick(fields.callback_authorization_reason, AUTHORIZATION_REASON_RE);
188
+ const failureMechanism = code === "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE" &&
189
+ ["origin_config_missing", "origin_digest_missing"].includes(fields.failure_mechanism)
190
+ ? fields.failure_mechanism : null;
188
191
  return {
189
192
  run_id: runId,
190
193
  lease_id: validStackLeaseId(fields.lease_id),
@@ -208,6 +211,7 @@ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = ran
208
211
  signal_parent: pick(fields.stop_signal.parent, TOKEN_RE),
209
212
  } : {}),
210
213
  ...(callbackAuthorizationReason ? { callback_authorization_reason: callbackAuthorizationReason } : {}),
214
+ ...(failureMechanism ? { failure_mechanism: failureMechanism } : {}),
211
215
  };
212
216
  }
213
217
 
@@ -236,7 +240,19 @@ export async function reportStackRunOutcome({ fields, exitCode, runId, opts, env
236
240
  timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, timeoutMs);
237
241
  });
238
242
  try {
239
- const call = Promise.resolve().then(() => channel.callTool(STACK_RUN_OUTCOME_TOOL, payload, { auth: channel.auth, signal: ac.signal }));
243
+ const call = Promise.resolve().then(async () => {
244
+ const first = await channel.callTool(STACK_RUN_OUTCOME_TOOL, payload, { auth: channel.auth, signal: ac.signal });
245
+ // Older servers reject unknown keys before writing. Retry only that exact,
246
+ // explicit rejection with the same run id; never retry an uncertain send.
247
+ if (!ac.signal.aborted && payload.failure_mechanism && first?.ok && first?.isError &&
248
+ first?.data?.code === "INVALID_STACK_RUN_OUTCOME" &&
249
+ typeof first?.data?.message === "string" &&
250
+ first.data.message.startsWith("unknown key 'failure_mechanism'")) {
251
+ const { failure_mechanism: _omitted, ...legacyPayload } = payload;
252
+ return await channel.callTool(STACK_RUN_OUTCOME_TOOL, legacyPayload, { auth: channel.auth, signal: ac.signal });
253
+ }
254
+ return first;
255
+ });
240
256
  const result = await Promise.race([call.catch(() => null), timeout]);
241
257
  if (result === TIMED_OUT) return { sent: false, reason: "timeout", payload };
242
258
  const capture = result?.data?.feedback_capture;
package/src/stack.mjs CHANGED
@@ -4250,7 +4250,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4250
4250
  let hasDeclaration = false;
4251
4251
  try { hasDeclaration = existsSync(declaration) && lstatSync(declaration).isFile(); } catch { hasDeclaration = false; }
4252
4252
  if (remoteVariant && !originConfigPath) {
4253
- const error = new Error("the requester-origin config variant has no materialized config to replace"); error.code = "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE"; throw error;
4253
+ const error = new Error("the requester-origin config variant has no materialized config to replace"); error.code = "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE"; error.failureMechanism = "origin_config_missing"; throw error;
4254
4254
  }
4255
4255
  return buildBundle(stackDirFor(execution), {
4256
4256
  ...(remoteVariant ? { readFile: (abs, ...rest) => abs === originConfigPath ? Buffer.from(execution.originConfigText, "utf8") : readFileSync(abs, ...rest) } : {}),
@@ -4288,7 +4288,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4288
4288
  // hide it — and it happens before any lease exists.
4289
4289
  if (executionRequest === "remote" || bundleInclude.length) {
4290
4290
  const fixable = typeof error?.code === "string" && error.code.startsWith("BUNDLE_");
4291
- return { exitCode: fixable ? EXIT.INVALID : EXIT.INTERNAL, outcome: "error", code: error?.code ?? "BUNDLE_FAILED", error: message };
4291
+ return { exitCode: fixable ? EXIT.INVALID : EXIT.INTERNAL, outcome: "error", code: error?.code ?? "BUNDLE_FAILED", error: message,
4292
+ ...(error?.code === "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE" && error?.failureMechanism === "origin_config_missing" ? { failureMechanism: error.failureMechanism } : {}) };
4292
4293
  }
4293
4294
  // Codex #1077 P2: without a digest, `auto` could still pick a remote host and be
4294
4295
  // refused BUNDLE_REQUIRED instead of queueing here, so ask for this machine.
@@ -4346,7 +4347,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4346
4347
  let renewIdentifiedRequest = null;
4347
4348
  if (execution.admissionRequestId) {
4348
4349
  if (!stackDigest || !bundle?.sha256) {
4349
- return { exitCode: EXIT.INTERNAL, outcome: "error", code: "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE", error: "the requester-origin bundle digest could not be computed; no lease was requested" };
4350
+ return { exitCode: EXIT.INTERNAL, outcome: "error", code: "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE", failureMechanism: "origin_digest_missing", error: "the requester-origin bundle digest could not be computed; no lease was requested" };
4350
4351
  }
4351
4352
  leaseArgs.request_id = execution.admissionRequestId;
4352
4353
  // Durable request-to-context association before any request can commit (recovery key).
@@ -5323,6 +5324,8 @@ export function runReceiptFields(result) {
5323
5324
  ...(result.callbackCause ? { callback_cause: result.callbackCause } : {}),
5324
5325
  // BOT-2880: which check refused a CALLBACK_AUTHORIZATION_LOST (a bounded token, never a value).
5325
5326
  ...(result.callbackAuthorizationReason ? { callback_authorization_reason: result.callbackAuthorizationReason } : {}),
5327
+ ...(result.code === "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE" && ["origin_config_missing", "origin_digest_missing"].includes(result.failureMechanism)
5328
+ ? { failure_mechanism: result.failureMechanism } : {}),
5326
5329
  // BOT-2957: the control-plane gaps this run's callback route rode through (never on a run that had none).
5327
5330
  ...(result.callbackOutage ? { callback_outage: result.callbackOutage } : {}),
5328
5331
  // BOT-2780: the reserved requester origin generated auth links name (never a credential).
@@ -398,6 +398,26 @@ export function upgradeNotice(ctx) {
398
398
  return { upgrade: { reason: "installed_before_registry", message: "This pool was installed from a repository checkout; re-install it from the BotBuddy control plane (keeps counts and interval).", next: installCommandFor(ctx.tenant) } };
399
399
  }
400
400
 
401
+ // BOT-3016: the launchd job runs a pinned runtime copy, not this CLI. Status
402
+ // names both versions and, when they differ on a control-plane pool, the
403
+ // agent-safe refresh (bb update runs it too). cliVersion is null inside the
404
+ // bundled job copy, which has no package.json to read.
405
+ export function runtimeNotice(ctx, cliVersion) {
406
+ if (!ctx?.installed || ctx.installed.error) return {};
407
+ const runtimeVersion = ctx.installed.runtime?.version ?? null;
408
+ const versions = { runtimeVersion, ...(cliVersion ? { cliVersion } : {}) };
409
+ if (!cliVersion || runtimeVersion === cliVersion || ctx.installed.source !== "control-plane" || !ctx.tenant || !ctx.poolId) return versions;
410
+ const runs = runtimeVersion ? `runs v${runtimeVersion}` : "runs an untracked runtime copy";
411
+ return {
412
+ ...versions,
413
+ runtimeBehind: {
414
+ reason: "runtime_behind",
415
+ message: `The pool job ${runs}, but this CLI is v${cliVersion}. bb update refreshes it; so does the next command.`,
416
+ next: `bb staging-pool refresh-runtime --tenant ${shellWord(String(ctx.tenant))} --pool ${shellWord(String(ctx.poolId))}`,
417
+ },
418
+ };
419
+ }
420
+
401
421
  export async function runStagingPool(argv, deps = {}) {
402
422
  const {
403
423
  env = process.env,
@@ -415,6 +435,7 @@ export async function runStagingPool(argv, deps = {}) {
415
435
  nodeBin = process.execPath,
416
436
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
417
437
  readSecretFile = readFileSync,
438
+ cliVersion = null,
418
439
  } = deps;
419
440
 
420
441
  const redactor = createRedactor();
@@ -599,10 +620,10 @@ export async function runStagingPool(argv, deps = {}) {
599
620
  const entry = options.persona ? findPersona(ctx.poolBinding, options.persona) : null;
600
621
  if (options.persona && !entry) assertPersonaAllowed(target, options.persona);
601
622
  const health = await assessHealth({ ctx, pool, now, exec, uid, requestedPersona: entry?.email });
602
- emit({ command: "status", check: true, ...health, ...upgradeNotice(ctx) });
623
+ emit({ command: "status", check: true, ...health, ...upgradeNotice(ctx), ...runtimeNotice(ctx, cliVersion) });
603
624
  return health.ok ? 0 : 5;
604
625
  }
605
- emit({ ok: true, command: "status", ...(await pool.status()), ...upgradeNotice(ctx) });
626
+ emit({ ok: true, command: "status", ...(await pool.status()), ...upgradeNotice(ctx), ...runtimeNotice(ctx, cliVersion) });
606
627
  return 0;
607
628
  },
608
629
 
@@ -96,14 +96,16 @@ ANYONE (agents too) — existing runtime maintenance, never sign-in
96
96
  and this published CLI package. Preserves credentials, sessions, counts,
97
97
  interval and plist. The existing launchd job refills on its next tick.
98
98
  No caller path or runtime content is accepted; no sign-in is performed.
99
+ bb update runs it for every installed pool after a confirmed upgrade.
99
100
 
100
101
  HUMAN ONLY (Jono, from Terminal.app or iTerm) — refused inside a Claude or Codex session
101
102
  These sign in with the persona password or change the standing sessions.
102
103
  install --tenant <slug> [--pool <pool_id>] --botbuddy-bin <absolute path> [--interval <seconds>]
103
104
  Reads the tenant's registered pool from the BotBuddy control plane, copies
104
105
  the job to the pool home, writes its config and loads the launchd job. Run
105
- it from any directory. Re-run after upgrading @botbuddy/cli; it keeps the
106
- counts and interval. --pool is needed only when the tenant has several.
106
+ it from any directory. bb update refreshes the job's runtime, so re-run
107
+ install only when status or bb update names it; it keeps the counts and
108
+ interval. --pool is needed only when the tenant has several.
107
109
  e.g. bb staging-pool install --tenant botbuddy --botbuddy-bin "$(command -v botbuddy)"
108
110
  tick [--pool <pool_id>] [--home <pool home>]
109
111
  One refresh and refill pass now (what the job runs).
@@ -11,14 +11,23 @@
11
11
  // `cmdUpdate` deliberately does NOT update in-process (BOT-1499: a self-update
12
12
  // that replaces its own running files can kill itself mid-install). It hands
13
13
  // off to a detached, unref'd `npm install -g` and exits 0.
14
+ //
15
+ // BOT-3016: an installed staging session pool is a standing launchd job that
16
+ // runs a pinned copy of the CLI, so npm alone leaves it on old code. After a
17
+ // confirmed update, `cmdUpdate` refreshes each control-plane pool with the NEW
18
+ // package's agent-safe `staging-pool refresh-runtime` (BOT-2673), which derives
19
+ // its runtime from its own package path — hence a child of the new binary, not
20
+ // this process. A refusal is reported once and never fails the update.
14
21
 
15
- import { mkdir, readFile, writeFile } from "node:fs/promises";
22
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
16
23
  import { readFileSync } from "node:fs";
17
- import { spawn, spawnSync } from "node:child_process";
24
+ import { execFile, spawn, spawnSync } from "node:child_process";
18
25
  import { homedir } from "node:os";
19
26
  import { dirname, join } from "node:path";
27
+ import { promisify } from "node:util";
20
28
 
21
29
  import { VERSION } from "./version.mjs";
30
+ import { installLine } from "./staging-pool/errors.mjs";
22
31
 
23
32
  export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
24
33
  export const NPM_LATEST_URL = "https://registry.npmjs.org/@botbuddy/cli/latest";
@@ -118,11 +127,16 @@ export async function maybeWarnStale({
118
127
  }
119
128
  }
120
129
 
121
- export function readInstalledVersion({ npmBin = "npm", shellOpt = {}, probeImpl = spawnSync, readPackage = readFileSync } = {}) {
130
+ export function readNpmGlobalRoot({ npmBin = "npm", shellOpt = {}, probeImpl = spawnSync } = {}) {
122
131
  const root = probeImpl(npmBin, ["root", "-g"], { encoding: "utf8", ...shellOpt });
123
132
  if (root?.error || root?.status !== 0) throw new Error(`npm global root lookup failed for ${npmBin}`);
124
133
  const directory = String(root?.stdout ?? "").trim();
125
134
  if (!directory) throw new Error("npm global root lookup returned no directory");
135
+ return directory;
136
+ }
137
+
138
+ export function readInstalledVersion({ npmBin = "npm", shellOpt = {}, probeImpl = spawnSync, readPackage = readFileSync } = {}) {
139
+ const directory = readNpmGlobalRoot({ npmBin, shellOpt, probeImpl });
126
140
  const packageJson = JSON.parse(readPackage(join(directory, "@botbuddy", "cli", "package.json"), "utf8"));
127
141
  if (typeof packageJson?.version !== "string" || !parseSemver(packageJson.version)) throw new Error("installed @botbuddy/cli package has no valid version");
128
142
  return packageJson.version;
@@ -130,7 +144,7 @@ export function readInstalledVersion({ npmBin = "npm", shellOpt = {}, probeImpl
130
144
 
131
145
  // `botbuddy update` waits for npm because the terminal result, rather than the
132
146
  // child-process launch, is the only trustworthy confirmation an update worked.
133
- export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = spawnSync, readInstalledVersion: installedVersion = readInstalledVersion, log = (line) => console.log(line), platform = process.platform, stderr = process.stderr } = {}) {
147
+ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = spawnSync, readInstalledVersion: installedVersion = readInstalledVersion, refreshPools = refreshPoolsFromNewPackage, log = (line) => console.log(line), platform = process.platform, stderr = process.stderr } = {}) {
134
148
  // BOT-1566 (Codex P1/P2): on Windows npm is the `npm.cmd` shim, and Node
135
149
  // cannot spawn a .cmd file shell-free (docs) — run it through a shell on
136
150
  // win32 (args are all static constants, so no injection surface) and select
@@ -179,14 +193,114 @@ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, probeImpl = sp
179
193
  resolve(1);
180
194
  return;
181
195
  }
196
+ let installed;
182
197
  try {
183
- const installed = installedVersion({ npmBin, shellOpt });
184
- log(`✓ Updated to v${installed}`);
185
- resolve(0);
198
+ installed = installedVersion({ npmBin, shellOpt });
186
199
  } catch (error) {
187
200
  try { stderr.write(`botbuddy update: npm completed but could not verify the installed version (${error?.message ?? error})\n`); } catch { /* ignore */ }
188
201
  resolve(1);
202
+ return;
189
203
  }
204
+ log(`✓ Updated to v${installed}`);
205
+ // BOT-3016: the update succeeded; a pool refresh problem is reported, never an exit code.
206
+ Promise.resolve()
207
+ .then(() => refreshPools({ installedVersion: installed, npmBin, shellOpt, probeImpl, log, stderr }))
208
+ .catch((error) => {
209
+ try { stderr.write(`botbuddy update: the installed staging pools were not refreshed (${error?.message ?? error}); run bb staging-pool status\n`); } catch { /* ignore */ }
210
+ })
211
+ .finally(() => resolve(0));
190
212
  });
191
213
  });
192
214
  }
215
+
216
+ // ─── BOT-3016: refresh installed staging-pool runtimes after an update ───────
217
+
218
+ export const REFRESH_TIMEOUT_MS = 120_000;
219
+ const HUMAN_NEXT = "human, from Terminal.app or iTerm";
220
+
221
+ export function stagingPoolsRoot(home = homedir()) {
222
+ return join(home, ".botbuddy", "staging-session-pool");
223
+ }
224
+
225
+ function lastJsonLine(text) {
226
+ const lines = String(text ?? "").split("\n").filter((line) => line.startsWith("{"));
227
+ for (const line of lines.reverse()) {
228
+ try { return JSON.parse(line); } catch { /* keep looking */ }
229
+ }
230
+ return null;
231
+ }
232
+
233
+ async function refreshOne({ dirName, installedVersion, cliEntry, nodeBin, home, execFileImpl }) {
234
+ let config;
235
+ try {
236
+ config = JSON.parse(await readFile(join(stagingPoolsRoot(home), dirName, "config.json"), "utf8"));
237
+ } catch (error) {
238
+ if (error?.code === "ENOENT") return null; // not a pool home
239
+ return { poolId: dirName, status: "unreadable", code: "config_unreadable", message: `Its config.json could not be read (${error?.message ?? error}).`, next: "bb staging-pool status --check --pool " + dirName, actor: "agent or human" };
240
+ }
241
+ const poolId = typeof config?.poolId === "string" ? config.poolId : dirName;
242
+ const tenant = config?.tenant ?? config?.binding?.tenant ?? null;
243
+ if (config?.source !== "control-plane" || !tenant) {
244
+ return { poolId, status: "legacy", code: "installed_before_registry", message: "It was installed before the control-plane registry, so only a human re-install can update it.", next: tenant ? installLine(tenant, poolId) : `bb staging-pool status --pool ${poolId}`, actor: HUMAN_NEXT };
245
+ }
246
+ const runtimeVersion = config?.runtime?.version ?? null;
247
+ if (runtimeVersion === installedVersion) return { poolId, status: "current", runtimeVersion };
248
+ const retry = `bb staging-pool refresh-runtime --tenant ${tenant} --pool ${poolId}`;
249
+ try {
250
+ const { stdout } = await execFileImpl(nodeBin, [cliEntry, "staging-pool", "refresh-runtime", "--tenant", tenant, "--pool", poolId], { timeout: REFRESH_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
251
+ const receipt = lastJsonLine(stdout);
252
+ if (receipt?.ok === true) return { poolId, status: "refreshed", runtimeVersion: receipt.runtimeVersion ?? installedVersion };
253
+ return { poolId, status: "refused", code: receipt?.code ?? "refresh_failed", message: receipt?.message ?? "The refresh printed no success receipt.", next: retry, actor: "agent or human" };
254
+ } catch (error) {
255
+ // A refusal is one JSON line on stderr (then text guidance); stdout stays empty.
256
+ const receipt = lastJsonLine(error?.stderr) ?? lastJsonLine(error?.stdout);
257
+ if (receipt?.code) {
258
+ const human = /^human/i.test(String(receipt.actor ?? "")) || /install/.test(String(receipt.next ?? ""));
259
+ const actor = typeof receipt.actor === "string" && receipt.actor ? (human ? `${receipt.actor}, from Terminal.app or iTerm` : receipt.actor) : (human ? HUMAN_NEXT : "agent or human");
260
+ return { poolId, status: "refused", code: receipt.code, message: [receipt.message, receipt.detail].filter(Boolean).join(" "), next: receipt.next ?? retry, actor };
261
+ }
262
+ return { poolId, status: "refused", code: "refresh_failed", message: `The refresh did not finish (${error?.message ?? error}).`, next: retry, actor: "agent or human" };
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Refresh every control-plane staging pool installed under ~/.botbuddy whose
268
+ * recorded runtime differs from `installedVersion`, by running the new
269
+ * package's `staging-pool refresh-runtime`. Reports one line per pool and
270
+ * never throws for a pool's refusal. Returns [{ poolId, status, ... }].
271
+ */
272
+ export async function refreshInstalledPools({
273
+ installedVersion,
274
+ cliEntry,
275
+ nodeBin = process.execPath,
276
+ home = homedir(),
277
+ execFileImpl = promisify(execFile),
278
+ log = (line) => console.log(line),
279
+ stderr = process.stderr,
280
+ } = {}) {
281
+ let entries;
282
+ try {
283
+ entries = await readdir(stagingPoolsRoot(home), { withFileTypes: true });
284
+ } catch (error) {
285
+ if (error?.code === "ENOENT") return [];
286
+ throw error;
287
+ }
288
+ const results = [];
289
+ for (const entry of entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
290
+ const result = await refreshOne({ dirName: entry.name, installedVersion, cliEntry, nodeBin, home, execFileImpl });
291
+ if (!result) continue;
292
+ results.push(result);
293
+ if (result.status === "refreshed") log(`✓ Staging pool ${result.poolId} now runs v${result.runtimeVersion}`);
294
+ else if (result.status === "current") log(`✓ Staging pool ${result.poolId} already runs v${result.runtimeVersion}`);
295
+ else {
296
+ try { stderr.write(`! Staging pool ${result.poolId} was not refreshed (${result.code}): ${result.message}\n Next (${result.actor}): ${result.next}\n`); } catch { /* ignore */ }
297
+ }
298
+ }
299
+ return results;
300
+ }
301
+
302
+ // The default for `cmdUpdate`: locate the newly installed package and refresh with it.
303
+ export async function refreshPoolsFromNewPackage({ installedVersion, npmBin, shellOpt, probeImpl, log, stderr }) {
304
+ const root = readNpmGlobalRoot({ npmBin, shellOpt, probeImpl });
305
+ return refreshInstalledPools({ installedVersion, cliEntry: join(root, "@botbuddy", "cli", "bin", "botbuddy.mjs"), log, stderr });
306
+ }