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