@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,429 @@
1
+ /**
2
+ * src/server/loop-runtime.mjs
3
+ *
4
+ * v10.6.0 — G-autoloop Phase 2. Background loop runtime.
5
+ *
6
+ * A "loop" is a long-running scheduler that:
7
+ * 1. Wakes every N milliseconds (configurable per-loop).
8
+ * 2. Scans the project's task store for ready tasks (status: 'queued',
9
+ * no unresolved blockers, no inflight assignee).
10
+ * 3. Claims one task at a time (atomic move 'queued' → 'doing') and
11
+ * dispatches it via `taskDelegator.dispatchSingleTask` — the same
12
+ * path the dashboard's "Start" button already uses.
13
+ * 4. Writes a `state.json` snapshot under
14
+ * `<BIZAR_LOOPS_ROOT>/<loopId>/state.json` on every tick so the
15
+ * loop can be resumed cleanly after a crash.
16
+ *
17
+ * Persistence shape (per loop):
18
+ *
19
+ * <BIZAR_LOOPS_ROOT>/<loopId>/state.json
20
+ * {
21
+ * loopId, projectId, name, status, createdAt, updatedAt,
22
+ * intervalMs, lastTickAt, lastError,
23
+ * dispatched: [{ taskId, dispatchedAt, bgInstanceId? }],
24
+ * iteration, maxIterations
25
+ * }
26
+ *
27
+ * `status ∈ { 'pending' | 'running' | 'stopped' | 'error' }`.
28
+ *
29
+ * Crash recovery: on `start()` we read the existing state.json; if
30
+ * `status === 'running'` and `updatedAt` is older than 5 minutes, we
31
+ * assume the previous process died and resume — re-claiming tasks that
32
+ * are still in 'doing' but have no live bg instance (per
33
+ * backgroundStore.list()) and re-dispatching them.
34
+ *
35
+ * Non-goals: This module does NOT depend on the dashboard server. It
36
+ * can be driven from a CLI daemon (`bizar-loop start`) just as easily
37
+ * as from inside the dashboard, since dispatch goes through
38
+ * `taskDelegator` which is the same path both use.
39
+ */
40
+
41
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, unlinkSync, statSync } from 'node:fs';
42
+ import { join, dirname } from 'node:path';
43
+ import { homedir } from 'node:os';
44
+ import { randomBytes } from 'node:crypto';
45
+
46
+ const HOME = homedir();
47
+
48
+ /** Override via env var (used by tests). */
49
+ export const LOOPS_ROOT = process.env.BIZAR_LOOPS_ROOT
50
+ || join(HOME, '.bizar', 'loops');
51
+
52
+ const STATUSES = new Set(['pending', 'running', 'stopped', 'error']);
53
+
54
+ /**
55
+ * Random id for new loops. Short, URL-safe, distinct.
56
+ * @returns {string}
57
+ */
58
+ export function genLoopId() {
59
+ return `loop_${randomBytes(4).toString('hex')}`;
60
+ }
61
+
62
+ function nowIso() {
63
+ return new Date().toISOString();
64
+ }
65
+
66
+ /**
67
+ * Path to a loop's state.json.
68
+ * @param {string} loopId
69
+ */
70
+ export function stateFile(loopId) {
71
+ return join(LOOPS_ROOT, loopId, 'state.json');
72
+ }
73
+
74
+ /**
75
+ * Safely read+parse JSON. Returns fallback on missing or corrupt.
76
+ * @param {string} file
77
+ * @param {Object} fallback
78
+ */
79
+ function safeReadJson(file, fallback) {
80
+ try {
81
+ if (!existsSync(file)) return fallback;
82
+ const text = readFileSync(file, 'utf8');
83
+ if (!text.trim()) return fallback;
84
+ return JSON.parse(text);
85
+ } catch {
86
+ return fallback;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Atomic write: tmp file + rename.
92
+ * @param {string} file
93
+ * @param {Object} data
94
+ */
95
+ function atomicWriteJson(file, data) {
96
+ mkdirSync(dirname(file), { recursive: true });
97
+ const tmp = file + '.tmp';
98
+ writeFileSync(tmp, JSON.stringify(data, null, 2));
99
+ renameSync(tmp, file);
100
+ }
101
+
102
+ /**
103
+ * Validate a partial state object; fill missing fields with safe defaults.
104
+ * @param {Object} input
105
+ * @param {string} loopId
106
+ */
107
+ function normalize(input, loopId) {
108
+ const status = STATUSES.has(input.status) ? input.status : 'pending';
109
+ return {
110
+ loopId,
111
+ projectId: input.projectId || '',
112
+ name: input.name || loopId,
113
+ status,
114
+ createdAt: input.createdAt || nowIso(),
115
+ updatedAt: nowIso(),
116
+ intervalMs: Number(input.intervalMs) > 0 ? Number(input.intervalMs) : 30000,
117
+ lastTickAt: input.lastTickAt || null,
118
+ lastError: input.lastError || null,
119
+ dispatched: Array.isArray(input.dispatched) ? input.dispatched : [],
120
+ iteration: Number.isFinite(input.iteration) ? input.iteration : 0,
121
+ maxIterations: Number.isFinite(input.maxIterations) ? input.maxIterations : Infinity,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Create a new loop. Writes initial state.json and returns it.
127
+ *
128
+ * @param {Object} spec
129
+ * @param {string} spec.projectId
130
+ * @param {string} [spec.name]
131
+ * @param {number} [spec.intervalMs] wake interval; default 30s
132
+ * @param {number} [spec.maxIterations] cap before stopping; default Infinity
133
+ * @returns {Object} state
134
+ */
135
+ export function createLoop(spec = {}) {
136
+ if (!spec.projectId) throw new TypeError('createLoop: projectId required');
137
+ const loopId = genLoopId();
138
+ const state = normalize({ ...spec, status: 'pending' }, loopId);
139
+ atomicWriteJson(stateFile(loopId), state);
140
+ return state;
141
+ }
142
+
143
+ /**
144
+ * Read a loop's state. Returns null if the loop doesn't exist.
145
+ *
146
+ * @param {string} loopId
147
+ * @returns {Object|null}
148
+ */
149
+ export function readLoop(loopId) {
150
+ const file = stateFile(loopId);
151
+ if (!existsSync(file)) return null;
152
+ return safeReadJson(file, null);
153
+ }
154
+
155
+ /**
156
+ * Patch loop state with partial fields. Recomputes updatedAt.
157
+ *
158
+ * @param {string} loopId
159
+ * @param {Object} patch
160
+ * @returns {Object|null} new state, or null if loop doesn't exist
161
+ */
162
+ export function patchLoop(loopId, patch = {}) {
163
+ const cur = readLoop(loopId);
164
+ if (!cur) return null;
165
+ const next = normalize({ ...cur, ...patch, loopId }, loopId);
166
+ atomicWriteJson(stateFile(loopId), next);
167
+ return next;
168
+ }
169
+
170
+ /**
171
+ * Delete a loop's state files. Idempotent.
172
+ *
173
+ * @param {string} loopId
174
+ */
175
+ export function deleteLoop(loopId) {
176
+ const dir = join(LOOPS_ROOT, loopId);
177
+ try {
178
+ if (!existsSync(dir)) return;
179
+ for (const f of readdirSync(dir)) {
180
+ try { unlinkSync(join(dir, f)); } catch { /* */ }
181
+ }
182
+ } catch { /* */ }
183
+ }
184
+
185
+ /**
186
+ * List all loop ids known on disk. Skips entries that aren't directories.
187
+ *
188
+ * @returns {string[]}
189
+ */
190
+ export function listLoops() {
191
+ if (!existsSync(LOOPS_ROOT)) return [];
192
+ const out = [];
193
+ for (const name of readdirSync(LOOPS_ROOT)) {
194
+ try {
195
+ if (statSync(join(LOOPS_ROOT, name)).isDirectory()) out.push(name);
196
+ } catch { /* */ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * Snapshot the full loop registry: { loopId: state, ... }.
203
+ * @returns {Object}
204
+ */
205
+ export function snapshotLoops() {
206
+ const out = {};
207
+ for (const id of listLoops()) {
208
+ const s = readLoop(id);
209
+ if (s) out[id] = s;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // In-process scheduler (Phase 2 wiring)
216
+ // ---------------------------------------------------------------------------
217
+ // We keep an in-process registry of active timers so multiple loops can
218
+ // coexist in one Node process (dashboard server + `bizar-loop start` CLI).
219
+ // Crash recovery is best-effort: a `kill -9` of the loop's owning process
220
+ // loses the timer; the next `start()` of that loopId resumes from state.json
221
+ // (see resume logic below).
222
+
223
+ const activeTimers = new Map(); // loopId → { timer, projectId, intervalMs, getTaskStore, dispatch }
224
+
225
+ function ensureFreshDispatched(state) {
226
+ if (!Array.isArray(state.dispatched)) state.dispatched = [];
227
+ return state;
228
+ }
229
+
230
+ /**
231
+ * Find ready tasks for a project. A task is "ready" when:
232
+ * - status === 'queued'
233
+ * - archived === false
234
+ * - no unmet dependencies
235
+ * - no in-flight assignee (we don't double-dispatch)
236
+ *
237
+ * Pure function over the tasks array so it works with whatever
238
+ * TaskStore abstraction the caller passes in (real store in
239
+ * production, in-memory array in tests).
240
+ *
241
+ * @param {Array} tasks
242
+ * @returns {Array}
243
+ */
244
+ export function pickReadyTasks(tasks) {
245
+ if (!Array.isArray(tasks)) return [];
246
+ const byId = new Map();
247
+ for (const t of tasks) if (t && t.id) byId.set(t.id, t);
248
+ return tasks
249
+ .filter((t) => t && t.status === 'queued' && !t.archived)
250
+ .filter((t) => {
251
+ const deps = Array.isArray(t.dependencies) ? t.dependencies : [];
252
+ return deps.every((d) => {
253
+ const dep = byId.get(d);
254
+ return dep && dep.status === 'done';
255
+ });
256
+ })
257
+ .sort((a, b) => {
258
+ // Priority first (lower number = higher priority), then age.
259
+ const pa = Number.isFinite(a.priority) ? a.priority : 5;
260
+ const pb = Number.isFinite(b.priority) ? b.priority : 5;
261
+ if (pa !== pb) return pa - pb;
262
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Run one tick of the loop: scan → pick the highest-priority ready task
268
+ * → claim it (move 'queued' → 'doing') → dispatch. Returns a structured
269
+ * result the caller can persist.
270
+ *
271
+ * Inputs are injected so tests don't need a real task store. The
272
+ * caller passes:
273
+ * - getTasks: () => Array<Task>
274
+ * - claimTask: (id) => updated Task | null (move to 'doing', set assignee + workedBy)
275
+ * - dispatch: (task) => { ok: boolean, bgInstanceId?: string, error?: string }
276
+ *
277
+ * @param {Object} ctx
278
+ * @param {Function} ctx.getTasks
279
+ * @param {Function} ctx.claimTask
280
+ * @param {Function} ctx.dispatch
281
+ * @returns {Object} { picked, dispatched, iteration, lastError }
282
+ */
283
+ export async function runTick({ getTasks, claimTask, dispatch }) {
284
+ if (typeof getTasks !== 'function') throw new TypeError('runTick: getTasks required');
285
+ if (typeof claimTask !== 'function') throw new TypeError('runTick: claimTask required');
286
+ if (typeof dispatch !== 'function') throw new TypeError('runTick: dispatch required');
287
+ const tasks = getTasks() || [];
288
+ const ready = pickReadyTasks(tasks);
289
+ if (ready.length === 0) {
290
+ return { picked: null, dispatched: null, iteration: 0, lastError: null };
291
+ }
292
+ const target = ready[0];
293
+ const claimed = claimTask(target.id);
294
+ if (!claimed) {
295
+ return { picked: target.id, dispatched: null, iteration: 0, lastError: 'claim_failed' };
296
+ }
297
+ let result;
298
+ try {
299
+ result = await dispatch(claimed);
300
+ } catch (err) {
301
+ return {
302
+ picked: target.id,
303
+ dispatched: null,
304
+ iteration: 0,
305
+ lastError: err && err.message ? String(err.message) : String(err),
306
+ };
307
+ }
308
+ if (!result || !result.ok) {
309
+ return {
310
+ picked: target.id,
311
+ dispatched: null,
312
+ iteration: 0,
313
+ lastError: result && result.error ? String(result.error) : 'dispatch_failed',
314
+ };
315
+ }
316
+ return {
317
+ picked: target.id,
318
+ dispatched: { taskId: target.id, dispatchedAt: nowIso(), bgInstanceId: result.bgInstanceId || null },
319
+ iteration: 1,
320
+ lastError: null,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Start (or resume) a loop. Idempotent — calling twice on the same
326
+ * loopId is a no-op for the second call.
327
+ *
328
+ * @param {string} loopId
329
+ * @param {Object} ctx
330
+ * @param {Function} ctx.getTasks () => Array<Task>
331
+ * @param {Function} ctx.claimTask (id) => Task|null
332
+ * @param {Function} ctx.dispatch (task) => { ok, bgInstanceId?, error? }
333
+ * @param {Function} [ctx.onTick] called after each tick with the result
334
+ * @param {Function} [ctx.onError] called on uncaught tick errors
335
+ * @returns {Object} state
336
+ */
337
+ export function startLoop(loopId, ctx = {}) {
338
+ const cur = readLoop(loopId);
339
+ if (!cur) throw new Error(`startLoop: loop ${loopId} not found`);
340
+ if (activeTimers.has(loopId)) return cur; // already running
341
+
342
+ const tick = async () => {
343
+ const state = readLoop(loopId);
344
+ if (!state || state.status === 'stopped') return;
345
+ let result;
346
+ try {
347
+ result = await runTick({
348
+ getTasks: ctx.getTasks,
349
+ claimTask: ctx.claimTask,
350
+ dispatch: ctx.dispatch,
351
+ });
352
+ } catch (err) {
353
+ patchLoop(loopId, { status: 'error', lastError: err.message || String(err), updatedAt: nowIso() });
354
+ if (typeof ctx.onError === 'function') ctx.onError(err, loopId);
355
+ return;
356
+ }
357
+ ensureFreshDispatched(state);
358
+ const dispatched = result.dispatched
359
+ ? [...state.dispatched, result.dispatched]
360
+ : state.dispatched;
361
+ const iteration = state.iteration + (result.iteration || 0);
362
+ const next = patchLoop(loopId, {
363
+ status: 'running',
364
+ lastTickAt: nowIso(),
365
+ lastError: result.lastError,
366
+ dispatched,
367
+ iteration,
368
+ });
369
+ if (typeof ctx.onTick === 'function') ctx.onTick(result, next);
370
+ if (Number.isFinite(next.maxIterations) && next.iteration >= next.maxIterations) {
371
+ patchLoop(loopId, { status: 'stopped' });
372
+ stopLoop(loopId);
373
+ return;
374
+ }
375
+ };
376
+
377
+ patchLoop(loopId, { status: 'running' });
378
+ const timer = setInterval(tick, cur.intervalMs);
379
+ // Don't keep the Node process alive just to run a loop.
380
+ if (typeof timer.unref === 'function') timer.unref();
381
+ activeTimers.set(loopId, { timer, loopId });
382
+ return readLoop(loopId);
383
+ }
384
+
385
+ /**
386
+ * Stop a loop. Cancels the in-process timer (if any) and marks the
387
+ * state as 'stopped' so a future start() resumes from the snapshot
388
+ * rather than starting fresh.
389
+ *
390
+ * @param {string} loopId
391
+ */
392
+ export function stopLoop(loopId) {
393
+ const entry = activeTimers.get(loopId);
394
+ if (entry) {
395
+ clearInterval(entry.timer);
396
+ activeTimers.delete(loopId);
397
+ }
398
+ const cur = readLoop(loopId);
399
+ // `error` is terminal — don't overwrite it with 'stopped' on a
400
+ // subsequent explicit stop. Otherwise an errored loop silently
401
+ // masks its failure.
402
+ if (cur && cur.status !== 'stopped' && cur.status !== 'error') {
403
+ patchLoop(loopId, { status: 'stopped' });
404
+ }
405
+ return readLoop(loopId);
406
+ }
407
+
408
+ /**
409
+ * Drop the in-process timer without touching state.json. Used by tests
410
+ * and by the dashboard's shutdown handler.
411
+ */
412
+ export function _dropInProcessTimers() {
413
+ for (const [, entry] of activeTimers) clearInterval(entry.timer);
414
+ activeTimers.clear();
415
+ }
416
+
417
+ /**
418
+ * Crash-recovery helper. Returns `{ resumed, orphanedTaskIds }` where
419
+ * `orphanedTaskIds` are tasks that were marked 'doing' but no longer
420
+ * have a live bg instance — those should be re-claimed by a fresh
421
+ * tick after `startLoop`.
422
+ *
423
+ * Currently a no-op placeholder; the real implementation lives in
424
+ * the dashboard integration in Phase 5 (uses backgroundStore.list()
425
+ * to compare). Wire it once Phase 5 lands.
426
+ */
427
+ export function planResume(_loopId) {
428
+ return { resumed: false, orphanedTaskIds: [] };
429
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * src/server/routes/agent-bus.mjs
3
+ *
4
+ * G-autoloop Phase 5 — HTTP surface for the agent-bus.
5
+ *
6
+ * Endpoints (mounted under `/api/agent-bus`):
7
+ * GET /api/agent-bus/presence — current presence snapshot
8
+ * GET /api/agent-bus/channels — list known channels
9
+ * GET /api/agent-bus/channels/:name — recent messages on a channel
10
+ * POST /api/agent-bus/channels/:name/publish — publish a raw message
11
+ * POST /api/agent-bus/steer — convenience: steer wrapper
12
+ * POST /api/agent-bus/correct — convenience: correct wrapper
13
+ * POST /api/agent-bus/handoff — convenience: handoff wrapper
14
+ * POST /api/agent-bus/announce — update presence
15
+ * DELETE /api/agent-bus/agents/:name — leave (remove from presence)
16
+ *
17
+ * Phase 5 deliberately keeps this endpoint surface read-mostly with
18
+ * a thin publish helper — the consumer sites (Loop sidebar, Agent
19
+ * viewer) are mostly subscribers via WebSocket, not publishers. The
20
+ * `publish` endpoint is included so curl/operators can drop a steer
21
+ * in from outside without writing a node script.
22
+ *
23
+ * Authz: every endpoint goes through the bearer-token gate the server
24
+ * already applies. We deliberately do NOT enforce address-from ==
25
+ * current user; that's reserved for a later pill once we layer in the
26
+ * Bizar identity model.
27
+ */
28
+
29
+ import { Router } from 'express';
30
+ import { existsSync, readdirSync, statSync } from 'node:fs';
31
+ import {
32
+ getPresence,
33
+ announce,
34
+ leave,
35
+ readChannel,
36
+ publish as busPublish,
37
+ steer as busSteer,
38
+ correct as busCorrect,
39
+ handoff as busHandoff,
40
+ AGENT_BUS_LOG_DIR,
41
+ DEFAULT_CHANNEL,
42
+ isValidAddress,
43
+ } from '../agent-bus.mjs';
44
+ import { wrap } from './_shared.mjs';
45
+
46
+ const VALID_KINDS = new Set(['request', 'response', 'steer', 'correct', 'handoff', 'ack', 'status', 'error']);
47
+
48
+ /**
49
+ * @param {Object} deps
50
+ * @param {Function} [deps.broadcast]
51
+ */
52
+ export function createAgentBusRouter({ broadcast = () => {} } = {}) {
53
+ const router = Router();
54
+
55
+ router.get('/agent-bus/presence', wrap(async (req, res) => {
56
+ const p = getPresence();
57
+ res.json({ agents: p, count: Object.keys(p).length });
58
+ }));
59
+
60
+ router.get('/agent-bus/channels', wrap(async (req, res) => {
61
+ if (!existsSync(AGENT_BUS_LOG_DIR)) {
62
+ res.json({ channels: [], count: 0 });
63
+ return;
64
+ }
65
+ const channels = [];
66
+ for (const f of readdirSync(AGENT_BUS_LOG_DIR)) {
67
+ if (!f.endsWith('.jsonl')) continue;
68
+ try {
69
+ const fullStat = statSync(`${AGENT_BUS_LOG_DIR}/${f}`);
70
+ if (fullStat.isFile()) {
71
+ channels.push({
72
+ name: f.replace(/\.jsonl$/, ''),
73
+ bytes: fullStat.size,
74
+ });
75
+ }
76
+ } catch { /* ignore */ }
77
+ }
78
+ res.json({ channels, count: channels.length });
79
+ }));
80
+
81
+ router.get('/agent-bus/channels/:name', wrap(async (req, res) => {
82
+ const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
83
+ const since = req.query.since || null;
84
+ const messages = readChannel(req.params.name, { limit, since });
85
+ res.json({ channel: req.params.name, messages, count: messages.length });
86
+ }));
87
+
88
+ router.post('/agent-bus/channels/:name/publish', wrap(async (req, res) => {
89
+ const body = req.body || {};
90
+ if (!body.from || !isValidAddress(body.from)) {
91
+ res.status(400).json({ error: 'bad_request', message: 'from (valid agent://address) required' });
92
+ return;
93
+ }
94
+ if (!body.to || !isValidAddress(body.to)) {
95
+ res.status(400).json({ error: 'bad_request', message: 'to (valid agent://address) required' });
96
+ return;
97
+ }
98
+ if (!body.kind || !VALID_KINDS.has(body.kind)) {
99
+ res.status(400).json({ error: 'bad_request', message: 'kind required and valid' });
100
+ return;
101
+ }
102
+ const id = busPublish({
103
+ from: body.from,
104
+ to: body.to,
105
+ channel: req.params.name === DEFAULT_CHANNEL ? undefined : req.params.name,
106
+ kind: body.kind,
107
+ payload: body.payload || {},
108
+ correlationId: body.correlationId || null,
109
+ taskId: body.taskId || null,
110
+ });
111
+ broadcast({ type: 'agent-bus:message', channel: req.params.name });
112
+ res.status(202).json({ id, ok: true });
113
+ }));
114
+
115
+ router.post('/agent-bus/steer', wrap(async (req, res) => {
116
+ const body = req.body || {};
117
+ if (!body.from || !body.to) {
118
+ res.status(400).json({ error: 'bad_request', message: 'from and to required' });
119
+ return;
120
+ }
121
+ const id = busSteer(body.from, body.to, body.note || '', {
122
+ channel: body.channel,
123
+ taskId: body.taskId,
124
+ });
125
+ if (body.channel) broadcast({ type: 'agent-bus:message', channel: body.channel });
126
+ res.status(202).json({ id, ok: true });
127
+ }));
128
+
129
+ router.post('/agent-bus/correct', wrap(async (req, res) => {
130
+ const body = req.body || {};
131
+ if (!body.from || !body.to) {
132
+ res.status(400).json({ error: 'bad_request', message: 'from and to required' });
133
+ return;
134
+ }
135
+ const id = busCorrect(body.from, body.to, body.note || '', {
136
+ before: body.before ?? null,
137
+ after: body.after ?? null,
138
+ reason: body.reason || null,
139
+ }, {
140
+ channel: body.channel,
141
+ taskId: body.taskId,
142
+ });
143
+ if (body.channel) broadcast({ type: 'agent-bus:message', channel: body.channel });
144
+ res.status(202).json({ id, ok: true });
145
+ }));
146
+
147
+ router.post('/agent-bus/handoff', wrap(async (req, res) => {
148
+ const body = req.body || {};
149
+ if (!body.from || !body.to || !body.taskId) {
150
+ res.status(400).json({ error: 'bad_request', message: 'from, to, taskId required' });
151
+ return;
152
+ }
153
+ const id = busHandoff(body.from, body.to, body.taskId, body.reason || '', {
154
+ channel: body.channel,
155
+ });
156
+ if (body.channel) broadcast({ type: 'agent-bus:message', channel: body.channel });
157
+ res.status(202).json({ id, ok: true });
158
+ }));
159
+
160
+ router.post('/agent-bus/announce', wrap(async (req, res) => {
161
+ const body = req.body || {};
162
+ try {
163
+ announce({
164
+ name: body.name,
165
+ sessionId: body.sessionId,
166
+ capabilities: body.capabilities,
167
+ currentTask: body.currentTask,
168
+ model: body.model,
169
+ });
170
+ } catch (err) {
171
+ res.status(400).json({ error: 'bad_request', message: err.message });
172
+ return;
173
+ }
174
+ broadcast({ type: 'agent-bus:presence', agents: getPresence() });
175
+ res.json({ ok: true });
176
+ }));
177
+
178
+ router.delete('/agent-bus/agents/:name', wrap(async (req, res) => {
179
+ leave(req.params.name);
180
+ broadcast({ type: 'agent-bus:presence', agents: getPresence() });
181
+ res.status(204).end();
182
+ }));
183
+
184
+ return router;
185
+ }