@botbuddy/cli 1.60.5 → 1.60.6

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.
@@ -0,0 +1,380 @@
1
+ // BOT-2084: every `bb staging-pool` and `bb pw … auth` refusal or failure, as a
2
+ // stable code with what happened, the likely cause, who acts, the exact next
3
+ // command and the doc section. `bb help symptom <code>` prints the same entry.
4
+ //
5
+ // Node-stdlib-only and self-contained (the installed job copies this directory).
6
+ // Templates take only non-secret values (lane, persona alias, paths, labels);
7
+ // a missing value renders as a <placeholder>, never as an empty string.
8
+
9
+ export const DOC_URL = "https://github.com/bot-buddy/botbuddy-webapp/blob/main/docs/agent-staging-acceptance-mfa.md";
10
+
11
+ // Heading anchors in DOC_URL (checked by errors.test.mjs against the doc).
12
+ export const DOC_SECTIONS = Object.freeze({
13
+ overview: "staging-session-pool-bot-2084",
14
+ configure: "configure-a-repository",
15
+ install: "install-update-remove-human-only",
16
+ take: "take-a-session-agent",
17
+ close: "close-and-the-residual-window",
18
+ repair: "repair-the-pool-human-only",
19
+ codes: "staging-session-pool-error-codes",
20
+ });
21
+
22
+ export const ACTOR_LABELS = Object.freeze({ agent: "Agent", human: "Human (Jono)" });
23
+
24
+ const HUMAN_ONLY = "Agents never run this: it signs in with the persona password or changes the standing sessions.";
25
+
26
+ const AUTH = "bb pw --environment staging {lane} auth --persona {persona}";
27
+ const REOPEN = "bb pw {lane} close && bb pw --environment staging {lane} open about:blank";
28
+ const INSTALL = "{installCommand}";
29
+
30
+ const entry = (actor, doc, message, cause, next, agentAction = actor === "human" ? "report_blocker" : "fix_and_retry") =>
31
+ Object.freeze({ actor, doc, message, cause, next, agentAction });
32
+
33
+ export const ERROR_CODES = Object.freeze({
34
+ // ── bb pw … auth: the caller's lane and arguments (agent fixes and retries)
35
+ auth_requires_staging_environment: entry("agent", "take",
36
+ "bb pw auth installs staging sessions only, and this lane is not on staging.",
37
+ "--environment staging was not given before the lane (or BB_PW_ENV names another environment).",
38
+ AUTH),
39
+ auth_requires_lane_lock: entry("agent", "take",
40
+ "bb pw auth needs a lock-gated lane, and this run is unlocked.",
41
+ "BB_PW_NO_LOCK=1 is set in this shell.",
42
+ `bb locks -m --lane {lane} --ticket <ticket> && env -u BB_PW_NO_LOCK ${AUTH}`),
43
+ lane_not_claimed: entry("agent", "take",
44
+ "The lane lock is not held by this agent session.",
45
+ "The lane was never claimed, its lock expired, or another agent holds it.",
46
+ `bb locks -m --lane {lane} --ticket <ticket> && ${AUTH}`),
47
+ exact_lane_holder_required: entry("agent", "take",
48
+ "The lane lock is held by another agent session of your owner, not by this session.",
49
+ "The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
50
+ `bb locks -m --lane {lane} --ticket <ticket> # from THIS session, then: ${AUTH}`),
51
+ persona_not_allowlisted: entry("agent", "take",
52
+ "Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
53
+ "A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
54
+ "bb pw --environment staging {lane} auth --persona {firstAllowed}"),
55
+ lane_browser_not_open: entry("agent", "take",
56
+ "Lane {lane} has no open browser to install the session into.",
57
+ "auth runs after open; the lane was never opened or was closed.",
58
+ `bb pw --environment staging {lane} open about:blank && ${AUTH}`),
59
+ browser_session_not_staging: entry("agent", "take",
60
+ "Lane {lane}'s open browser session is tracked as another environment.",
61
+ "The lane was opened with a different --environment.",
62
+ `${REOPEN} && ${AUTH}`),
63
+ persistent_profile_refused: entry("agent", "take",
64
+ "Lane {lane} uses a persistent browser profile, which would write the session to disk.",
65
+ "The lane was opened with --persistent or --profile.",
66
+ `${REOPEN} && ${AUTH} # no --persistent / --profile`),
67
+ pool_not_configured: entry("agent", "configure",
68
+ "This directory's repository declares no staging_session_pool, so there is no pool to take from.",
69
+ "The command ran outside the repository checkout, or the repository has not opted in.",
70
+ "cd <repository checkout> && {retry}"),
71
+
72
+ // ── bb pw … auth: install into the lane (agent retries once, then reports)
73
+ session_install_failed: entry("agent", "take",
74
+ "The session could not be installed in lane {lane}; its slot was deleted and the lane cleared.",
75
+ "The lane's page navigated or closed mid-install, or the daemon was restarting.",
76
+ `${REOPEN} && ${AUTH} # once; if it repeats, report this code`, "retry_once"),
77
+ staging_session_cleanup_pending: entry("agent", "close",
78
+ "Lane {lane}'s browser may still hold a staging session that should have been cleared, and clearing it failed.",
79
+ "The lane's browser stopped answering while bb pw removed the session.",
80
+ "bb pw {lane} close"),
81
+ session_not_aal2: entry("agent", "take",
82
+ "The pool's session for {persona} is no longer AAL2, so it was discarded.",
83
+ "The persona's TOTP factor was rotated (the staging seed job ran) after the session was minted.",
84
+ `${AUTH} # once; the next ready slot is fresh. If it repeats, report this code`, "retry_once"),
85
+ session_expired: entry("agent", "take",
86
+ "The pool's session for {persona} had expired, so it was discarded.",
87
+ "The pool job has not refreshed recently (the Mac slept or the job stopped).",
88
+ `${AUTH} # once; if it repeats, run: bb staging-pool status --check`, "retry_once"),
89
+ session_persona_mismatch: entry("human", "repair",
90
+ "The pool handed over a session for a different user than {persona}; it was discarded.",
91
+ "The pool's slot files were edited or mixed up by hand.",
92
+ "bb staging-pool uninstall && " + INSTALL),
93
+ slot_state_invalid: entry("human", "repair",
94
+ "The pool's slot file is not a single-origin, single-key session state; it was discarded.",
95
+ "The pool's slot files were edited by hand, or an older pool wrote them.",
96
+ "bb staging-pool uninstall && " + INSTALL),
97
+ slot_state_unreadable: entry("agent", "take",
98
+ "The lane's browser could not read the pool's slot file; it was discarded.",
99
+ "The lane's browser runs as another user or in a sandbox that cannot read ~/.botbuddy.",
100
+ `${REOPEN} && ${AUTH} # once; if it repeats, report this code`, "retry_once"),
101
+ daemon_node_unsupported: entry("human", "take",
102
+ "The lane's Playwright daemon runs a Node.js without process.getBuiltinModule.",
103
+ "Node.js older than 22.3 runs the daemon.",
104
+ "node --version # upgrade to Node.js 22.3 or newer, then: " + REOPEN),
105
+ daemon_run_failed: entry("agent", "take",
106
+ "The lane's browser did not run the install step.",
107
+ "The lane's daemon exited or restarted.",
108
+ `${REOPEN} && ${AUTH} # once; if it repeats, report this code`, "retry_once"),
109
+ daemon_result_missing: entry("agent", "take",
110
+ "The lane's browser returned no install result.",
111
+ "The lane's daemon output changed shape (a @playwright/cli upgrade).",
112
+ "bb pw --version # report this code with that version", "report_blocker"),
113
+ daemon_result_invalid: entry("agent", "take",
114
+ "The lane's browser returned an unreadable install result.",
115
+ "The lane's daemon output changed shape (a @playwright/cli upgrade).",
116
+ "bb pw --version # report this code with that version", "report_blocker"),
117
+ pool_take_failed: entry("agent", "take",
118
+ "Taking a session from the pool failed unexpectedly (see detail).",
119
+ "A filesystem or pool error outside the known cases.",
120
+ `${AUTH} # once; if it repeats, report this code and detail`, "retry_once"),
121
+
122
+ // ── the pool's health (human repairs; agents report once and stop)
123
+ pool_empty: entry("human", "repair",
124
+ "The staging session pool is healthy but has no ready session for {persona}.",
125
+ "Agents took every ready session since the last tick, or the ready ones were dead or AAL1 (factor rotated) and were discarded.",
126
+ "{tickCommand}"),
127
+ job_not_loaded: entry("human", "install",
128
+ "The staging session pool is not installed (or not loaded) on this Mac.",
129
+ "The one-time install has not been run here, or the Mac was migrated.",
130
+ INSTALL),
131
+ no_tick_yet: entry("human", "repair",
132
+ "The pool job is installed but has not completed a tick.",
133
+ "It was installed moments ago, or its first tick failed (see {log}).",
134
+ "{tickCommand}"),
135
+ tick_stale: entry("human", "repair",
136
+ "The pool job has not ticked for 3+ intervals.",
137
+ "The Mac was asleep or the launchd job is wedged.",
138
+ "launchctl kickstart -k gui/$(id -u)/{label}"),
139
+ mint_backing_off: entry("human", "repair",
140
+ "Staging sign-in for {persona} keeps failing, so the job is backing off.",
141
+ "The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
142
+ "grep mint_failed {log} | tail -1 # then the matching code: bb help symptom <code>"),
143
+ last_tick_failed: entry("human", "repair",
144
+ "The pool job's last tick could not sign {persona} in.",
145
+ "The last mint_failed line in {log} names the cause.",
146
+ "grep mint_failed {log} | tail -1 # repair that code, then: {tickCommand}"),
147
+ ticks_failing: entry("human", "repair",
148
+ "Pool ticks run but none has succeeded for 3+ intervals.",
149
+ "A persistent mint failure; {log} names it.",
150
+ "grep mint_failed {log} | tail -1 # then: bb help symptom <code>"),
151
+ persona_below_target: entry("human", "repair",
152
+ "The pool has been below its configured size for {persona} for 3+ ticks.",
153
+ "Agents take sessions faster than the job refills them, or mints are partly failing ({log}).",
154
+ "grep mint_failed {log} | tail -1 # if clean, raise the persona's count (max 4) in {configPath}"),
155
+ persona_not_configured: entry("human", "repair",
156
+ "The pool does not serve {persona} (absent from, or 0 in, {configPath} personas).",
157
+ "The persona was added to the binding after install, or its count was set to 0.",
158
+ INSTALL + " # re-install adds new personas; or set a count of 1-4 in {configPath}"),
159
+ config_invalid: entry("human", "repair",
160
+ "The installed pool config {configPath} is invalid (see detail).",
161
+ "It was edited by hand, or an incompatible CLI wrote it.",
162
+ INSTALL + " # rewrites it from the binding; or fix the named field in {configPath}"),
163
+ pool_not_installed: entry("human", "install",
164
+ "The pool job is not installed, so it cannot tick or mint.",
165
+ "The one-time install has not been run on this Mac.",
166
+ INSTALL),
167
+ pool_target_mismatch: entry("human", "repair",
168
+ "The pool home belongs to a different staging target than the repository binding now names.",
169
+ "The binding's app_origin or supabase_url changed after install.",
170
+ "bb staging-pool uninstall && " + INSTALL),
171
+ state_file_rejected: entry("human", "repair",
172
+ "A minted session did not pass the pool's state checks (one origin, one key, an allowlisted persona), so it was not stored.",
173
+ "The binding's storage_key or personas do not match what staging Auth returned.",
174
+ "grep mint_failed {log} | tail -1 # compare with .botbuddy-agent.json storage_key / personas"),
175
+ tick_in_progress: entry("agent", "overview",
176
+ "A pool tick is already running.",
177
+ "The launchd job is mid-tick (each tick takes a few seconds per persona).",
178
+ "bb staging-pool status --check # once, a minute later; do not loop", "retry_once"),
179
+
180
+ // ── who may run what
181
+ agent_context: entry("human", "install",
182
+ "This bb staging-pool command is human-only and this shell is inside an agent session ({markers}).",
183
+ `It ran from a Claude or Codex terminal. ${HUMAN_ONLY}`,
184
+ "From Terminal.app or iTerm: cd {checkout} && bb staging-pool {command}"),
185
+ agent_context_unknown: entry("human", "install",
186
+ "bb staging-pool could not establish that this is not an agent session (ps failed), so it refused.",
187
+ `/bin/ps is unavailable or restricted in this shell. ${HUMAN_ONLY}`,
188
+ "From Terminal.app or iTerm: cd {checkout} && bb staging-pool {command}"),
189
+
190
+ // ── configuration (repository binding and installed config)
191
+ config_missing: entry("human", "configure",
192
+ "A required staging_session_pool value is missing (see detail).",
193
+ "The repository binding or installed config lacks a field.",
194
+ INSTALL + " # after fixing .botbuddy-agent.json staging_session_pool"),
195
+ pool_binding_invalid: entry("human", "configure",
196
+ "The repository's .botbuddy-agent.json staging_session_pool is invalid (see detail).",
197
+ "A committed edit broke a field; the message names it.",
198
+ "git log -p -1 -- .botbuddy-agent.json # fix the named field in a PR, then: bb staging-pool status"),
199
+ origin_not_allowed: entry("human", "configure",
200
+ "The binding's app origin or Supabase URL is not an allowed staging target (see detail).",
201
+ "It is http, loopback, a path rather than a bare origin, or a production origin.",
202
+ "git log -p -1 -- .botbuddy-agent.json # point app_origin / supabase_url at staging in a PR"),
203
+ persona_not_allowed: entry("agent", "take",
204
+ "Persona \"{persona}\" is not an allowlisted seeded persona of this pool.",
205
+ "A typo, or a persona the binding does not list. Allowed: {allowed}.",
206
+ "bb staging-pool status --check --persona {firstAllowed}"),
207
+ usage: entry("agent", "overview",
208
+ "bb staging-pool did not understand the command line (see detail).",
209
+ "An unknown subcommand or flag, or a flag without its value.",
210
+ "bb staging-pool --help"),
211
+ botbuddy_bin_required: entry("human", "install",
212
+ "install needs the absolute path of the botbuddy CLI the job will call.",
213
+ "--botbuddy-bin was omitted or is relative.",
214
+ INSTALL),
215
+
216
+ // ── signing in (logged as mint_failed by the job; mint-one prints them)
217
+ botbuddy_cli_missing: entry("human", "repair",
218
+ "The job cannot find the botbuddy CLI it was installed with.",
219
+ "@botbuddy/cli was moved or uninstalled (fnm/nvm Node switch).",
220
+ "npm i -g @botbuddy/cli@latest && " + INSTALL),
221
+ totp_source_failed: entry("human", "repair",
222
+ "botbuddy call get_totp_code failed, so the job could not complete the MFA step.",
223
+ "The botbuddy CLI is signed out on this Mac, or the persona's TOTP seed is not registered.",
224
+ "botbuddy login && bb staging-pool probe"),
225
+ totp_response_invalid: entry("human", "repair",
226
+ "get_totp_code answered without a usable 6-digit code.",
227
+ "The BotBuddy server or CLI is out of date.",
228
+ "npm i -g @botbuddy/cli@latest && bb staging-pool probe"),
229
+ totp_response_mismatch: entry("human", "repair",
230
+ "get_totp_code answered for a different app, persona or environment.",
231
+ "The binding's app_slug or the registered seed changed.",
232
+ "bb staging-pool probe # compare with .botbuddy-agent.json app_slug / personas"),
233
+ probe_response_invalid: entry("human", "repair",
234
+ "list_totp_seeds returned something other than a list of seeds.",
235
+ "The botbuddy CLI is signed out or out of date.",
236
+ "botbuddy login && bb staging-pool probe"),
237
+ password_source_missing: entry("human", "repair",
238
+ "The persona password is missing from {envFile}.",
239
+ "The file is absent, or lacks the persona's password_env variable (never committed).",
240
+ "ls -l {envFile} # restore it with the persona's variable, then: bb staging-pool mint-one --persona {persona}"),
241
+ password_source_invalid: entry("human", "repair",
242
+ "{envFile} is not a regular file.",
243
+ "It is a directory, socket or broken link.",
244
+ "ls -l {envFile} # replace it with a regular file, then: bb staging-pool mint-one --persona {persona}"),
245
+ password_source_permissions: entry("human", "repair",
246
+ "{envFile} is readable by group/others or not owned by you, so the job will not read it.",
247
+ "It was created without mode 0600 or copied from another user.",
248
+ "chmod 600 {envFile} && bb staging-pool mint-one --persona {persona}"),
249
+ password_rejected: entry("human", "repair",
250
+ "Staging Auth rejected the persona password.",
251
+ "Seed drift: the staging persona was re-seeded with another password, or the env file is stale.",
252
+ "bb staging-pool mint-one --persona {persona} # after re-running the staging persona seed or updating the env file"),
253
+ no_verified_totp_factor: entry("human", "repair",
254
+ "The persona has no verified TOTP factor on staging.",
255
+ "Seed drift: the staging seed job has not enrolled the factor.",
256
+ "bb staging-pool probe # after re-running the staging persona seed"),
257
+ challenge_failed: entry("human", "repair",
258
+ "Staging Auth refused the MFA challenge.",
259
+ "Auth rate limiting or a GoTrue change; {log} has the status.",
260
+ "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
261
+ verify_failed: entry("human", "repair",
262
+ "Staging Auth refused the TOTP code.",
263
+ "The Mac clock is skewed, or the registered seed differs from the enrolled factor.",
264
+ "sntp time.apple.com # prints the clock offset; fix it in System Settings > Date & Time, then: bb staging-pool mint-one --persona {persona}"),
265
+ verify_response_invalid: entry("human", "repair",
266
+ "Staging Auth's MFA verify answer was not a usable session.",
267
+ "A GoTrue change; the pool's REST shapes are pinned by dated fixtures.",
268
+ "grep mint_failed {log} | tail -1 # report this code"),
269
+ mint_retries_exhausted: entry("human", "repair",
270
+ "The sign-in gave up after its retries.",
271
+ "Transient Auth or network failures; {log} names the last one.",
272
+ "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
273
+ not_aal2: entry("human", "repair",
274
+ "MFA verify returned a session that is not AAL2.",
275
+ "The persona's factor is not verified, or Auth's MFA settings changed.",
276
+ "bb staging-pool probe # after re-running the staging persona seed"),
277
+ persona_mismatch: entry("human", "repair",
278
+ "MFA verify returned a session for a different user.",
279
+ "The binding's persona email does not match the seeded user.",
280
+ "git log -p -1 -- .botbuddy-agent.json # check personas, then: bb staging-pool mint-one --persona {persona}"),
281
+
282
+ unexpected_error: entry("agent", "codes",
283
+ "bb staging-pool failed unexpectedly (see detail).",
284
+ "An error outside the known cases.",
285
+ "bb staging-pool status # once; report this code, the detail and the CLI version (bb version)", "report_blocker"),
286
+ });
287
+
288
+ // Vars that are already whole command lines; every other var is a single shell word.
289
+ const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry"]);
290
+ const SHELL_SAFE = /^[A-Za-z0-9_./:@,=~+-]+$/;
291
+ // A <placeholder> stays as written: the reader replaces it.
292
+ const shellWord = (value) => (SHELL_SAFE.test(value) || /^<[^<>]+>$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
293
+
294
+ // In a `next` command a caller-supplied value (a typed persona, a checkout path
295
+ // with spaces) is shell-quoted, so the line stays one safe copy-paste.
296
+ function fill(template, vars, { shell = false } = {}) {
297
+ return String(template).replace(/\{([A-Za-z]+)\}/g, (_, key) => {
298
+ const value = vars?.[key];
299
+ if (Array.isArray(value)) return value.length ? value.map((item) => (shell ? shellWord(String(item)) : String(item))).join(", ") : `<${key}>`;
300
+ if (value === undefined || value === null || value === "") return `<${key}>`;
301
+ return shell && !COMMAND_VARS.has(key) ? shellWord(String(value)) : String(value);
302
+ });
303
+ }
304
+
305
+ export const isKnownCode = (code) => Object.hasOwn(ERROR_CODES, String(code ?? ""));
306
+
307
+ /** The guidance for one code, interpolated with non-secret vars. */
308
+ export function guidance(code, vars = {}) {
309
+ const known = isKnownCode(code);
310
+ const spec = known ? ERROR_CODES[code] : ERROR_CODES.unexpected_error;
311
+ const allowed = Array.isArray(vars.allowed) ? vars.allowed : vars.allowed ? [vars.allowed] : [];
312
+ const filled = {
313
+ ...vars,
314
+ allowed,
315
+ firstAllowed: vars.firstAllowed ?? allowed[0],
316
+ // Without a resolved pool these still read as runnable commands from the checkout.
317
+ tickCommand: vars.tickCommand ?? "bb staging-pool tick",
318
+ installCommand: vars.installCommand ?? `cd ${vars.checkout ? shellWord(String(vars.checkout)) : "<checkout>"} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"`,
319
+ };
320
+ // What a "go to the right checkout" step re-runs: auth by default.
321
+ filled.retry = vars.retry ?? fill(AUTH, filled, { shell: true });
322
+ return {
323
+ code: known ? code : String(code ?? "unexpected_error"),
324
+ message: fill(spec.message, filled),
325
+ cause: fill(spec.cause, filled),
326
+ actor: spec.actor,
327
+ actor_label: ACTOR_LABELS[spec.actor],
328
+ next: fill(spec.next, filled, { shell: true }),
329
+ agent_action: spec.agentAction,
330
+ doc: `${DOC_URL}#${DOC_SECTIONS[spec.doc]}`,
331
+ help: `bb help symptom ${known ? code : "unexpected_error"}`,
332
+ };
333
+ }
334
+
335
+ /** One line for JSON receipts and health records: who acts and the exact command. */
336
+ export function remedyLine(g) {
337
+ const where = g.actor === "human" ? ", from Terminal.app or iTerm" : "";
338
+ return `${g.actor_label}${where}: ${g.next} (${g.cause} Docs: ${g.doc})`;
339
+ }
340
+
341
+ /** Plain-text block for stderr. */
342
+ export function formatGuidance(g, { prefix = "bb staging-pool" } = {}) {
343
+ const lines = [
344
+ `${prefix}: ${g.message} [${g.code}]`,
345
+ ` Likely cause: ${g.cause}`,
346
+ ` Next (${g.actor_label}${g.actor === "human" ? ", from Terminal.app or iTerm" : ""}): ${g.next}`,
347
+ ];
348
+ if (g.actor === "human") lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
349
+ lines.push(` Docs: ${g.doc}`, ` Explain: ${g.help}`);
350
+ return lines.join("\n");
351
+ }
352
+
353
+ /** `bb help symptom staging-pool` and `bb help symptom <code>`. Null for an unknown topic. */
354
+ export function symptomHelp(topic) {
355
+ const key = String(topic ?? "").trim().toLowerCase().replace(/-/g, "_");
356
+ if (key === "staging_pool" || key === "staging_pool_codes") {
357
+ const rows = Object.entries(ERROR_CODES).map(([code, spec]) => ` ${code.padEnd(36)} ${ACTOR_LABELS[spec.actor].padEnd(13)} ${spec.message.replace(/\{[A-Za-z]+\}/g, "…")}`);
358
+ return [
359
+ "bb staging-pool / bb pw auth error codes (BOT-2084). Actor = who runs the next step.",
360
+ "",
361
+ ...rows,
362
+ "",
363
+ "bb help symptom <code> shows the cause and the exact next command.",
364
+ `Docs: ${DOC_URL}#${DOC_SECTIONS.codes}`,
365
+ ].join("\n");
366
+ }
367
+ if (!isKnownCode(key)) return null;
368
+ const g = guidance(key);
369
+ return [
370
+ `Symptom: ${key} (bb pw auth / bb staging-pool, BOT-2084)`,
371
+ "",
372
+ `What happened ${g.message}`,
373
+ `Likely cause ${g.cause}`,
374
+ `Who acts ${g.actor_label}${g.actor === "human" ? " (agents report this once and stop)" : ""}`,
375
+ `Next ${g.next}`,
376
+ `Docs ${g.doc}`,
377
+ "",
378
+ "<placeholders> are filled in when the command prints this for a real refusal.",
379
+ ].join("\n");
380
+ }
@@ -0,0 +1,84 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120 health.mjs): pool health — one
2
+ // machine-readable reason, the human remedy and the agent's action. Pure and
3
+ // Node-stdlib-only; the remedy text is per repo (label, log, install command, docs).
4
+
5
+ import { guidance, remedyLine } from "./errors.mjs";
6
+
7
+ // Each reason's guidance (what happened, cause, who acts, next command, docs)
8
+ // comes from the shared error registry (errors.mjs); `remedy` is its one-line form.
9
+ export const HEALTH_REASON_CODES = Object.freeze([
10
+ "config_invalid", "persona_not_configured", "job_not_loaded", "no_tick_yet", "tick_stale",
11
+ "mint_backing_off", "last_tick_failed", "ticks_failing", "persona_below_target",
12
+ ]);
13
+
14
+ // report_blocker → state the reason + remedy to the human ONCE and stop.
15
+ export function healthReasons({ label, log, installCommand, docs, configPath, tickCommand, checkout, persona }) {
16
+ const vars = { label, log, installCommand, docs, configPath, tickCommand, checkout, persona };
17
+ return Object.freeze(Object.fromEntries(HEALTH_REASON_CODES.map((code) => {
18
+ const g = guidance(code, vars);
19
+ return [code, Object.freeze({ agentAction: g.agent_action, remedy: remedyLine(g), vars })];
20
+ })));
21
+ }
22
+
23
+ const STALE_INTERVALS = 3;
24
+ const BELOW_TARGET_TICK_LIMIT = 2;
25
+ const MAX_BACKOFF_MS = 2 * 60 * 60_000;
26
+
27
+ export function nextMintBackoffMs({ failures, intervalMs }) {
28
+ return Math.min(intervalMs * 2 ** failures, MAX_BACKOFF_MS);
29
+ }
30
+
31
+ export function assessPoolHealth({ status, personas, intervalSeconds, nowMs, jobLoaded, configError, requestedPersona, reasons }) {
32
+ const unhealthy = (reason, extra = {}) => {
33
+ const { agentAction, vars } = reasons[reason];
34
+ let { remedy } = reasons[reason];
35
+ const record = { ok: false, reason, agentAction, ...extra };
36
+ if (vars) {
37
+ // Re-rendered with the persona this assessment is about.
38
+ const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona });
39
+ remedy = remedyLine(g);
40
+ Object.assign(record, { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help });
41
+ }
42
+ return { ...record, remedy };
43
+ };
44
+ const intervalMs = intervalSeconds * 1000;
45
+ if (configError) return unhealthy("config_invalid", { detail: configError });
46
+ if (jobLoaded === false) return unhealthy("job_not_loaded");
47
+ // Only when the configured persona set is known (an installed config, or --personas).
48
+ if (requestedPersona && Object.keys(personas).length > 0 && !(personas[requestedPersona] > 0)) {
49
+ return unhealthy("persona_not_configured", { persona: requestedPersona });
50
+ }
51
+ const lastTick = status?.lastTick;
52
+ if (!lastTick?.at) return unhealthy("no_tick_yet");
53
+ if (nowMs - Date.parse(lastTick.at) > STALE_INTERVALS * intervalMs) {
54
+ return unhealthy("tick_stale", { detail: `last tick ${lastTick.at}` });
55
+ }
56
+ const personaHealth = status.health?.personas ?? {};
57
+ // A take is diagnosed for its own persona; an unscoped `status --check` looks at all.
58
+ const scope = requestedPersona ? [requestedPersona] : Object.keys(personas);
59
+ for (const persona of scope) {
60
+ const entry = personaHealth[persona];
61
+ if (entry?.nextMintAttemptAt && Date.parse(entry.nextMintAttemptAt) > nowMs) {
62
+ return unhealthy("mint_backing_off", {
63
+ persona,
64
+ detail: `${entry.consecutiveMintFailures} consecutive failures, last ${entry.lastMintError}; next attempt ${entry.nextMintAttemptAt}`,
65
+ });
66
+ }
67
+ }
68
+ const requestedFailing = requestedPersona ? (personaHealth[requestedPersona]?.consecutiveMintFailures ?? 0) > 0 : true;
69
+ if (lastTick.mintFailures > 0 && requestedFailing) {
70
+ return unhealthy("last_tick_failed", { detail: `${lastTick.mintFailures} mint failure(s) at ${lastTick.at}` });
71
+ }
72
+ // Job-wide: a scoped take is judged by its persona's own backoff / shortfall instead,
73
+ // so one persona's repeated failure does not block another persona's take.
74
+ const lastSuccess = status.health?.lastSuccessfulTickAt;
75
+ if (!requestedPersona && (!lastSuccess || nowMs - Date.parse(lastSuccess) > STALE_INTERVALS * intervalMs)) {
76
+ return unhealthy("ticks_failing", { detail: `last successful tick ${lastSuccess ?? "never"}` });
77
+ }
78
+ for (const persona of scope) {
79
+ if ((personaHealth[persona]?.belowTargetTicks ?? 0) > BELOW_TARGET_TICK_LIMIT) {
80
+ return unhealthy("persona_below_target", { persona, detail: `${personaHealth[persona].belowTargetTicks} ticks below ${personas[persona]}` });
81
+ }
82
+ }
83
+ return { ok: true, reason: "healthy" };
84
+ }
@@ -0,0 +1,125 @@
1
+ // BOT-2084: help for `bb staging-pool` and `bb pw … auth`. Node-stdlib-only and
2
+ // self-contained (bundled with the installed job).
3
+ import { DOC_SECTIONS, DOC_URL } from "./errors.mjs";
4
+
5
+ const doc = (section) => `${DOC_URL}#${DOC_SECTIONS[section]}`;
6
+
7
+ export const BINDING_KEYS_HELP = ` .botbuddy-agent.json (committed, at the repository root) → staging_session_pool:
8
+ schema_version 1, pool_id, environment "staging", app_slug, adapter
9
+ "supabase-gotrue-totp", app_origin, supabase_url, publishable_key (public),
10
+ storage_key, production_origins, password_env_file, launchd_label, docs,
11
+ install_command, personas { <alias>: { email, password_env, default_count 0-4 } }.
12
+ No flag or environment variable can change the origin or add a persona.`;
13
+
14
+ export const PW_AUTH_HELP = `bb pw auth — sign a claimed browser lane in to staging as a seeded test persona (BOT-2084).
15
+
16
+ USAGE
17
+ bb pw --environment staging <lane> auth --persona <alias>
18
+ (--environment and credential flags go BEFORE the lane; --persona goes after auth)
19
+
20
+ WHO RUNS IT
21
+ Agents (and humans). It never types a password or one-time code: it installs a
22
+ session the human-installed staging session pool already signed in
23
+ (bb staging-pool --help).
24
+
25
+ BEFORE YOU RUN IT
26
+ 1. You are in a checkout of a repository whose .botbuddy-agent.json declares
27
+ staging_session_pool (check: bb staging-pool status).
28
+ 2. The human has installed the pool on this Mac
29
+ (check: bb staging-pool status --check prints "ok": true).
30
+ 3. THIS agent session holds the lane lock, and the lane is open on staging and
31
+ not persistent:
32
+ bb locks -m --lane <lane> --ticket <ticket>
33
+ bb pw --environment staging <lane> open about:blank
34
+ 4. <alias> is a key of the binding's personas (BotBuddy: staging-admin).
35
+
36
+ EXAMPLE (BotBuddy, lane 8)
37
+ bb pw --environment staging 8 open about:blank
38
+ bb pw --environment staging 8 auth --persona staging-admin
39
+ bb pw 8 goto https://staging.bot-buddy.ai/
40
+ …acceptance…
41
+ bb pw 8 verdict pass
42
+ bb pw 8 close # discards the browser's copy (no sign-out), then release the lock
43
+
44
+ WHAT IT PRINTS
45
+ stdout: one JSON receipt {"staging_auth": {…}} with ok, persona (alias), origin,
46
+ lane, aal, session_ref (12 hex, non-secret), access_expires_at, the landing path
47
+ and on_close. A refusal has ok: false, code, message, cause, actor, next, doc and
48
+ remedy, and stderr repeats it as text. Exit 0 installed, 2 fix the command,
49
+ 3 refused or blocked.
50
+ Never printed, logged or kept: the password, the one-time code, the access or
51
+ refresh token, or the slot file's path or contents.
52
+
53
+ CONFIGURATION
54
+ ${BINDING_KEYS_HELP}
55
+
56
+ IF IT REFUSES
57
+ The refusal names the code, who acts ("Agent" or "Human (Jono)") and the exact
58
+ next command. For a Human step, report the code and next step once and stop;
59
+ never sign in yourself and never loop on status.
60
+ bb help symptom <code> one code's cause and next command
61
+ bb help symptom staging-pool every code
62
+ Docs: ${doc("take")}`;
63
+
64
+ export const STAGING_POOL_HELP = `bb staging-pool — the human-installed job that keeps signed-in staging sessions ready for agents.
65
+
66
+ A launchd job on this Mac signs each allowlisted seeded persona in to the
67
+ repository's staging app (password + TOTP via BotBuddy get_totp_code) every
68
+ 5 minutes and keeps a few single-use sessions ready. Agents never sign in: they
69
+ take a session with bb pw --environment staging <lane> auth --persona <alias>
70
+ (bb pw auth --help).
71
+
72
+ Run from the repository checkout: every command reads .botbuddy-agent.json there.
73
+
74
+ ANYONE (agents too) — read-only
75
+ status [--check] [--persona <alias>]
76
+ Counts and last tick; never session material. --check prints one health
77
+ reason with who acts and the next command (exit 5 when unhealthy).
78
+ e.g. bb staging-pool status --check --persona staging-admin
79
+ probe [--botbuddy-bin <path>]
80
+ Confirms each persona's TOTP seed is registered (list_totp_seeds). Never
81
+ mints a code or signs in.
82
+ e.g. bb staging-pool probe
83
+ print-plist [--interval <seconds>]
84
+ Prints the launchd plist install would write. Writes nothing.
85
+ e.g. bb staging-pool print-plist
86
+
87
+ HUMAN ONLY (Jono, from Terminal.app or iTerm) — refused inside a Claude or Codex session
88
+ These sign in with the persona password or change the standing sessions.
89
+ install --botbuddy-bin <absolute path> [--interval <seconds>]
90
+ Copies the job to the pool home, writes its config and loads the launchd
91
+ job. Re-run after upgrading @botbuddy/cli; it keeps counts and interval.
92
+ e.g. cd <checkout> && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"
93
+ tick [--home <pool home>]
94
+ One refresh and refill pass now (what the job runs).
95
+ e.g. bb staging-pool tick
96
+ mint-one --persona <alias>
97
+ One real sign-in into the pool, to prove a repair worked.
98
+ e.g. bb staging-pool mint-one --persona staging-admin
99
+ uninstall [--keep-sessions]
100
+ Stops the job and deletes its sessions (keeps them with --keep-sessions).
101
+ e.g. bb staging-pool uninstall
102
+
103
+ PREREQUISITES (human, once per Mac)
104
+ - @botbuddy/cli installed globally, and botbuddy login done (the job calls
105
+ botbuddy call get_totp_code).
106
+ - The persona password in the binding's password_env_file, mode 0600, never
107
+ committed (BotBuddy: ~/.config/botbuddy-web/.env.local.shared).
108
+
109
+ CONFIGURATION
110
+ ${BINDING_KEYS_HELP}
111
+
112
+ FILES
113
+ ~/.botbuddy/staging-session-pool/<pool_id>/
114
+ config.json, app/, pool/{ready,work,taken}/, log/pool.log (dirs 0700, files 0600)
115
+ ~/Library/LaunchAgents/<launchd_label>.plist
116
+
117
+ OUTPUT
118
+ JSON lines on stdout. A failure prints one JSON line on stderr (code, message,
119
+ cause, actor, next, doc) and the same guidance as text. Never prints a
120
+ password, one-time code, token or session file contents.
121
+ Exit 0 ok, 1 a tick had mint failures, 2 refused (agent session, persona,
122
+ origin), 3 pool empty, 4 configuration or usage, 5 unhealthy.
123
+
124
+ bb help symptom <code> explains one code; bb help symptom staging-pool lists them.
125
+ Docs: ${doc("overview")}`;
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // BOT-2084: launchd entry for the installed staging session pool job
3
+ // (<pool home>/app/job.mjs tick --home <pool home>). It runs the installed copy
4
+ // of these modules and reads only the installed config, never a repo checkout.
5
+ import { runStagingPool } from "./cli.mjs";
6
+
7
+ process.exitCode = await runStagingPool(process.argv.slice(2));