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/src/core.mjs ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * GhostBus core — transport-agnostic agent-to-agent message bus + shared workspace.
3
+ *
4
+ * One workspace holds: registered agents, direct/broadcast messages, claimable
5
+ * tasks (with leases + an approval gate), shared files, a shared context object,
6
+ * and an append-only provenance event log. Every mutation is stamped with the
7
+ * acting agent and a timestamp — nothing enters the workspace anonymously.
8
+ *
9
+ * Storage: any object with async load() -> state|null and async save(state).
10
+ * Ships with MemoryStore and FileStore (atomic JSON file).
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import crypto from 'node:crypto';
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+
17
+ export const GHOSTBUS_VERSION = '0.5.2';
18
+ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
19
+ export const TASK_STATUSES = ['queued', 'claimed', 'needs-approval', 'done', 'cancelled'];
20
+ const CLAIM_LEASE_MS = 15 * 60 * 1000;
21
+
22
+ const now = () => new Date().toISOString();
23
+ const clone = (v) => JSON.parse(JSON.stringify(v));
24
+
25
+ function emptyState(workspace = {}) {
26
+ return {
27
+ workspace: {
28
+ id: workspace.id || 'default',
29
+ name: workspace.name || 'GhostBus Workspace',
30
+ createdAt: now(),
31
+ },
32
+ seq: 0, // event sequence
33
+ msgSeq: 0,
34
+ taskSeq: 0,
35
+ agents: [], // {name, role, capabilities, joinedAt, lastSeenAt}
36
+ messages: [], // {id, from, to, channel, body, threadId, createdAt, readBy[]}
37
+ tasks: [], // {id, title, body, from, assignee, status, priority, ...}
38
+ files: {}, // path -> {text, updatedBy, updatedAt}
39
+ context: null, // arbitrary shared object (must have .projects? no — free-form)
40
+ events: [], // {seq, type, actor, at, summary}
41
+ };
42
+ }
43
+
44
+ export class MemoryStore {
45
+ constructor() { this.state = null; }
46
+ async load() { return this.state ? clone(this.state) : null; }
47
+ async save(state) { this.state = clone(state); }
48
+ }
49
+
50
+ export class FileStore {
51
+ constructor(file) { this.file = file; this.shared = true; this._chain = Promise.resolve(); }
52
+ async load() {
53
+ try { return JSON.parse(await fs.promises.readFile(this.file, 'utf8')); }
54
+ catch { return null; }
55
+ }
56
+ async save(state) {
57
+ // Serialize saves within this process, and give every save its own temp
58
+ // file: two writers (this process or another sharing the same store
59
+ // file) must never collide on one fixed '.tmp' name — the first rename
60
+ // moves it away and the second save then fails ENOENT.
61
+ const run = async () => {
62
+ await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
63
+ const tmp = this.file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
64
+ try {
65
+ await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2));
66
+ await fs.promises.rename(tmp, this.file); // atomic replace
67
+ } finally {
68
+ await fs.promises.unlink(tmp).catch(() => {});
69
+ }
70
+ };
71
+ const p = this._chain.then(run, run);
72
+ this._chain = p.catch(() => {});
73
+ return p;
74
+ }
75
+ }
76
+
77
+ export class GhostBus {
78
+ constructor(store, workspace = {}, opts = {}) {
79
+ this.store = store;
80
+ this.workspaceInfo = workspace;
81
+ this.state = null;
82
+ // When true, every act by an agent that holds a token must present it.
83
+ // Registration issues the token once; only its sha256 is stored.
84
+ this.requireTokens = !!opts.requireTokens;
85
+ this.listeners = new Set(); // live event subscribers (SSE relay)
86
+ // Mutation mutex (v0.5.1): a promise chain serializing every public
87
+ // operation's load -> mutate -> save cycle on THIS instance. Without it,
88
+ // concurrent calls interleave their cycles against the shared store and
89
+ // silently lose each other's updates (one op's reload discards another's
90
+ // not-yet-saved state). The AsyncLocalStorage marker makes the chain
91
+ // reentrant: public methods that call other public methods (updateCapsule
92
+ // -> getCapsule, restore -> status, heartbeat -> listAgents) run inline
93
+ // instead of deadlocking on their own queue.
94
+ this._chain = Promise.resolve();
95
+ this._als = new AsyncLocalStorage();
96
+ }
97
+
98
+ /**
99
+ * Run `fn` inside this bus's serialized section. Callers queue FIFO on the
100
+ * promise chain; a throwing operation still releases the chain (finally),
101
+ * so one failure can never wedge the bus. Scope: per Bus instance, in this
102
+ * process — it cannot span processes sharing one store file (each op there
103
+ * is still a single atomic read-modify-write) nor serverless invocations.
104
+ */
105
+ async _serialized(fn) {
106
+ if (this._als.getStore() === this) return fn();
107
+ const prev = this._chain;
108
+ let release;
109
+ const gate = new Promise((resolve) => { release = resolve; });
110
+ this._chain = prev.then(() => gate);
111
+ await prev;
112
+ try {
113
+ return await this._als.run(this, fn);
114
+ } finally {
115
+ release();
116
+ }
117
+ }
118
+
119
+ /** Subscribe to provenance events as they happen. Returns an unsubscribe fn. */
120
+ onEvent(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
121
+
122
+ async init() { return this._serialized(() => this._init()); }
123
+ async _init() {
124
+ // Shared (file) stores are re-read on every call: several processes
125
+ // (stdio servers, the CLI, a relay) can then share ONE workspace file
126
+ // and actually see each other's writes. Each mutation is a fresh
127
+ // read-modify-write with an atomic replace, so the race window is a
128
+ // single operation. Memory stores skip the reload.
129
+ if (this.state && !this.store.shared) return this;
130
+ const loaded = await this.store.load();
131
+ if (loaded) this.state = loaded;
132
+ else if (!this.state) this.state = emptyState(this.workspaceInfo);
133
+ return this;
134
+ }
135
+
136
+ async _save() { await this.store.save(this.state); }
137
+
138
+ _event(type, actor, summary, meta = {}) {
139
+ this.state.seq += 1;
140
+ const ev = { seq: this.state.seq, type, actor: actor || 'system', at: now(), summary, ...meta };
141
+ this.state.events.push(ev);
142
+ if (this.state.events.length > 5000) this.state.events.splice(0, this.state.events.length - 5000);
143
+ for (const fn of this.listeners) { try { fn(clone(ev)); } catch { /* a dead listener never breaks the bus */ } }
144
+ }
145
+
146
+ _agent(name) { return this.state.agents.find(a => a.name === name) || null; }
147
+
148
+ _publicAgent(a) {
149
+ const { tokenHash, ...pub } = a;
150
+ return { ...pub, online: Date.now() - Date.parse(a.lastSeenAt) < 120_000, hasToken: !!tokenHash };
151
+ }
152
+
153
+ _existsAgent(name) {
154
+ const a = this._agent(name);
155
+ if (!a) throw Object.assign(new Error(`agent not registered: ${name}`), { code: 'UNKNOWN_AGENT' });
156
+ return a;
157
+ }
158
+
159
+ _requireAgent(name, token) {
160
+ const a = this._agent(name);
161
+ if (!a) throw Object.assign(new Error(`agent not registered: ${name}`), { code: 'UNKNOWN_AGENT' });
162
+ if (this.requireTokens && a.tokenHash) {
163
+ if (!token || sha256(token) !== a.tokenHash) {
164
+ throw Object.assign(new Error(`agent token required or wrong for: ${name}`), { code: 'AGENT_AUTH' });
165
+ }
166
+ }
167
+ a.lastSeenAt = now();
168
+ return a;
169
+ }
170
+
171
+ // ---------- agents ----------
172
+ async registerAgent(...args) { return this._serialized(() => this._registerAgent(...args)); }
173
+ async _registerAgent({ name, role = '', capabilities = [], token = null }) {
174
+ await this.init();
175
+ if (!name || typeof name !== 'string') throw new Error('agent name required');
176
+ name = name.trim();
177
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error('agent name: letters, digits, . _ - only (max 64)');
178
+ const existing = this._agent(name);
179
+ if (existing) {
180
+ // In token mode, changing a registered identity requires its token.
181
+ if (this.requireTokens && existing.tokenHash && (role || (Array.isArray(capabilities) && capabilities.length))) {
182
+ this._requireAgent(name, token);
183
+ }
184
+ existing.role = role || existing.role;
185
+ if (Array.isArray(capabilities) && capabilities.length) existing.capabilities = capabilities.map(String).slice(0, 32);
186
+ existing.lastSeenAt = now();
187
+ this._event('agent.rejoin', name, `${name} rejoined`);
188
+ await this._save();
189
+ return { agent: this._publicAgent(existing), rejoined: true };
190
+ }
191
+ const rawToken = crypto.randomBytes(24).toString('hex');
192
+ const agent = { name, role: String(role).slice(0, 200), capabilities: Array.isArray(capabilities) ? capabilities.map(String).slice(0, 32) : [], joinedAt: now(), lastSeenAt: now(), tokenHash: sha256(rawToken) };
193
+ this.state.agents.push(agent);
194
+ this._event('agent.join', name, `${name} joined (${agent.role || 'no role'})`);
195
+ await this._save();
196
+ // The raw token is returned ONCE, here. Only its hash is ever stored.
197
+ return { agent: this._publicAgent(agent), rejoined: false, token: rawToken, tokenNote: 'Store this token — it is shown once and only its hash is kept. Present it as `token` (tools) or x-agent-token (HTTP) when the bus runs with requireTokens.' };
198
+ }
199
+
200
+ async rotateAgentToken(...args) { return this._serialized(() => this._rotateAgentToken(...args)); }
201
+ async _rotateAgentToken({ name, token = null }) {
202
+ await this.init();
203
+ const a = this._requireAgent(name, token);
204
+ const rawToken = crypto.randomBytes(24).toString('hex');
205
+ a.tokenHash = sha256(rawToken);
206
+ this._event('agent.token_rotate', name, `${name} rotated its agent token`);
207
+ await this._save();
208
+ return { agent: this._publicAgent(a), token: rawToken, tokenNote: 'New token — the old one no longer works. Shown once.' };
209
+ }
210
+
211
+ async heartbeat(...args) { return this._serialized(() => this._heartbeat(...args)); }
212
+ async _heartbeat({ name, token = null }) {
213
+ await this.init();
214
+ const a = this._requireAgent(name, token);
215
+ return { agent: this._publicAgent(a), agents: await this.listAgents() };
216
+ }
217
+
218
+ async listAgents(...args) { return this._serialized(() => this._listAgents(...args)); }
219
+ async _listAgents() { await this.init(); return this.state.agents.map(a => this._publicAgent(a)); }
220
+
221
+ // ---------- messages ----------
222
+ async sendMessage(...args) { return this._serialized(() => this._sendMessage(...args)); }
223
+ async _sendMessage({ from, to = '*', body, channel = 'general', threadId = null, token = null }) {
224
+ await this.init();
225
+ this._requireAgent(from, token);
226
+ if (to !== '*') this._existsAgent(to);
227
+ if (!body || !String(body).trim()) throw new Error('message body required');
228
+ this.state.msgSeq += 1;
229
+ const msg = {
230
+ id: this.state.msgSeq, from, to, channel: String(channel).slice(0, 64),
231
+ body: String(body), threadId: threadId ? Number(threadId) : null,
232
+ createdAt: now(), readBy: [from],
233
+ };
234
+ this.state.messages.push(msg);
235
+ if (this.state.messages.length > 10000) this.state.messages.splice(0, this.state.messages.length - 10000);
236
+ this._event('message.send', from, `${from} -> ${to} (#${msg.id})`, { to, channel: msg.channel, messageId: msg.id, excerpt: msg.body.slice(0, 280) });
237
+ await this._save();
238
+ return clone(msg);
239
+ }
240
+
241
+ /** Messages visible to `agent`: addressed to it, broadcast, or sent by it. Unread-first filtering optional. */
242
+ async inbox(...args) { return this._serialized(() => this._inbox(...args)); }
243
+ async _inbox(agentName, { unreadOnly = false, limit = 50, markRead = true, token = null } = {}) {
244
+ await this.init();
245
+ this._requireAgent(agentName, token);
246
+ let msgs = this.state.messages.filter(m =>
247
+ m.to === agentName || m.to === '*' || m.from === agentName);
248
+ if (unreadOnly) msgs = msgs.filter(m => !m.readBy.includes(agentName));
249
+ msgs = msgs.slice(-Math.min(limit, 500));
250
+ if (markRead) {
251
+ let changed = false;
252
+ for (const m of msgs) if (!m.readBy.includes(agentName)) { m.readBy.push(agentName); changed = true; }
253
+ if (changed) await this._save();
254
+ }
255
+ return clone(msgs);
256
+ }
257
+
258
+ async thread(...args) { return this._serialized(() => this._thread(...args)); }
259
+ async _thread(id) {
260
+ await this.init();
261
+ const root = this.state.messages.find(m => m.id === Number(id));
262
+ if (!root) throw new Error(`message not found: ${id}`);
263
+ return clone(this.state.messages.filter(m => m.id === root.id || m.threadId === root.id));
264
+ }
265
+
266
+ // ---------- tasks ----------
267
+ async createTask(...args) { return this._serialized(() => this._createTask(...args)); }
268
+ async _createTask({ from, title, body = '', assignee = null, priority = 'normal', needsApproval = false, blockedBy = [], token = null }) {
269
+ await this.init();
270
+ this._requireAgent(from, token);
271
+ if (assignee) this._existsAgent(assignee);
272
+ if (!title || !String(title).trim()) throw new Error('task title required');
273
+ this.state.taskSeq += 1;
274
+ const task = {
275
+ id: this.state.taskSeq, title: String(title).slice(0, 300), body: String(body),
276
+ from, assignee: assignee || null, priority: priority === 'urgent' ? 'urgent' : 'normal',
277
+ status: needsApproval ? 'needs-approval' : 'queued',
278
+ needsApproval: !!needsApproval, approvedBy: null,
279
+ claimedBy: null, claimExpiresAt: null, result: null, comments: [], blockedBy: Array.isArray(blockedBy) ? blockedBy.map(Number).filter(n => n >= 1) : [],
280
+ createdAt: now(), updatedAt: now(),
281
+ history: [{ at: now(), by: from, action: needsApproval ? 'created (awaiting approval)' : 'created' }],
282
+ };
283
+ for (const b of task.blockedBy) if (!this.state.tasks.some(t => t.id === b)) throw new Error(`blocker task not found: ${b}`);
284
+ this.state.tasks.push(task);
285
+ this._event('task.create', from, `task #${task.id}: ${task.title}`);
286
+ await this._save();
287
+ return clone(task);
288
+ }
289
+
290
+ _expireClaims() {
291
+ const t = Date.now();
292
+ for (const task of this.state.tasks) {
293
+ if (task.status === 'claimed' && task.claimExpiresAt && Date.parse(task.claimExpiresAt) < t) {
294
+ task.status = 'queued'; task.claimedBy = null; task.claimExpiresAt = null; task.updatedAt = now();
295
+ task.history.push({ at: now(), by: 'system', action: 'claim lease expired — returned to queue' });
296
+ this._event('task.claim_expired', 'system', `task #${task.id} claim expired`);
297
+ }
298
+ }
299
+ }
300
+
301
+ async listTasks(...args) { return this._serialized(() => this._listTasks(...args)); }
302
+ async _listTasks({ status = null, assignee = null } = {}) {
303
+ await this.init();
304
+ this._expireClaims(); await this._save();
305
+ let tasks = this.state.tasks;
306
+ if (status) tasks = tasks.filter(t => t.status === status);
307
+ if (assignee) tasks = tasks.filter(t => t.assignee === assignee || t.claimedBy === assignee);
308
+ return clone(tasks.sort((a, b) => b.id - a.id));
309
+ }
310
+
311
+ async approveTask(...args) { return this._serialized(() => this._approveTask(...args)); }
312
+ async _approveTask(id, { by, token = null }) {
313
+ await this.init();
314
+ this._requireAgent(by, token);
315
+ const task = this._task(id);
316
+ if (task.status !== 'needs-approval') throw new Error(`task #${id} is not awaiting approval (status: ${task.status})`);
317
+ task.status = 'queued'; task.approvedBy = by; task.updatedAt = now();
318
+ task.history.push({ at: now(), by, action: 'approved' });
319
+ this._event('task.approve', by, `task #${id} approved`);
320
+ await this._save();
321
+ return clone(task);
322
+ }
323
+
324
+ /** Claim is exclusive: a second claim while a lease is live fails loudly. */
325
+ async claimTask(...args) { return this._serialized(() => this._claimTask(...args)); }
326
+ async _claimTask(id, { by, token = null }) {
327
+ await this.init();
328
+ this._requireAgent(by, token);
329
+ this._expireClaims();
330
+ const task = this._task(id);
331
+ if (task.status === 'claimed') throw Object.assign(new Error(`task #${id} already claimed by ${task.claimedBy}`), { code: 'ALREADY_CLAIMED' });
332
+ if (task.status !== 'queued') throw new Error(`task #${id} cannot be claimed (status: ${task.status})`);
333
+ if (task.assignee && task.assignee !== by) throw new Error(`task #${id} is assigned to ${task.assignee}`);
334
+ const openBlockers = (task.blockedBy || []).filter(bid => { const b = this.state.tasks.find(t => t.id === bid); return b && !['done', 'cancelled'].includes(b.status); });
335
+ if (openBlockers.length) throw Object.assign(new Error(`task #${id} is blocked by unfinished task(s): ${openBlockers.join(', ')}`), { code: 'BLOCKED' });
336
+ task.status = 'claimed'; task.claimedBy = by;
337
+ task.claimExpiresAt = new Date(Date.now() + CLAIM_LEASE_MS).toISOString();
338
+ task.updatedAt = now();
339
+ task.history.push({ at: now(), by, action: 'claimed' });
340
+ this._event('task.claim', by, `task #${id} claimed by ${by}`);
341
+ await this._save();
342
+ return clone(task);
343
+ }
344
+
345
+ async completeTask(...args) { return this._serialized(() => this._completeTask(...args)); }
346
+ async _completeTask(id, { by, result = '', token = null }) {
347
+ await this.init();
348
+ this._requireAgent(by, token);
349
+ const task = this._task(id);
350
+ if (task.status !== 'claimed') throw new Error(`task #${id} is not claimed (status: ${task.status})`);
351
+ if (task.claimedBy !== by) throw new Error(`task #${id} is claimed by ${task.claimedBy}, not ${by}`);
352
+ task.status = 'done'; task.result = String(result); task.claimExpiresAt = null; task.updatedAt = now();
353
+ task.history.push({ at: now(), by, action: 'completed' });
354
+ this._event('task.complete', by, `task #${id} completed`);
355
+ await this._save();
356
+ return clone(task);
357
+ }
358
+
359
+ async cancelTask(...args) { return this._serialized(() => this._cancelTask(...args)); }
360
+ async _cancelTask(id, { by, token = null }) {
361
+ await this.init();
362
+ this._requireAgent(by, token);
363
+ const task = this._task(id);
364
+ if (['done', 'cancelled'].includes(task.status)) throw new Error(`task #${id} already ${task.status}`);
365
+ task.status = 'cancelled'; task.updatedAt = now();
366
+ task.history.push({ at: now(), by, action: 'cancelled' });
367
+ this._event('task.cancel', by, `task #${id} cancelled`);
368
+ await this._save();
369
+ return clone(task);
370
+ }
371
+
372
+ _task(id) {
373
+ const task = this.state.tasks.find(t => t.id === Number(id));
374
+ if (!task) throw new Error(`task not found: ${id}`);
375
+ return task;
376
+ }
377
+
378
+ // ---------- shared workspace: files + context + board ----------
379
+ async putFile(...args) { return this._serialized(() => this._putFile(...args)); }
380
+ async _putFile({ by, path: p, text, token = null }) {
381
+ await this.init();
382
+ this._requireAgent(by, token);
383
+ if (!p || typeof text !== 'string') throw new Error('path and text required');
384
+ if (String(p).includes('..')) throw new Error('path may not contain ..');
385
+ const prev = this.state.files[p];
386
+ const history = prev ? [...(prev.history || []), { text: prev.text, updatedBy: prev.updatedBy, updatedAt: prev.updatedAt }].slice(-10) : [];
387
+ this.state.files[p] = { text, updatedBy: by, updatedAt: now(), history };
388
+ this._event('file.put', by, `${by} wrote ${p} (${text.length} chars)`);
389
+ await this._save();
390
+ return { path: p, bytes: Buffer.byteLength(text) };
391
+ }
392
+
393
+ async getFile(...args) { return this._serialized(() => this._getFile(...args)); }
394
+ async _getFile(p, { withHistory = false } = {}) {
395
+ await this.init();
396
+ const f = this.state.files[p];
397
+ if (!f) throw new Error(`file not found: ${p}`);
398
+ const { history, ...rest } = f;
399
+ return { path: p, ...clone(rest), versions: (history || []).length + 1, ...(withHistory ? { history: clone(history || []) } : {}) };
400
+ }
401
+
402
+ async fileHistory(...args) { return this._serialized(() => this._fileHistory(...args)); }
403
+ async _fileHistory(p) {
404
+ await this.init();
405
+ const f = this.state.files[p];
406
+ if (!f) throw new Error(`file not found: ${p}`);
407
+ return { path: p, current: { text: f.text, updatedBy: f.updatedBy, updatedAt: f.updatedAt }, history: clone(f.history || []) };
408
+ }
409
+
410
+ async listFiles(...args) { return this._serialized(() => this._listFiles(...args)); }
411
+ async _listFiles() {
412
+ await this.init();
413
+ return Object.entries(this.state.files).map(([p, f]) => ({ path: p, bytes: Buffer.byteLength(f.text), updatedBy: f.updatedBy, updatedAt: f.updatedAt }));
414
+ }
415
+
416
+ async putContext(...args) { return this._serialized(() => this._putContext(...args)); }
417
+ async _putContext({ by, context, token = null }) {
418
+ await this.init();
419
+ this._requireAgent(by, token);
420
+ if (!context || typeof context !== 'object' || Array.isArray(context)) throw new Error('context must be an object');
421
+ this.state.context = clone(context);
422
+ this._event('context.put', by, `${by} published context`);
423
+ await this._save();
424
+ return { ok: true };
425
+ }
426
+
427
+ async getContext(...args) { return this._serialized(() => this._getContext(...args)); }
428
+ async _getContext() {
429
+ await this.init();
430
+ if (!this.state.context) throw new Error('no context published yet');
431
+ return clone(this.state.context);
432
+ }
433
+
434
+ async addTaskComment(...args) { return this._serialized(() => this._addTaskComment(...args)); }
435
+ async _addTaskComment(id, { by, body, token = null }) {
436
+ await this.init();
437
+ this._requireAgent(by, token);
438
+ if (!body || !String(body).trim()) throw new Error('comment body required');
439
+ const task = this._task(id);
440
+ task.comments = task.comments || [];
441
+ const comment = { by, body: String(body), at: now() };
442
+ task.comments.push(comment);
443
+ task.updatedAt = now();
444
+ task.history.push({ at: now(), by, action: 'commented' });
445
+ this._event('task.comment', by, `${by} commented on task #${id}`, { taskId: task.id });
446
+ await this._save();
447
+ return clone(task);
448
+ }
449
+
450
+ async deleteFile(...args) { return this._serialized(() => this._deleteFile(...args)); }
451
+ async _deleteFile({ by, path: p, token = null }) {
452
+ await this.init();
453
+ this._requireAgent(by, token);
454
+ if (!this.state.files[p]) throw new Error(`file not found: ${p}`);
455
+ delete this.state.files[p];
456
+ this._event('file.delete', by, `${by} deleted ${p}`);
457
+ await this._save();
458
+ return { path: p, deleted: true };
459
+ }
460
+
461
+ async listChannels(...args) { return this._serialized(() => this._listChannels(...args)); }
462
+ async _listChannels() {
463
+ await this.init();
464
+ const counts = {};
465
+ for (const m of this.state.messages) counts[m.channel] = (counts[m.channel] || 0) + 1;
466
+ return Object.entries(counts).map(([channel, messages]) => ({ channel, messages })).sort((a, b) => b.messages - a.messages);
467
+ }
468
+
469
+ /** Full-text search across messages, tasks, and shared files. */
470
+ async search(...args) { return this._serialized(() => this._search(...args)); }
471
+ async _search(query, { limit = 20 } = {}) {
472
+ await this.init();
473
+ const q = String(query || '').toLowerCase().trim();
474
+ if (q.length < 2) throw new Error('search query needs at least 2 characters');
475
+ const hit = (text) => String(text || '').toLowerCase().includes(q);
476
+ return {
477
+ query,
478
+ messages: clone(this.state.messages.filter(m => hit(m.body)).slice(-limit)),
479
+ tasks: clone(this.state.tasks.filter(t => hit(t.title) || hit(t.body) || hit(t.result)).slice(-limit)),
480
+ files: Object.entries(this.state.files).filter(([p, f]) => hit(p) || hit(f.text)).slice(0, limit)
481
+ .map(([p, f]) => ({ path: p, updatedBy: f.updatedBy, updatedAt: f.updatedAt, excerpt: f.text.slice(0, 200) })),
482
+ };
483
+ }
484
+
485
+
486
+ _capsulePath(slug) {
487
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(slug))) throw new Error('capsule slug: lowercase letters, digits, - only');
488
+ return `capsules/${slug}.md`;
489
+ }
490
+
491
+ _capsuleTemplate(slug, title) {
492
+ return `# Capsule: ${title || slug}\n\n## State\n\n## Decisions (locked)\n\n## Next\n\n## Session log\n`;
493
+ }
494
+
495
+ /** Read a project capsule, creating it from the template on first touch when create=true. */
496
+ async getCapsule(...args) { return this._serialized(() => this._getCapsule(...args)); }
497
+ async _getCapsule(slug, { create = false, title = '', by = null, token = null } = {}) {
498
+ await this.init();
499
+ const pth = this._capsulePath(slug);
500
+ if (!this.state.files[pth]) {
501
+ if (!create) throw new Error(`capsule not found: ${slug}`);
502
+ if (by) this._requireAgent(by, token);
503
+ this.state.files[pth] = { text: this._capsuleTemplate(slug, title), updatedBy: by || 'system', updatedAt: now(), history: [] };
504
+ this._event('capsule.create', by || 'system', `capsule ${slug} created`);
505
+ await this._save();
506
+ }
507
+ return this.getFile(pth);
508
+ }
509
+
510
+ /** Replace one capsule section (State / Decisions / Next) or append to the Session log. */
511
+ async updateCapsule(...args) { return this._serialized(() => this._updateCapsule(...args)); }
512
+ async _updateCapsule(slug, { by, section, text, token = null }) {
513
+ await this.init();
514
+ this._requireAgent(by, token);
515
+ await this.getCapsule(slug, { create: true, by, token });
516
+ const pth = this._capsulePath(slug);
517
+ const cur = this.state.files[pth].text;
518
+ const SECTIONS = ['State', 'Decisions (locked)', 'Next', 'Session log'];
519
+ if (!SECTIONS.includes(section)) throw new Error(`section must be one of: ${SECTIONS.join(' | ')}`);
520
+ const marker = `## ${section}`;
521
+ const start = cur.indexOf(marker);
522
+ if (start < 0) throw new Error(`capsule ${slug} is missing section ${section}`);
523
+ const afterHead = start + marker.length;
524
+ const rest = cur.slice(afterHead);
525
+ const nextIdx = rest.indexOf('\n## ');
526
+ const tail = nextIdx >= 0 ? rest.slice(nextIdx) : '';
527
+ const entry = section === 'Session log'
528
+ ? `${rest.trimEnd() ? rest.replace(/\s+$/, '') + '\n' : '\n'}- ${now()} · ${by} — ${String(text).trim()}\n`
529
+ : `\n\n${String(text).trim()}\n`;
530
+ const updated = cur.slice(0, afterHead) + entry + tail.replace(/^\n+/, tail ? '\n' : '');
531
+ const prev = this.state.files[pth];
532
+ this.state.files[pth] = { text: updated.endsWith('\n') ? updated : updated + '\n', updatedBy: by, updatedAt: now(), history: [...(prev.history || []), { text: prev.text, updatedBy: prev.updatedBy, updatedAt: prev.updatedAt }].slice(-10) };
533
+ this._event('capsule.update', by, `${by} updated capsule ${slug} [${section}]`, { capsule: slug });
534
+ await this._save();
535
+ return this.getFile(pth);
536
+ }
537
+
538
+ async listCapsules(...args) { return this._serialized(() => this._listCapsules(...args)); }
539
+ async _listCapsules() {
540
+ await this.init();
541
+ return Object.keys(this.state.files).filter(p => p.startsWith('capsules/') && p.endsWith('.md')).map(p => p.slice('capsules/'.length, -3)).sort();
542
+ }
543
+
544
+ /** A human-readable board rendered from live state — the shared picture at a glance. */
545
+ async board(...args) { return this._serialized(() => this._board(...args)); }
546
+ async _board() {
547
+ await this.init();
548
+ this._expireClaims();
549
+ const s = this.state;
550
+ const lines = [
551
+ `# ${s.workspace.name} — Board`,
552
+ `Updated: ${now()} · GhostBus v${GHOSTBUS_VERSION}`,
553
+ '',
554
+ `## Agents (${s.agents.length})`,
555
+ ...(s.agents.length ? s.agents.map(a => `- **${a.name}** — ${a.role || 'agent'}${a.capabilities.length ? ` · ${a.capabilities.join(', ')}` : ''} · last seen ${a.lastSeenAt}`) : ['- (none registered)']),
556
+ '',
557
+ `## Open tasks (${s.tasks.filter(t => !['done', 'cancelled'].includes(t.status)).length})`,
558
+ ...(s.tasks.filter(t => !['done', 'cancelled'].includes(t.status)).length
559
+ ? s.tasks.filter(t => !['done', 'cancelled'].includes(t.status)).map(t => `- #${t.id} [${t.status}] ${t.title}${t.claimedBy ? ` — claimed by ${t.claimedBy}` : ''}${t.assignee ? ` — for ${t.assignee}` : ''}`)
560
+ : ['- (none)']),
561
+ '',
562
+ `## Files (${Object.keys(s.files).length})`,
563
+ ...(Object.keys(s.files).length ? Object.keys(s.files).sort().map(p => `- ${p} (${s.files[p].updatedBy}, ${s.files[p].updatedAt})`) : ['- (none)']),
564
+ '',
565
+ `## Recent activity`,
566
+ ...s.events.slice(-10).reverse().map(e => `- ${e.at} · ${e.actor} · ${e.summary}`),
567
+ '',
568
+ ];
569
+ return lines.join('\n');
570
+ }
571
+
572
+ /** Catch-up sync: every event after `sinceSeq`, plus the current head seq.
573
+ * An agent that was offline replays exactly what it missed — nothing more. */
574
+ async sync(...args) { return this._serialized(() => this._sync(...args)); }
575
+ async _sync({ sinceSeq = 0, limit = 500 } = {}) {
576
+ await this.init();
577
+ const evs = this.state.events.filter(e => e.seq > Number(sinceSeq));
578
+ return { events: clone(evs.slice(0, Math.min(limit, 1000))), headSeq: this.state.seq, truncated: evs.length > Math.min(limit, 1000) };
579
+ }
580
+
581
+ async events(...args) { return this._serialized(() => this._events(...args)); }
582
+ async _events({ limit = 50 } = {}) {
583
+ await this.init();
584
+ return clone(this.state.events.slice(-Math.min(limit, 500)));
585
+ }
586
+
587
+ async status(...args) { return this._serialized(() => this._status(...args)); }
588
+ async _status() {
589
+ await this.init();
590
+ this._expireClaims();
591
+ const s = this.state;
592
+ return {
593
+ server: 'ghostbus', version: GHOSTBUS_VERSION, workspace: s.workspace,
594
+ agents: s.agents.length,
595
+ messages: s.messages.length,
596
+ tasks: { total: s.tasks.length, queued: s.tasks.filter(t => t.status === 'queued').length, claimed: s.tasks.filter(t => t.status === 'claimed').length, needsApproval: s.tasks.filter(t => t.status === 'needs-approval').length, done: s.tasks.filter(t => t.status === 'done').length },
597
+ files: Object.keys(s.files).length,
598
+ hasContext: !!s.context,
599
+ events: s.events.length,
600
+ };
601
+ }
602
+
603
+ /** Full-state snapshot for backup / migration. The state holds agent token
604
+ * HASHES only, never plaintext secrets; workspace keys live outside the
605
+ * state (hosted registry) and are never part of a snapshot. */
606
+ async snapshot(...args) { return this._serialized(() => this._snapshot(...args)); }
607
+ async _snapshot() {
608
+ await this.init();
609
+ return clone(this.state);
610
+ }
611
+
612
+ /** Replace this workspace's state with a snapshot (restore / import).
613
+ * The snapshot is shape-validated, the workspace identity is preserved
614
+ * unless the snapshot carries one, and the restore itself is recorded
615
+ * in the provenance log. Returns the post-restore status. */
616
+ async restore(...args) { return this._serialized(() => this._restore(...args)); }
617
+ async _restore(snapshot, { by = 'operator' } = {}) {
618
+ await this.init();
619
+ const bad = validateSnapshot(snapshot);
620
+ if (bad) throw Object.assign(new Error(`invalid snapshot: ${bad}`), { code: 'BAD_SNAPSHOT' });
621
+ const next = clone(snapshot);
622
+ next.workspace = { ...next.workspace, id: this.state.workspace.id, name: next.workspace.name || this.state.workspace.name };
623
+ this.state = next;
624
+ this._event('workspace.restore', by, `workspace restored from snapshot (${next.agents.length} agents, ${next.tasks.length} tasks, ${next.messages.length} messages)`);
625
+ await this._save();
626
+ return this.status();
627
+ }
628
+ }
629
+
630
+ /** Shape-check a candidate workspace snapshot. Returns null when valid,
631
+ * otherwise a short description of the first problem found. */
632
+ export function validateSnapshot(s) {
633
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return 'not an object';
634
+ if (!s.workspace || typeof s.workspace !== 'object') return 'missing workspace';
635
+ for (const k of ['seq', 'msgSeq', 'taskSeq']) if (typeof s[k] !== 'number') return `missing numeric ${k}`;
636
+ for (const k of ['agents', 'messages', 'tasks', 'events']) if (!Array.isArray(s[k])) return `missing array ${k}`;
637
+ if (!s.files || typeof s.files !== 'object' || Array.isArray(s.files)) return 'missing files object';
638
+ return null;
639
+ }
640
+
641
+ export async function createBus(store, workspace, opts = {}) {
642
+ const bus = new GhostBus(store, workspace, opts);
643
+ await bus.init();
644
+ return bus;
645
+ }