@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.
- package/bizar-dash/src/server/agent-bus.mjs +485 -0
- package/bizar-dash/src/server/api.mjs +6 -0
- package/bizar-dash/src/server/loop-runtime.mjs +429 -0
- package/bizar-dash/src/server/routes/agent-bus.mjs +185 -0
- package/bizar-dash/src/server/routes/loops.mjs +138 -0
- package/bizar-dash/src/server/task-splitter.mjs +241 -0
- package/bizar-dash/tests/agent-bus.test.mjs +289 -0
- package/bizar-dash/tests/loop-runtime.test.mjs +312 -0
- package/bizar-dash/tests/loops-agent-bus-routes.test.mjs +293 -0
- package/bizar-dash/tests/memory-protocol-drift.test.mjs +18 -11
- package/bizar-dash/tests/task-splitter.test.mjs +268 -0
- package/cli/__tests__/feature-list-bridge.test.mjs +42 -42
- package/cli/agent-browser-up.sh +35 -35
- package/cli/agent-browser-update.mjs +42 -42
- package/cli/agent-browser-update.test.mjs +7 -7
- package/cli/audit.mjs +3 -3
- package/cli/bin.mjs +10 -10
- package/cli/bootstrap.mjs +1 -1
- package/cli/cli-commands-validation.test.mjs +2 -2
- package/cli/commands/claim.mjs +3 -3
- package/cli/commands/claude-cmd.mjs +3 -3
- package/cli/commands/eval.mjs +6 -6
- package/cli/commands/util.mjs +18 -18
- package/cli/commands/validate.mjs +10 -10
- package/cli/init.mjs +1 -1
- package/cli/install/postinstall.mjs +4 -4
- package/cli/plow-through.test.mjs +7 -6
- package/cli/prompts.mjs +11 -11
- package/cli/provision.mjs +4 -16
- package/config/skills/bizar/SKILL.md +1 -1
- package/config/skills/skillopt/SKILL.md +129 -0
- package/config/skills/thinking-archetypes/SKILL.md +90 -0
- package/config/skills/thinking-bayesian/SKILL.md +267 -0
- package/config/skills/thinking-bounded-rationality/SKILL.md +406 -0
- package/config/skills/thinking-circle-of-competence/SKILL.md +216 -0
- package/config/skills/thinking-cynefin/SKILL.md +70 -0
- package/config/skills/thinking-debiasing/SKILL.md +192 -0
- package/config/skills/thinking-dual-process/SKILL.md +282 -0
- package/config/skills/thinking-effectuation/SKILL.md +366 -0
- package/config/skills/thinking-feedback-loops/SKILL.md +464 -0
- package/config/skills/thinking-fermi-estimation/SKILL.md +263 -0
- package/config/skills/thinking-first-principles/SKILL.md +167 -0
- package/config/skills/thinking-five-whys-plus/SKILL.md +139 -0
- package/config/skills/thinking-inversion/SKILL.md +195 -0
- package/config/skills/thinking-jobs-to-be-done/SKILL.md +363 -0
- package/config/skills/thinking-kepner-tregoe/SKILL.md +154 -0
- package/config/skills/thinking-leverage-points/SKILL.md +390 -0
- package/config/skills/thinking-lindy-effect/SKILL.md +331 -0
- package/config/skills/thinking-map-territory/SKILL.md +111 -0
- package/config/skills/thinking-margin-of-safety/SKILL.md +330 -0
- package/config/skills/thinking-model-combination/SKILL.md +406 -0
- package/config/skills/thinking-model-router/SKILL.md +360 -0
- package/config/skills/thinking-model-selection/SKILL.md +341 -0
- package/config/skills/thinking-occams-razor/SKILL.md +129 -0
- package/config/skills/thinking-ooda/SKILL.md +127 -0
- package/config/skills/thinking-opportunity-cost/SKILL.md +360 -0
- package/config/skills/thinking-pre-mortem/SKILL.md +170 -0
- package/config/skills/thinking-probabilistic/SKILL.md +324 -0
- package/config/skills/thinking-red-team/SKILL.md +142 -0
- package/config/skills/thinking-regret-minimization/SKILL.md +335 -0
- package/config/skills/thinking-reversibility/SKILL.md +326 -0
- package/config/skills/thinking-scientific-method/SKILL.md +162 -0
- package/config/skills/thinking-second-order/SKILL.md +184 -0
- package/config/skills/thinking-socratic/SKILL.md +198 -0
- package/config/skills/thinking-steel-manning/SKILL.md +332 -0
- package/config/skills/thinking-systems/SKILL.md +238 -0
- package/config/skills/thinking-theory-of-constraints/SKILL.md +338 -0
- package/config/skills/thinking-thought-experiment/SKILL.md +354 -0
- package/config/skills/thinking-triz/SKILL.md +171 -0
- package/config/skills/thinking-via-negativa/SKILL.md +358 -0
- package/package.json +2 -3
- package/packages/sdk/dist/consensus/index.js +4 -4
- package/packages/sdk/dist/router/agent-model-registry.js +2 -2
- package/packages/sdk/dist/router/index.d.ts +3 -3
- package/packages/sdk/dist/router/index.js +4 -4
- package/packages/sdk/dist/router/q-learning-router.d.ts +2 -2
- package/packages/sdk/dist/router/q-learning-router.js +18 -18
- package/packages/sdk/package.json +1 -1
- package/scripts/bh-full-e2e.mjs +10 -8
- package/scripts/check-agents.mjs +27 -24
- package/scripts/mirror-claude-md.sh +0 -0
- package/scripts/sync-skills-mirror.mjs +113 -0
- package/scripts/verify-thinking-skills.mjs +135 -0
- package/config/agents/_shared/AGENT_BASELINE.md +0 -155
- package/config/agents/_shared/CLINE_TOOLS.md +0 -398
- package/config/agents/_shared/SKILLS.md +0 -109
- package/config/agents/agent-browser.md +0 -90
- package/config/agents/baldr.md +0 -60
- package/config/agents/forseti.md +0 -60
- package/config/agents/frigg.md +0 -52
- package/config/agents/heimdall.md +0 -44
- package/config/agents/hermod.md +0 -64
- package/config/agents/mimir.md +0 -60
- package/config/agents/odin.md +0 -310
- package/config/agents/quick.md +0 -47
- package/config/agents/semble-search.md +0 -59
- package/config/agents/thor.md +0 -64
- package/config/agents/tyr.md +0 -67
- package/config/agents/vidarr.md +0 -65
- package/config/agents/vor.md +0 -62
- package/config/commands/audit.md +0 -5
- package/config/commands/bizar.md +0 -22
- package/config/commands/explain.md +0 -5
- package/config/commands/init.md +0 -26
- package/config/commands/learn.md +0 -5
- package/config/commands/plan.md +0 -30
- package/config/commands/plow-through.md +0 -47
- package/config/commands/pr-review.md +0 -5
- package/config/commands/setup-provider.md +0 -95
- package/config/commands/tailscale-serve.md +0 -97
- package/config/commands/team.md +0 -124
- package/config/commands/test.md +0 -60
- package/config/commands/validate.md +0 -66
- package/config/commands/visual-plan.md +0 -19
- package/config/hooks/PostToolUse +0 -66
- package/config/hooks/PreToolUse +0 -82
- package/config/hooks/README.md +0 -68
- package/config/hooks/TaskResume +0 -48
- package/config/hooks/TaskStart +0 -47
- 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/*.
|