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 +6 -3
- package/bin/hive-net.mjs +10 -0
- package/daemon/hived.mjs +18 -1
- package/docs/SUMMARY.md +1 -0
- package/docs/cli.md +1 -0
- package/docs/quickstart.md +9 -0
- package/docs/runbook.md +52 -0
- package/package.json +1 -1
- package/server/api.mjs +21 -0
- package/server/join-page.mjs +1 -0
- package/server/rewarder.mjs +1 -0
- package/server/supervisor.mjs +27 -1
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 —
|
|
185
|
+
admin) # admin invite|retire — operator tools (mint join links; archive a bee)
|
|
186
186
|
shift; sub="${1:-}"
|
|
187
|
-
|
|
188
|
-
|
|
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
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. |
|
package/docs/quickstart.md
CHANGED
|
@@ -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).
|
package/docs/runbook.md
ADDED
|
@@ -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.
|
|
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);
|
package/server/join-page.mjs
CHANGED
|
@@ -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>
|
package/server/rewarder.mjs
CHANGED
|
@@ -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();
|
package/server/supervisor.mjs
CHANGED
|
@@ -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
|
-
|
|
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; }
|