joinhive 2.0.0 → 2.1.0

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.
@@ -11,10 +11,12 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync, ren
11
11
  import { join } from 'node:path';
12
12
  import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
13
13
  import nacl from 'tweetnacl';
14
- import { sealSecrets } from '../shared/sealed.mjs';
14
+ import { sealSecrets, openSecrets } from '../shared/sealed.mjs';
15
15
  import { signedFetch } from '../shared/nip98.mjs';
16
16
  import { verifyAuthTag } from '../shared/nip-oa.mjs';
17
- import { validateConfig } from '../shared/config-schema.mjs';
17
+ import { validateConfig, DEFAULTS, OPENAI_COMPAT_BASES } from '../shared/config-schema.mjs';
18
+ import { EV } from '../shared/events.mjs';
19
+ import { RelayClient } from '../daemon/relay/client.mjs';
18
20
 
19
21
  const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
20
22
  const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
@@ -23,12 +25,13 @@ const GENESIS_JELLY = 500;
23
25
  const GENESIS_ETH = 0.05;
24
26
 
25
27
  export class Provisioner {
26
- constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, log = console.log }) {
28
+ constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, supervisorPort = 8787, log = console.log }) {
27
29
  this.dataDir = dataDir;
28
30
  this.relayUrl = relayUrl;
29
31
  this.kek = kek;
30
32
  this.stewardKey = stewardKey; // nostr privkey hex — relay owner, mints invites
31
33
  this.boxSecretKey = boxSecretKey; // X25519 secret (Uint8Array) for sealed payloads
34
+ this.supervisorPort = supervisorPort;
32
35
  this.log = log;
33
36
  this.invitesPath = join(dataDir, 'invites.json');
34
37
  this.registryPath = join(dataDir, 'registry.json');
@@ -102,6 +105,9 @@ export class Provisioner {
102
105
  const state = prior || { name, owner_pubkey: req.owner_pubkey, steps: {}, created_at: Math.floor(Date.now() / 1000) };
103
106
  const done = (step) => !!state.steps[step];
104
107
  const mark = (step, extra = true) => { state.steps[step] = extra; writeAtomic(statePath, JSON.stringify(state, null, 2)); };
108
+ // Steps added after a bee first completed provisioning must not retro-fire
109
+ // on its idempotent re-POSTs (a years-old bee getting a "welcome" is wrong).
110
+ const preexisting = !!(prior && prior.steps && prior.steps.done);
105
111
 
106
112
  // 1. invite
107
113
  if (!done('invite_checked')) {
@@ -143,6 +149,33 @@ export class Provisioner {
143
149
  }
144
150
  }
145
151
 
152
+ // 3b. Channel membership with role "bot" — this is what makes the bee
153
+ // appear in the Buzz desktop Agents directory (its relay listing only
154
+ // surfaces pubkeys whose relay-signed kind-39002 membership carries a
155
+ // bot-role p-tag; the NIP-OA pair alone is NOT enough). Must run
156
+ // BEFORE the daemon's first boot: adding a NEW member with a role is
157
+ // open to any authenticated user, changing an EXISTING member's role
158
+ // needs channel admin. The daemon's own 9021 re-join later is a
159
+ // membership no-op, so the role sticks. Best-effort: a failure is
160
+ // recorded, never fatal (the admin `rebot` endpoint is the retrofit).
161
+ if (!done('bot_role')) {
162
+ if (preexisting) mark('bot_role', 'skipped-preexisting');
163
+ else {
164
+ try {
165
+ const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
166
+ for (const chName of Object.values(DEFAULTS.channels)) {
167
+ const chId = await steward.ensureChannel(chName);
168
+ const r = await steward.publish(9000, '', [['h', chId], ['p', beePubkey], ['role', 'bot']]);
169
+ if (!r.ok && !/duplicate|already/i.test(r.message)) throw new Error(`add-member(bot) to ${chName}: ${r.message || 'rejected'}`);
170
+ }
171
+ mark('bot_role');
172
+ } catch (e) {
173
+ this.log(`bot_role for ${name} failed (bee works, Agents-tab listing needs admin rebot): ${String(e.message).slice(0, 160)}`);
174
+ mark('bot_role', `failed: ${String(e.message).slice(0, 120)}`);
175
+ }
176
+ }
177
+ }
178
+
146
179
  // 4. secrets: open the client's nacl.box, re-seal at rest under the KEK.
147
180
  if (!done('secrets_stored')) {
148
181
  const s = req.sealed || {};
@@ -155,7 +188,9 @@ export class Provisioner {
155
188
  if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
156
189
  let secrets;
157
190
  try { secrets = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
158
- if (!secrets.llm_api_key) throw httpErr(400, 'sealed payload missing llm_api_key');
191
+ // Echo-first onboarding: a keyless bee is legal ONLY as an echo bee
192
+ // (it heartbeats but computes nothing); `hive key set` upgrades it.
193
+ if (!secrets.llm_api_key && req.provider !== 'echo') throw httpErr(400, 'sealed payload missing llm_api_key');
159
194
  writeFileSync(join(home, 'secrets.enc.json'), JSON.stringify(sealSecrets(this.kek, secrets)), { mode: 0o600 });
160
195
  mark('secrets_stored');
161
196
  }
@@ -168,6 +203,10 @@ export class Provisioner {
168
203
  ...(req.base_url ? { base_url: req.base_url } : {}),
169
204
  ...(req.model_extract ? { model_extract: req.model_extract } : {}),
170
205
  ...(req.model_compute ? { model_compute: req.model_compute } : {}),
206
+ // Echo-first: the CLI says explicitly that this echo bee is WAITING
207
+ // for a brain (vs a deliberate echo test bee, which never sets this).
208
+ // The daemon mutes compute/extract on this flag; `hive key set` clears it.
209
+ ...(req.awaiting_key && req.provider === 'echo' ? { awaiting_key: true } : {}),
171
210
  relay: this.relayUrl,
172
211
  poll_secs: 10,
173
212
  owner_pubkey: req.owner_pubkey,
@@ -232,11 +271,106 @@ export class Provisioner {
232
271
  mark('grants_queued');
233
272
  }
234
273
 
274
+ // 9. Welcome moment: a steward-signed intent FOR the new member, so their
275
+ // first feed isn't empty — other bees introduce themselves within a
276
+ // tick or two (origin "welcome" makes profile-mismatched bees eligible;
277
+ // the election still caps how many answer). Best-effort: relay trouble
278
+ // must never fail an otherwise-complete provision.
279
+ if (!done('welcome_posted')) {
280
+ if (preexisting) mark('welcome_posted', 'skipped-preexisting');
281
+ else {
282
+ try {
283
+ const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
284
+ const stewardPub = getPublicKey(Uint8Array.from(Buffer.from(this.stewardKey, 'hex')));
285
+ const ownerName = String(req.owner_name || name).slice(0, 40);
286
+ const logsId = await steward.ensureChannel(DEFAULTS.channels.logs);
287
+ const intentsId = await steward.ensureChannel(DEFAULTS.channels.intents);
288
+ const w = await steward.sendMessage(logsId, JSON.stringify({
289
+ type: EV.INTENT,
290
+ intent: `welcome ${ownerName} to the hive: introduce yourself briefly and offer ONE concrete thing you could do for them, based on what your owner is into`,
291
+ origin: 'welcome', for: req.owner_pubkey, by: stewardPub,
292
+ }));
293
+ if (!w.ok) throw new Error(w.message || 'welcome intent rejected');
294
+ await steward.sendMessage(intentsId, `🐝 ${name}.bee just joined the hive — say hi to ${ownerName}`);
295
+ mark('welcome_posted');
296
+ } catch (e) {
297
+ this.log(`welcome for ${name} failed (non-fatal): ${String(e.message).slice(0, 160)}`);
298
+ mark('welcome_posted', `failed: ${String(e.message).slice(0, 120)}`);
299
+ }
300
+ }
301
+ }
302
+
235
303
  mark('done');
236
304
  this.log(`provisioned bee ${name} (${beePubkey.slice(0, 12)}) for ${String(req.owner_name || '')}`);
237
305
  return this.status(name);
238
306
  }
239
307
 
308
+ // ---- key upgrade: echo-first bees get their brain AFTER the first win ------
309
+ // req: {provider, base_url?, model_extract?, model_compute?, sealed:{nonce,box,client_pub}}
310
+ // Signed by the owner. Merges llm_api_key into the at-rest secrets (the
311
+ // wallet mnemonic stays), rewrites config, and bounces the daemon so the
312
+ // new engine boots. A re-POST of /api/bees can NOT do this: secrets_stored
313
+ // and config_written are completed steps and never re-run.
314
+ async setKey(name, req, signerPubkey) {
315
+ const home = join(this.dataDir, 'bees', name);
316
+ const state = loadJson(join(home, 'provision.json'), null);
317
+ if (!state) throw httpErr(404, 'unknown bee');
318
+ if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can set this bee\'s key');
319
+ const provider = String(req.provider || '').toLowerCase();
320
+ if (!['anthropic', 'openai', 'openrouter', 'hermes'].includes(provider)) throw httpErr(400, `provider must be anthropic|openai|openrouter|hermes, got "${provider}"`);
321
+
322
+ const s = req.sealed || {};
323
+ const opened = nacl.box.open(
324
+ Buffer.from(s.box || '', 'base64'),
325
+ Buffer.from(s.nonce || '', 'base64'),
326
+ Buffer.from(s.client_pub || '', 'base64'),
327
+ this.boxSecretKey,
328
+ );
329
+ if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
330
+ let incoming;
331
+ try { incoming = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
332
+ if (!incoming.llm_api_key) throw httpErr(400, 'sealed payload missing llm_api_key');
333
+
334
+ const encPath = join(home, 'secrets.enc.json');
335
+ const existing = existsSync(encPath) ? openSecrets(this.kek, loadJson(encPath, null)) : {};
336
+ const tmp = `${encPath}.tmp`;
337
+ writeFileSync(tmp, JSON.stringify(sealSecrets(this.kek, { ...existing, llm_api_key: incoming.llm_api_key })), { mode: 0o600 });
338
+ renameSync(tmp, encPath);
339
+
340
+ const cfgPath = join(home, 'config.json');
341
+ const cfg = loadJson(cfgPath, {});
342
+ const next = {
343
+ ...cfg,
344
+ provider,
345
+ base_url: req.base_url || OPENAI_COMPAT_BASES[provider] || undefined,
346
+ ...(req.model_extract ? { model_extract: req.model_extract } : {}),
347
+ ...(req.model_compute ? { model_compute: req.model_compute } : {}),
348
+ };
349
+ delete next.awaiting_key;
350
+ if (!next.base_url) delete next.base_url;
351
+ const { errors } = validateConfig(next, { requireBee: true });
352
+ if (errors.length) throw httpErr(400, `config invalid: ${errors.join('; ')}`);
353
+ writeAtomic(cfgPath, JSON.stringify(next, null, 2));
354
+
355
+ // Bounce the daemon so it re-reads config + secrets. Supervisor endpoint
356
+ // first; fall back to signalling the pid from the last heartbeat (same
357
+ // container) — the supervisor's exit handler respawns either way.
358
+ let restarted = 'none';
359
+ try {
360
+ const r = await fetch(`http://127.0.0.1:${this.supervisorPort}/restart`, {
361
+ method: 'POST', headers: { 'content-type': 'application/json' },
362
+ body: JSON.stringify({ name }), signal: AbortSignal.timeout(5000),
363
+ });
364
+ if (r.ok) restarted = 'supervisor';
365
+ } catch {}
366
+ if (restarted === 'none') {
367
+ const hb = loadJson(join(home, 'heartbeat.json'), {});
368
+ if (hb.pid) { try { process.kill(hb.pid, 'SIGTERM'); restarted = 'signal'; } catch {} }
369
+ }
370
+ this.log(`key set for ${name}: provider ${provider}, restart via ${restarted}`);
371
+ return { ...this.status(name), restarted };
372
+ }
373
+
240
374
  status(name) {
241
375
  const home = join(this.dataDir, 'bees', name);
242
376
  const state = loadJson(join(home, 'provision.json'), null);
@@ -250,7 +384,9 @@ export class Provisioner {
250
384
  return {
251
385
  name,
252
386
  bee_pubkey: state.steps.bee_key || null,
387
+ owner_pubkey: state.owner_pubkey || null,
253
388
  steps: Object.keys(state.steps),
389
+ brain: cfg.awaiting_key ? 'awaiting-key' : (cfg.provider || null),
254
390
  daemon: hb.at ? { last_tick_at: hb.at, pid: hb.pid, paused: hb.paused || false } : null,
255
391
  grants: grant,
256
392
  budget: spend ? { date: spend.date, jelly_spent: spend.jelly_spent, daily_cap: cfg.spend?.jelly_daily_cap ?? 15 } : { daily_cap: cfg.spend?.jelly_daily_cap ?? 15 },
@@ -184,6 +184,7 @@ export const computeEpoch = (events, registry, state, rewards = REWARDS) => {
184
184
  const servedKeys = {}; // author -> Set(intentKey)
185
185
  for (const r of results) {
186
186
  if (!isBee(r.author)) continue;
187
+ if (isBee(r.for)) continue; // serving another AGENT is loop debris, not service
187
188
  if (downed.has(r.id)) continue;
188
189
  if (reports[r.author]?.size) continue;
189
190
  const keys = servedKeys[r.author] = servedKeys[r.author] || new Set();
@@ -64,8 +64,18 @@ const readSecrets = (home) => {
64
64
  };
65
65
 
66
66
  // Regenerate the roster from disk truth. Idempotent; provisioning re-runs it.
67
+ // Bee rows not backed by a live dir are dropped (retired bees leave the
68
+ // fan-out roster and the leaderboard resolution).
67
69
  const rebuildRegistry = () => {
68
70
  const reg = loadJson(REGISTRY, {});
71
+ const live = new Set();
72
+ for (const name of listBeeDirs()) {
73
+ const id = loadJson(join(BEES_DIR, name, 'identity.json'), {});
74
+ if (id.pubkey) live.add(id.pubkey);
75
+ }
76
+ for (const [pk, row] of Object.entries(reg)) {
77
+ if (row?.is_bee && !live.has(pk)) delete reg[pk];
78
+ }
69
79
  for (const name of listBeeDirs()) {
70
80
  const home = join(BEES_DIR, name);
71
81
  const identity = loadJson(join(home, 'identity.json'), {});
@@ -133,6 +143,12 @@ const spawnBee = (name) => {
133
143
  entry.pid = null;
134
144
  entry.child = null;
135
145
  if (shuttingDown) return;
146
+ // Retired while running: the dir is archived away — forget, don't restart.
147
+ if (!existsSync(join(home, 'config.json'))) {
148
+ log(`bee ${name} gone from disk (retired) — not restarting`);
149
+ bees.delete(name);
150
+ return;
151
+ }
136
152
  const now = Date.now();
137
153
  entry.restarts = [...entry.restarts.filter((t) => now - t < 10 * 60_000), now];
138
154
  if (entry.restarts.length > 10) {
@@ -150,7 +166,17 @@ const spawnBee = (name) => {
150
166
 
151
167
  const rescan = () => {
152
168
  rebuildRegistry();
153
- for (const name of listBeeDirs()) {
169
+ const present = new Set(listBeeDirs());
170
+ // Reap retired bees: dir gone (archived by the retire endpoint) -> stop the
171
+ // child and forget it. The registry rebuild below already dropped its row.
172
+ for (const [name, e] of bees) {
173
+ if (present.has(name)) continue;
174
+ log(`bee ${name} retired — stopping`);
175
+ try { e.child?.kill('SIGTERM'); } catch {}
176
+ e.state = 'retired';
177
+ bees.delete(name);
178
+ }
179
+ for (const name of present) {
154
180
  const e = bees.get(name);
155
181
  if (!e || (!e.child && e.state !== 'backoff')) {
156
182
  if (e) { e.restarts = []; e.backoffMs = 1000; }
@@ -190,6 +216,33 @@ createServer((req, res) => {
190
216
  res.end(JSON.stringify({ respawned: true }));
191
217
  return;
192
218
  }
219
+ if (url.pathname === '/restart' && req.method === 'POST') {
220
+ // Restart ONE bee with fresh config+secrets from disk (used by the api
221
+ // worker after `hive key set` rewrites them). Internal port only.
222
+ let body = '';
223
+ req.on('data', (c) => { body += c; });
224
+ req.on('end', () => {
225
+ let name = '';
226
+ try { name = String(JSON.parse(body || '{}').name || ''); } catch {}
227
+ const e = bees.get(name);
228
+ if (!name || (!e && !existsSync(join(BEES_DIR, name, 'config.json')))) {
229
+ res.writeHead(404, { 'content-type': 'application/json' });
230
+ res.end('{"error":"unknown bee"}');
231
+ return;
232
+ }
233
+ if (e) { e.restarts = []; e.backoffMs = 1000; }
234
+ if (e?.child) {
235
+ try { e.child.kill('SIGTERM'); } catch {} // exit handler respawns
236
+ } else {
237
+ if (e) e.state = 'stopped';
238
+ spawnBee(name);
239
+ }
240
+ log(`restart requested for bee ${name}`);
241
+ res.writeHead(200, { 'content-type': 'application/json' });
242
+ res.end(JSON.stringify({ restarting: name }));
243
+ });
244
+ return;
245
+ }
193
246
  res.writeHead(404, { 'content-type': 'application/json' });
194
247
  res.end('{"error":"not found"}');
195
248
  }).listen(HEALTH_PORT, () => log(`health on :${HEALTH_PORT}/healthz`));
@@ -0,0 +1,168 @@
1
+ // shared/prompt — the CLI's interactive moments (select/ask/confirm/pause)
2
+ // and progress rendering (spinner/checkpoints), hand-rolled to keep the pack
3
+ // dependency-light.
4
+ //
5
+ // TTY rules:
6
+ // - The arrow-key select needs a real TTY; when stdin/stdout isn't one
7
+ // (CI, pipes, heredocs) every prompt degrades to numbered/plain line
8
+ // input with identical semantics. install-remote.sh re-attaches /dev/tty
9
+ // before exec'ing the join, so `curl | bash` lands on the TTY path.
10
+ // - Ctrl-C during a raw-mode select exits 130 — a half-answered wizard must
11
+ // never half-provision.
12
+ // Every function accepts {input, output} for tests (fake streams exercise the
13
+ // non-TTY paths without a terminal).
14
+ import { createInterface } from 'node:readline/promises';
15
+
16
+ const isTTY = (s) => !!(s && s.isTTY);
17
+ const DIM = '\x1b[2m';
18
+ const CYAN = '\x1b[36m';
19
+ const RESET = '\x1b[0m';
20
+
21
+ const line = async ({ input, output, prompt }) => {
22
+ // On stdin EOF (</dev/null, closed pipes) a pending question() neither
23
+ // resolves nor rejects — the event loop just drains and node exits 0
24
+ // mid-flow. Two guards: an input that ALREADY ended ('end' fires once, so
25
+ // a second readline on it would wait forever) throws straight away, and a
26
+ // mid-question EOF is turned into a rejection by racing the close event.
27
+ // Every caller maps the rejection to "use the default".
28
+ if (!input || input.readableEnded || input.destroyed || input.closed) {
29
+ output.write(`${prompt}\n`);
30
+ throw new Error('input closed');
31
+ }
32
+ const rl = createInterface({ input, output });
33
+ try {
34
+ return await Promise.race([
35
+ rl.question(prompt),
36
+ new Promise((_, reject) => rl.once('close', () => reject(new Error('input closed')))),
37
+ ]);
38
+ } finally { rl.close(); }
39
+ };
40
+
41
+ // One line of text. Empty input takes `def`; `validate` (sync or async)
42
+ // returns true or an error string and re-prompts on failure.
43
+ export const ask = async ({ prompt, def = '', validate = null, input = process.stdin, output = process.stdout }) => {
44
+ for (;;) {
45
+ let v;
46
+ try { v = (await line({ input, output, prompt: `? ${prompt}${def ? ` ${DIM}(${def})${RESET}` : ''} › ` })).trim(); }
47
+ catch { v = ''; }
48
+ if (!v) v = def;
49
+ if (!validate) return v;
50
+ const ok = await validate(v);
51
+ if (ok === true) return v;
52
+ output.write(` ✗ ${ok || 'invalid'}\n`);
53
+ }
54
+ };
55
+
56
+ export const confirm = async ({ prompt, def = true, input = process.stdin, output = process.stdout }) => {
57
+ let v;
58
+ try { v = (await line({ input, output, prompt: `? ${prompt} ${def ? '(Y/n)' : '(y/N)'} › ` })).trim().toLowerCase(); }
59
+ catch { v = ''; }
60
+ if (!v) return def;
61
+ return v === 'y' || v === 'yes';
62
+ };
63
+
64
+ // Hard pause: nothing continues until the human presses Enter.
65
+ export const pause = async ({ prompt, input = process.stdin, output = process.stdout }) => {
66
+ try { await line({ input, output, prompt: `${prompt} ` }); } catch {}
67
+ };
68
+
69
+ // Arrow-key select on a TTY; numbered list everywhere else.
70
+ // options: [{label, hint?, value}] -> resolves to the chosen option's value.
71
+ export const select = async ({ title, options, defaultIndex = 0, input = process.stdin, output = process.stdout }) => {
72
+ if (!Array.isArray(options) || !options.length) throw new Error('select needs options');
73
+ let idx = Math.min(Math.max(defaultIndex, 0), options.length - 1);
74
+
75
+ if (!isTTY(input) || !isTTY(output) || typeof input.setRawMode !== 'function') {
76
+ output.write(`? ${title}\n`);
77
+ options.forEach((o, i) => output.write(` ${i + 1}. ${o.label}${o.hint ? ` ${DIM}— ${o.hint}${RESET}` : ''}${i === idx ? ' (default)' : ''}\n`));
78
+ let v;
79
+ try { v = (await line({ input, output, prompt: ` pick 1-${options.length} › ` })).trim(); } catch { v = ''; }
80
+ const n = Number(v);
81
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) idx = n - 1;
82
+ return options[idx].value;
83
+ }
84
+
85
+ const lines = options.length + 1;
86
+ const render = (first) => {
87
+ if (!first) output.write(`\x1b[${lines}A`);
88
+ output.write(`\x1b[0J? ${title} ${DIM}(↑/↓, Enter)${RESET}\n`);
89
+ options.forEach((o, i) => {
90
+ const on = i === idx;
91
+ const label = on ? `${CYAN}❯ ${o.label}${RESET}` : ` ${o.label}`;
92
+ output.write(` ${label}${o.hint ? ` ${DIM}— ${o.hint}${RESET}` : ''}\n`);
93
+ });
94
+ };
95
+
96
+ render(true);
97
+ input.setRawMode(true);
98
+ input.resume();
99
+ const picked = await new Promise((resolve) => {
100
+ const cleanup = () => { input.off('data', onData); input.setRawMode(false); input.pause(); };
101
+ const onData = (buf) => {
102
+ const s = buf.toString();
103
+ if (s === '\x03') { cleanup(); output.write('\n'); process.exit(130); }
104
+ if (s === '\r' || s === '\n') { cleanup(); return resolve(idx); }
105
+ if (/^[1-9]$/.test(s) && Number(s) <= options.length) { idx = Number(s) - 1; cleanup(); return resolve(idx); }
106
+ if (s === '\x1b[A' || s === 'k') idx = (idx - 1 + options.length) % options.length;
107
+ else if (s === '\x1b[B' || s === 'j' || s === '\t') idx = (idx + 1) % options.length;
108
+ else return;
109
+ render(false);
110
+ };
111
+ input.on('data', onData);
112
+ });
113
+ output.write(`\x1b[${lines}A\x1b[0J? ${title} › ${CYAN}${options[picked].label}${RESET}\n`);
114
+ return options[picked].value;
115
+ };
116
+
117
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
118
+
119
+ // Single-line spinner. On non-TTY output each text() prints its own line.
120
+ export const spinner = (text, { output = process.stdout } = {}) => {
121
+ const tty = isTTY(output);
122
+ let cur = text;
123
+ let i = 0;
124
+ let timer = null;
125
+ const draw = () => output.write(`\r\x1b[K${CYAN}${FRAMES[i = (i + 1) % FRAMES.length]}${RESET} ${cur}`);
126
+ if (tty) { draw(); timer = setInterval(draw, 100); } else output.write(`… ${cur}\n`);
127
+ return {
128
+ text(t) { cur = t; if (!tty) output.write(`… ${t}\n`); },
129
+ stop(final) {
130
+ if (timer) { clearInterval(timer); timer = null; }
131
+ if (tty) output.write('\r\x1b[K');
132
+ if (final) output.write(`${final}\n`);
133
+ },
134
+ };
135
+ };
136
+
137
+ // Named checkpoints on one live line: `invite ✓ → bee key ⠸ → grants`.
138
+ // items: [{key, label}]. set(key, 'active'|'done'|'skip') re-renders; stop()
139
+ // finalizes the line. Non-TTY output prints one line per completed step.
140
+ export const checkpoints = (items, { output = process.stdout } = {}) => {
141
+ const tty = isTTY(output);
142
+ const state = new Map();
143
+ let spin = 0;
144
+ let timer = null;
145
+ const renderLine = () => items.map(({ key, label }) => {
146
+ const st = state.get(key);
147
+ if (st === 'done') return `${label} ✓`;
148
+ if (st === 'skip') return `${label} ⤼`;
149
+ if (st === 'active') return `${label} ${CYAN}${FRAMES[spin % FRAMES.length]}${RESET}`;
150
+ return `${DIM}${label}${RESET}`;
151
+ }).join(' → ');
152
+ const draw = () => { spin++; output.write(`\r\x1b[K▸ ${renderLine()}`); };
153
+ if (tty) { draw(); timer = setInterval(draw, 120); }
154
+ return {
155
+ set(key, st = 'done') {
156
+ if (state.get(key) === st) return;
157
+ state.set(key, st);
158
+ if (tty) draw();
159
+ else if (st === 'done' || st === 'skip') output.write(` ${st === 'done' ? '✓' : '⤼'} ${items.find((i) => i.key === key)?.label || key}\n`);
160
+ },
161
+ has(key) { return state.has(key); },
162
+ stop(final) {
163
+ if (timer) { clearInterval(timer); timer = null; }
164
+ if (tty) output.write('\r\x1b[K');
165
+ if (final) output.write(`${final}\n`);
166
+ },
167
+ };
168
+ };