ghostbus 0.0.0-stage → 0.5.2
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/CHANGELOG.md +83 -0
- package/LICENSE +21 -0
- package/PLAN.md +40 -0
- package/README.md +108 -2
- package/docs/client-setup.md +48 -0
- package/docs/hosted.md +126 -0
- package/examples/mcp_config.example.json +1 -0
- package/examples/three-agent-team.mjs +50 -0
- package/examples/two-agent-demo.mjs +34 -0
- package/examples/wake-hook/README.md +15 -0
- package/examples/wake-hook/wake.sh +39 -0
- package/package.json +46 -4
- package/src/board.html +78 -0
- package/src/cli.mjs +77 -0
- package/src/core.mjs +645 -0
- package/src/handler.mjs +166 -0
- package/src/hosted-server.mjs +239 -0
- package/src/http-server.mjs +47 -0
- package/src/mcp-server.mjs +61 -0
- package/src/tools.mjs +71 -0
package/src/handler.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GhostBus HTTP request handler — transport logic shared by the single-workspace
|
|
3
|
+
* relay (http-server.mjs) and the hosted multi-workspace server (hosted-server.mjs).
|
|
4
|
+
*
|
|
5
|
+
* createBusHandler({ id, bus, key }) -> async (req, res, url)
|
|
6
|
+
* - `url` is a URL whose pathname is the path WITHIN the workspace (any
|
|
7
|
+
* /w/<id> prefix already stripped by the caller).
|
|
8
|
+
* - `key` is the workspace key ('' = open). /health, /probe and the board page
|
|
9
|
+
* stay open; everything else needs the key when one is set.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import { GHOSTBUS_VERSION } from './core.mjs';
|
|
14
|
+
import { TOOLS, callTool } from './tools.mjs';
|
|
15
|
+
|
|
16
|
+
export const BOARD_HTML = fs.readFileSync(new URL('./board.html', import.meta.url), 'utf8');
|
|
17
|
+
|
|
18
|
+
const agentTokenOf = (req, body = {}, q = {}) =>
|
|
19
|
+
body.token || q.token || req.headers['x-agent-token'] || null;
|
|
20
|
+
|
|
21
|
+
const hashEq = (given, expected) => {
|
|
22
|
+
const a = Buffer.from(crypto.createHash('sha256').update(String(given)).digest('hex'));
|
|
23
|
+
const b = Buffer.from(crypto.createHash('sha256').update(String(expected)).digest('hex'));
|
|
24
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
25
|
+
};
|
|
26
|
+
export const keyMatches = (given, expected) => !!expected && !!given && hashEq(given, expected);
|
|
27
|
+
|
|
28
|
+
export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
29
|
+
const buckets = new Map();
|
|
30
|
+
const rateOk = (req) => {
|
|
31
|
+
const ip = (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
32
|
+
const t = Date.now();
|
|
33
|
+
let b = buckets.get(ip);
|
|
34
|
+
if (!b || t - b.start > 60_000) { b = { start: t, count: 0 }; buckets.set(ip, b); }
|
|
35
|
+
b.count += 1;
|
|
36
|
+
return b.count <= 300;
|
|
37
|
+
};
|
|
38
|
+
const keyOk = (req) => {
|
|
39
|
+
if (!key) return true;
|
|
40
|
+
const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
41
|
+
return keyMatches(given, key);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return async function handle(req, res, url) {
|
|
45
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
46
|
+
res.setHeader('referrer-policy', 'no-referrer');
|
|
47
|
+
const send = (status, obj, type = 'application/json') => {
|
|
48
|
+
const body = type === 'application/json' ? JSON.stringify(obj) : String(obj);
|
|
49
|
+
res.writeHead(status, { 'content-type': type }); res.end(body);
|
|
50
|
+
};
|
|
51
|
+
if (!rateOk(req) && url.pathname !== '/health' && url.pathname !== '/probe') {
|
|
52
|
+
return send(429, { error: 'rate limit: 300 requests/min per IP per workspace' });
|
|
53
|
+
}
|
|
54
|
+
const readBody = () => new Promise((resolve) => {
|
|
55
|
+
let data = ''; req.on('data', c => { data += c; if (data.length > 5_000_000) req.destroy(); });
|
|
56
|
+
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); } });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(BOARD_HTML); }
|
|
61
|
+
if (req.method === 'GET' && url.pathname === '/health') return send(200, { ok: true, server: 'ghostbus', workspace: id, version: GHOSTBUS_VERSION });
|
|
62
|
+
if (req.method === 'GET' && url.pathname === '/probe') {
|
|
63
|
+
const s = await bus.status();
|
|
64
|
+
return send(200, { workspace: id, queued: s.tasks.queued, needsApproval: s.tasks.needsApproval, agents: s.agents, version: GHOSTBUS_VERSION });
|
|
65
|
+
}
|
|
66
|
+
if (!keyOk(req)) return send(401, { error: 'missing or wrong bus key' });
|
|
67
|
+
|
|
68
|
+
if (req.method === 'GET' && url.pathname === '/api/stream') {
|
|
69
|
+
const agent = url.searchParams.get('agent');
|
|
70
|
+
if (!agent) return send(400, { error: 'agent query param required' });
|
|
71
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
72
|
+
res.write(`event: ready\ndata: ${JSON.stringify({ agent, workspace: id, version: GHOSTBUS_VERSION })}\n\n`);
|
|
73
|
+
const off = bus.onEvent((ev) => {
|
|
74
|
+
const forMe = ev.type === 'message.send' ? (ev.to === agent || ev.to === '*' || ev.actor === agent) : true;
|
|
75
|
+
if (!forMe) return;
|
|
76
|
+
res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
77
|
+
});
|
|
78
|
+
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
|
|
79
|
+
req.on('close', () => { clearInterval(ping); off(); });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (req.method === 'GET' && url.pathname === '/api/wait') {
|
|
84
|
+
const agent = url.searchParams.get('agent');
|
|
85
|
+
if (!agent) return send(400, { error: 'agent query param required' });
|
|
86
|
+
const timeout = Math.min(Number(url.searchParams.get('timeout') || 25), 55) * 1000;
|
|
87
|
+
const sinceSeq = url.searchParams.has('sinceSeq') ? Number(url.searchParams.get('sinceSeq')) : (await bus.sync({})).headSeq;
|
|
88
|
+
const immediate = await bus.sync({ sinceSeq, limit: 100 });
|
|
89
|
+
const relevantNow = immediate.events.filter(ev => ev.type !== 'message.send' || ev.to === agent || ev.to === '*' || ev.actor === agent);
|
|
90
|
+
if (relevantNow.length) return send(200, { events: relevantNow, headSeq: immediate.headSeq, waited: false });
|
|
91
|
+
return await new Promise((resolve) => {
|
|
92
|
+
let done = false;
|
|
93
|
+
const finish = (events) => { if (done) return; done = true; off(); clearTimeout(timer); resolve(send(200, { events, headSeq: events.length ? events[events.length - 1].seq : sinceSeq, waited: true })); };
|
|
94
|
+
const off = bus.onEvent((ev) => {
|
|
95
|
+
if (ev.seq <= sinceSeq) return;
|
|
96
|
+
if (ev.type === 'message.send' && !(ev.to === agent || ev.to === '*' || ev.actor === agent)) return;
|
|
97
|
+
finish([ev]);
|
|
98
|
+
});
|
|
99
|
+
const timer = setTimeout(() => finish([]), timeout);
|
|
100
|
+
req.on('close', () => { if (!done) { done = true; off(); clearTimeout(timer); resolve(); } });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (req.method === 'POST' && url.pathname === '/mcp') {
|
|
105
|
+
const msg = await readBody();
|
|
106
|
+
if (!msg) return send(400, { error: 'invalid JSON body' });
|
|
107
|
+
if (msg.method === 'tools/list') return send(200, { jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
|
|
108
|
+
if (msg.method === 'tools/call') {
|
|
109
|
+
try {
|
|
110
|
+
const argsIn = { ...(msg.params.arguments || {}) };
|
|
111
|
+
const hdrToken = req.headers['x-agent-token'];
|
|
112
|
+
if (hdrToken && !argsIn.token) argsIn.token = hdrToken;
|
|
113
|
+
const out = await callTool(bus, msg.params.name, argsIn);
|
|
114
|
+
return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] } });
|
|
115
|
+
} catch (e) { return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true } }); }
|
|
116
|
+
}
|
|
117
|
+
return send(400, { error: `unsupported MCP method over HTTP relay: ${msg.method} (use tools/list or tools/call)` });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const body = ['POST', 'PUT', 'DELETE'].includes(req.method) ? await readBody().catch(() => ({})) : {};
|
|
121
|
+
const safeBody = body && typeof body === 'object' ? body : {};
|
|
122
|
+
const q = Object.fromEntries(url.searchParams);
|
|
123
|
+
|
|
124
|
+
if (req.method === 'GET' && url.pathname === '/api/status') return send(200, await bus.status());
|
|
125
|
+
if (req.method === 'GET' && url.pathname === '/api/agents') return send(200, { agents: await bus.listAgents() });
|
|
126
|
+
if (req.method === 'POST' && url.pathname === '/api/agents') return send(200, await bus.registerAgent({ name: safeBody.agent, role: safeBody.role, capabilities: safeBody.capabilities, token: agentTokenOf(req, safeBody) }));
|
|
127
|
+
if (req.method === 'POST' && url.pathname === '/api/messages') return send(200, await bus.sendMessage({ from: safeBody.agent, to: safeBody.to ?? '*', body: safeBody.body, channel: safeBody.channel ?? 'general', threadId: safeBody.threadId ?? null, token: agentTokenOf(req, safeBody) }));
|
|
128
|
+
if (req.method === 'GET' && url.pathname === '/api/inbox') return send(200, { messages: await bus.inbox(q.agent, { unreadOnly: q.unread === '1', limit: Number(q.limit || 50), token: agentTokenOf(req, {}, q) }) });
|
|
129
|
+
if (req.method === 'POST' && url.pathname === '/api/heartbeat') return send(200, await bus.heartbeat({ name: safeBody.agent, token: agentTokenOf(req, safeBody) }));
|
|
130
|
+
if (req.method === 'GET' && url.pathname === '/api/search') return send(200, await bus.search(q.q || '', { limit: Number(q.limit || 20) }));
|
|
131
|
+
if (req.method === 'GET' && url.pathname === '/api/channels') return send(200, { channels: await bus.listChannels() });
|
|
132
|
+
if (req.method === 'POST' && url.pathname === '/api/tasks') return send(200, await bus.createTask({ from: safeBody.agent, title: safeBody.title, body: safeBody.body ?? '', assignee: safeBody.assignee ?? null, priority: safeBody.priority ?? 'normal', needsApproval: !!safeBody.needsApproval, blockedBy: safeBody.blockedBy ?? [], token: agentTokenOf(req, safeBody) }));
|
|
133
|
+
if (req.method === 'GET' && url.pathname === '/api/tasks') return send(200, { tasks: await bus.listTasks({ status: q.status || null, assignee: q.assignee || null }) });
|
|
134
|
+
const taskAction = url.pathname.match(/^\/api\/tasks\/(\d+)\/(approve|claim|complete|cancel)$/);
|
|
135
|
+
if (req.method === 'POST' && taskAction) {
|
|
136
|
+
const [, tid, action] = taskAction;
|
|
137
|
+
const tk = agentTokenOf(req, safeBody);
|
|
138
|
+
if (action === 'approve') return send(200, await bus.approveTask(tid, { by: safeBody.agent, token: tk }));
|
|
139
|
+
if (action === 'claim') return send(200, await bus.claimTask(tid, { by: safeBody.agent, token: tk }));
|
|
140
|
+
if (action === 'complete') return send(200, await bus.completeTask(tid, { by: safeBody.agent, result: safeBody.result ?? '', token: tk }));
|
|
141
|
+
if (action === 'cancel') return send(200, await bus.cancelTask(tid, { by: safeBody.agent, token: tk }));
|
|
142
|
+
}
|
|
143
|
+
const taskComment = url.pathname.match(/^\/api\/tasks\/(\d+)\/comment$/);
|
|
144
|
+
if (req.method === 'POST' && taskComment) return send(200, await bus.addTaskComment(taskComment[1], { by: safeBody.agent, body: safeBody.body, token: agentTokenOf(req, safeBody) }));
|
|
145
|
+
if (req.method === 'PUT' && url.pathname === '/api/files') return send(200, await bus.putFile({ by: safeBody.agent, path: safeBody.path, text: safeBody.text, token: agentTokenOf(req, safeBody) }));
|
|
146
|
+
if (req.method === 'DELETE' && url.pathname === '/api/files') return send(200, await bus.deleteFile({ by: q.agent || safeBody.agent, path: q.path || safeBody.path, token: agentTokenOf(req, safeBody, q) }));
|
|
147
|
+
if (req.method === 'GET' && url.pathname === '/api/files') return send(200, await bus.getFile(q.path));
|
|
148
|
+
if (req.method === 'GET' && url.pathname === '/api/files/list') return send(200, { files: await bus.listFiles() });
|
|
149
|
+
if (req.method === 'GET' && url.pathname === '/api/files/history') return send(200, await bus.fileHistory(q.path));
|
|
150
|
+
if (req.method === 'PUT' && url.pathname === '/api/context') return send(200, await bus.putContext({ by: safeBody.agent, context: safeBody.context, token: agentTokenOf(req, safeBody) }));
|
|
151
|
+
if (req.method === 'GET' && url.pathname === '/api/context') return send(200, await bus.getContext());
|
|
152
|
+
if (req.method === 'GET' && url.pathname === '/api/board') return send(200, { board: await bus.board() });
|
|
153
|
+
if (req.method === 'GET' && url.pathname === '/api/events') return send(200, { events: await bus.events({ limit: Number(q.limit || 50) }) });
|
|
154
|
+
if (req.method === 'GET' && url.pathname === '/api/sync') return send(200, await bus.sync({ sinceSeq: Number(q.sinceSeq || 0), limit: Number(q.limit || 500) }));
|
|
155
|
+
if (req.method === 'GET' && url.pathname === '/api/capsules') return send(200, { capsules: await bus.listCapsules() });
|
|
156
|
+
const capsuleGet = url.pathname.match(/^\/api\/capsules\/([a-z0-9-]+)$/);
|
|
157
|
+
if (req.method === 'GET' && capsuleGet) return send(200, await bus.getCapsule(capsuleGet[1], { create: q.create === '1', by: q.agent || null }));
|
|
158
|
+
const capsulePut = url.pathname.match(/^\/api\/capsules\/([a-z0-9-]+)\/section$/);
|
|
159
|
+
if (req.method === 'POST' && capsulePut) return send(200, await bus.updateCapsule(capsulePut[1], { by: safeBody.agent, section: safeBody.section, text: safeBody.text, token: agentTokenOf(req, safeBody) }));
|
|
160
|
+
|
|
161
|
+
return send(404, { error: 'not found' });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return send(400, { error: e.message, code: e.code || undefined });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GhostBus HOSTED server — many isolated workspaces, one process.
|
|
4
|
+
*
|
|
5
|
+
* GHOSTBUS_ADMIN_KEY=<long secret> node src/hosted-server.mjs [--port 8388] [--data-dir ./ghostbus-hosted]
|
|
6
|
+
*
|
|
7
|
+
* - Each workspace lives under /w/<id>/… (board at /w/<id>/, REST at
|
|
8
|
+
* /w/<id>/api/*, MCP at /w/<id>/mcp, probe at /w/<id>/probe).
|
|
9
|
+
* - Workspaces are fully isolated: separate stores, agents, tasks, files.
|
|
10
|
+
* - Every workspace gets its OWN key at creation (returned once; only its
|
|
11
|
+
* SHA-256 is kept in the registry). The admin key manages workspaces and
|
|
12
|
+
* also works as a master key inside any workspace.
|
|
13
|
+
* - Admin API (x-bus-key: <admin key>):
|
|
14
|
+
* POST /api/workspaces {id?, name} -> {id, name, key (once)}
|
|
15
|
+
* GET /api/workspaces -> [{id, name, createdAt, agents, queued}]
|
|
16
|
+
* DELETE /api/workspaces/<id> -> removes registry entry + data file
|
|
17
|
+
* POST /api/workspaces/<id>/rotate-key -> {id, key (once)}; old key dies at once
|
|
18
|
+
* GET /api/workspaces/<id>/export -> full-state export envelope (backup/migrate)
|
|
19
|
+
* POST /api/workspaces/import {id?, name?, state|export} -> {id, name, key (once)}
|
|
20
|
+
* - Registry + one data file per workspace live in the data dir.
|
|
21
|
+
* - Per-agent tokens are ENFORCED in all workspaces by default (v0.5.2):
|
|
22
|
+
* hosted mode serves strangers, so a stolen agent name must not be enough
|
|
23
|
+
* to act. GHOSTBUS_REQUIRE_TOKENS=0 is the only opt-out and starts with a
|
|
24
|
+
* loud warning. GHOSTBUS_ADMIN_KEY is required and must be at least 16
|
|
25
|
+
* characters — it is the operator root key (see docs/hosted.md).
|
|
26
|
+
*/
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
|
|
32
|
+
import { createBusHandler, keyMatches } from './handler.mjs';
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
36
|
+
const port = Number(argVal('--port') || process.env.GHOSTBUS_PORT || 8388);
|
|
37
|
+
const dataDir = argVal('--data-dir') || process.env.GHOSTBUS_DATA_DIR || './ghostbus-hosted';
|
|
38
|
+
const ADMIN_KEY = process.env.GHOSTBUS_ADMIN_KEY || '';
|
|
39
|
+
// v0.5.2: enforcement is the hosted DEFAULT. Only an explicit
|
|
40
|
+
// GHOSTBUS_REQUIRE_TOKENS=0 turns it off (anything else, including unset,
|
|
41
|
+
// means on) — the hosted server is the one deployment that serves people
|
|
42
|
+
// other than the operator, so the safe posture cannot be opt-in.
|
|
43
|
+
const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS !== '0';
|
|
44
|
+
|
|
45
|
+
if (!ADMIN_KEY) {
|
|
46
|
+
process.stderr.write('GHOSTBUS_ADMIN_KEY is required for the hosted server (workspace creation must not be open).\n');
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
if (ADMIN_KEY.length < 16) {
|
|
50
|
+
process.stderr.write('GHOSTBUS_ADMIN_KEY is too short: use at least 16 characters (32+ random hex recommended). The admin key is the operator root key — it creates/deletes workspaces and acts as a master key inside every one of them.\n');
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
if (!REQUIRE_TOKENS) {
|
|
54
|
+
process.stderr.write('WARNING: GHOSTBUS_REQUIRE_TOKENS=0 — per-agent token enforcement is DISABLED in every workspace.\n');
|
|
55
|
+
process.stderr.write('WARNING: anyone holding a workspace key can act under ANY agent name in that workspace (impersonation).\n');
|
|
56
|
+
process.stderr.write('WARNING: hosted mode serves strangers; run with enforcement on unless you have a specific reason not to.\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
60
|
+
const registryPath = path.join(dataDir, 'registry.json');
|
|
61
|
+
const loadRegistry = () => { try { return JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return { workspaces: {} }; } };
|
|
62
|
+
const saveRegistry = (reg) => {
|
|
63
|
+
// Unique temp per save: two processes sharing this registry must never
|
|
64
|
+
// collide on one fixed '.tmp' name (first rename wins, second throws ENOENT).
|
|
65
|
+
const tmp = registryPath + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(reg, null, 2));
|
|
68
|
+
fs.renameSync(tmp, registryPath);
|
|
69
|
+
} finally {
|
|
70
|
+
try { fs.unlinkSync(tmp); } catch { /* already renamed away */ }
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
74
|
+
const validId = (id) => /^[a-z0-9][a-z0-9-]{1,62}$/.test(String(id || ''));
|
|
75
|
+
|
|
76
|
+
const buses = new Map(); // id -> {bus, handle}
|
|
77
|
+
async function getWorkspace(id) {
|
|
78
|
+
if (buses.has(id)) return buses.get(id);
|
|
79
|
+
const reg = loadRegistry();
|
|
80
|
+
const meta = reg.workspaces[id];
|
|
81
|
+
if (!meta) return null;
|
|
82
|
+
const bus = await createBus(new FileStore(path.join(dataDir, `${id}.json`)), { id, name: meta.name }, { requireTokens: REQUIRE_TOKENS });
|
|
83
|
+
const entry = { bus, meta };
|
|
84
|
+
buses.set(id, entry);
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Per-workspace handler cache with hash-based key check: we build the core
|
|
89
|
+
// handler with key '' (open) and gate in the router using the stored hash.
|
|
90
|
+
const handlers = new Map();
|
|
91
|
+
function handlerFor(id, bus) {
|
|
92
|
+
if (!handlers.has(id)) handlers.set(id, createBusHandler({ id, bus, key: '' }));
|
|
93
|
+
return handlers.get(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isAdmin = (req) => keyMatches(req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, ''), ADMIN_KEY);
|
|
97
|
+
const wsKeyOk = (req, meta) => {
|
|
98
|
+
const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
99
|
+
if (!given) return false;
|
|
100
|
+
if (keyMatches(given, ADMIN_KEY)) return true; // master key
|
|
101
|
+
return sha256(given) === meta.keyHash;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const LANDING = `<!doctype html><html><head><meta charset="utf-8"><title>GhostBus Hosted</title>
|
|
105
|
+
<style>body{font-family:system-ui;background:#0e0c14;color:#ece8f5;margin:0;padding:40px}h1{font-size:22px}code{background:#17131f;padding:2px 7px;border-radius:6px;color:#c9b8ff}p{color:#9b8fb5;max-width:640px;line-height:1.55}</style></head>
|
|
106
|
+
<body><h1>👻 GhostBus Hosted</h1>
|
|
107
|
+
<p>Multi-workspace agent message bus. Each workspace lives at <code>/w/<id>/</code> with its own board, REST API, MCP endpoint, and key. Workspace data is never shown here — open your workspace board with its key.</p>
|
|
108
|
+
<p>Operators: see <code>POST /api/workspaces</code> (admin key) and the repo's <code>docs/hosted.md</code>.</p>
|
|
109
|
+
<p style="font-size:13px">GhostBus v${GHOSTBUS_VERSION} · Ghost Developer Studio</p></body></html>`;
|
|
110
|
+
|
|
111
|
+
const server = http.createServer(async (req, res) => {
|
|
112
|
+
const url = new URL(req.url, 'http://localhost');
|
|
113
|
+
const send = (status, obj, type = 'application/json') => {
|
|
114
|
+
const b = type === 'application/json' ? JSON.stringify(obj) : String(obj);
|
|
115
|
+
res.writeHead(status, { 'content-type': type }); res.end(b);
|
|
116
|
+
};
|
|
117
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
118
|
+
res.setHeader('referrer-policy', 'no-referrer');
|
|
119
|
+
const readBody = () => new Promise((resolve) => {
|
|
120
|
+
let data = ''; req.on('data', c => { data += c; if (data.length > 5_000_000) req.destroy(); });
|
|
121
|
+
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); } });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(LANDING); }
|
|
126
|
+
if (req.method === 'GET' && url.pathname === '/health') return send(200, { ok: true, server: 'ghostbus-hosted', version: GHOSTBUS_VERSION, workspaces: Object.keys(loadRegistry().workspaces).length });
|
|
127
|
+
|
|
128
|
+
// ---- admin ----
|
|
129
|
+
if (url.pathname === '/api/workspaces' && req.method === 'POST') {
|
|
130
|
+
if (!isAdmin(req)) return send(401, { error: 'admin key required' });
|
|
131
|
+
const body = await readBody();
|
|
132
|
+
if (!body) return send(400, { error: 'invalid JSON body' });
|
|
133
|
+
const reg = loadRegistry();
|
|
134
|
+
const id = body.id || `ws-${crypto.randomBytes(4).toString('hex')}`;
|
|
135
|
+
if (!validId(id)) return send(400, { error: 'workspace id: lowercase letters, digits, - (2-63 chars)' });
|
|
136
|
+
if (reg.workspaces[id]) return send(409, { error: `workspace already exists: ${id}` });
|
|
137
|
+
const key = crypto.randomBytes(24).toString('hex');
|
|
138
|
+
reg.workspaces[id] = { name: String(body.name || id).slice(0, 120), keyHash: sha256(key), createdAt: new Date().toISOString() };
|
|
139
|
+
saveRegistry(reg);
|
|
140
|
+
return send(201, { id, name: reg.workspaces[id].name, key, keyNote: 'Workspace key — shown ONCE, only its hash is stored. Agents use it as x-bus-key (or Bearer) under /w/' + id + '/.', board: `/w/${id}/` });
|
|
141
|
+
}
|
|
142
|
+
if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
143
|
+
if (!isAdmin(req)) return send(401, { error: 'admin key required' });
|
|
144
|
+
const reg = loadRegistry();
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const [id, meta] of Object.entries(reg.workspaces)) {
|
|
147
|
+
const w = await getWorkspace(id);
|
|
148
|
+
const s = await w.bus.status();
|
|
149
|
+
out.push({ id, name: meta.name, createdAt: meta.createdAt, agents: s.agents, queued: s.tasks.queued, needsApproval: s.tasks.needsApproval, messages: s.messages });
|
|
150
|
+
}
|
|
151
|
+
return send(200, { workspaces: out });
|
|
152
|
+
}
|
|
153
|
+
const rotateMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/rotate-key$/);
|
|
154
|
+
if (req.method === 'POST' && rotateMatch) {
|
|
155
|
+
if (!isAdmin(req)) return send(401, { error: 'admin key required' });
|
|
156
|
+
const reg = loadRegistry();
|
|
157
|
+
const id = rotateMatch[1];
|
|
158
|
+
if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
|
|
159
|
+
const key = crypto.randomBytes(24).toString('hex');
|
|
160
|
+
reg.workspaces[id].keyHash = sha256(key);
|
|
161
|
+
reg.workspaces[id].keyRotatedAt = new Date().toISOString();
|
|
162
|
+
saveRegistry(reg);
|
|
163
|
+
// Refresh the cached meta so the old key dies immediately, not on restart.
|
|
164
|
+
const cached = buses.get(id);
|
|
165
|
+
if (cached) cached.meta = reg.workspaces[id];
|
|
166
|
+
return send(200, { id, key, keyNote: 'New workspace key — shown ONCE. The previous key is invalid from this moment.', keyRotatedAt: reg.workspaces[id].keyRotatedAt });
|
|
167
|
+
}
|
|
168
|
+
const exportMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/export$/);
|
|
169
|
+
if (req.method === 'GET' && exportMatch) {
|
|
170
|
+
if (!isAdmin(req)) return send(401, { error: 'admin key required' });
|
|
171
|
+
const id = exportMatch[1];
|
|
172
|
+
const w = await getWorkspace(id);
|
|
173
|
+
if (!w) return send(404, { error: `no such workspace: ${id}` });
|
|
174
|
+
const state = await w.bus.snapshot();
|
|
175
|
+
return send(200, { ghostbusExport: 1, version: GHOSTBUS_VERSION, exportedAt: new Date().toISOString(), workspace: { id, name: w.meta.name }, state });
|
|
176
|
+
}
|
|
177
|
+
if (url.pathname === '/api/workspaces/import' && req.method === 'POST') {
|
|
178
|
+
if (!isAdmin(req)) return send(401, { error: 'admin key required' });
|
|
179
|
+
const body = await readBody();
|
|
180
|
+
if (!body) return send(400, { error: 'invalid JSON body' });
|
|
181
|
+
// Accept either a full export envelope or a bare {state} / raw state.
|
|
182
|
+
const env = body.ghostbusExport ? body : null;
|
|
183
|
+
const state = env ? env.state : (body.state || body);
|
|
184
|
+
const bad = validateSnapshot(state);
|
|
185
|
+
if (bad) return send(400, { error: `invalid snapshot: ${bad}` });
|
|
186
|
+
const reg = loadRegistry();
|
|
187
|
+
const id = body.id || (env && env.workspace && env.workspace.id) || state.workspace.id || `ws-${crypto.randomBytes(4).toString('hex')}`;
|
|
188
|
+
if (!validId(id)) return send(400, { error: 'workspace id: lowercase letters, digits, - (2-63 chars)' });
|
|
189
|
+
if (reg.workspaces[id]) return send(409, { error: `workspace already exists: ${id} (delete it first, or import under a new id)` });
|
|
190
|
+
const name = String(body.name || (env && env.workspace && env.workspace.name) || state.workspace.name || id).slice(0, 120);
|
|
191
|
+
const key = crypto.randomBytes(24).toString('hex');
|
|
192
|
+
reg.workspaces[id] = { name, keyHash: sha256(key), createdAt: new Date().toISOString(), importedAt: new Date().toISOString() };
|
|
193
|
+
saveRegistry(reg);
|
|
194
|
+
// Write the state file first, then load it through the normal path so
|
|
195
|
+
// the bus sees exactly what a restart would see.
|
|
196
|
+
const file = path.join(dataDir, `${id}.json`);
|
|
197
|
+
const tmp = file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
198
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
199
|
+
fs.renameSync(tmp, file);
|
|
200
|
+
const w = await getWorkspace(id);
|
|
201
|
+
const status = await w.bus.status();
|
|
202
|
+
return send(201, { id, name, key, keyNote: 'Workspace key — shown ONCE, only its hash is stored. Imported workspaces always get a FRESH key; the source key is not carried over.', agents: status.agents, messages: status.messages, tasks: status.tasks.total, board: `/w/${id}/` });
|
|
203
|
+
}
|
|
204
|
+
const delMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)$/);
|
|
205
|
+
if (req.method === 'DELETE' && delMatch) {
|
|
206
|
+
if (!isAdmin(req)) return send(401, { error: 'admin key required' });
|
|
207
|
+
const reg = loadRegistry();
|
|
208
|
+
const id = delMatch[1];
|
|
209
|
+
if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
|
|
210
|
+
delete reg.workspaces[id];
|
|
211
|
+
saveRegistry(reg);
|
|
212
|
+
buses.delete(id); handlers.delete(id);
|
|
213
|
+
try { fs.unlinkSync(path.join(dataDir, `${id}.json`)); } catch {}
|
|
214
|
+
return send(200, { id, deleted: true });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- workspace traffic ----
|
|
218
|
+
const wMatch = url.pathname.match(/^\/w\/([a-z0-9-]+)(\/.*)?$/);
|
|
219
|
+
if (wMatch) {
|
|
220
|
+
const id = wMatch[1];
|
|
221
|
+
const w = await getWorkspace(id);
|
|
222
|
+
if (!w) return send(404, { error: `no such workspace: ${id}` });
|
|
223
|
+
const sub = new URL(url.toString());
|
|
224
|
+
sub.pathname = wMatch[2] || '/';
|
|
225
|
+
// Open surfaces mirror the single relay: board page, health, probe.
|
|
226
|
+
const open = sub.pathname === '/' || sub.pathname === '/health' || sub.pathname === '/probe';
|
|
227
|
+
if (!open && !wsKeyOk(req, w.meta)) return send(401, { error: 'missing or wrong workspace key' });
|
|
228
|
+
return handlerFor(id, w.bus)(req, res, sub);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return send(404, { error: 'not found' });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return send(500, { error: e.message });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
server.listen(port, () => {
|
|
238
|
+
process.stderr.write(`ghostbus HOSTED v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · data=${dataDir} · workspaces=${Object.keys(loadRegistry().workspaces).length} · agentTokens=${REQUIRE_TOKENS ? 'enforced' : 'issued, not enforced'}\n`);
|
|
239
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GhostBus HTTP relay (single workspace) — zero dependencies.
|
|
4
|
+
* Same core + same MCP tools over HTTP: agents that can't share a local file
|
|
5
|
+
* connect to one relay instead. For many isolated workspaces on one process,
|
|
6
|
+
* use hosted-server.mjs.
|
|
7
|
+
*
|
|
8
|
+
* node src/http-server.mjs [--port 8377] [--store ./ghostbus-data.json]
|
|
9
|
+
* Env: GHOSTBUS_PORT, GHOSTBUS_STORE, GHOSTBUS_WORKSPACE_NAME,
|
|
10
|
+
* GHOSTBUS_KEY — if set, every /api and /mcp call needs header
|
|
11
|
+
* `x-bus-key: <key>` or `Authorization: Bearer <key>`. /health and
|
|
12
|
+
* /probe (counts only) stay open.
|
|
13
|
+
* GHOSTBUS_REQUIRE_TOKENS=1 enforces per-agent tokens.
|
|
14
|
+
*/
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import { createBus, MemoryStore, FileStore, GHOSTBUS_VERSION } from './core.mjs';
|
|
17
|
+
import { createBusHandler } from './handler.mjs';
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
21
|
+
const port = Number(argVal('--port') || process.env.GHOSTBUS_PORT || 8377);
|
|
22
|
+
const storePath = argVal('--store') || process.env.GHOSTBUS_STORE || './ghostbus-data.json';
|
|
23
|
+
const wsName = argVal('--name') || process.env.GHOSTBUS_WORKSPACE_NAME || 'GhostBus Workspace';
|
|
24
|
+
const KEY = process.env.GHOSTBUS_KEY || '';
|
|
25
|
+
|
|
26
|
+
const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS === '1';
|
|
27
|
+
const store = storePath === ':memory:' ? new MemoryStore() : new FileStore(storePath);
|
|
28
|
+
const bus = await createBus(store, { name: wsName }, { requireTokens: REQUIRE_TOKENS });
|
|
29
|
+
const handle = createBusHandler({ id: 'default', bus, key: KEY });
|
|
30
|
+
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
const url = new URL(req.url, 'http://localhost');
|
|
33
|
+
handle(req, res, url).catch((e) => { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); } catch {} });
|
|
34
|
+
});
|
|
35
|
+
server.listen(port, () => {
|
|
36
|
+
process.stderr.write(`ghostbus http relay v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · store=${storePath} · auth=${KEY ? 'workspace key required' : 'open (set GHOSTBUS_KEY)'}\n`);
|
|
37
|
+
// Node's listen() with no host binds ALL network interfaces, not just
|
|
38
|
+
// loopback — the 127.0.0.1 above is the local URL, not a bind restriction.
|
|
39
|
+
// Local trust is the relay's default model and stays unchanged, but an
|
|
40
|
+
// unkeyed or tokenless relay must never be network-reachable *silently*.
|
|
41
|
+
if (!KEY) {
|
|
42
|
+
process.stderr.write('WARNING: GHOSTBUS_KEY is not set — this relay is OPEN: anyone who can reach this port can read and write the workspace.\n');
|
|
43
|
+
process.stderr.write('WARNING: the relay listens on all network interfaces, not just localhost. Set GHOSTBUS_KEY (a long secret) before exposing it beyond this machine, or firewall the port.\n');
|
|
44
|
+
} else if (!REQUIRE_TOKENS) {
|
|
45
|
+
process.stderr.write('WARNING: per-agent tokens are not enforced (GHOSTBUS_REQUIRE_TOKENS is not 1): anyone holding the workspace key can act under any agent name. The relay listens on all network interfaces — set GHOSTBUS_REQUIRE_TOKENS=1 if untrusted agents share the key.\n');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GhostBus MCP server (stdio) — zero dependencies.
|
|
4
|
+
* Any MCP client (Claude, Cursor, Antigravity, custom agents) connects here and
|
|
5
|
+
* every connected client shares the SAME workspace store, so agents actually
|
|
6
|
+
* meet each other instead of each holding a private copy.
|
|
7
|
+
*
|
|
8
|
+
* node src/mcp-server.mjs [--store ./ghostbus-data.json] [--name "My Workspace"]
|
|
9
|
+
*
|
|
10
|
+
* Env: GHOSTBUS_STORE (file path; default ./ghostbus-data.json),
|
|
11
|
+
* GHOSTBUS_WORKSPACE_NAME.
|
|
12
|
+
* Use ":memory:" as the store for an ephemeral workspace.
|
|
13
|
+
*/
|
|
14
|
+
import readline from 'node:readline';
|
|
15
|
+
import { createBus, MemoryStore, FileStore, GHOSTBUS_VERSION } from './core.mjs';
|
|
16
|
+
import { TOOLS, callTool } from './tools.mjs';
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
20
|
+
const storePath = argVal('--store') || process.env.GHOSTBUS_STORE || './ghostbus-data.json';
|
|
21
|
+
const wsName = argVal('--name') || process.env.GHOSTBUS_WORKSPACE_NAME || 'GhostBus Workspace';
|
|
22
|
+
|
|
23
|
+
const store = storePath === ':memory:' ? new MemoryStore() : new FileStore(storePath);
|
|
24
|
+
const bus = await createBus(store, { name: wsName }, { requireTokens: process.env.GHOSTBUS_REQUIRE_TOKENS === '1' });
|
|
25
|
+
|
|
26
|
+
const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
27
|
+
const result = (id, value) => send({ jsonrpc: '2.0', id, result: value });
|
|
28
|
+
const error = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
29
|
+
|
|
30
|
+
async function handle(msg) {
|
|
31
|
+
const { id, method, params } = msg;
|
|
32
|
+
if (method === 'initialize') {
|
|
33
|
+
return result(id, {
|
|
34
|
+
protocolVersion: '2024-11-05',
|
|
35
|
+
capabilities: { tools: {} },
|
|
36
|
+
serverInfo: { name: 'ghostbus', version: GHOSTBUS_VERSION },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return; // no id reply
|
|
40
|
+
if (method === 'ping') return result(id, {});
|
|
41
|
+
if (method === 'tools/list') return result(id, { tools: TOOLS });
|
|
42
|
+
if (method === 'tools/call') {
|
|
43
|
+
try {
|
|
44
|
+
const out = await callTool(bus, params.name, params.arguments || {});
|
|
45
|
+
const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
|
|
46
|
+
return result(id, { content: [{ type: 'text', text }] });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return result(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (id !== undefined) return error(id, -32601, `method not found: ${method}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
55
|
+
rl.on('line', async (line) => {
|
|
56
|
+
if (!line.trim()) return;
|
|
57
|
+
let msg;
|
|
58
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
59
|
+
await handle(msg);
|
|
60
|
+
});
|
|
61
|
+
process.stderr.write(`ghostbus mcp-server v${GHOSTBUS_VERSION} · store=${storePath} · workspace="${wsName}"\n`);
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GhostBus MCP tool definitions — shared by the stdio server and the HTTP /mcp endpoint.
|
|
3
|
+
* Every tool takes `agent` (the caller's registered name) where it acts on someone's behalf,
|
|
4
|
+
* so the provenance log always names a real participant.
|
|
5
|
+
*/
|
|
6
|
+
export const TOOLS = [
|
|
7
|
+
{ name: 'bus_status', description: 'Workspace status: agents, message/task/file counts, version.', inputSchema: { type: 'object', properties: {} } },
|
|
8
|
+
{ name: 'bus_register', description: 'Register (or rejoin) this agent on the bus with a role and capabilities. First registration returns a per-agent token ONCE — store it; on a token-enforced bus it must be passed as `token` on later calls.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, role: { type: 'string' }, capabilities: { type: 'array', items: { type: 'string' } }, token: { type: 'string' } } } },
|
|
9
|
+
{ name: 'bus_rotate_token', description: 'Rotate this agent\'s token (requires the current token on an enforced bus). The old token stops working; the new one is shown once.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, token: { type: 'string' } } } },
|
|
10
|
+
{ name: 'bus_heartbeat', description: 'Heartbeat: mark this agent present and get the live agent list with online status.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, token: { type: 'string' } } } },
|
|
11
|
+
{ name: 'bus_search', description: 'Search messages, tasks, and shared files by text.', inputSchema: { type: 'object', required: ['query'], properties: { query: { type: 'string' }, limit: { type: 'number', default: 20 } } } },
|
|
12
|
+
{ name: 'bus_channels', description: 'List message channels with message counts.', inputSchema: { type: 'object', properties: {} } },
|
|
13
|
+
{ name: 'bus_agents', description: 'List registered agents with roles, capabilities, and last-seen times.', inputSchema: { type: 'object', properties: {} } },
|
|
14
|
+
{ name: 'bus_send', description: 'Send a message to one agent (to = its name) or broadcast (to = "*"). Set threadId to reply in a thread.', inputSchema: { type: 'object', required: ['agent', 'body'], properties: { agent: { type: 'string' }, to: { type: 'string', default: '*' }, body: { type: 'string' }, channel: { type: 'string', default: 'general' }, threadId: { type: 'number' } } } },
|
|
15
|
+
{ name: 'bus_inbox', description: 'Read messages visible to this agent (direct, broadcast, and its own). Marks them read unless markRead=false.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, unreadOnly: { type: 'boolean', default: false }, limit: { type: 'number', default: 50 }, markRead: { type: 'boolean', default: true } } } },
|
|
16
|
+
{ name: 'bus_thread', description: 'Read a message thread (root message + its replies).', inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'number' } } } },
|
|
17
|
+
{ name: 'bus_create_task', description: 'Create a handoff task. Optionally assign it, mark urgent, or gate it behind human/agent approval (needsApproval).', inputSchema: { type: 'object', required: ['agent', 'title'], properties: { agent: { type: 'string' }, title: { type: 'string' }, body: { type: 'string' }, assignee: { type: 'string' }, priority: { type: 'string', enum: ['normal', 'urgent'] }, needsApproval: { type: 'boolean', default: false }, blockedBy: { type: 'array', items: { type: 'number' } } } } },
|
|
18
|
+
{ name: 'bus_list_tasks', description: 'List tasks, optionally filtered by status or assignee/claimer.', inputSchema: { type: 'object', properties: { status: { type: 'string' }, assignee: { type: 'string' } } } },
|
|
19
|
+
{ name: 'bus_approve_task', description: 'Approve a task that is waiting in needs-approval, releasing it to the queue.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' } } } },
|
|
20
|
+
{ name: 'bus_claim_task', description: 'Claim a queued task exclusively (15-minute lease). Fails if another agent holds it.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' } } } },
|
|
21
|
+
{ name: 'bus_complete_task', description: 'Complete a task you claimed, with a result summary for the sender.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' }, result: { type: 'string' } } } },
|
|
22
|
+
{ name: 'bus_cancel_task', description: 'Cancel an open task.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' } } } },
|
|
23
|
+
{ name: 'workspace_put_file', description: 'Write a shared workspace file (capsule, note, deliverable). Path may not contain "..".', inputSchema: { type: 'object', required: ['agent', 'path', 'text'], properties: { agent: { type: 'string' }, path: { type: 'string' }, text: { type: 'string' } } } },
|
|
24
|
+
{ name: 'workspace_get_file', description: 'Read a shared workspace file.', inputSchema: { type: 'object', required: ['path'], properties: { path: { type: 'string' } } } },
|
|
25
|
+
{ name: 'workspace_list_files', description: 'List shared workspace files with size and last writer.', inputSchema: { type: 'object', properties: {} } },
|
|
26
|
+
{ name: 'workspace_put_context', description: 'Publish the shared context object (project state every agent reads first).', inputSchema: { type: 'object', required: ['agent', 'context'], properties: { agent: { type: 'string' }, context: { type: 'object' } } } },
|
|
27
|
+
{ name: 'workspace_get_context', description: 'Read the shared context object.', inputSchema: { type: 'object', properties: {} } },
|
|
28
|
+
{ name: 'workspace_board', description: 'Render the shared board: agents, open tasks, files, recent activity.', inputSchema: { type: 'object', properties: {} } },
|
|
29
|
+
{ name: 'bus_comment_task', description: 'Comment on a task (discussion lives on the task, next to its history).', inputSchema: { type: 'object', required: ['agent', 'id', 'body'], properties: { agent: { type: 'string' }, id: { type: 'number' }, body: { type: 'string' }, token: { type: 'string' } } } },
|
|
30
|
+
{ name: 'workspace_delete_file', description: 'Delete a shared workspace file.', inputSchema: { type: 'object', required: ['agent', 'path'], properties: { agent: { type: 'string' }, path: { type: 'string' }, token: { type: 'string' } } } },
|
|
31
|
+
{ name: 'bus_sync', description: 'Catch-up sync: all provenance events after a sequence number (an agent that was offline replays exactly what it missed). Returns headSeq to store as the next cursor.', inputSchema: { type: 'object', properties: { sinceSeq: { type: 'number', default: 0 }, limit: { type: 'number', default: 500 } } } },
|
|
32
|
+
{ name: 'workspace_get_capsule', description: 'Read a project capsule (structured shared memory: State / Decisions (locked) / Next / Session log). Creates it from the template when create=true.', inputSchema: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' }, create: { type: 'boolean', default: false }, title: { type: 'string' }, agent: { type: 'string' }, token: { type: 'string' } } } },
|
|
33
|
+
{ name: 'workspace_update_capsule', description: 'Update one capsule section (State, Decisions (locked), Next) or append a Session log entry. Other sections are preserved.', inputSchema: { type: 'object', required: ['agent', 'slug', 'section', 'text'], properties: { agent: { type: 'string' }, slug: { type: 'string' }, section: { type: 'string', enum: ['State', 'Decisions (locked)', 'Next', 'Session log'] }, text: { type: 'string' }, token: { type: 'string' } } } },
|
|
34
|
+
{ name: 'workspace_file_history', description: 'Version history of a shared file (current version + up to 10 previous).', inputSchema: { type: 'object', required: ['path'], properties: { path: { type: 'string' } } } },
|
|
35
|
+
{ name: 'bus_events', description: 'Read the provenance event log (who did what, when).', inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 50 } } } },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export async function callTool(bus, name, args = {}) {
|
|
39
|
+
switch (name) {
|
|
40
|
+
case 'bus_status': return bus.status();
|
|
41
|
+
case 'bus_register': return bus.registerAgent({ name: args.agent, role: args.role, capabilities: args.capabilities, token: args.token ?? null });
|
|
42
|
+
case 'bus_agents': return { agents: await bus.listAgents() };
|
|
43
|
+
case 'bus_send': return bus.sendMessage({ from: args.agent, to: args.to ?? '*', body: args.body, channel: args.channel ?? 'general', threadId: args.threadId ?? null, token: args.token ?? null });
|
|
44
|
+
case 'bus_inbox': return { messages: await bus.inbox(args.agent, { unreadOnly: !!args.unreadOnly, limit: args.limit ?? 50, markRead: args.markRead !== false, token: args.token ?? null }) };
|
|
45
|
+
case 'bus_thread': return { messages: await bus.thread(args.id) };
|
|
46
|
+
case 'bus_create_task': return bus.createTask({ from: args.agent, title: args.title, body: args.body ?? '', assignee: args.assignee ?? null, priority: args.priority ?? 'normal', needsApproval: !!args.needsApproval, blockedBy: args.blockedBy ?? [], token: args.token ?? null });
|
|
47
|
+
case 'bus_list_tasks': return { tasks: await bus.listTasks({ status: args.status ?? null, assignee: args.assignee ?? null }) };
|
|
48
|
+
case 'bus_approve_task': return bus.approveTask(args.id, { by: args.agent, token: args.token ?? null });
|
|
49
|
+
case 'bus_claim_task': return bus.claimTask(args.id, { by: args.agent, token: args.token ?? null });
|
|
50
|
+
case 'bus_complete_task': return bus.completeTask(args.id, { by: args.agent, result: args.result ?? '', token: args.token ?? null });
|
|
51
|
+
case 'bus_cancel_task': return bus.cancelTask(args.id, { by: args.agent, token: args.token ?? null });
|
|
52
|
+
case 'workspace_put_file': return bus.putFile({ by: args.agent, path: args.path, text: args.text, token: args.token ?? null });
|
|
53
|
+
case 'workspace_get_file': return bus.getFile(args.path);
|
|
54
|
+
case 'workspace_list_files': return { files: await bus.listFiles() };
|
|
55
|
+
case 'workspace_put_context': return bus.putContext({ by: args.agent, context: args.context, token: args.token ?? null });
|
|
56
|
+
case 'workspace_get_context': return bus.getContext();
|
|
57
|
+
case 'workspace_board': return { board: await bus.board() };
|
|
58
|
+
case 'bus_events': return { events: await bus.events({ limit: args.limit ?? 50 }) };
|
|
59
|
+
case 'bus_rotate_token': return bus.rotateAgentToken({ name: args.agent, token: args.token ?? null });
|
|
60
|
+
case 'bus_heartbeat': return bus.heartbeat({ name: args.agent, token: args.token ?? null });
|
|
61
|
+
case 'bus_search': return bus.search(args.query, { limit: args.limit ?? 20 });
|
|
62
|
+
case 'bus_channels': return { channels: await bus.listChannels() };
|
|
63
|
+
case 'bus_comment_task': return bus.addTaskComment(args.id, { by: args.agent, body: args.body, token: args.token ?? null });
|
|
64
|
+
case 'workspace_delete_file': return bus.deleteFile({ by: args.agent, path: args.path, token: args.token ?? null });
|
|
65
|
+
case 'bus_sync': return bus.sync({ sinceSeq: args.sinceSeq ?? 0, limit: args.limit ?? 500 });
|
|
66
|
+
case 'workspace_get_capsule': return bus.getCapsule(args.slug, { create: !!args.create, title: args.title ?? '', by: args.agent ?? null, token: args.token ?? null });
|
|
67
|
+
case 'workspace_update_capsule': return bus.updateCapsule(args.slug, { by: args.agent, section: args.section, text: args.text, token: args.token ?? null });
|
|
68
|
+
case 'workspace_file_history': return bus.fileHistory(args.path);
|
|
69
|
+
default: throw Object.assign(new Error(`unknown tool: ${name}`), { code: 'UNKNOWN_TOOL' });
|
|
70
|
+
}
|
|
71
|
+
}
|