@polderlabs/bizar 10.5.0 → 10.7.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.
Files changed (120) hide show
  1. package/bizar-dash/src/server/agent-bus.mjs +485 -0
  2. package/bizar-dash/src/server/api.mjs +6 -0
  3. package/bizar-dash/src/server/loop-runtime.mjs +429 -0
  4. package/bizar-dash/src/server/routes/agent-bus.mjs +185 -0
  5. package/bizar-dash/src/server/routes/loops.mjs +138 -0
  6. package/bizar-dash/src/server/task-splitter.mjs +241 -0
  7. package/bizar-dash/tests/agent-bus.test.mjs +289 -0
  8. package/bizar-dash/tests/loop-runtime.test.mjs +312 -0
  9. package/bizar-dash/tests/loops-agent-bus-routes.test.mjs +293 -0
  10. package/bizar-dash/tests/memory-protocol-drift.test.mjs +18 -11
  11. package/bizar-dash/tests/task-splitter.test.mjs +268 -0
  12. package/cli/__tests__/feature-list-bridge.test.mjs +42 -42
  13. package/cli/agent-browser-up.sh +35 -35
  14. package/cli/agent-browser-update.mjs +42 -42
  15. package/cli/agent-browser-update.test.mjs +7 -7
  16. package/cli/audit.mjs +3 -3
  17. package/cli/bin.mjs +10 -10
  18. package/cli/bootstrap.mjs +1 -1
  19. package/cli/cli-commands-validation.test.mjs +2 -2
  20. package/cli/commands/claim.mjs +3 -3
  21. package/cli/commands/claude-cmd.mjs +3 -3
  22. package/cli/commands/eval.mjs +6 -6
  23. package/cli/commands/util.mjs +18 -18
  24. package/cli/commands/validate.mjs +10 -10
  25. package/cli/init.mjs +1 -1
  26. package/cli/install/postinstall.mjs +4 -4
  27. package/cli/plow-through.test.mjs +7 -6
  28. package/cli/prompts.mjs +11 -11
  29. package/cli/provision.mjs +4 -16
  30. package/config/skills/bizar/SKILL.md +1 -1
  31. package/config/skills/skillopt/SKILL.md +129 -0
  32. package/config/skills/thinking-archetypes/SKILL.md +90 -0
  33. package/config/skills/thinking-bayesian/SKILL.md +267 -0
  34. package/config/skills/thinking-bounded-rationality/SKILL.md +406 -0
  35. package/config/skills/thinking-circle-of-competence/SKILL.md +216 -0
  36. package/config/skills/thinking-cynefin/SKILL.md +70 -0
  37. package/config/skills/thinking-debiasing/SKILL.md +192 -0
  38. package/config/skills/thinking-dual-process/SKILL.md +282 -0
  39. package/config/skills/thinking-effectuation/SKILL.md +366 -0
  40. package/config/skills/thinking-feedback-loops/SKILL.md +464 -0
  41. package/config/skills/thinking-fermi-estimation/SKILL.md +263 -0
  42. package/config/skills/thinking-first-principles/SKILL.md +167 -0
  43. package/config/skills/thinking-five-whys-plus/SKILL.md +139 -0
  44. package/config/skills/thinking-inversion/SKILL.md +195 -0
  45. package/config/skills/thinking-jobs-to-be-done/SKILL.md +363 -0
  46. package/config/skills/thinking-kepner-tregoe/SKILL.md +154 -0
  47. package/config/skills/thinking-leverage-points/SKILL.md +390 -0
  48. package/config/skills/thinking-lindy-effect/SKILL.md +331 -0
  49. package/config/skills/thinking-map-territory/SKILL.md +111 -0
  50. package/config/skills/thinking-margin-of-safety/SKILL.md +330 -0
  51. package/config/skills/thinking-model-combination/SKILL.md +406 -0
  52. package/config/skills/thinking-model-router/SKILL.md +360 -0
  53. package/config/skills/thinking-model-selection/SKILL.md +341 -0
  54. package/config/skills/thinking-occams-razor/SKILL.md +129 -0
  55. package/config/skills/thinking-ooda/SKILL.md +127 -0
  56. package/config/skills/thinking-opportunity-cost/SKILL.md +360 -0
  57. package/config/skills/thinking-pre-mortem/SKILL.md +170 -0
  58. package/config/skills/thinking-probabilistic/SKILL.md +324 -0
  59. package/config/skills/thinking-red-team/SKILL.md +142 -0
  60. package/config/skills/thinking-regret-minimization/SKILL.md +335 -0
  61. package/config/skills/thinking-reversibility/SKILL.md +326 -0
  62. package/config/skills/thinking-scientific-method/SKILL.md +162 -0
  63. package/config/skills/thinking-second-order/SKILL.md +184 -0
  64. package/config/skills/thinking-socratic/SKILL.md +198 -0
  65. package/config/skills/thinking-steel-manning/SKILL.md +332 -0
  66. package/config/skills/thinking-systems/SKILL.md +238 -0
  67. package/config/skills/thinking-theory-of-constraints/SKILL.md +338 -0
  68. package/config/skills/thinking-thought-experiment/SKILL.md +354 -0
  69. package/config/skills/thinking-triz/SKILL.md +171 -0
  70. package/config/skills/thinking-via-negativa/SKILL.md +358 -0
  71. package/package.json +2 -3
  72. package/packages/sdk/dist/consensus/index.js +4 -4
  73. package/packages/sdk/dist/router/agent-model-registry.js +2 -2
  74. package/packages/sdk/dist/router/index.d.ts +3 -3
  75. package/packages/sdk/dist/router/index.js +4 -4
  76. package/packages/sdk/dist/router/q-learning-router.d.ts +2 -2
  77. package/packages/sdk/dist/router/q-learning-router.js +18 -18
  78. package/packages/sdk/package.json +1 -1
  79. package/scripts/bh-full-e2e.mjs +10 -8
  80. package/scripts/check-agents.mjs +27 -24
  81. package/scripts/mirror-claude-md.sh +0 -0
  82. package/scripts/sync-skills-mirror.mjs +113 -0
  83. package/scripts/verify-thinking-skills.mjs +135 -0
  84. package/config/agents/_shared/AGENT_BASELINE.md +0 -155
  85. package/config/agents/_shared/CLINE_TOOLS.md +0 -398
  86. package/config/agents/_shared/SKILLS.md +0 -109
  87. package/config/agents/agent-browser.md +0 -90
  88. package/config/agents/baldr.md +0 -60
  89. package/config/agents/forseti.md +0 -60
  90. package/config/agents/frigg.md +0 -52
  91. package/config/agents/heimdall.md +0 -44
  92. package/config/agents/hermod.md +0 -64
  93. package/config/agents/mimir.md +0 -60
  94. package/config/agents/odin.md +0 -310
  95. package/config/agents/quick.md +0 -47
  96. package/config/agents/semble-search.md +0 -59
  97. package/config/agents/thor.md +0 -64
  98. package/config/agents/tyr.md +0 -67
  99. package/config/agents/vidarr.md +0 -65
  100. package/config/agents/vor.md +0 -62
  101. package/config/commands/audit.md +0 -5
  102. package/config/commands/bizar.md +0 -22
  103. package/config/commands/explain.md +0 -5
  104. package/config/commands/init.md +0 -26
  105. package/config/commands/learn.md +0 -5
  106. package/config/commands/plan.md +0 -30
  107. package/config/commands/plow-through.md +0 -47
  108. package/config/commands/pr-review.md +0 -5
  109. package/config/commands/setup-provider.md +0 -95
  110. package/config/commands/tailscale-serve.md +0 -97
  111. package/config/commands/team.md +0 -124
  112. package/config/commands/test.md +0 -60
  113. package/config/commands/validate.md +0 -66
  114. package/config/commands/visual-plan.md +0 -19
  115. package/config/hooks/PostToolUse +0 -66
  116. package/config/hooks/PreToolUse +0 -82
  117. package/config/hooks/README.md +0 -68
  118. package/config/hooks/TaskResume +0 -48
  119. package/config/hooks/TaskStart +0 -47
  120. package/config/hooks/UserPromptSubmit +0 -55
@@ -0,0 +1,485 @@
1
+ /**
2
+ * src/server/agent-bus.mjs
3
+ *
4
+ * v10.6.0 — G-autoloop Phase 4. Inter-agent messaging bus.
5
+ *
6
+ * Inspired by xhluca/agent-talk (a thin plugin over retalk). That
7
+ * project is intentionally minimal — a messaging primitive with no
8
+ * shared task list, no lead, no automatic synthesis, no routing —
9
+ * justified by contrasting itself with Claude Code Agent Teams. We
10
+ * port its core pattern (peer-addressed messages over a persistent
11
+ * channel store) and add the higher-level primitives that Bizar's
12
+ * orchestrator needs:
13
+ *
14
+ * - request / response (correlation IDs)
15
+ * - steer (in-flight plan adjustment on the receiver)
16
+ * - correct (steer + attached before/after diff)
17
+ * - handoff (transfer task ownership to another agent)
18
+ * - ack / status / error (control-plane signals)
19
+ * - presence (announce / heartbeat / leave)
20
+ *
21
+ * Persistence: every message appends to a per-channel JSONL file
22
+ * under `.bizar/agent-bus/<channel>.jsonl`. This gives every agent
23
+ * (and the operator) a full audit trail — important for post-mortem
24
+ * analysis when autonomous loops run for hours.
25
+ *
26
+ * Transport: dual-mode. The same publish call writes to the JSONL
27
+ * log AND emits on an in-process Node EventEmitter. In-process
28
+ * consumers get real-time delivery with zero filesystem overhead.
29
+ * Cross-process consumers (CLI tools, separate dashboard workers)
30
+ * poll the JSONL tail.
31
+ *
32
+ * Crypto / encryption: NONE. Bizar runs single-tenant on a local
33
+ * box. agent-talk's E2E is overkill here. The defence layer for
34
+ * secrets is .gitignore + the Bizar trust model, not transport
35
+ * crypto. (We can add signed envelopes via packages/sdk/src/federation/
36
+ * later if cross-machine becomes a real requirement.)
37
+ *
38
+ * Address shape: `agent://<name>` (loose) or `agent://<name>/<sid>`
39
+ * (targeted at one specific session of an agent). Loose addressing
40
+ * means "any live session of <name>". Targeted addressing requires
41
+ * the receiving agent to expose its current session id via /presence.
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} AgentMessage
46
+ * @property {string} id ULID-ish (time + random)
47
+ * @property {string} from e.g. "agent://odin"
48
+ * @property {string} to e.g. "agent://thor" or "agent://*"
49
+ * @property {string} channel human-readable channel name ("default", "task-tsk_abc", "goal-G-xyz")
50
+ * @property {'request'|'response'|'steer'|'correct'|'handoff'|'ack'|'status'|'error'} kind
51
+ * @property {Object} payload kind-specific body
52
+ * @property {string} ts ISO timestamp
53
+ * @property {string=} correlationId set on `response` to link to a `request`
54
+ * @property {string=} taskId optional task linkage
55
+ */
56
+
57
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
58
+ import { randomBytes } from 'node:crypto';
59
+ import { dirname, join } from 'node:path';
60
+ import { EventEmitter } from 'node:events';
61
+ import { homedir } from 'node:os';
62
+
63
+ const HOME = homedir();
64
+
65
+ /** Root directory for persistent message logs and presence state. */
66
+ export const AGENT_BUS_ROOT = process.env.BIZAR_AGENT_BUS_ROOT
67
+ || join(HOME, '.bizar', 'agent-bus');
68
+
69
+ /** Per-channel message log. */
70
+ export const AGENT_BUS_LOG_DIR = join(AGENT_BUS_ROOT, 'channels');
71
+
72
+ /** Presence registry — single JSON file. Reread on every change. */
73
+ export const AGENT_BUS_PRESENCE_FILE = join(AGENT_BUS_ROOT, 'presence.json');
74
+
75
+ /**
76
+ * Default channel when callers omit one. Multi-agent broadcasts in
77
+ * "default" channel; per-task/per-goal channels are auto-created on
78
+ * first publish to avoid collisions.
79
+ */
80
+ export const DEFAULT_CHANNEL = 'default';
81
+
82
+ const VALID_KINDS = new Set(['request', 'response', 'steer', 'correct', 'handoff', 'ack', 'status', 'error']);
83
+
84
+ /**
85
+ * In-process event emitter — same message stream that hits the
86
+ * JSONL log. All subscribers see every message; they filter on
87
+ * `to` and `channel` themselves. The emitter is module-level so
88
+ * every consumer in the same Node process gets the live stream.
89
+ */
90
+ const emitter = new EventEmitter();
91
+ emitter.setMaxListeners(0);
92
+
93
+ let presence = {};
94
+ try {
95
+ if (existsSync(AGENT_BUS_PRESENCE_FILE)) {
96
+ presence = JSON.parse(readFileSync(AGENT_BUS_PRESENCE_FILE, 'utf8'));
97
+ }
98
+ } catch { /* fresh start */ }
99
+
100
+ function persistPresence() {
101
+ mkdirSync(AGENT_BUS_ROOT, { recursive: true });
102
+ const tmp = AGENT_BUS_PRESENCE_FILE + '.tmp';
103
+ writeFileSync(tmp, JSON.stringify(presence, null, 2));
104
+ renameSync(tmp, AGENT_BUS_PRESENCE_FILE);
105
+ }
106
+
107
+ /**
108
+ * Generate a sortable id. Encodes time + 8 random bytes as hex. Not
109
+ * a ULID but close enough — monotonically sortable by `ts`, distinct
110
+ * across concurrent writers within ~1ms thanks to randomness.
111
+ *
112
+ * @returns {string}
113
+ */
114
+ function newId() {
115
+ const time = Date.now().toString(36);
116
+ const rand = randomBytes(8).toString('hex');
117
+ return `${time}-${rand}`;
118
+ }
119
+
120
+ /**
121
+ * Validate an address. We accept loose (`agent://odin`) and
122
+ * targeted (`agent://odin/session-abc123`) plus the broadcast
123
+ * wildcard `agent://*` for fan-out.
124
+ *
125
+ * @param {string} addr
126
+ * @returns {boolean}
127
+ */
128
+ export function isValidAddress(addr) {
129
+ return typeof addr === 'string'
130
+ && /^agent:\/\/([a-z0-9_-]+|\*)(\/[a-z0-9_-]+)?$/.test(addr);
131
+ }
132
+
133
+ /**
134
+ * Resolve a loose address to its currently live targeted
135
+ * session(s). Returns an array of `{ name, sessionId }`. Empty
136
+ * array if the agent is not present.
137
+ *
138
+ * @param {string} addr e.g. "agent://odin"
139
+ */
140
+ export function resolveAddress(addr) {
141
+ if (!isValidAddress(addr)) return [];
142
+ const m = /^agent:\/\/([a-z0-9_-]+)(?:\/([a-z0-9_-]+))?$/.exec(addr);
143
+ if (!m) return [];
144
+ const [, name, sid] = m;
145
+ if (sid) {
146
+ const p = presence[name];
147
+ if (p && p.sessionId === sid) return [{ name, sessionId: sid }];
148
+ return [];
149
+ }
150
+ // Loose: return every live session of `name`.
151
+ const list = [];
152
+ for (const [n, p] of Object.entries(presence)) {
153
+ if (n === name && p.sessionId) list.push({ name: n, sessionId: p.sessionId });
154
+ }
155
+ return list;
156
+ }
157
+
158
+ /**
159
+ * Publish a message. Returns the assigned message id.
160
+ *
161
+ * @param {Omit<AgentMessage, 'id' | 'ts'>} msg
162
+ * @returns {string} id
163
+ */
164
+ export function publish(msg) {
165
+ if (!msg || typeof msg !== 'object') {
166
+ throw new TypeError('publish: msg must be an object');
167
+ }
168
+ if (!isValidAddress(msg.from)) {
169
+ throw new TypeError(`publish: invalid from address: ${msg.from}`);
170
+ }
171
+ if (!isValidAddress(msg.to)) {
172
+ throw new TypeError(`publish: invalid to address: ${msg.to}`);
173
+ }
174
+ if (!VALID_KINDS.has(msg.kind)) {
175
+ throw new TypeError(`publish: invalid kind: ${msg.kind}`);
176
+ }
177
+ const channel = msg.channel || DEFAULT_CHANNEL;
178
+ const record = {
179
+ id: msg.id || newId(),
180
+ from: msg.from,
181
+ to: msg.to,
182
+ channel,
183
+ kind: msg.kind,
184
+ payload: msg.payload ?? {},
185
+ correlationId: msg.correlationId || null,
186
+ taskId: msg.taskId || null,
187
+ ts: new Date().toISOString(),
188
+ };
189
+
190
+ // 1. Append to JSONL log (persistent audit).
191
+ const logFile = join(AGENT_BUS_LOG_DIR, `${channel}.jsonl`);
192
+ mkdirSync(dirname(logFile), { recursive: true });
193
+ appendFileSync(logFile, JSON.stringify(record) + '\n');
194
+
195
+ // 2. Notify in-process subscribers.
196
+ emitter.emit('message', record);
197
+ emitter.emit(`channel:${channel}`, record);
198
+
199
+ return record.id;
200
+ }
201
+
202
+ /**
203
+ * Convenience: send a steered adjustment to an agent. Wraps
204
+ * `publish` with `kind: 'steer'`. Returns the assigned id.
205
+ *
206
+ * @param {string} from
207
+ * @param {string} to
208
+ * @param {string} note
209
+ * @param {Object} [opts]
210
+ * @param {string} [opts.channel]
211
+ * @param {string} [opts.taskId]
212
+ */
213
+ export function steer(from, to, note, opts = {}) {
214
+ return publish({
215
+ from,
216
+ to,
217
+ channel: opts.channel,
218
+ taskId: opts.taskId,
219
+ kind: 'steer',
220
+ payload: { note: String(note || '') },
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Convenience: send a correction (a steer with attached before/after
226
+ * diff). The receiver should reconcile its current state against
227
+ * the `before` field and apply the `after` change.
228
+ *
229
+ * @param {string} from
230
+ * @param {string} to
231
+ * @param {string} note
232
+ * @param {Object} correction { before, after, reason? }
233
+ * @param {Object} [opts]
234
+ */
235
+ export function correct(from, to, note, correction = {}, opts = {}) {
236
+ return publish({
237
+ from,
238
+ to,
239
+ channel: opts.channel,
240
+ taskId: opts.taskId,
241
+ kind: 'correct',
242
+ payload: {
243
+ note: String(note || ''),
244
+ before: correction.before ?? null,
245
+ after: correction.after ?? null,
246
+ reason: correction.reason || null,
247
+ },
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Convenience: hand off a task to another agent. The receiver should
253
+ * claim ownership (update tasks-store with `assignee`/`updatedBy`)
254
+ * and resume work.
255
+ *
256
+ * @param {string} from
257
+ * @param {string} to
258
+ * @param {string} taskId
259
+ * @param {string} [reason]
260
+ * @param {Object} [opts]
261
+ */
262
+ export function handoff(from, to, taskId, reason = '', opts = {}) {
263
+ return publish({
264
+ from,
265
+ to,
266
+ channel: opts.channel,
267
+ taskId,
268
+ kind: 'handoff',
269
+ payload: { taskId, reason: String(reason || '') },
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Convenience: send a request and (optionally) wait for a
275
+ * correlated response. The returned Promise resolves when a
276
+ * `response` message with matching `correlationId` arrives via
277
+ * `subscribe`, or rejects on timeout.
278
+ *
279
+ * @param {string} from
280
+ * @param {string} to
281
+ * @param {Object} payload
282
+ * @param {Object} [opts]
283
+ * @param {number} [opts.timeoutMs=30000]
284
+ * @param {string} [opts.channel]
285
+ * @returns {Promise<AgentMessage>}
286
+ */
287
+ export function request(from, to, payload, opts = {}) {
288
+ const timeoutMs = Number(opts.timeoutMs) || 30000;
289
+ const channel = opts.channel || DEFAULT_CHANNEL;
290
+ const id = newId();
291
+ return new Promise((resolve, reject) => {
292
+ let settled = false;
293
+ const timer = setTimeout(() => {
294
+ if (settled) return;
295
+ settled = true;
296
+ unsub();
297
+ reject(Object.assign(new Error(`timeout waiting for response to ${id}`), { code: 'TIMEOUT', requestId: id }));
298
+ }, timeoutMs);
299
+ const onMessage = (msg) => {
300
+ if (settled) return;
301
+ if (msg.kind !== 'response') return;
302
+ if (msg.correlationId === id) {
303
+ settled = true;
304
+ clearTimeout(timer);
305
+ unsub();
306
+ resolve(msg);
307
+ }
308
+ };
309
+ const unsub = subscribe(onMessage);
310
+ // Subscribe BEFORE publish so a synchronous response from a
311
+ // same-process subscriber is not missed.
312
+ publish({
313
+ id,
314
+ from,
315
+ to,
316
+ channel,
317
+ kind: 'request',
318
+ payload: payload ?? {},
319
+ });
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Respond to a request. Sets `correlationId` automatically.
325
+ *
326
+ * @param {AgentMessage} requestMsg
327
+ * @param {string} from
328
+ * @param {Object} payload
329
+ * @returns {string} response id
330
+ */
331
+ export function respond(requestMsg, from, payload) {
332
+ return publish({
333
+ from,
334
+ to: requestMsg.from,
335
+ channel: requestMsg.channel,
336
+ taskId: requestMsg.taskId,
337
+ kind: 'response',
338
+ correlationId: requestMsg.id,
339
+ payload: payload ?? {},
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Subscribe to all messages in the current process. Returns an
345
+ * unsubscribe function. The listener receives every message
346
+ * published after subscription.
347
+ *
348
+ * @param {(msg: AgentMessage) => void} listener
349
+ * @returns {() => void}
350
+ */
351
+ export function subscribe(listener) {
352
+ if (typeof listener !== 'function') {
353
+ throw new TypeError('subscribe: listener must be a function');
354
+ }
355
+ emitter.on('message', listener);
356
+ return () => emitter.off('message', listener);
357
+ }
358
+
359
+ /**
360
+ * Subscribe to messages on a specific channel only. Slightly cheaper
361
+ * than `subscribe` for high-frequency per-task channels.
362
+ *
363
+ * @param {string} channel
364
+ * @param {(msg: AgentMessage) => void} listener
365
+ */
366
+ export function subscribeChannel(channel, listener) {
367
+ const ev = `channel:${channel}`;
368
+ emitter.on(ev, listener);
369
+ return () => emitter.off(ev, listener);
370
+ }
371
+
372
+ /**
373
+ * Read recent messages from a channel's JSONL log. Used by:
374
+ * - the dashboard Loops sidebar (initial render before live subscription)
375
+ * - cross-process consumers (CLI tools that tail the log)
376
+ * - the E2E tests (assert persisted messages round-trip)
377
+ *
378
+ * @param {string} channel
379
+ * @param {Object} [opts]
380
+ * @param {number} [opts.limit=50] max messages to return
381
+ * @param {string} [opts.since] ISO ts — only messages after this
382
+ * @returns {AgentMessage[]}
383
+ */
384
+ export function readChannel(channel, opts = {}) {
385
+ const limit = Number(opts.limit) || 50;
386
+ const since = opts.since || null;
387
+ const logFile = join(AGENT_BUS_LOG_DIR, `${channel || DEFAULT_CHANNEL}.jsonl`);
388
+ if (!existsSync(logFile)) return [];
389
+ const text = readFileSync(logFile, 'utf8');
390
+ const out = [];
391
+ for (const line of text.split('\n')) {
392
+ if (!line) continue;
393
+ try {
394
+ const msg = JSON.parse(line);
395
+ if (since && msg.ts <= since) continue;
396
+ out.push(msg);
397
+ } catch { /* skip corrupt line — append-only JSONL can survive a torn write */ }
398
+ }
399
+ return out.slice(-limit);
400
+ }
401
+
402
+ /**
403
+ * Announce presence. Idempotent: calling with the same (name,
404
+ * sessionId) updates the heartbeat timestamp; calling with a
405
+ * different session id replaces the prior entry.
406
+ *
407
+ * @param {Object} entry
408
+ * @param {string} entry.name agent name (e.g. "odin")
409
+ * @param {string} entry.sessionId session id or process id
410
+ * @param {string=} entry.capabilities free-form: ["plan","impl","test"]
411
+ * @param {string=} entry.currentTask task id the agent is currently working on
412
+ * @param {string=} entry.model model name
413
+ */
414
+ export function announce(entry) {
415
+ if (!entry || !entry.name) throw new TypeError('announce: name required');
416
+ if (!entry.sessionId) throw new TypeError('announce: sessionId required');
417
+ presence[entry.name] = {
418
+ sessionId: entry.sessionId,
419
+ capabilities: entry.capabilities || [],
420
+ currentTask: entry.currentTask || null,
421
+ model: entry.model || null,
422
+ lastSeenAt: new Date().toISOString(),
423
+ };
424
+ persistPresence();
425
+ }
426
+
427
+ /**
428
+ * Remove an agent from presence. Called on clean shutdown.
429
+ *
430
+ * @param {string} name
431
+ */
432
+ export function leave(name) {
433
+ if (presence[name]) {
434
+ delete presence[name];
435
+ persistPresence();
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Heartbeat — bump `lastSeenAt` for an agent without changing
441
+ * other fields. Cheap; no file lock, just overwrite.
442
+ *
443
+ * @param {string} name
444
+ * @param {string} sessionId
445
+ */
446
+ export function heartbeat(name, sessionId) {
447
+ if (!presence[name] || presence[name].sessionId !== sessionId) return;
448
+ presence[name].lastSeenAt = new Date().toISOString();
449
+ // Lazy persist — every N calls. Avoids fsync thrash on tight loops.
450
+ persistPresence();
451
+ }
452
+
453
+ /**
454
+ * Snapshot of presence — used by /api/agents/presence and tests.
455
+ * Stale entries (lastSeenAt > 5 min ago) are filtered out.
456
+ *
457
+ * @returns {Object} { [name]: { sessionId, capabilities, currentTask, lastSeenAt } }
458
+ */
459
+ export function getPresence() {
460
+ const cutoff = Date.now() - 5 * 60 * 1000;
461
+ const out = {};
462
+ for (const [name, p] of Object.entries(presence)) {
463
+ const t = Date.parse(p.lastSeenAt);
464
+ if (!Number.isFinite(t) || t < cutoff) continue;
465
+ out[name] = p;
466
+ }
467
+ return out;
468
+ }
469
+
470
+ /**
471
+ * Convenience for tests: reset all state. Calls leave() on every
472
+ * known agent and unlinks the log/presence files.
473
+ */
474
+ export function _resetForTests() {
475
+ for (const name of Object.keys(presence)) delete presence[name];
476
+ try { persistPresence(); } catch { /* */ }
477
+ // Drop all subscribers on the global + per-channel events so tests
478
+ // don't see messages from prior cases.
479
+ emitter.removeAllListeners('message');
480
+ for (const ev of emitter.eventNames()) {
481
+ if (typeof ev === 'string' && ev.startsWith('channel:')) {
482
+ emitter.removeAllListeners(ev);
483
+ }
484
+ }
485
+ }
@@ -52,6 +52,9 @@ import { createThemesRouter } from './routes/themes.mjs';
52
52
  import { createNotificationsRouter } from './routes/notifications.mjs';
53
53
  import { createMinimaxRouter } from './routes/minimax.mjs';
54
54
  import { createMiscRouter } from './routes/misc.mjs';
55
+ // G-autoloop Phase 5 — loop runtime + agent-bus HTTP surface.
56
+ import { createLoopsRouter } from './routes/loops.mjs';
57
+ import { createAgentBusRouter } from './routes/agent-bus.mjs';
55
58
  // Pillar A — session heartbeat list + stop (reads .harness/traces/heartbeat.jsonl)
56
59
  import { createSessionsRouter } from './routes/sessions.mjs';
57
60
  // v9.0.5 — Admin endpoints (gc, cache clear, activity export, memory
@@ -215,6 +218,9 @@ export async function createApiRouter({
215
218
  const { createTailscaleRouter } = await import('./routes/tailscale.mjs');
216
219
  router.use(createTailscaleRouter({}));
217
220
  router.use(createMiscRouter({ state, broadcast }));
221
+ // G-autoloop Phase 5 — background loop runtime + inter-agent bus.
222
+ router.use(createLoopsRouter({ broadcast }));
223
+ router.use(createAgentBusRouter({ broadcast }));
218
224
  // Pillar A — session heartbeat list + stop
219
225
  router.use(createSessionsRouter({ projectRoot }));
220
226
  // v9.0.5 — Settings page admin buttons live at /api/admin/*.