joinhive 2.0.1 → 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.
package/bin/hive-join.mjs CHANGED
@@ -1,34 +1,46 @@
1
1
  #!/usr/bin/env node
2
- // hive-join — full member onboarding, from community link to live bee.
2
+ // hive-join — full member onboarding: one command live bee → Buzz UI.
3
3
  //
4
- // hive join --invite <code> --server https://api.<domain> [--export chat.zip] [--name sid]
4
+ // npx joinhive join --invite <code> (primary path)
5
+ // hive join --invite <code> [--server https://api.<domain>]
5
6
  //
6
7
  // What happens where (the privacy contract):
7
8
  // LAPTOP: identity keys, the shared wallet mnemonic (Apple Keychain), your
8
9
  // LLM API key (login Keychain, for the sync watcher), and the
9
10
  // profile distillation — RAW CHAT HISTORY NEVER LEAVES.
10
- // UPLOADED: the distilled profile + a nacl.box-sealed {wallet_mnemonic,
11
- // llm_api_key} that only the bee-host can open (your bee needs the
12
- // key to think and the wallet to transact 24/7 — your decision #9).
13
- // Every step is idempotent: re-running resumes wherever it stopped.
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
15
- import { execFileSync, spawnSync } from 'node:child_process';
11
+ // UPLOADED: the distilled profile (shown to you first) + a nacl.box-sealed
12
+ // {wallet_mnemonic, llm_api_key?} only the bee-host can open.
13
+ //
14
+ // Echo-first: you can join WITHOUT an LLM key — your bee is born in echo mode
15
+ // (alive, visible, silent) and `hive key set` switches its brain on later.
16
+ // Every step is idempotent: re-running the same command resumes.
17
+ //
18
+ // Flags: --name --provider --model-extract --model-compute --export <zip>
19
+ // --yes (skip confirmations) --no-watcher --redistill
20
+ // Headless: --provider … with HIVE_LLM_KEY set, --skip-key (echo-first,
21
+ // brainless until `hive key set`), or --provider echo (test seam: answers).
22
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, cpSync, symlinkSync, rmSync, appendFileSync } from 'node:fs';
23
+ import { execFileSync, spawnSync, spawn } from 'node:child_process';
16
24
  import { homedir, platform, userInfo } from 'node:os';
17
- import { join, dirname } from 'node:path';
25
+ import { join, dirname, resolve, sep } from 'node:path';
18
26
  import { fileURLToPath } from 'node:url';
19
- import { createInterface } from 'node:readline/promises';
20
27
  import nacl from 'tweetnacl';
21
28
  import { finalizeEvent } from 'nostr-tools/pure';
22
29
  import { signedFetch } from '../shared/nip98.mjs';
23
30
  import { computeAuthTag } from '../shared/nip-oa.mjs';
24
31
  import { createEngine } from '../daemon/engines/index.mjs';
25
- import { OPENAI_COMPAT_BASES, MODEL_TIERS } from '../shared/config-schema.mjs';
32
+ import { OPENAI_COMPAT_BASES, MODEL_TIERS, DEFAULTS } from '../shared/config-schema.mjs';
33
+ import { EV, tryJson } from '../shared/events.mjs';
34
+ import { RelayClient } from '../daemon/relay/client.mjs';
35
+ import { select, ask, confirm, pause, spinner, checkpoints } from '../shared/prompt.mjs';
26
36
 
27
37
  const PACK_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
28
38
  const HIVE_HOME = process.env.HIVE_HOME || join(homedir(), '.hive');
29
39
  const args = process.argv.slice(2);
30
40
  const flag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
41
+ const has = (n) => args.includes(`--${n}`);
31
42
  const say = (...a) => console.log('🐝', ...a);
43
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
32
44
  const die = (msg) => { console.error('❌', msg); process.exit(1); };
33
45
  const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
34
46
  const node = (script, argv = [], opts = {}) => {
@@ -36,13 +48,71 @@ const node = (script, argv = [], opts = {}) => {
36
48
  return { code: r.status, out: (r.stdout || '') + (r.status !== 0 ? (r.stderr || '') : '') };
37
49
  };
38
50
 
51
+ // The flagship community. Invite links can point anywhere via --server.
52
+ const DEFAULT_SERVER = 'https://bee-host-production.up.railway.app';
39
53
  const INVITE = flag('invite');
40
- const SERVER = (flag('server') || '').replace(/\/+$/, '');
41
- if (!INVITE || !SERVER) die('usage: hive join --invite <code> --server https://api.<domain> [--export <claude-export.zip>] [--name <you>]');
54
+ const SERVER = (flag('server') || DEFAULT_SERVER).replace(/\/+$/, '');
55
+ if (!INVITE) die('usage: npx joinhive join --invite <code> [--server https://api.<domain>]');
42
56
 
43
- const rl = createInterface({ input: process.stdin, output: process.stdout });
57
+ // ---- npx self-install -------------------------------------------------------
58
+ // `npx joinhive …` runs from npm's _npx cache, which npm garbage-collects —
59
+ // anything durable pointed there (the launchd watcher plist, the PATH shim)
60
+ // dies silently later. So the first npx run copies the pack to ~/.hive-pack
61
+ // (the same canonical home the curl installer uses) and re-execs from there.
62
+ // If anything about the copy fails we keep going from the cache, minus the
63
+ // watcher.
64
+ let watcherUnsafe = false;
65
+ const vgte = (a, b) => { // "2.1.0" >= "2.0.1"
66
+ const pa = String(a).split('.').map(Number); const pb = String(b).split('.').map(Number);
67
+ for (let i = 0; i < 3; i++) { if ((pa[i] || 0) > (pb[i] || 0)) return true; if ((pa[i] || 0) < (pb[i] || 0)) return false; }
68
+ return true;
69
+ };
70
+ const installShim = (dest) => {
71
+ mkdirSync(join(homedir(), '.hive', 'bin'), { recursive: true });
72
+ const shim = join(homedir(), '.hive', 'bin', 'hive');
73
+ try { rmSync(shim, { force: true }); } catch {}
74
+ symlinkSync(join(dest, 'bin', 'hive'), shim);
75
+ for (const rc of [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]) {
76
+ try {
77
+ if (existsSync(rc) && !readFileSync(rc, 'utf8').includes('.hive/bin')) {
78
+ appendFileSync(rc, '\nexport PATH="$HOME/.hive/bin:$PATH"\n');
79
+ }
80
+ } catch {}
81
+ }
82
+ };
83
+ const selfInstall = () => {
84
+ if (process.env.HIVE_NO_SELF_INSTALL) return;
85
+ const DEST = join(homedir(), '.hive-pack');
86
+ if (resolve(PACK_DIR) === resolve(DEST)) return;
87
+ const fromNpx = PACK_DIR.split(sep).includes('_npx') || process.env.npm_command === 'exec';
88
+ if (!fromNpx) return;
89
+ try {
90
+ const mine = loadJson(join(PACK_DIR, 'package.json'), {}).version || '0.0.0';
91
+ const theirs = loadJson(join(DEST, 'package.json'), {}).version;
92
+ if (!(theirs && vgte(theirs, mine))) {
93
+ say(`installing the hive pack to ~/.hive-pack (v${mine}) — npx cache dirs get garbage-collected …`);
94
+ cpSync(PACK_DIR, DEST, {
95
+ recursive: true, force: true,
96
+ filter: (src) => !/\/(node_modules|\.git)(\/|$)/.test(src) && !/\/onchain\/(lib|out|cache)(\/|$)/.test(src),
97
+ });
98
+ const inst = spawnSync('npm', ['install', '--omit=dev', '--silent', '--no-fund', '--no-audit'], { cwd: DEST, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
99
+ if (inst.status !== 0) throw new Error(`npm install: ${String(inst.stderr).slice(0, 160)}`);
100
+ }
101
+ installShim(DEST);
102
+ const r = spawnSync(process.execPath, [join(DEST, 'bin', 'hive-join.mjs'), ...args], {
103
+ stdio: 'inherit', env: { ...process.env, HIVE_NO_SELF_INSTALL: '1' },
104
+ });
105
+ process.exit(r.status ?? 0);
106
+ } catch (e) {
107
+ say(`⚠️ self-install skipped (${String(e.message).slice(0, 120)}) — continuing from the npx cache without the auto-sync watcher`);
108
+ watcherUnsafe = true;
109
+ }
110
+ };
111
+ selfInstall();
44
112
 
45
113
  const main = async () => {
114
+ const joinStart = Math.floor(Date.now() / 1000);
115
+
46
116
  // 0. Server hello: provisioning box key + the community's relay URL.
47
117
  say(`checking in with ${SERVER} …`);
48
118
  let hello;
@@ -51,13 +121,17 @@ const main = async () => {
51
121
  if (!hello.box_pub || !hello.relay) die('server did not return a provisioning key');
52
122
  const RELAY = hello.relay;
53
123
 
54
- // 1. Identity (idempotent).
124
+ // 1. Identity (idempotent) — needed before the name check can tell
125
+ // "taken by someone else" from "mine, resuming".
55
126
  const kg = node('bin/hive-keygen.mjs');
56
127
  if (kg.code !== 0) die(`keygen failed: ${kg.out.slice(0, 200)}`);
57
128
  const identity = loadJson(join(HIVE_HOME, 'identity.json'), null) || die('identity missing after keygen');
58
129
  say(`your key: ${identity.pubkey.slice(0, 12)}…`);
59
130
 
60
- // 2. Join the community relay (config + invite claim).
131
+ // 2. Join the community relay AS YOURSELF (config + invite claim). The
132
+ // server claims membership for the BEE key separately — without this
133
+ // claim the OWNER can't publish anything (kind-30177, channel joins,
134
+ // every future `hive ask`).
61
135
  const cfgPath = join(HIVE_HOME, 'config.json');
62
136
  const cfg = loadJson(cfgPath, {});
63
137
  cfg.relay = RELAY;
@@ -68,113 +142,237 @@ const main = async () => {
68
142
  if (claimJson.error === 'age_attestation_required') die('this community requires an age attestation — re-run with the CLI flag --age-confirmed via: hive claim-invite');
69
143
  // Open relay (dev/local) has no membership gate: if reads work without a
70
144
  // claim, continue. On a closed relay the probe 403s and this is fatal.
71
- const { signedFetch: probeFetch } = await import('../shared/nip98.mjs');
72
- const probe = await probeFetch(identity.privkey, 'POST', `${RELAY.replace(/^ws/, 'http')}/query`, [{ kinds: [39000], limit: 1 }]);
145
+ const probe = await signedFetch(identity.privkey, 'POST', `${RELAY.replace(/^ws/, 'http')}/query`, [{ kinds: [39000], limit: 1 }]);
73
146
  if (probe.status !== 200) die(`relay invite claim failed: ${claim.out.slice(0, 300)}`);
74
147
  say('relay has no membership gate (open/dev) — continuing without a claim');
75
148
  }
76
149
  say(`joined relay ${RELAY}`);
77
150
 
78
- // 3. Shared walletmnemonic into the Apple Keychain, shown ONCE.
79
- const w = node('bin/hive-wallet.mjs');
80
- if (w.code !== 0) die(`wallet failed: ${w.out.slice(0, 200)}`);
81
- const wallet = JSON.parse(w.out.trim().split('\n').pop());
82
- say(`shared wallet: ${wallet.evm_address}`);
83
- if (wallet.status === 'created') {
84
- const exp = node('bin/hive-wallet.mjs', ['export']);
151
+ // 3. PROMPT 1 — the bee's name, availability-checked before any real work.
152
+ const defaultName = (flag('name') || cfg.owner_name || userInfo().username || 'member').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
153
+ let resuming = false;
154
+ const nameCheck = async (n) => {
155
+ if (!/^[a-z0-9][a-z0-9-]{1,23}$/.test(n)) return 'letters, digits, dashes — 2 to 24 chars';
85
156
  try {
86
- const mn = JSON.parse(exp.out).mnemonic;
87
- say('———— WRITE THIS DOWN (recovery phrase shown once, stored in your Apple Keychain) ————');
88
- console.log(` ${mn}`);
89
- say('————————————————————————————————————————————————————————————————————————');
90
- } catch {}
91
- }
157
+ const r = await fetch(`${SERVER}/api/bees/${n}/status`, { signal: AbortSignal.timeout(6000) });
158
+ if (r.status === 404) return true;
159
+ const st = await r.json().catch(() => ({}));
160
+ if (st.owner_pubkey && st.owner_pubkey !== identity.pubkey) return `"${n}.bee" already belongs to another member — pick a different name`;
161
+ resuming = !!st.owner_pubkey;
162
+ return true;
163
+ } catch { return true; } // network hiccup — provisioning still arbitrates (409)
164
+ };
165
+ const name = flag('name')
166
+ ? await (async () => { const v = await nameCheck(defaultName); if (v !== true) die(v); return defaultName; })()
167
+ : await ask({ prompt: "your bee's name", def: defaultName, validate: nameCheck });
168
+ if (resuming) say(`${name}.bee is already yours — resuming that provision`);
92
169
 
93
- // 4. Your LLM provider + API key (your bee thinks with YOUR key).
94
- const name = (flag('name') || cfg.owner_name || userInfo().username || 'member').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
95
- let provider = flag('provider') || (['anthropic', 'openai', 'openrouter', 'hermes'].includes(cfg.provider) ? cfg.provider : null);
170
+ // 3. PROMPT 2 the bee's brain. "Skip for now" is first on purpose: the
171
+ // hardest ask of onboarding (go get an API key) comes AFTER the first
172
+ // win, not before it.
173
+ let provider = flag('provider') || (process.env.HIVE_LLM_KEY && ['anthropic', 'openai', 'openrouter', 'hermes'].includes(cfg.provider) ? cfg.provider : null);
174
+ let awaitingKey = false;
175
+ if (has('skip-key')) { provider = 'echo'; awaitingKey = true; } // headless echo-first
96
176
  if (!provider) {
97
- provider = (await rl.question('LLM provider for your bee [anthropic/openai/openrouter/hermes] (default anthropic): ')).trim().toLowerCase() || 'anthropic';
177
+ const pick = await select({
178
+ title: "your bee's brain",
179
+ options: [
180
+ { label: 'Skip for now — decide later', hint: 'your bee joins in echo mode; `hive key set` adds a brain in 60s', value: 'skip' },
181
+ { label: 'OpenRouter', hint: 'recommended: one key, every model — $5 lasts months (openrouter.ai/keys)', value: 'openrouter' },
182
+ { label: 'Anthropic', hint: 'API key from console.anthropic.com (a Claude subscription is NOT an API key)', value: 'anthropic' },
183
+ { label: 'OpenAI', hint: 'API key from platform.openai.com', value: 'openai' },
184
+ { label: 'Nous Hermes', hint: 'API key from portal.nousresearch.com', value: 'hermes' },
185
+ ],
186
+ });
187
+ if (pick === 'skip') { provider = 'echo'; awaitingKey = true; }
188
+ else provider = pick;
98
189
  }
99
190
  if (!['anthropic', 'openai', 'openrouter', 'hermes', 'echo'].includes(provider)) die(`unknown provider "${provider}"`);
191
+
100
192
  let llmKey = process.env.HIVE_LLM_KEY || null;
101
193
  let models = { extract: flag('model-extract'), compute: flag('model-compute') };
102
- if (provider === 'echo') {
103
- llmKey = 'echo'; // test seam: no key, no thinking, no spend — rehearsals/CI
104
- } else {
194
+ if (provider === 'echo' && !awaitingKey) {
195
+ llmKey = 'echo'; // test seam (--provider echo): no key, no thinking, no spend — rehearsals/CI
196
+ } else if (provider !== 'echo') {
105
197
  if (!llmKey && platform() === 'darwin') {
106
198
  try { llmKey = execFileSync('security', ['find-generic-password', '-a', provider, '-s', 'hive-llm-key', '-w'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); say(`using the ${provider} key already in your Keychain`); } catch {}
107
199
  }
108
- if (!llmKey) llmKey = (await rl.question(`${provider} API key (stored in your login Keychain, uploaded sealed for your bee): `)).trim();
109
- if (!llmKey) die('an API key is required — your bee cannot think without one');
110
- if ((provider === 'openrouter' || provider === 'hermes') && (!models.extract || !models.compute)) {
111
- models.extract = models.extract || (await rl.question(`${provider} model id for cheap extraction: `)).trim();
112
- models.compute = models.compute || (await rl.question(`${provider} model id for strong compute: `)).trim();
200
+ if (!llmKey) llmKey = (await ask({ prompt: `${provider} API key ${dim('(stored in your login Keychain, uploaded sealed for your bee)')}` })).trim();
201
+ if (!llmKey) die('an API key is required for that provider or re-run and pick "Skip for now"');
202
+ if (provider === 'openrouter' || provider === 'hermes') {
203
+ // Sensible defaults instead of blank raw-model-ID prompts (Enter-Enter).
204
+ const defs = provider === 'openrouter'
205
+ ? { extract: 'anthropic/claude-haiku-4.5', compute: 'anthropic/claude-sonnet-5' }
206
+ : { extract: 'Hermes-4-405B', compute: 'Hermes-4-405B' };
207
+ models.extract = models.extract || await ask({ prompt: `${provider} model for cheap extraction`, def: defs.extract });
208
+ models.compute = models.compute || await ask({ prompt: `${provider} model for strong compute`, def: defs.compute });
113
209
  }
114
- say('validating the key with a 1-token self-test …');
210
+ const spinKey = spinner('validating the key with a 1-token self-test …');
115
211
  const tiers = MODEL_TIERS[provider] || {};
116
212
  const engine = createEngine({ provider, base_url: OPENAI_COMPAT_BASES[provider], model_extract: models.extract || tiers.extract, model_compute: models.compute || tiers.compute }, { llm_api_key: llmKey }, {});
117
213
  const probe = await engine.selftest();
118
- if (String(probe).startsWith('engine-error')) die(`key check failed: ${probe.slice(0, 200)}`);
119
- say('key works ✓');
214
+ if (String(probe).startsWith('engine-error')) { spinKey.stop(); die(`key check failed: ${probe.slice(0, 200)}`); }
215
+ spinKey.stop('🐝 key works ✓');
120
216
  if (platform() === 'darwin') {
121
217
  try { execFileSync('security', ['add-generic-password', '-U', '-a', provider, '-s', 'hive-llm-key', '-l', `Hive LLM key (${provider})`, '-w', llmKey], { stdio: 'ignore' }); } catch {}
122
218
  }
123
219
  }
124
220
 
125
- // 5. Distill the private profile LOCALLY (the only data that gets uploaded).
221
+ // 4. PROMPT 3 memory. Local chat history is auto-detected; a claude.ai
222
+ // export zip makes the profile much richer for people who mostly chat
223
+ // in the app rather than the terminal.
224
+ let exportZip = flag('export');
225
+ const profilePath = join(HIVE_HOME, 'data-store', 'profile.md');
226
+ const hasProfile = existsSync(profilePath) && readFileSync(profilePath, 'utf8').trim().length > 50 && !has('redistill');
227
+ if (!exportZip && !hasProfile && !has('yes')) {
228
+ exportZip = await ask({
229
+ prompt: `claude.ai export zip for richer memory ${dim('(claude.ai → Settings → Export data; Enter to skip)')}`,
230
+ def: '',
231
+ validate: (v) => !v || existsSync(v) || 'no file at that path (Enter to skip)',
232
+ });
233
+ }
234
+
235
+ // 5. Shared wallet — mnemonic into the Apple Keychain, shown ONCE, and the
236
+ // flow HALTS until the human says they wrote it down (PROMPT 4).
237
+ const w = node('bin/hive-wallet.mjs');
238
+ if (w.code !== 0) die(`wallet failed: ${w.out.slice(0, 200)}`);
239
+ const wallet = JSON.parse(w.out.trim().split('\n').pop());
240
+ say(`shared wallet: ${wallet.evm_address}`);
241
+ if (wallet.status === 'created') {
242
+ const exp = node('bin/hive-wallet.mjs', ['export']);
243
+ try {
244
+ const mn = JSON.parse(exp.out).mnemonic;
245
+ say('———— RECOVERY PHRASE (shown once; backed up in your Apple Keychain) ————');
246
+ console.log(` ${mn}`);
247
+ say('————————————————————————————————————————————————————————————————————————');
248
+ if (!has('yes')) {
249
+ await pause({ prompt: ' write these 12 words down, then press Enter →' });
250
+ if (process.stdout.isTTY) process.stdout.write('\x1b[4A\x1b[0J');
251
+ say('recovery phrase acknowledged ✓ (it stays in your Keychain: hive wallet export)');
252
+ }
253
+ } catch {}
254
+ }
255
+
256
+ // 6. Distill the private profile LOCALLY (the only data that gets uploaded).
126
257
  // An existing profile is PRESERVED (migrations, re-joins) — pass
127
258
  // --redistill to rebuild it from scratch.
128
259
  mkdirSync(join(HIVE_HOME, 'data-store'), { recursive: true });
129
- const profilePath = join(HIVE_HOME, 'data-store', 'profile.md');
130
- if (existsSync(profilePath) && readFileSync(profilePath, 'utf8').trim().length > 50 && !args.includes('--redistill')) {
260
+ if (hasProfile) {
131
261
  say('using your existing profile (pass --redistill to rebuild it)');
132
262
  } else {
133
- say('distilling your profile from local chat history (raw chats never leave this machine) …');
134
- const distillArgs = ['--provider', provider, '--key', llmKey, '--out', profilePath];
135
- if (flag('export')) distillArgs.push('--export', flag('export'));
263
+ const spinD = spinner('distilling your profile from local chat history raw chats never leave this machine …');
264
+ // Echo/keyless joins go straight to the heuristic ladder (no LLM call).
265
+ const distillArgs = ['--out', profilePath];
266
+ if (provider !== 'echo' && llmKey && llmKey !== 'echo') distillArgs.push('--provider', provider, '--key', llmKey);
136
267
  if (models.extract) distillArgs.push('--model', models.extract);
137
- const d = node('watcher/distill.mjs', distillArgs);
268
+ if (exportZip) distillArgs.push('--export', exportZip);
269
+ const d = await new Promise((resolveP) => {
270
+ const p = spawn(process.execPath, [join(PACK_DIR, 'watcher', 'distill.mjs'), ...distillArgs], { env: { ...process.env, HIVE_HOME } });
271
+ let out = ''; let err = '';
272
+ p.stdout.on('data', (c) => { out += c; });
273
+ p.stderr.on('data', (c) => {
274
+ err += c;
275
+ const m = String(c).match(/corpus: (\d+) user turns from ~(\d+) sessions/);
276
+ if (m) spinD.text(`distilling locally: ${m[1]} of your chat turns from ~${m[2]} sessions …`);
277
+ });
278
+ p.on('close', (code) => resolveP({ code, out: out + (code !== 0 ? err : '') }));
279
+ p.on('error', (e) => resolveP({ code: 1, out: String(e.message) }));
280
+ });
281
+ spinD.stop();
138
282
  if (d.code !== 0 || !existsSync(profilePath)) die(`distillation failed: ${d.out.slice(0, 300)}`);
139
283
  }
140
284
  const profile = readFileSync(profilePath, 'utf8');
141
- say(`profile ready (${profile.length} chars) — review anytime: ~/.hive/data-store/profile.md`);
142
285
 
143
- // 6. Seal {mnemonic, llm key} to the server's box key and provision the bee.
286
+ // 7. PROMPT 5 the privacy promise, shown instead of claimed: this page is
287
+ // the ONLY personal data that leaves the laptop, and the member sees it
288
+ // before it goes.
289
+ const lines = profile.split('\n');
290
+ say(`profile ready (${profile.length} chars) — this one page is ALL that leaves your laptop:`);
291
+ console.log(dim(lines.slice(0, 15).map((l) => ` ${l}`).join('\n') + (lines.length > 15 ? `\n … (${lines.length - 15} more lines — full file: ~/.hive/data-store/profile.md)` : '')));
292
+ if (!has('yes')) {
293
+ const okUp = await confirm({ prompt: 'upload this profile to power your bee?', def: true });
294
+ if (!okUp) die('no problem — edit ~/.hive/data-store/profile.md, then re-run this exact command (it resumes where it stopped)');
295
+ }
296
+
297
+ // 8. Seal secrets and provision — while the server walks its state machine
298
+ // we stream its per-step progress instead of going silent.
144
299
  const mnemonic = (() => {
145
300
  try { return JSON.parse(node('bin/hive-wallet.mjs', ['export']).out).mnemonic; } catch { return null; }
146
301
  })();
147
302
  if (!mnemonic) die('could not read the wallet mnemonic back from the Keychain');
148
303
  const client = nacl.box.keyPair();
149
304
  const nonce = nacl.randomBytes(24);
150
- const box = nacl.box(
151
- Buffer.from(JSON.stringify({ wallet_mnemonic: mnemonic, llm_api_key: llmKey })),
152
- nonce, Buffer.from(hello.box_pub, 'base64'), client.secretKey,
153
- );
305
+ const sealedPayload = { wallet_mnemonic: mnemonic, ...(awaitingKey ? {} : { llm_api_key: llmKey }) };
306
+ const box = nacl.box(Buffer.from(JSON.stringify(sealedPayload)), nonce, Buffer.from(hello.box_pub, 'base64'), client.secretKey);
154
307
  say(`provisioning ${name}.bee on the community server …`);
155
308
  const body = {
156
309
  invite: INVITE, name, owner_pubkey: identity.pubkey, owner_name: name,
157
310
  evm_address: wallet.evm_address, provider,
311
+ ...(awaitingKey ? { awaiting_key: true } : {}),
158
312
  ...(OPENAI_COMPAT_BASES[provider] ? { base_url: OPENAI_COMPAT_BASES[provider] } : {}),
159
313
  ...(models.extract ? { model_extract: models.extract } : {}),
160
314
  ...(models.compute ? { model_compute: models.compute } : {}),
161
315
  sealed: { nonce: Buffer.from(nonce).toString('base64'), box: Buffer.from(box).toString('base64'), client_pub: Buffer.from(client.publicKey).toString('base64') },
162
316
  profile_md: profile,
163
317
  };
164
- const resp = await signedFetch(identity.privkey, 'POST', `${SERVER}/api/bees`, body);
165
- if (resp.status !== 200) die(`provisioning failed (http ${resp.status}): ${JSON.stringify(resp.json).slice(0, 300)}`);
166
- const beePubkey = resp.json.bee_pubkey;
318
+
319
+ const STEP_LABELS = [
320
+ { key: 'invite_checked', label: 'invite' },
321
+ { key: 'bee_key', label: 'bee key' },
322
+ { key: 'relay_membership', label: 'relay' },
323
+ { key: 'secrets_stored', label: 'secrets' },
324
+ { key: 'profile_written', label: 'profile' },
325
+ { key: 'grants_queued', label: '500 JELLY' },
326
+ { key: 'welcome_posted', label: 'welcome' },
327
+ { key: 'done', label: 'daemon' },
328
+ ];
329
+ const cp = checkpoints(STEP_LABELS);
330
+ let posted = null;
331
+ const postPromise = signedFetch(identity.privkey, 'POST', `${SERVER}/api/bees`, body)
332
+ .then((r) => { posted = r; }).catch((e) => { posted = { status: 0, json: { error: String(e.message || e) } }; });
333
+
334
+ let st = null;
335
+ for (let i = 0; i < 200; i++) {
336
+ await new Promise((r) => setTimeout(r, 1200));
337
+ try {
338
+ const r = await fetch(`${SERVER}/api/bees/${name}/status`, { signal: AbortSignal.timeout(5000) });
339
+ if (r.status === 200) {
340
+ st = await r.json();
341
+ for (const { key } of STEP_LABELS) {
342
+ if (key === 'done') continue;
343
+ if (st.steps?.includes(key)) cp.set(key, 'done');
344
+ }
345
+ if (st.steps?.includes('done')) cp.set('done', st.status === 'ready' ? 'done' : 'active');
346
+ if (st.status === 'ready') break;
347
+ }
348
+ } catch {}
349
+ if (posted && posted.status !== 200) break;
350
+ // Laptop-side work happens below while the daemon wakes; bail out of the
351
+ // streaming loop once the state machine is done and only the heartbeat
352
+ // is pending — the final wait continues after NIP-OA + config.
353
+ if (st?.steps?.includes('done')) break;
354
+ }
355
+ await postPromise;
356
+ if (posted && posted.status !== 200) {
357
+ cp.stop();
358
+ const err = JSON.stringify(posted.json).slice(0, 300);
359
+ if (awaitingKey && /llm_api_key/.test(err)) die('this community server predates echo-first joins — re-run and pick a brain, or ask the operator to update the bee-host');
360
+ die(`provisioning failed (http ${posted.status}): ${err}`);
361
+ }
362
+ cp.stop();
363
+ const beePubkey = posted?.json?.bee_pubkey || st?.bee_pubkey;
167
364
  say(`${name}.bee exists: ${String(beePubkey).slice(0, 12)}…`);
168
365
 
169
- // 6b. Buzz Agents directory (NIP-OA): the OWNER key signs both halves of
366
+ // 8b. Buzz Agents directory (NIP-OA): the OWNER key signs both halves of
170
367
  // the pairing right here — it never leaves this laptop.
171
368
  // (a) kind-30177 managed-agent record (owner-authored, bee pubkey in
172
369
  // the d tag), published straight to the relay: the relay requires
173
370
  // event author == HTTP signer, so only this laptop can publish it.
174
371
  // (b) the NIP-OA auth tag co-signature over the bee's pubkey, shipped
175
372
  // to the server (it holds the bee key) so the bee's kind-0 profile
176
- // carries it. Together these make the bee show up under Agents in
177
- // the Buzz desktop app, cryptographically verified as YOURS.
373
+ // carries it. Owner verification comes from this pair; the Agents
374
+ // TAB listing additionally needs the bot-role channel membership
375
+ // the server grants at provision (bot_role step).
178
376
  // Best-effort: the bee works without it; a re-join retries both halves.
179
377
  if (/^[0-9a-f]{64}$/.test(String(beePubkey))) {
180
378
  try {
@@ -190,23 +388,34 @@ const main = async () => {
190
388
  const authTag = computeAuthTag(identity.privkey, beePubkey, '');
191
389
  const resp2 = await signedFetch(identity.privkey, 'POST', `${SERVER}/api/bees`, { ...body, nip_oa_auth: authTag });
192
390
  if (resp2.status !== 200) throw new Error(`auth-tag delivery: http=${resp2.status} ${JSON.stringify(resp2.json).slice(0, 160)}`);
193
- say(`registered in the Buzz Agents directory (owner-verified: ${name}.bee)`);
391
+ say(`Buzz Agents record published (owner-verified: ${name}.bee — visible in the app via hive buzz)`);
194
392
  } catch (e) {
195
393
  say(`⚠️ Buzz Agents registration incomplete (${String(e.message).slice(0, 160)}) — re-run this join to retry; the bee itself is unaffected`);
196
394
  }
197
395
  }
198
396
 
199
- // 7. Remember the pairing locally + enable sync, and publish YOUR kind-0
200
- // profile so apps (Buzz desktop included) show your name, not a pubkey.
397
+ // 9. Remember the pairing locally (invite + server power `hive buzz` and
398
+ // `hive key set` later), join the hive channels as YOURSELF (the Buzz
399
+ // app only lists agents in channels you share with them), and publish
400
+ // your kind-0 so apps show your name, not a pubkey.
201
401
  const cfg2 = loadJson(cfgPath, {});
202
- Object.assign(cfg2, { owner_name: name, bee_name: `${name}.bee`, bee_pubkey: beePubkey, server_url: SERVER, sync: { enabled: true, provider } });
402
+ Object.assign(cfg2, {
403
+ owner_name: name, bee_name: `${name}.bee`, bee_pubkey: beePubkey,
404
+ server_url: SERVER, invite: INVITE,
405
+ sync: { enabled: true, provider },
406
+ });
203
407
  writeFileSync(cfgPath, JSON.stringify(cfg2, null, 2) + '\n');
408
+ try {
409
+ const me = new RelayClient({ relayUrl: RELAY, privkey: identity.privkey, log: () => {} });
410
+ for (const chName of Object.values(DEFAULTS.channels)) await me.ensureChannel(chName);
411
+ } catch (e) { say(`⚠️ channel self-join incomplete (${String(e.message).slice(0, 100)}) — hive buzz will fix this`); }
204
412
  const prof = node('bin/hive-net.mjs', ['set-name', name]);
205
413
  if (prof.code === 0) say(`your profile name is set: ${name}`);
206
414
 
207
- // 8. Auto-sync watcher (launchd, macOS). --no-watcher skips it — used for
208
- // secondary endpoints on the same machine (one watcher per Mac).
209
- if (platform() === 'darwin' && !args.includes('--no-watcher')) {
415
+ // 10. Auto-sync watcher (launchd, macOS). --no-watcher skips it — used for
416
+ // secondary endpoints on the same machine (one watcher per Mac). Never
417
+ // installed from an npx cache dir (npm may delete it under the plist).
418
+ if (platform() === 'darwin' && !has('no-watcher') && !watcherUnsafe) {
210
419
  try {
211
420
  const tmpl = readFileSync(join(PACK_DIR, 'watcher', 'global.nfh.hive.sync.plist.tmpl'), 'utf8')
212
421
  .replaceAll('__NODE__', process.execPath).replaceAll('__PACK__', PACK_DIR).replaceAll('__HOME__', homedir());
@@ -219,25 +428,67 @@ const main = async () => {
219
428
  } catch (e) { say(`watcher install skipped (${String(e.message).slice(0, 80)}) — run: hive sync now`); }
220
429
  }
221
430
 
222
- // 9. Wait for the bee to come alive + grants to land.
223
- say('waiting for your bee to wake up (engine self-test + genesis grants) …');
224
- for (let i = 0; i < 30; i++) {
225
- await new Promise((r) => setTimeout(r, 5000));
226
- try {
227
- const st = await (await fetch(`${SERVER}/api/bees/${name}/status`, { signal: AbortSignal.timeout(5000) })).json();
228
- if (st.status === 'ready') {
229
- say('———— YOU ARE IN ————');
230
- say(`bee: ${name}.bee (${String(beePubkey).slice(0, 12)}…)`);
231
- say(`wallet: ${wallet.evm_address} (shared: you + your bee)`);
232
- if (st.grants?.jelly_tx) say(`grant: 500 JELLY https://sepolia.etherscan.io/tx/${st.grants.jelly_tx}`);
233
- say(`try: hive ask "what should we watch this weekend" · hive feed · hive leaderboard`);
234
- rl.close();
235
- return;
236
- }
237
- } catch {}
431
+ // 11. Wait for the bee's first heartbeat, then catch its welcome. The
432
+ // steward posted a welcome intent during provisioning; other bees'
433
+ // replies are the newcomer's first feed.
434
+ let ready = st?.status === 'ready';
435
+ if (!ready) {
436
+ const spinUp = spinner(`waiting for ${name}.bee to wake up (engine self-test + genesis grants) …`);
437
+ for (let i = 0; i < 30 && !ready; i++) {
438
+ await new Promise((r) => setTimeout(r, 5000));
439
+ try {
440
+ const r = await fetch(`${SERVER}/api/bees/${name}/status`, { signal: AbortSignal.timeout(5000) });
441
+ if (r.status === 200) { st = await r.json(); ready = st.status === 'ready'; }
442
+ } catch {}
443
+ }
444
+ spinUp.stop();
445
+ }
446
+ if (!ready) {
447
+ say('provisioned, but the bee has not heartbeated yet — check later: hive agent status');
448
+ return;
238
449
  }
239
- say('provisioned, but the bee has not heartbeated yet — check later: hive agent status');
240
- rl.close();
450
+
451
+ say('———— YOU ARE IN ————');
452
+ say(`bee: ${name}.bee (${String(beePubkey).slice(0, 12)}…)`);
453
+ say(`wallet: ${wallet.evm_address} (shared: you + your bee)`);
454
+ if (st?.grants?.jelly_tx) say(`grant: 500 JELLY — https://sepolia.etherscan.io/tx/${st.grants.jelly_tx}`);
455
+ if (awaitingKey) say(`brain: none yet — ${name}.bee is in echo mode (alive + visible, not thinking)`);
456
+
457
+ // Welcome tail: print the first replies addressed to the new member.
458
+ try {
459
+ const me = new RelayClient({ relayUrl: RELAY, privkey: identity.privkey, log: () => {} });
460
+ const logsId = await me.ensureChannel(DEFAULTS.channels.logs);
461
+ const spinW = spinner('the hive is saying hello …');
462
+ let greeted = 0;
463
+ const names = new Map();
464
+ for (let i = 0; i < 22 && greeted < 2; i++) {
465
+ await new Promise((r) => setTimeout(r, 4000));
466
+ const evts = await me.query([{ kinds: [9, 40002], '#h': [logsId], since: joinStart, limit: 200 }]) || [];
467
+ for (const e of evts.sort((a, b) => a.created_at - b.created_at)) {
468
+ const j = tryJson(e.content);
469
+ if (!j || j.type !== EV.RESULT || j.for !== identity.pubkey) continue;
470
+ if (j.by && j.by !== e.pubkey) continue;
471
+ if (names.has(e.id)) continue;
472
+ names.set(e.id, true);
473
+ if (greeted === 0) spinW.stop();
474
+ let who = `${e.pubkey.slice(0, 8)}…`;
475
+ try {
476
+ const p = await me.query([{ kinds: [0], authors: [e.pubkey], limit: 1 }]);
477
+ const meta = tryJson(p?.[0]?.content || '');
478
+ if (meta?.name) who = meta.name;
479
+ } catch {}
480
+ console.log(`\n💬 ${who}: ${String(j.result).slice(0, 400)}\n`);
481
+ greeted++;
482
+ if (greeted >= 2) break;
483
+ }
484
+ }
485
+ if (!greeted) spinW.stop('🐝 welcome replies are on their way — they land in: hive feed');
486
+ } catch {}
487
+
488
+ say('next steps:');
489
+ if (awaitingKey) say(' hive key set # give your bee a real brain (60 seconds)');
490
+ say(' hive buzz # open the community app — your bee is under Agents');
491
+ say(` hive ask "what should we watch this weekend" · hive feed · hive leaderboard`);
241
492
  };
242
493
 
243
- main().catch((e) => { rl.close(); die(String(e.message || e)); });
494
+ main().catch((e) => die(String(e.message || e)));