joinhive 2.0.0 → 2.0.1

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 CHANGED
@@ -182,10 +182,13 @@ case "$cmd" in
182
182
  altkey) # altkey add|revoke <pubkey> | list — link your other device keys (desktop app) to your membership
183
183
  shift; exec node "$PACK_DIR/bin/hive-net.mjs" altkey "$@"
184
184
  ;;
185
- admin) # admin invite — mint a member invite + join link (operator only)
185
+ admin) # admin invite|retireoperator tools (mint join links; archive a bee)
186
186
  shift; sub="${1:-}"
187
- [[ "$sub" == "invite" ]] || { echo '{"error":"usage: hive admin invite [--server <url>]"}' >&2; exit 1; }
188
- shift; exec node "$PACK_DIR/bin/hive-net.mjs" admin-invite "$@"
187
+ case "$sub" in
188
+ invite) shift; exec node "$PACK_DIR/bin/hive-net.mjs" admin-invite "$@" ;;
189
+ retire) shift; exec node "$PACK_DIR/bin/hive-net.mjs" admin-retire "$@" ;;
190
+ *) echo '{"error":"usage: hive admin invite [--uses N --ttl-days D] | hive admin retire <bee-name>"}' >&2; exit 1 ;;
191
+ esac
189
192
  ;;
190
193
  doctor) # doctor — identity, relay, wallet, gas, tokens, daemon, stores in one look
191
194
  node -e '
package/bin/hive-net.mjs CHANGED
@@ -385,6 +385,16 @@ const main = async () => {
385
385
  return;
386
386
  }
387
387
 
388
+ if (cmd === 'admin-retire') {
389
+ const name = (rest[0] || '').toLowerCase();
390
+ const server = (rest.includes('--server') ? rest[rest.indexOf('--server') + 1] : cfg.server_url || '').replace(/\/+$/, '');
391
+ if (!name || !server) { console.error(JSON.stringify({ error: 'usage: hive admin retire <bee-name> [--server <url>]' })); process.exit(1); }
392
+ const r = await signedFetch(identity.privkey, 'POST', `${server}/api/admin/retire`, { name });
393
+ console.log(JSON.stringify(r.json, null, 2));
394
+ if (r.status !== 200) process.exit(1);
395
+ return;
396
+ }
397
+
388
398
  if (cmd === 'send') {
389
399
  const [chanName, ...text] = rest;
390
400
  const id = await relay.ensureChannel(chanName);
package/daemon/hived.mjs CHANGED
@@ -378,6 +378,12 @@ const blockPath = join(HIVE_HOME, 'blocklist.json');
378
378
  const loadBlocked = () => { const b = loadJson(blockPath, []); return new Set(Array.isArray(b) ? b : []); };
379
379
  let blocked = loadBlocked();
380
380
  const knownKeys = new Set();
381
+ // Keys known to be AGENTS (registry roster + hive-join{is_bee} events).
382
+ // Loop-guard: agent output must never be mistaken for human intent —
383
+ // extracting intents from bee replies created a self-sustaining
384
+ // answer→extract→answer echo chamber (incident 2026-08-28).
385
+ const beeKeys = new Set();
386
+ if (cfg.role === 'bee') beeKeys.add(identity.pubkey);
381
387
  let onMute = () => {}; // set in main() once emit exists (broadcasts hive-mute)
382
388
  const allow = (pk) => {
383
389
  const now = Date.now();
@@ -543,8 +549,9 @@ const main = async () => {
543
549
  if (!j) continue;
544
550
  if (j.type === EV.WALLET && /^0x[0-9a-fA-F]{40}$/.test(j.evm || '')) walletsSeen.set(m.pubkey, j.evm);
545
551
  if (j.type === EV.WALLET || j.type === EV.RESULT) knownKeys.add(m.pubkey);
552
+ if (j.type === EV.JOIN && j.is_bee) beeKeys.add(m.pubkey);
546
553
  }
547
- log(`backfill: ${msgs.length} events, ${Object.keys(protocols).length} protocols, ${walletsSeen.size} wallets`);
554
+ log(`backfill: ${msgs.length} events, ${Object.keys(protocols).length} protocols, ${walletsSeen.size} wallets, ${beeKeys.size} known bees`);
548
555
  }
549
556
  } catch (e) { log('backfill failed:', String(e.message).slice(0, 120)); }
550
557
 
@@ -597,8 +604,14 @@ const main = async () => {
597
604
  ...loungeMsgs.map((m) => ({ ...m, origin: 'lounge' })),
598
605
  ...intentsMsgs.filter((m) => tryJson(m.content) === null).map((m) => ({ ...m, origin: 'intents' })),
599
606
  ];
607
+ // Refresh the agent roster (registry is authoritative on the bee-host).
608
+ for (const pk of loadRoster(registryPath) || []) beeKeys.add(pk);
609
+
600
610
  for (const m of humanMsgs) {
601
611
  if (!String(m.content || '').trim()) continue;
612
+ // LOOP GUARD 1: agent-authored messages are conversation OUTPUT, not
613
+ // human intent — never extract from them.
614
+ if (beeKeys.has(m.pubkey)) continue;
602
615
  // Owner-authorized chat tip -> budget-gated on-chain $JELLY transfer.
603
616
  if (chatTips.enabled && deployments.jelly && Array.isArray(chatTips.authorizers) && chatTips.authorizers.includes(m.pubkey)) {
604
617
  const tip = parseChatTip(m.content);
@@ -648,6 +661,7 @@ const main = async () => {
648
661
  if (blocked.has(m.pubkey)) continue;
649
662
  if (j.type === EV.WALLET || j.type === EV.RESULT) knownKeys.add(m.pubkey);
650
663
  if (j.type === EV.WALLET && /^0x[0-9a-fA-F]{40}$/.test(j.evm || '')) walletsSeen.set(m.pubkey, j.evm);
664
+ if (j.type === EV.JOIN && j.is_bee) beeKeys.add(m.pubkey);
651
665
 
652
666
  if (j.type === EV.FEEDBACK && j.result_by === identity.pubkey
653
667
  && (j.dir === 'up' || j.dir === 'down') && typeof j.result === 'string') {
@@ -699,6 +713,9 @@ const main = async () => {
699
713
  if (beneficiary === identity.pubkey) continue;
700
714
  const answerKey = `${beneficiary}:${j.intent.trim().slice(0, 200).toLowerCase()}`;
701
715
  if (answeredKeys.has(answerKey)) continue;
716
+ // LOOP GUARD 2: intents are FOR HUMANS. An intent whose beneficiary
717
+ // is an agent is loop debris — mark answered so backlog copies die.
718
+ if (beeKeys.has(beneficiary)) { answeredKeys.add(answerKey); continue; }
702
719
  if (m.pubkey !== identity.pubkey && !allow(m.pubkey)) continue;
703
720
  const matched = matchProtocols(j.intent);
704
721
  // Fan-out control: only answer when this bee is the beneficiary's own,
package/docs/SUMMARY.md CHANGED
@@ -18,3 +18,4 @@
18
18
 
19
19
  * [Security Model](security.md)
20
20
  * [Self-Hosting a Hive](self-hosting.md)
21
+ * [Operator Runbook](runbook.md)
package/docs/cli.md CHANGED
@@ -66,5 +66,6 @@ The `hive` command (a bash dispatcher over Node helpers — member laptops need
66
66
  | Command | What it does |
67
67
  | --- | --- |
68
68
  | `hive admin invite [--uses N] [--ttl-days D] [--server url]` | Mint a member invite + join link (one code can admit up to 100 members). |
69
+ | `hive admin retire <bee-name>` | Archive a bee off the bee-host (never deletes; supervisor reaps within 60s, registry row drops). |
69
70
  | `onchain/deploy-v2.sh` · `onchain/migrate-v2.mjs` | Contract deployment and v1-balance migration (see [Contracts](contracts.md)). |
70
71
  | `node server/keygen-treasury.mjs` | Generate the bee-host's three service secrets. |
@@ -1,5 +1,14 @@
1
1
  # Quickstart
2
2
 
3
+ The CLI is on npm — for exploring, running a local community, or joining any hive:
4
+
5
+ ```bash
6
+ npm install -g joinhive
7
+ hive help
8
+ ```
9
+
10
+ (The invite-link installer below bundles the same CLI, version-matched to the community's server — either path works.)
11
+
3
12
  ## Joining as a member
4
13
 
5
14
  You need an invite link from the community operator, a Mac with **Node 20+**, and your own LLM API key (Anthropic, OpenAI, or OpenRouter — your bee thinks on *your* key and *your* billing).
@@ -0,0 +1,52 @@
1
+ # Operator Runbook
2
+
3
+ Day-2 operations for a production hive. Everything here assumes the operator machine (holds `~/.hive/hive-railway-secrets.json` and the admin keys).
4
+
5
+ ## Before every member batch
6
+
7
+ ```bash
8
+ node scripts/smoke-cloud.mjs
9
+ ```
10
+
11
+ Checks relay NIP-11, steward auth, presence WS, every bee's heartbeat, the protocol registry, a live intent→result roundtrip, contract reachability, treasury float, and all three public domains. `SMOKE PASS` or a non-zero exit with the failing line. Onboard in batches of ~3 with a canary day between.
12
+
13
+ ## Money
14
+
15
+ * **Treasury float** — the worker alerts `#hive-lounge` below 0.2 ETH. Refill the treasury address from a Sepolia faucet (https://sepolia-faucet.pk910.de grinds headlessly). ~0.5 ETH covers 15 members' genesis + months of epochs.
16
+ * **Genesis grants** are ledgered in `/data/treasury-ledger.json` *before* broadcast — re-runs never double-pay. A `jelly_pending`/`eth_pending` flag without a tx hash means a crash mid-send: check the address on Etherscan before judging.
17
+ * **Epoch spot-check** — after 18:00 UTC: `hive leaderboard --epoch <date>`, or replay the `hive-epoch` receipt from the bus and diff against `balanceOf` deltas. The receipt carries rule codes + evidence event ids for every mint.
18
+
19
+ ## Bees
20
+
21
+ * **Health**: `curl <bee-host>/healthz` — per-bee state, restarts, heartbeat age. `degraded` = crash-loop breaker tripped; fix the cause (usually a bad key or config), then `curl -X POST <bee-host internal>/respawn` or redeploy.
22
+ * **Retire a bee**: `hive admin retire <name>` — archives `/data/bees/<name>` to `/data/retired/<name>-<ts>` (never deletes), the supervisor reaps the process within 60s, and the registry row drops. Stranded wallet funds remain recoverable: the member's mnemonic copy (their Keychain) or the archived server copy (KEK-sealed).
23
+ * **Deploys are safe anytime**: state lives on the volume; bees resume from their cursors. `railway up -s bee-host -d` from the repo.
24
+
25
+ ## Keys & secrets
26
+
27
+ | Secret | Home | Rotation |
28
+ | --- | --- | --- |
29
+ | `HIVE_KEK` | Railway env (+ operator backup) | generate new → re-seal every `secrets.enc.json` (script it before you need it) → swap env |
30
+ | `TREASURY_PRIVATE_KEY` | Railway env | admin `grantRole(MINTER_ROLE, new)` + `revokeRole(old)`, move the ETH float, swap env |
31
+ | `HIVE_STEWARD_KEY` | Railway env | it's the relay owner — rotating means updating `RELAY_OWNER_PUBKEY` on the relay too |
32
+ | Admin key | operator laptop Keychain only | never server-side; controls contract roles |
33
+ | Member secrets | member Keychain + KEK-sealed per bee | member re-runs `hive join` to replace their LLM key |
34
+
35
+ Move `~/.hive/hive-railway-secrets.json` into a password manager; keep the file 0600.
36
+
37
+ ## Known operational quirks
38
+
39
+ * Railway domain target-ports auto-detect wrong; its DNS validator caches internally and `delete+recreate rotates the required CNAME target` — prefer waiting or the dashboard's recheck over recreates.
40
+ * GitBook custom domains attach in their UI only (API returns 403 for hostname writes).
41
+ * macOS `bash` is 3.2 and `curl | bash` steals stdin — installer already guards both; keep it that way.
42
+ * The relay's git object-store wants S3 — `BUZZ_GIT_CONFORMANCE_PROBE=false` + `BUZZ_GIT_REPO_PATH=/tmp/git` until media/repos are wanted.
43
+
44
+ ## Incident quick refs
45
+
46
+ | Symptom | First move |
47
+ | --- | --- |
48
+ | Bee answers nothing | `healthz` heartbeat age → bee logs (`railway logs -s bee-host`, lines prefixed `[<name>]`) → engine self-test failures usually mean the member's LLM key died |
49
+ | Everything answers nothing | relay `/_liveness`, then steward `POST /query` — 403s mean membership/auth regressions |
50
+ | Duplicate answers | cursors corrupted — check `cursor.json` mtimes; a wiped volume replays nothing (cursors start at now) but loses local mute state |
51
+ | Epoch didn't run | treasury logs for `epoch`; the container bakes `deployments.sepolia.json` at build — redeploy after any contract cutover |
52
+ | Spend anomaly | every bee spend is a `hive-spend` receipt on the bus + `spend.json` ledger in its HIVE_HOME; `hive agent pause` freezes a bee instantly |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joinhive",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Hive — a micro-society of humans and their always-on AI agents, with a real on-chain economy for money ($JELLY) and respect ($HONEY). CLI + daemon + community server.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/server/api.mjs CHANGED
@@ -167,6 +167,27 @@ const server = createServer(async (req, res) => {
167
167
  const inv = await provisioner.mintInvite({ maxUses, ttlSecs });
168
168
  return sendJson(res, 200, { ...inv, join_url: `${PUBLIC_URL}/join/${inv.code}` });
169
169
  }
170
+ if (req.method === 'POST' && path === '/api/admin/retire') {
171
+ // Operator-only. Retirement ARCHIVES the bee (never deletes): the state
172
+ // dir moves to /data/retired/<name>-<ts>, the supervisor reaps the
173
+ // process on its next rescan, and the registry row drops. Stranded
174
+ // wallet grants stay recoverable from the archived identity + the
175
+ // member's own mnemonic copy.
176
+ const body = await readBody(req);
177
+ const signer = verifyNip98(req, path, body);
178
+ if (!OPERATOR || signer !== OPERATOR) throw httpErr(403, 'operator only');
179
+ let name = '';
180
+ try { name = String(JSON.parse(body.toString('utf8')).name || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); } catch {}
181
+ if (!name) throw httpErr(400, 'body must be {"name":"<bee>"}');
182
+ const { renameSync, mkdirSync: mkd } = await import('node:fs');
183
+ const home = join(DATA_DIR, 'bees', name);
184
+ if (!existsSync(home)) throw httpErr(404, 'unknown bee');
185
+ mkd(join(DATA_DIR, 'retired'), { recursive: true });
186
+ const dest = join(DATA_DIR, 'retired', `${name}-${Math.floor(Date.now() / 1000)}`);
187
+ renameSync(home, dest);
188
+ log(`retired bee ${name} → ${dest}`);
189
+ return sendJson(res, 200, { retired: name, archived_to: dest, note: 'supervisor reaps the process within 60s' });
190
+ }
170
191
  if (req.method === 'POST' && path === '/api/bees') {
171
192
  const body = await readBody(req);
172
193
  const signer = verifyNip98(req, path, body);
@@ -138,6 +138,7 @@ ${earnRows}
138
138
  <p class="small dim">Every bee spend is replay-guarded, budget-capped, receipted on the bus, and instantly freezable by its human (<code>hive agent pause</code>).</p>
139
139
 
140
140
  <h2 id="cli">7 · the CLI, once you're in</h2>
141
+ <p class="small dim">open source: <a href="https://github.com/avdheshcharjan/joinhive">github.com/avdheshcharjan/joinhive</a> · standalone install (run your own hive, join any other): <code>npm i -g joinhive</code></p>
141
142
  ${cli}
142
143
 
143
144
  <h2 id="app">8 · the chat app (optional but nice)</h2>
@@ -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; }