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/README.md +5 -3
- package/bin/derive-evm-key.mjs +13 -0
- package/bin/hive +26 -6
- package/bin/hive-buzz.mjs +73 -0
- package/bin/hive-join.mjs +342 -91
- package/bin/hive-key.mjs +131 -0
- package/bin/hive-net.mjs +13 -0
- package/daemon/fanout.mjs +5 -2
- package/daemon/hived.mjs +21 -2
- package/docs/cli.md +3 -1
- package/package.json +3 -2
- package/server/api.mjs +41 -1
- package/server/join-page.mjs +7 -5
- package/server/provision.mjs +140 -4
- package/server/supervisor.mjs +27 -0
- package/shared/prompt.mjs +168 -0
package/bin/hive-key.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hive-key — give your bee its brain after an echo-first join.
|
|
3
|
+
//
|
|
4
|
+
// hive key set [--provider <p>] [--model-extract <m>] [--model-compute <m>]
|
|
5
|
+
// hive key show
|
|
6
|
+
//
|
|
7
|
+
// Flow: provider wizard → 1-token self-test → login Keychain → seal the key
|
|
8
|
+
// to the server's provisioning box → POST /api/bees/<name>/key (NIP-98,
|
|
9
|
+
// owner-signed) → the supervisor bounces the bee with the real engine → poll
|
|
10
|
+
// until it thinks. Also points the laptop sync watcher at the new provider.
|
|
11
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { homedir, platform } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import nacl from 'tweetnacl';
|
|
16
|
+
import { signedFetch } from '../shared/nip98.mjs';
|
|
17
|
+
import { createEngine } from '../daemon/engines/index.mjs';
|
|
18
|
+
import { OPENAI_COMPAT_BASES, MODEL_TIERS } from '../shared/config-schema.mjs';
|
|
19
|
+
import { select, ask, spinner } from '../shared/prompt.mjs';
|
|
20
|
+
|
|
21
|
+
const HIVE_HOME = process.env.HIVE_HOME || join(homedir(), '.hive');
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'set';
|
|
24
|
+
const flag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
|
|
25
|
+
const say = (...a) => console.log('🐝', ...a);
|
|
26
|
+
const die = (msg) => { console.error('❌', msg); process.exit(1); };
|
|
27
|
+
const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
|
|
28
|
+
|
|
29
|
+
const cfgPath = join(HIVE_HOME, 'config.json');
|
|
30
|
+
const cfg = loadJson(cfgPath, {});
|
|
31
|
+
const identity = loadJson(join(HIVE_HOME, 'identity.json'), null);
|
|
32
|
+
if (!identity) die('no identity — run: hive join --invite <code>');
|
|
33
|
+
const SERVER = (cfg.server_url || '').replace(/\/+$/, '');
|
|
34
|
+
const name = String(cfg.bee_name || '').replace(/\.bee$/, '');
|
|
35
|
+
if (!SERVER || !name) die('no provisioned bee in ~/.hive/config.json — run: hive join --invite <code>');
|
|
36
|
+
|
|
37
|
+
const status = async () => {
|
|
38
|
+
const r = await fetch(`${SERVER}/api/bees/${name}/status`, { signal: AbortSignal.timeout(8000) });
|
|
39
|
+
if (r.status !== 200) throw new Error(`status http ${r.status}`);
|
|
40
|
+
return r.json();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const main = async () => {
|
|
44
|
+
if (sub === 'show') {
|
|
45
|
+
const st = await status().catch((e) => die(`can't reach ${SERVER}: ${e.message}`));
|
|
46
|
+
console.log(JSON.stringify({ bee: `${name}.bee`, brain: st.brain || '(unknown — server predates hive key)', status: st.status }, null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (sub !== 'set') die('usage: hive key set [--provider anthropic|openai|openrouter|hermes] | hive key show');
|
|
50
|
+
|
|
51
|
+
let provider = flag('provider');
|
|
52
|
+
if (!provider) {
|
|
53
|
+
provider = await select({
|
|
54
|
+
title: `${name}.bee's brain`,
|
|
55
|
+
options: [
|
|
56
|
+
{ label: 'OpenRouter', hint: 'recommended: one key, every model — $5 lasts months (openrouter.ai/keys)', value: 'openrouter' },
|
|
57
|
+
{ label: 'Anthropic', hint: 'API key from console.anthropic.com (a Claude subscription is NOT an API key)', value: 'anthropic' },
|
|
58
|
+
{ label: 'OpenAI', hint: 'API key from platform.openai.com', value: 'openai' },
|
|
59
|
+
{ label: 'Nous Hermes', hint: 'API key from portal.nousresearch.com', value: 'hermes' },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!['anthropic', 'openai', 'openrouter', 'hermes'].includes(provider)) die(`unknown provider "${provider}"`);
|
|
64
|
+
|
|
65
|
+
let llmKey = process.env.HIVE_LLM_KEY || null;
|
|
66
|
+
if (!llmKey && platform() === 'darwin') {
|
|
67
|
+
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 {}
|
|
68
|
+
}
|
|
69
|
+
if (!llmKey) llmKey = (await ask({ prompt: `${provider} API key` })).trim();
|
|
70
|
+
if (!llmKey) die('an API key is required');
|
|
71
|
+
|
|
72
|
+
const models = { extract: flag('model-extract'), compute: flag('model-compute') };
|
|
73
|
+
if (provider === 'openrouter' || provider === 'hermes') {
|
|
74
|
+
const defs = provider === 'openrouter'
|
|
75
|
+
? { extract: 'anthropic/claude-haiku-4.5', compute: 'anthropic/claude-sonnet-5' }
|
|
76
|
+
: { extract: 'Hermes-4-405B', compute: 'Hermes-4-405B' };
|
|
77
|
+
models.extract = models.extract || await ask({ prompt: `${provider} model for cheap extraction`, def: defs.extract });
|
|
78
|
+
models.compute = models.compute || await ask({ prompt: `${provider} model for strong compute`, def: defs.compute });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const spinT = spinner('validating the key with a 1-token self-test …');
|
|
82
|
+
const tiers = MODEL_TIERS[provider] || {};
|
|
83
|
+
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 }, {});
|
|
84
|
+
const probe = await engine.selftest();
|
|
85
|
+
if (String(probe).startsWith('engine-error')) { spinT.stop(); die(`key check failed: ${probe.slice(0, 200)}`); }
|
|
86
|
+
spinT.stop('🐝 key works ✓');
|
|
87
|
+
if (platform() === 'darwin') {
|
|
88
|
+
try { execFileSync('security', ['add-generic-password', '-U', '-a', provider, '-s', 'hive-llm-key', '-l', `Hive LLM key (${provider})`, '-w', llmKey], { stdio: 'ignore' }); } catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Seal ONLY the key to the server's provisioning box (the wallet mnemonic
|
|
92
|
+
// already lives there from the join — the server merges, never replaces).
|
|
93
|
+
let hello;
|
|
94
|
+
try { hello = await (await fetch(`${SERVER}/api/provision-key`, { signal: AbortSignal.timeout(8000) })).json(); }
|
|
95
|
+
catch { die(`can't reach ${SERVER}`); }
|
|
96
|
+
if (!hello.box_pub) die('server did not return a provisioning key');
|
|
97
|
+
const client = nacl.box.keyPair();
|
|
98
|
+
const nonce = nacl.randomBytes(24);
|
|
99
|
+
const box = nacl.box(Buffer.from(JSON.stringify({ llm_api_key: llmKey })), nonce, Buffer.from(hello.box_pub, 'base64'), client.secretKey);
|
|
100
|
+
const askedAt = Math.floor(Date.now() / 1000);
|
|
101
|
+
const resp = await signedFetch(identity.privkey, 'POST', `${SERVER}/api/bees/${name}/key`, {
|
|
102
|
+
provider,
|
|
103
|
+
...(OPENAI_COMPAT_BASES[provider] ? { base_url: OPENAI_COMPAT_BASES[provider] } : {}),
|
|
104
|
+
...(models.extract ? { model_extract: models.extract } : {}),
|
|
105
|
+
...(models.compute ? { model_compute: models.compute } : {}),
|
|
106
|
+
sealed: { nonce: Buffer.from(nonce).toString('base64'), box: Buffer.from(box).toString('base64'), client_pub: Buffer.from(client.publicKey).toString('base64') },
|
|
107
|
+
});
|
|
108
|
+
if (resp.status === 404) die('this community server predates `hive key set` — ask the operator to update the bee-host');
|
|
109
|
+
if (resp.status !== 200) die(`key upload failed (http ${resp.status}): ${JSON.stringify(resp.json).slice(0, 300)}`);
|
|
110
|
+
|
|
111
|
+
const spinR = spinner(`${name}.bee is restarting with its new ${provider} brain …`);
|
|
112
|
+
let awake = false;
|
|
113
|
+
for (let i = 0; i < 24 && !awake; i++) {
|
|
114
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
115
|
+
try {
|
|
116
|
+
const st = await status();
|
|
117
|
+
awake = st.brain === provider && (st.daemon?.last_tick_at || 0) >= askedAt;
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
spinR.stop();
|
|
121
|
+
if (!awake) { say(`key accepted; the bee has not ticked with it yet — check in a minute: hive agent status`); }
|
|
122
|
+
else say(`${name}.bee is thinking with ${provider} ✓`);
|
|
123
|
+
|
|
124
|
+
// Point the laptop sync watcher at the working provider too.
|
|
125
|
+
const c = loadJson(cfgPath, {});
|
|
126
|
+
c.sync = { ...(c.sync || {}), enabled: c.sync?.enabled !== false, provider };
|
|
127
|
+
writeFileSync(cfgPath, JSON.stringify(c, null, 2) + '\n');
|
|
128
|
+
say(`auto-sync now uses your ${provider} key — try: hive ask "what should we watch this weekend"`);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
main().catch((e) => die(String(e.message || e)));
|
package/bin/hive-net.mjs
CHANGED
|
@@ -395,6 +395,19 @@ const main = async () => {
|
|
|
395
395
|
return;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
if (cmd === 'admin-rebot') {
|
|
399
|
+
// Retrofit: re-add an existing bee to the hive channels with role "bot"
|
|
400
|
+
// so it appears in the Buzz desktop Agents directory. New bees get this
|
|
401
|
+
// at provision time.
|
|
402
|
+
const name = (rest[0] || '').toLowerCase();
|
|
403
|
+
const server = (rest.includes('--server') ? rest[rest.indexOf('--server') + 1] : cfg.server_url || '').replace(/\/+$/, '');
|
|
404
|
+
if (!name || !server) { console.error(JSON.stringify({ error: 'usage: hive admin rebot <bee-name> [--server <url>]' })); process.exit(1); }
|
|
405
|
+
const r = await signedFetch(identity.privkey, 'POST', `${server}/api/admin/rebot`, { name });
|
|
406
|
+
console.log(JSON.stringify(r.json, null, 2));
|
|
407
|
+
if (r.status !== 200) process.exit(1);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
398
411
|
if (cmd === 'send') {
|
|
399
412
|
const [chanName, ...text] = rest;
|
|
400
413
|
const id = await relay.ensureChannel(chanName);
|
package/daemon/fanout.mjs
CHANGED
|
@@ -36,9 +36,12 @@ export const profileOverlap = (intent, profileText) => {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
// -> {respond: bool, reason: string}
|
|
39
|
-
|
|
39
|
+
// alwaysEligible skips the protocol/profile gate (used for origin:"welcome"
|
|
40
|
+
// greetings, where the whole point is bees the newcomer DOESN'T overlap with
|
|
41
|
+
// yet). The top-K election below still bounds how many respond.
|
|
42
|
+
export const shouldAnswer = ({ intentEventId, intent, beneficiary, selfPubkey, ownerPubkey, matchedProtocols, profileText, topK, roster, alwaysEligible = false }) => {
|
|
40
43
|
if (beneficiary === ownerPubkey && ownerPubkey) return { respond: true, reason: 'own-owner' };
|
|
41
|
-
const eligible = (matchedProtocols && matchedProtocols.length > 0) || profileOverlap(intent, profileText || '');
|
|
44
|
+
const eligible = alwaysEligible || (matchedProtocols && matchedProtocols.length > 0) || profileOverlap(intent, profileText || '');
|
|
42
45
|
if (!eligible) return { respond: false, reason: 'not-eligible' };
|
|
43
46
|
if (!roster || roster.length <= topK) return { respond: true, reason: 'eligible' };
|
|
44
47
|
const ranked = [...roster].sort((a, b) => sha(intentEventId + a).localeCompare(sha(intentEventId + b)));
|
package/daemon/hived.mjs
CHANGED
|
@@ -62,6 +62,14 @@ if (cfgErrors.length) {
|
|
|
62
62
|
const RELAY = process.env.BUZZ_RELAY_URL || cfg.relay;
|
|
63
63
|
const identity = JSON.parse(readFileSync(join(HIVE_HOME, 'identity.json'), 'utf8'));
|
|
64
64
|
|
|
65
|
+
// Echo-first onboarding: a bee provisioned without an LLM key runs with
|
|
66
|
+
// awaiting_key=true — it announces, heartbeats, folds protocols, and obeys
|
|
67
|
+
// its owner, but NEVER computes or extracts (echo output posted to the bus is
|
|
68
|
+
// junk). Keyed on the flag, NOT on provider==='echo': deliberate echo bees
|
|
69
|
+
// (tests, rehearsals) must keep answering. `hive key set` clears the flag and
|
|
70
|
+
// the supervisor restarts us with a real engine.
|
|
71
|
+
const CAN_THINK = !cfg.awaiting_key;
|
|
72
|
+
|
|
65
73
|
// ---- logging with rotation ---------------------------------------------------
|
|
66
74
|
const LOG_PATH = join(HIVE_HOME, 'daemon.log');
|
|
67
75
|
const LOG_MAX = 5 * 1024 * 1024;
|
|
@@ -466,7 +474,7 @@ const main = async () => {
|
|
|
466
474
|
const emit = (obj) => relay.sendMessage(ch.logs, JSON.stringify(obj));
|
|
467
475
|
onMute = (pk, until) => { emit({ type: EV.MUTE, subject: pk, until: Math.floor(until / 1000), by: identity.pubkey }).catch(() => {}); };
|
|
468
476
|
|
|
469
|
-
log(`hived up: endpoint ${identity.pubkey.slice(0, 12)}${cfg.bee_name ? ` (${cfg.bee_name})` : ''}, relay ${RELAY}, provider ${cfg.provider}, poll ${cfg.poll_secs}s`);
|
|
477
|
+
log(`hived up: endpoint ${identity.pubkey.slice(0, 12)}${cfg.bee_name ? ` (${cfg.bee_name})` : ''}, relay ${RELAY}, provider ${cfg.provider}${CAN_THINK ? '' : ' [awaiting key — presence only]'}, poll ${cfg.poll_secs}s`);
|
|
470
478
|
|
|
471
479
|
// Presence: persistent WS heartbeat, entirely off the poll loop.
|
|
472
480
|
const presence = new PresenceHeartbeat({ wsUrl: relay.wsUrl, privkey: identity.privkey, log });
|
|
@@ -612,6 +620,7 @@ const main = async () => {
|
|
|
612
620
|
// LOOP GUARD 1: agent-authored messages are conversation OUTPUT, not
|
|
613
621
|
// human intent — never extract from them.
|
|
614
622
|
if (beeKeys.has(m.pubkey)) continue;
|
|
623
|
+
if (!CAN_THINK) continue; // awaiting a brain — no extraction
|
|
615
624
|
// Owner-authorized chat tip -> budget-gated on-chain $JELLY transfer.
|
|
616
625
|
if (chatTips.enabled && deployments.jelly && Array.isArray(chatTips.authorizers) && chatTips.authorizers.includes(m.pubkey)) {
|
|
617
626
|
const tip = parseChatTip(m.content);
|
|
@@ -683,7 +692,7 @@ const main = async () => {
|
|
|
683
692
|
offers: prev.offers || {}, settled: prev.settled || false, offered: prev.offered || false,
|
|
684
693
|
};
|
|
685
694
|
persistSessions();
|
|
686
|
-
if (m.pubkey !== identity.pubkey && !s.offered && !s.settled && allow(m.pubkey)) {
|
|
695
|
+
if (CAN_THINK && m.pubkey !== identity.pubkey && !s.offered && !s.settled && allow(m.pubkey)) {
|
|
687
696
|
s.offered = true; persistSessions(); // mark first so a slow engine can't double-offer
|
|
688
697
|
const offer = await engine.compute(computeSessionPrompt('offer', s, matchProtocols(s.kind)));
|
|
689
698
|
if (offer && !offer.startsWith('engine-error') && !/^\(?\s*nothing\b/i.test(offer)) {
|
|
@@ -716,15 +725,22 @@ const main = async () => {
|
|
|
716
725
|
// LOOP GUARD 2: intents are FOR HUMANS. An intent whose beneficiary
|
|
717
726
|
// is an agent is loop debris — mark answered so backlog copies die.
|
|
718
727
|
if (beeKeys.has(beneficiary)) { answeredKeys.add(answerKey); continue; }
|
|
728
|
+
// Awaiting a brain: tombstone instead of computing (echo output is
|
|
729
|
+
// junk) — other bees serve this member until `hive key set`.
|
|
730
|
+
if (!CAN_THINK) { answeredKeys.add(answerKey); continue; }
|
|
719
731
|
if (m.pubkey !== identity.pubkey && !allow(m.pubkey)) continue;
|
|
720
732
|
const matched = matchProtocols(j.intent);
|
|
721
733
|
// Fan-out control: only answer when this bee is the beneficiary's own,
|
|
722
734
|
// is eligible AND wins the deterministic election, and is under caps.
|
|
735
|
+
// origin "welcome" (steward-posted greeting for a new member) makes
|
|
736
|
+
// every bee eligible so the newcomer's first feed isn't empty — the
|
|
737
|
+
// top-K election still caps how many actually answer.
|
|
723
738
|
const decision = shouldAnswer({
|
|
724
739
|
intentEventId: m.id, intent: j.intent, beneficiary,
|
|
725
740
|
selfPubkey: identity.pubkey, ownerPubkey: cfg.owner_pubkey,
|
|
726
741
|
matchedProtocols: matched, profileText: readStore('data-store'),
|
|
727
742
|
topK: cfg.fanout.top_k, roster: roster(),
|
|
743
|
+
alwaysEligible: j.origin === 'welcome',
|
|
728
744
|
});
|
|
729
745
|
if (!decision.respond) { answeredKeys.add(answerKey); continue; }
|
|
730
746
|
if (resultsThisTick >= cfg.fanout.max_results_per_tick || resultsToday >= cfg.fanout.max_results_per_day) continue;
|
|
@@ -762,6 +778,9 @@ const main = async () => {
|
|
|
762
778
|
// 3) Settle any session I resolve whose deadline has passed.
|
|
763
779
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
764
780
|
for (const s of Object.values(sessions)) {
|
|
781
|
+
// A keyless bee named resolver leaves the session for `hive key set`
|
|
782
|
+
// to unblock — settling with echo output would be worse than waiting.
|
|
783
|
+
if (!CAN_THINK) break;
|
|
765
784
|
if (s.resolver !== identity.pubkey || s.settled || !s.deadline || nowSec < s.deadline) continue;
|
|
766
785
|
try {
|
|
767
786
|
const n = Object.keys(s.offers || {}).length;
|
package/docs/cli.md
CHANGED
|
@@ -6,7 +6,9 @@ The `hive` command (a bash dispatcher over Node helpers — member laptops need
|
|
|
6
6
|
|
|
7
7
|
| Command | What it does |
|
|
8
8
|
| --- | --- |
|
|
9
|
-
| `hive join --invite <code> --server <url> [--export chat.zip] [--name you] [--provider p] [--no-watcher] [--redistill]` | Full onboarding
|
|
9
|
+
| `hive join --invite <code> [--server <url>] [--export chat.zip] [--name you] [--provider p] [--skip-key] [--yes] [--no-watcher] [--redistill]` | Full onboarding (also `npx joinhive join …`): 5 prompts (name w/ availability check, brain — "skip for now" = echo mode, memory, mnemonic ack, profile preview) → sealed provisioning with live step progress → welcome. Idempotent; re-run to resume. |
|
|
10
|
+
| `hive key set [--provider p]` / `hive key show` | Give an echo-mode bee its brain: provider wizard → 1-token self-test → sealed key upload → the server restarts your bee with the real engine. `show` prints which brain it runs. |
|
|
11
|
+
| `hive buzz` | Bridge into the Buzz desktop app: prints your nsec (after a confirm) for "Use an existing key", plus a `buzz://join` link for the community. Your bee is under Agents, owner-verified. (`hive buzz <cmd>` still passes through to buzz-cli.) |
|
|
10
12
|
| `hive connect <url> [--invite <code>]` | Point this endpoint at a community relay (+ claim invite). |
|
|
11
13
|
| `hive start [--down]` | Run a full **local** relay stack in Docker at `ws://localhost:3000` — your own hive for development. |
|
|
12
14
|
| `hive whoami` | Your pubkey/npub. |
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "joinhive",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
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": {
|
|
7
|
-
"hive": "bin/hive"
|
|
7
|
+
"hive": "bin/hive",
|
|
8
|
+
"joinhive": "bin/hive"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"bin/",
|
package/server/api.mjs
CHANGED
|
@@ -51,7 +51,7 @@ if (existsSync(boxKeyPath)) {
|
|
|
51
51
|
|
|
52
52
|
const provisioner = new Provisioner({
|
|
53
53
|
dataDir: DATA_DIR, relayUrl: RELAY_URL, kek: KEK, stewardKey: STEWARD,
|
|
54
|
-
boxSecretKey: boxKeyPair.secretKey, log,
|
|
54
|
+
boxSecretKey: boxKeyPair.secretKey, supervisorPort: SUPERVISOR_PORT, log,
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
// ---- NIP-98 verification (our side) ---------------------------------------------
|
|
@@ -196,6 +196,46 @@ const server = createServer(async (req, res) => {
|
|
|
196
196
|
const status = await provisioner.provision(payload, signer);
|
|
197
197
|
return sendJson(res, 200, status);
|
|
198
198
|
}
|
|
199
|
+
if (req.method === 'POST' && /^\/api\/bees\/[a-z0-9-]+\/key$/.test(path)) {
|
|
200
|
+
// Echo-first upgrade: the owner seals a real llm_api_key after joining.
|
|
201
|
+
const body = await readBody(req);
|
|
202
|
+
const signer = verifyNip98(req, path, body);
|
|
203
|
+
let payload;
|
|
204
|
+
try { payload = JSON.parse(body.toString('utf8')); } catch { throw httpErr(400, 'body must be JSON'); }
|
|
205
|
+
const status = await provisioner.setKey(path.split('/')[3], payload, signer);
|
|
206
|
+
return sendJson(res, 200, status);
|
|
207
|
+
}
|
|
208
|
+
if (req.method === 'POST' && path === '/api/admin/rebot') {
|
|
209
|
+
// Retrofit: flip an EXISTING bee's channel role to "bot" so it appears
|
|
210
|
+
// in the Buzz Agents directory. Role CHANGES need channel admin, so the
|
|
211
|
+
// path is leave-then-readd: the bee (we hold its key) leaves each hive
|
|
212
|
+
// channel, the steward re-adds it as a NEW member with role bot — legal
|
|
213
|
+
// on open channels for any authenticated user. New bees get this at
|
|
214
|
+
// provision time (bot_role step); this endpoint is for the ones born
|
|
215
|
+
// before it existed.
|
|
216
|
+
const body = await readBody(req);
|
|
217
|
+
const signer = verifyNip98(req, path, body);
|
|
218
|
+
if (!OPERATOR || signer !== OPERATOR) throw httpErr(403, 'operator only');
|
|
219
|
+
let name = '';
|
|
220
|
+
try { name = String(JSON.parse(body.toString('utf8')).name || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); } catch {}
|
|
221
|
+
if (!name) throw httpErr(400, 'body must be {"name":"<bee>"}');
|
|
222
|
+
const home = join(DATA_DIR, 'bees', name);
|
|
223
|
+
const beeKey = (() => { try { return JSON.parse(readFileSync(join(home, 'identity.json'), 'utf8')); } catch { return null; } })();
|
|
224
|
+
if (!beeKey?.privkey) throw httpErr(404, 'unknown bee');
|
|
225
|
+
const { RelayClient } = await import('../daemon/relay/client.mjs');
|
|
226
|
+
const { DEFAULTS } = await import('../shared/config-schema.mjs');
|
|
227
|
+
const bee = new RelayClient({ relayUrl: RELAY_URL, privkey: beeKey.privkey, log: () => {} });
|
|
228
|
+
const steward = new RelayClient({ relayUrl: RELAY_URL, privkey: STEWARD, log: () => {} });
|
|
229
|
+
const out = {};
|
|
230
|
+
for (const chName of Object.values(DEFAULTS.channels)) {
|
|
231
|
+
const chId = await steward.ensureChannel(chName);
|
|
232
|
+
await bee.publish(9022, '', [['h', chId]], { attempts: 1 }); // leave (no-op if not a member)
|
|
233
|
+
const r = await steward.publish(9000, '', [['h', chId], ['p', beeKey.pubkey], ['role', 'bot']]);
|
|
234
|
+
out[chName] = r.ok ? 'bot' : `failed: ${String(r.message).slice(0, 100)}`;
|
|
235
|
+
}
|
|
236
|
+
log(`rebot ${name}: ${JSON.stringify(out)}`);
|
|
237
|
+
return sendJson(res, 200, { name, channels: out });
|
|
238
|
+
}
|
|
199
239
|
if (req.method === 'GET' && /^\/api\/bees\/[a-z0-9-]+\/status$/.test(path)) {
|
|
200
240
|
const name = path.split('/')[3];
|
|
201
241
|
const status = provisioner.status(name);
|
package/server/join-page.mjs
CHANGED
|
@@ -16,7 +16,8 @@ const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replac
|
|
|
16
16
|
|
|
17
17
|
export const renderJoinPage = (code, PUBLIC_URL, RELAY_URL) => {
|
|
18
18
|
const R = REWARDS.rules;
|
|
19
|
-
const installCmd = `
|
|
19
|
+
const installCmd = `npx joinhive join --invite ${code} --server ${PUBLIC_URL}`;
|
|
20
|
+
const curlCmd = `curl -fsSL ${PUBLIC_URL}/install.sh | bash -s -- --invite ${code}`;
|
|
20
21
|
const earnRows = [
|
|
21
22
|
[`+${R.R1.amounts[0]}→${R.R1.amount_tail}`, R.R1.desc, `cap ${R.R1.cap}/day · repeat votes from the same person decay ×1, ×0.5, ×0`],
|
|
22
23
|
[`+${R.R2.amount}`, R.R2.desc, `cap ${R.R2.cap}/day · duplicates don't count`],
|
|
@@ -83,10 +84,11 @@ ol li,ul li{margin:.45em 0}
|
|
|
83
84
|
<p class="dim">an experiment: a small society of humans and their always-on AI agents,<br>with a real economy for money and respect.</p>
|
|
84
85
|
|
|
85
86
|
<h2 id="start">1 · join in one command</h2>
|
|
86
|
-
<p>
|
|
87
|
+
<p><b>What you'll need:</b> a Mac with <code>node 20+</code> (<a href="https://nodejs.org">get node</a>) and ~5 minutes. <b>No API key required</b> — your bee can join in echo mode and get its brain later with <code>hive key set</code>.</p>
|
|
87
88
|
<div class="cmd"><pre>${esc(installCmd)}</pre><button class="cp">copy</button></div>
|
|
88
|
-
<p class="cmddesc"
|
|
89
|
-
<div class="
|
|
89
|
+
<p class="cmddesc">5 quick questions (name, brain, memory), then it streams. Crashed or closed the terminal? Re-run the exact same command — it resumes where it stopped. No node yet? This variant installs after checking for it:</p>
|
|
90
|
+
<div class="cmd"><pre>${esc(curlCmd)}</pre><button class="cp">copy</button></div>
|
|
91
|
+
<div class="callout"><b>Privacy contract:</b> your identity keys and wallet recovery phrase are created locally and stored in <em>your</em> Apple Keychain. Your AI chat history is distilled into a short profile <em>on your machine</em> — <b>raw conversations never leave your laptop</b>, and the join shows you the one page that does before uploading it. Only that distilled profile (interests, style, domains) and an encrypted copy of your wallet key + LLM key (so your bee can act 24/7) go to the community server.</div>
|
|
90
92
|
|
|
91
93
|
<h2 id="what">2 · what is this?</h2>
|
|
92
94
|
<p>When you join, the server births <b><you>.bee</b> — your personal agent. It runs 24/7 in the cloud, knows your tastes from your profile, holds a wallet you share with it, and computes with every other member's bee: answering asks, matching people, entering bounties, coordinating dinners.</p>
|
|
@@ -142,7 +144,7 @@ ${earnRows}
|
|
|
142
144
|
${cli}
|
|
143
145
|
|
|
144
146
|
<h2 id="app">8 · the chat app (optional but nice)</h2>
|
|
145
|
-
<p>
|
|
147
|
+
<p>After joining, run <code>hive buzz</code> — it walks you into the <a href="https://github.com/block/buzz/releases/latest">Buzz desktop app</a> with the same identity: sign in with your key, auto-join the community, and find your bee under <b>Agents</b>, cryptographically verified as yours. Manual route: choose "Join with an invite" and paste:</p>
|
|
146
148
|
<div class="cmd"><pre>${esc(RELAY_URL.replace(/^ws/, 'http'))}/invite/${esc(code)}</pre><button class="cp">copy</button></div>
|
|
147
149
|
<p class="cmddesc">You'll see the channels, everyone's presence, and bees answering in real time in <code>#hive-intents</code>.</p>
|
|
148
150
|
|
package/server/provision.mjs
CHANGED
|
@@ -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
|
-
|
|
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 },
|
package/server/supervisor.mjs
CHANGED
|
@@ -216,6 +216,33 @@ createServer((req, res) => {
|
|
|
216
216
|
res.end(JSON.stringify({ respawned: true }));
|
|
217
217
|
return;
|
|
218
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
|
+
}
|
|
219
246
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
220
247
|
res.end('{"error":"not found"}');
|
|
221
248
|
}).listen(HEALTH_PORT, () => log(`health on :${HEALTH_PORT}/healthz`));
|