@intent-systems/nexus 2026.1.5-3 → 2026.1.5-4

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 (39) hide show
  1. package/dist/capabilities/detector.js +214 -0
  2. package/dist/capabilities/registry.js +98 -0
  3. package/dist/channels/location.js +44 -0
  4. package/dist/channels/web/index.js +2 -0
  5. package/dist/control-plane/broker/broker.js +969 -0
  6. package/dist/control-plane/compaction.js +284 -0
  7. package/dist/control-plane/factory.js +31 -0
  8. package/dist/control-plane/index.js +10 -0
  9. package/dist/control-plane/odu/agents.js +187 -0
  10. package/dist/control-plane/odu/interaction-tools.js +196 -0
  11. package/dist/control-plane/odu/prompt-loader.js +95 -0
  12. package/dist/control-plane/odu/runtime.js +467 -0
  13. package/dist/control-plane/odu/types.js +6 -0
  14. package/dist/control-plane/odu-control-plane.js +314 -0
  15. package/dist/control-plane/single-agent.js +249 -0
  16. package/dist/control-plane/types.js +11 -0
  17. package/dist/credentials/store.js +323 -0
  18. package/dist/logging/redact.js +109 -0
  19. package/dist/markdown/fences.js +58 -0
  20. package/dist/memory/embeddings.js +146 -0
  21. package/dist/memory/index.js +382 -0
  22. package/dist/memory/internal.js +163 -0
  23. package/dist/pairing/pairing-store.js +194 -0
  24. package/dist/plugins/cli.js +42 -0
  25. package/dist/plugins/discovery.js +253 -0
  26. package/dist/plugins/install.js +181 -0
  27. package/dist/plugins/loader.js +290 -0
  28. package/dist/plugins/registry.js +105 -0
  29. package/dist/plugins/status.js +29 -0
  30. package/dist/plugins/tools.js +39 -0
  31. package/dist/plugins/types.js +1 -0
  32. package/dist/routing/resolve-route.js +144 -0
  33. package/dist/routing/session-key.js +63 -0
  34. package/dist/utils/provider-utils.js +28 -0
  35. package/package.json +4 -29
  36. package/patches/@mariozechner__pi-ai.patch +215 -0
  37. package/patches/playwright-core@1.57.0.patch +13 -0
  38. package/patches/qrcode-terminal.patch +12 -0
  39. package/scripts/postinstall.js +202 -0
@@ -0,0 +1,969 @@
1
+ /**
2
+ * Active Message Broker - Agent Lifecycle Manager
3
+ *
4
+ * The broker is responsible for:
5
+ * - Receiving messages for agents
6
+ * - Starting agents when they have work
7
+ * - Restarting agents when new work arrives
8
+ * - Interrupting agents if needed
9
+ * - Managing agent lifecycle
10
+ *
11
+ * Agents are pure workers - they don't check queues or manage themselves.
12
+ *
13
+ * Ported from magic-toolbox and adapted for Nexus:
14
+ * - Uses Nexus session storage (sessions.json + transcript JSONL) instead of SQLite
15
+ * - Uses Nexus logging system (createSubsystemLogger)
16
+ * - Removed instance manager (sharding not yet supported)
17
+ * - Simplified for single ODU initially
18
+ */
19
+ import crypto from "node:crypto";
20
+ import { loadSession, writeSessionMetadata, } from "../../config/sessions.js";
21
+ import { createSubsystemLogger } from "../../logging.js";
22
+ import { normalizeAgentId, DEFAULT_AGENT_ID } from "../../routing/session-key.js";
23
+ /**
24
+ * Active Message Broker - manages agent lifecycle
25
+ */
26
+ export class ActiveMessageBroker {
27
+ // Message queues per agent
28
+ queues = new Map();
29
+ // Currently running agents
30
+ runningAgents = new Map();
31
+ // Agents currently being started (to prevent race conditions)
32
+ startingAgents = new Set();
33
+ // Session status tracking (in-memory only, resets on server restart)
34
+ agentStatus = new Map();
35
+ // Registered IAs (always-on singletons)
36
+ registeredIAs = new Map();
37
+ // Track external callers per agent (who has sent messages to this agent)
38
+ // Format: agentId -> Set<senderAgentId>
39
+ externalCallers = new Map();
40
+ // Delivery mode per agent (programmatic override)
41
+ deliveryModes = new Map();
42
+ // Agent factories per ODU (registered by each ODU)
43
+ agentFactories = new Map();
44
+ // Session store paths per ODU
45
+ sessionStorePaths = new Map();
46
+ // Completion callbacks for async waiting
47
+ completionCallbacks = new Map();
48
+ // Collection support for 'collect' mode
49
+ collectionTimers = new Map();
50
+ collectionBuffers = new Map();
51
+ collectDebounceMs = 500; // Default debounce delay
52
+ collectMaxMessages = 10; // Default max messages before auto-flush
53
+ // Logger
54
+ logger;
55
+ // Config reference
56
+ config;
57
+ constructor(config) {
58
+ this.logger = createSubsystemLogger('broker');
59
+ this.config = config;
60
+ }
61
+ /**
62
+ * Register an ODU with the broker
63
+ * Each ODU registers its agent factory and session store path
64
+ */
65
+ registerODU(oduName, sessionStorePath, agentFactory) {
66
+ this.sessionStorePaths.set(oduName, sessionStorePath);
67
+ this.agentFactories.set(oduName, agentFactory);
68
+ this.logger.info(`ODU registered: ${oduName}`, { oduName, sessionStorePath });
69
+ }
70
+ /**
71
+ * Register an IA with the broker
72
+ * IAs are singleton, always-on agents that can receive messages
73
+ */
74
+ registerIA(iaId, instance) {
75
+ this.registeredIAs.set(iaId, instance);
76
+ this.logger.info(`IA registered: ${iaId}`, { iaId });
77
+ }
78
+ /**
79
+ * Set delivery mode for an agent (programmatic only, for tests)
80
+ */
81
+ setDeliveryMode(agentId, mode) {
82
+ this.deliveryModes.set(agentId, mode);
83
+ this.logger.debug(`Delivery mode set for ${agentId}: ${mode}`, { agentId, mode });
84
+ }
85
+ /**
86
+ * Set collection parameters (for 'collect' mode)
87
+ */
88
+ setCollectionParams(debounceMs, maxMessages) {
89
+ if (debounceMs !== undefined)
90
+ this.collectDebounceMs = debounceMs;
91
+ if (maxMessages !== undefined)
92
+ this.collectMaxMessages = maxMessages;
93
+ this.logger.debug('Collection params updated', { debounceMs: this.collectDebounceMs, maxMessages: this.collectMaxMessages });
94
+ }
95
+ /**
96
+ * Handle collect mode: buffer messages and debounce delivery
97
+ */
98
+ handleCollectMode(message) {
99
+ const agentId = message.to;
100
+ // Add to collection buffer
101
+ if (!this.collectionBuffers.has(agentId)) {
102
+ this.collectionBuffers.set(agentId, []);
103
+ }
104
+ this.collectionBuffers.get(agentId).push(message);
105
+ // Clear existing timer
106
+ if (this.collectionTimers.has(agentId)) {
107
+ clearTimeout(this.collectionTimers.get(agentId));
108
+ }
109
+ // Check if we've hit max messages
110
+ const buffer = this.collectionBuffers.get(agentId);
111
+ if (buffer.length >= this.collectMaxMessages) {
112
+ // Flush immediately
113
+ this.flushCollectionBuffer(agentId);
114
+ return;
115
+ }
116
+ // Set new debounce timer
117
+ const timer = setTimeout(() => {
118
+ this.flushCollectionBuffer(agentId);
119
+ }, this.collectDebounceMs);
120
+ this.collectionTimers.set(agentId, timer);
121
+ }
122
+ /**
123
+ * Flush collection buffer to queue
124
+ */
125
+ flushCollectionBuffer(agentId) {
126
+ const buffer = this.collectionBuffers.get(agentId);
127
+ if (!buffer || buffer.length === 0)
128
+ return;
129
+ this.logger.debug(`Flushing collection buffer for ${agentId}`, { agentId, messageCount: buffer.length });
130
+ // Clear timer and buffer
131
+ if (this.collectionTimers.has(agentId)) {
132
+ clearTimeout(this.collectionTimers.get(agentId));
133
+ this.collectionTimers.delete(agentId);
134
+ }
135
+ this.collectionBuffers.delete(agentId);
136
+ // Enqueue all buffered messages
137
+ for (const message of buffer) {
138
+ this.enqueue(message);
139
+ }
140
+ // Trigger agent execution if not running
141
+ if (!this.runningAgents.has(agentId) && !this.registeredIAs.has(agentId)) {
142
+ const queue = this.queues.get(agentId) || [];
143
+ if (queue.length > 0) {
144
+ void this.startAgentWithBatch(agentId, queue);
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * Send message and wait for synchronous acknowledgment from IA
150
+ * Used for cross-ODU calls where caller needs immediate confirmation
151
+ *
152
+ * @param message - The message to send
153
+ * @returns Promise that resolves with acknowledgment string
154
+ */
155
+ async sendAndWaitForAck(message) {
156
+ this.logger.debug(`Sending with ack wait: ${message.from} → ${message.to}`, { message });
157
+ // Route message (resolve short names to full IDs)
158
+ const resolvedTo = this.routeMessage(message.from, message.to);
159
+ message.to = resolvedTo;
160
+ // Validate target is an IA
161
+ const ia = this.registeredIAs.get(message.to);
162
+ if (!ia) {
163
+ throw new Error(`Cannot wait for ack: ${message.to} is not a registered IA`);
164
+ }
165
+ // Track external caller if needed
166
+ if (message.from !== message.to && message.from !== 'user' && message.from !== 'system') {
167
+ if (!this.externalCallers.has(message.to)) {
168
+ this.externalCallers.set(message.to, new Set());
169
+ }
170
+ this.externalCallers.get(message.to).add(message.from);
171
+ }
172
+ // Add to queue for tracking
173
+ this.enqueue(message);
174
+ // Queue message at IA
175
+ if (ia.queueMessage) {
176
+ ia.queueMessage(message.content, message.priority || 'normal', message.from);
177
+ }
178
+ this.logger.debug(`Waiting for ack from ${message.to}...`, { to: message.to });
179
+ // Kick off processing and wait for acknowledgment
180
+ let ack = '';
181
+ if (ia.processQueue) {
182
+ ack = await ia.processQueue();
183
+ }
184
+ this.logger.debug(`Received ack from ${message.to}`, { to: message.to, ackLength: ack.length });
185
+ return ack;
186
+ }
187
+ /**
188
+ * Send a message to an agent
189
+ *
190
+ * The broker decides everything:
191
+ * - Should we interrupt?
192
+ * - Should we batch?
193
+ * - Should we start the agent?
194
+ */
195
+ async send(message) {
196
+ this.logger.debug(`Message from ${message.from} to ${message.to}`, {
197
+ from: message.from,
198
+ to: message.to,
199
+ priority: message.priority,
200
+ contentPreview: message.content.substring(0, 100),
201
+ });
202
+ // Route message (resolve short names to full IDs)
203
+ const resolvedTo = this.routeMessage(message.from, message.to);
204
+ message.to = resolvedTo;
205
+ this.logger.debug(`Routed to ${resolvedTo}`, { resolvedTo });
206
+ // 1. Track external caller (if not self-message)
207
+ if (message.from !== message.to && message.from !== 'user' && message.from !== 'system') {
208
+ if (!this.externalCallers.has(message.to)) {
209
+ this.externalCallers.set(message.to, new Set());
210
+ }
211
+ this.externalCallers.get(message.to).add(message.from);
212
+ }
213
+ // 2. Handle collect mode (buffer and debounce)
214
+ if (message.deliveryMode === 'collect') {
215
+ this.handleCollectMode(message);
216
+ return; // Don't enqueue immediately
217
+ }
218
+ // 3. Handle steer mode (interrupt like urgent)
219
+ if (message.deliveryMode === 'steer') {
220
+ // Steer mode interrupts current work
221
+ message.priority = 'urgent';
222
+ message.deliveryMode = 'interrupt';
223
+ }
224
+ // 4. Handle followup mode (queue without interrupting)
225
+ if (message.deliveryMode === 'followup') {
226
+ // Followup doesn't interrupt - just queues normally
227
+ // No special handling needed, just enqueue
228
+ }
229
+ // 5. Add to queue
230
+ this.enqueue(message);
231
+ // 3. Decide what to do
232
+ // Check if target is a registered IA
233
+ const ia = this.registeredIAs.get(message.to);
234
+ if (ia) {
235
+ // IA is always running - queue message and trigger processing
236
+ this.logger.debug(`Delivering to IA: ${message.to}`, { to: message.to, contentPreview: message.content.substring(0, 100) });
237
+ // Queue the message with sender information
238
+ if (ia.queueMessage) {
239
+ ia.queueMessage(message.content, message.priority || 'normal', message.from);
240
+ }
241
+ // Trigger processing by calling processQueue() if it exists, otherwise use chatSync
242
+ if (ia.processQueue) {
243
+ this.logger.debug(`Calling processQueue() for ${message.to}`, { to: message.to });
244
+ ia.processQueue().catch((error) => {
245
+ this.logger.error(`IA ${message.to} processQueue error: ${error.message}`, { to: message.to, error: error.message });
246
+ });
247
+ }
248
+ else if (ia.chatSync) {
249
+ // Fallback: Call chatSync with empty string
250
+ this.logger.debug(`Calling chatSync('') for ${message.to} (fallback)`, { to: message.to });
251
+ setImmediate(() => {
252
+ ia.chatSync('').catch((error) => {
253
+ this.logger.error(`IA ${message.to} chatSync error: ${error.message}`, { to: message.to, error: error.message });
254
+ });
255
+ });
256
+ }
257
+ return;
258
+ }
259
+ // Target is an EA - handle normally
260
+ const shouldInterrupt = this.shouldInterrupt(message);
261
+ const running = this.runningAgents.get(message.to);
262
+ if (shouldInterrupt && running) {
263
+ // Interrupt and restart immediately
264
+ this.logger.info(`Interrupting ${message.to} for urgent message`, { agentId: message.to });
265
+ await this.interruptAndRestart(message.to);
266
+ }
267
+ else if (!running && !this.startingAgents.has(message.to)) {
268
+ // Agent not running and not being started - mark as starting and process
269
+ this.startingAgents.add(message.to);
270
+ this.logger.info(`Processing batch for ${message.to}`, { agentId: message.to });
271
+ try {
272
+ await this.processNextBatch(message.to);
273
+ }
274
+ finally {
275
+ this.startingAgents.delete(message.to);
276
+ }
277
+ }
278
+ else {
279
+ // Agent running or being started - let it finish
280
+ this.logger.debug(`Message queued for ${message.to}, will process after current session`, { agentId: message.to });
281
+ // When it completes, broker will process next batch
282
+ }
283
+ }
284
+ /**
285
+ * Route message to correct agent
286
+ * Resolves short names to fully-qualified agent IDs
287
+ *
288
+ * Rules:
289
+ * 1. Fully-qualified name (e.g., "toolbox-ea-worktrees") → Route directly
290
+ * 2. Short name (e.g., "worktrees") → Expand to caller's ODU EA
291
+ */
292
+ routeMessage(from, to) {
293
+ // Rule 1: If fully-qualified, route directly
294
+ if (this.isFullyQualified(to)) {
295
+ // Validate agent exists or can be created
296
+ if (!this.validateAgentExists(to)) {
297
+ throw new Error(`Unknown agent: ${to}. Agent does not exist in session store or running agents.`);
298
+ }
299
+ return to;
300
+ }
301
+ // Rule 2: Short name - expand to caller's ODU EA
302
+ // Special case: 'user' and 'system' default to primary ODU
303
+ let callerODU;
304
+ if (from === 'user' || from === 'system') {
305
+ // Get first registered ODU (primary)
306
+ const odus = Array.from(this.agentFactories.keys());
307
+ callerODU = odus[0] || 'nexus';
308
+ }
309
+ else {
310
+ callerODU = this.getODUName(from);
311
+ }
312
+ const expandedId = this.expandAgentName(callerODU, to);
313
+ this.logger.debug(`Expanded "${to}" to "${expandedId}" for caller ${from}`, { from, to, expandedId });
314
+ return expandedId;
315
+ }
316
+ /**
317
+ * Check if agent name is fully-qualified
318
+ * Fully-qualified format: {oduName}-{ia|ea}-{identifier} OR {oduName}-ia
319
+ * Examples: "toolbox-ea-worktrees", "meta-ia"
320
+ */
321
+ isFullyQualified(name) {
322
+ const parts = name.split('-');
323
+ // Must have at least 2 parts (oduName-ia) or 3+ parts (oduName-ea-identifier)
324
+ if (parts.length < 2) {
325
+ return false;
326
+ }
327
+ // Second part must be 'ia' or 'ea'
328
+ const agentType = parts[1];
329
+ return agentType === 'ia' || agentType === 'ea';
330
+ }
331
+ /**
332
+ * Expand short agent name to fully-qualified ID
333
+ * Short name "worktrees" → "toolbox-ea-worktrees"
334
+ */
335
+ expandAgentName(callerODU, shortName) {
336
+ return `${callerODU}-ea-${shortName}`;
337
+ }
338
+ /**
339
+ * Validate that agent exists or can be created
340
+ * Checks: registered IAs, running agents, or if ODU is registered
341
+ */
342
+ validateAgentExists(agentId) {
343
+ // Check if it's a registered IA
344
+ if (this.registeredIAs.has(agentId)) {
345
+ return true;
346
+ }
347
+ // Check if agent is currently running
348
+ if (this.runningAgents.has(agentId)) {
349
+ return true;
350
+ }
351
+ // Check if ODU is registered (can create agent)
352
+ try {
353
+ const oduName = this.getODUName(agentId);
354
+ const storePath = this.sessionStorePaths.get(oduName);
355
+ if (!storePath) {
356
+ // ODU not registered - can't create agent
357
+ return false;
358
+ }
359
+ // ODU is registered, so we can create the agent if needed
360
+ return true;
361
+ }
362
+ catch (error) {
363
+ this.logger.error(`Error validating agent ${agentId}`, {
364
+ agentId,
365
+ error: error instanceof Error ? error.message : String(error),
366
+ });
367
+ return false;
368
+ }
369
+ }
370
+ /**
371
+ * Check if agent has pending messages
372
+ */
373
+ hasPending(agentId) {
374
+ const queue = this.queues.get(agentId);
375
+ return queue ? queue.length > 0 : false;
376
+ }
377
+ /**
378
+ * Get current session status for an agent
379
+ * Returns 'idle' if agent never started or completed
380
+ */
381
+ getAgentStatus(agentId) {
382
+ return this.agentStatus.get(agentId) || 'idle';
383
+ }
384
+ /**
385
+ * Set session status for an agent
386
+ * Called internally during agent lifecycle
387
+ */
388
+ setAgentStatus(agentId, status) {
389
+ const oldStatus = this.agentStatus.get(agentId);
390
+ this.agentStatus.set(agentId, status);
391
+ this.logger.debug(`Agent ${agentId} status: ${status}`, { agentId, status });
392
+ // Emit status change event
393
+ if (oldStatus !== status) {
394
+ this.emit('agent_status_changed', {
395
+ agentId,
396
+ oldStatus,
397
+ newStatus: status,
398
+ timestamp: Date.now(),
399
+ });
400
+ }
401
+ }
402
+ /**
403
+ * Check if agent is currently active (processing messages)
404
+ */
405
+ isAgentActive(agentId) {
406
+ return this.getAgentStatus(agentId) === 'active';
407
+ }
408
+ /**
409
+ * Get list of external agents that have sent messages to this agent
410
+ * Returns fully-qualified agent IDs
411
+ */
412
+ getExternalCallers(agentId) {
413
+ const callers = this.externalCallers.get(agentId);
414
+ return callers ? Array.from(callers) : [];
415
+ }
416
+ /**
417
+ * Get queue size for agent (for debugging/monitoring)
418
+ */
419
+ getQueueSize(agentId) {
420
+ return this.queues.get(agentId)?.length || 0;
421
+ }
422
+ /**
423
+ * Wait for specific agent to complete
424
+ * Returns Promise that resolves when agent finishes (success or error)
425
+ *
426
+ * Usage:
427
+ * const result = await broker.onceAgentCompletes('toolbox-ea-worktrees');
428
+ * if (result.success) { ... }
429
+ */
430
+ onceAgentCompletes(agentId) {
431
+ return new Promise((resolve) => {
432
+ if (!this.completionCallbacks.has(agentId)) {
433
+ this.completionCallbacks.set(agentId, []);
434
+ }
435
+ this.completionCallbacks.get(agentId).push(resolve);
436
+ });
437
+ }
438
+ /**
439
+ * Decide if we should interrupt based on context-aware rules
440
+ */
441
+ shouldInterrupt(message) {
442
+ // Rule 1: External → IA always interrupts
443
+ if (message.to.endsWith('-ia') && message.metadata?.source === 'user') {
444
+ return true;
445
+ }
446
+ // Rule 2: EA → parent IA never interrupts
447
+ if (message.to.endsWith('-ia') && message.metadata?.source === 'ea') {
448
+ return false;
449
+ }
450
+ // Rule 3: Explicit interrupt mode
451
+ if (message.deliveryMode === 'interrupt') {
452
+ return true;
453
+ }
454
+ // Rule 4: Priority-based
455
+ if (message.priority === 'urgent') {
456
+ return true;
457
+ }
458
+ if (message.priority === 'high') {
459
+ const running = this.runningAgents.get(message.to);
460
+ if (running && (Date.now() - running.startedAt > 30000)) {
461
+ return true; // Running > 30s, interrupt
462
+ }
463
+ }
464
+ return false; // Default: don't interrupt
465
+ }
466
+ /**
467
+ * Process next batch of messages for an agent
468
+ * Batches consecutive messages from the same sender
469
+ *
470
+ * NOTE: Caller should add agent to startingAgents before calling this
471
+ */
472
+ async processNextBatch(agentId) {
473
+ const queue = this.queues.get(agentId);
474
+ if (!queue || queue.length === 0) {
475
+ this.logger.debug(`No messages for ${agentId}`, { agentId });
476
+ return;
477
+ }
478
+ // Get consecutive messages from same sender
479
+ const firstSender = queue[0].from;
480
+ const batch = [];
481
+ // Take all consecutive messages from the same sender
482
+ while (queue.length > 0 && queue[0].from === firstSender) {
483
+ batch.push(queue.shift());
484
+ }
485
+ this.logger.info(`Batched ${batch.length} messages from ${firstSender} for ${agentId}`, {
486
+ agentId,
487
+ batchSize: batch.length,
488
+ sender: firstSender,
489
+ });
490
+ // Start agent with this batch
491
+ await this.startAgentWithBatch(agentId, batch);
492
+ }
493
+ /**
494
+ * Start an agent with a specific batch of messages
495
+ *
496
+ * Steps:
497
+ * 1. Parse agent ID to find ODU
498
+ * 2. Load session history from store
499
+ * 3. Format batch messages
500
+ * 4. Create agent via factory
501
+ * 5. Start agent.execute()
502
+ * 6. Monitor completion
503
+ */
504
+ async startAgentWithBatch(agentId, batch) {
505
+ try {
506
+ // 1. Parse agent ID to find ODU
507
+ const oduName = this.getODUName(agentId);
508
+ const factory = this.agentFactories.get(oduName);
509
+ const storePath = this.sessionStorePaths.get(oduName);
510
+ if (!factory || !storePath) {
511
+ throw new Error(`ODU not registered: ${oduName} (for agent ${agentId})`);
512
+ }
513
+ // 2. Register EA (creates if new, updates if exists)
514
+ const displayName = this.getDisplayName(agentId);
515
+ await this.registerEA(storePath, agentId, displayName);
516
+ // 3. Load session history from store
517
+ const session = await this.loadSessionFromStore(storePath, agentId);
518
+ const history = session?.history || [];
519
+ this.logger.debug(`Loaded session for ${agentId}`, {
520
+ agentId,
521
+ historyLength: history.length,
522
+ displayName,
523
+ });
524
+ // 4. Format batch messages
525
+ let taskDescription;
526
+ const deliveryMode = this.deliveryModes.get(agentId) || 'batch';
527
+ if (deliveryMode === 'single' || batch.length === 1) {
528
+ // Single message
529
+ taskDescription = batch[0].content;
530
+ this.logger.debug(`Processing single message from ${this.getDisplayName(batch[0].from)}`, {
531
+ agentId,
532
+ from: batch[0].from,
533
+ });
534
+ }
535
+ else {
536
+ // Multiple messages from same sender - batch them
537
+ if (batch.length === 1) {
538
+ taskDescription = batch[0].content;
539
+ }
540
+ else {
541
+ taskDescription = batch
542
+ .map((m, i) => `Message ${i + 1}:\n${m.content}`)
543
+ .join('\n\n---\n\n');
544
+ }
545
+ this.logger.debug(`Batched ${batch.length} messages into prompt`, {
546
+ agentId,
547
+ batchSize: batch.length,
548
+ });
549
+ }
550
+ // 5. Create agent via factory
551
+ const agent = factory(agentId, taskDescription, history);
552
+ // 6. Start and track
553
+ const promise = agent.execute();
554
+ // Mark agent as active
555
+ this.setAgentStatus(agentId, 'active');
556
+ // Emit agent started event
557
+ this.emit('agent_started', {
558
+ agentId,
559
+ oduName: this.getODUName(agentId),
560
+ timestamp: Date.now(),
561
+ queueSize: batch.length,
562
+ });
563
+ this.runningAgents.set(agentId, {
564
+ agentId,
565
+ instance: agent,
566
+ promise,
567
+ startedAt: Date.now(),
568
+ status: 'active',
569
+ });
570
+ this.logger.info(`Agent ${agentId} started (status: active)`, {
571
+ agentId,
572
+ historyLength: history.length,
573
+ queuedMessages: batch.length,
574
+ sender: batch[0].from,
575
+ });
576
+ // 7. Monitor completion
577
+ promise
578
+ .then(() => this.onAgentComplete(agentId))
579
+ .catch((error) => this.onAgentError(agentId, error));
580
+ }
581
+ catch (error) {
582
+ this.logger.error(`Failed to start agent ${agentId}`, {
583
+ agentId,
584
+ error: error instanceof Error ? error.message : String(error),
585
+ });
586
+ throw error;
587
+ }
588
+ }
589
+ /**
590
+ * Get display name from fully-qualified agent ID
591
+ * toolbox-ea-worktrees → worktrees
592
+ * meta-ia → meta-ia (keep IAs fully-qualified)
593
+ */
594
+ getDisplayName(agentId) {
595
+ const parts = agentId.split('-');
596
+ if (parts.length >= 3 && parts[1] === 'ea') {
597
+ // EA: return task name part
598
+ return parts.slice(2).join('-');
599
+ }
600
+ // IA or other: return full name
601
+ return agentId;
602
+ }
603
+ /**
604
+ * When agent completes, check for more work
605
+ */
606
+ onAgentComplete(agentId) {
607
+ this.logger.info(`Agent ${agentId} completed`, { agentId });
608
+ // Mark agent as idle
609
+ this.setAgentStatus(agentId, 'idle');
610
+ // Remove from running agents
611
+ this.runningAgents.delete(agentId);
612
+ // Emit agent completed event
613
+ this.emit('agent_completed', {
614
+ agentId,
615
+ oduName: this.getODUName(agentId),
616
+ timestamp: Date.now(),
617
+ success: true,
618
+ });
619
+ // Notify completion listeners
620
+ this.notifyCompletion(agentId, { success: true });
621
+ // Are there more messages queued?
622
+ if (this.hasPending(agentId) && !this.startingAgents.has(agentId)) {
623
+ const queueSize = this.getQueueSize(agentId);
624
+ this.logger.info(`Processing next batch for ${agentId} (${queueSize} messages queued)`, {
625
+ agentId,
626
+ queueSize,
627
+ });
628
+ // Mark as starting and process next batch
629
+ this.startingAgents.add(agentId);
630
+ this.processNextBatch(agentId)
631
+ .then(() => this.startingAgents.delete(agentId))
632
+ .catch((error) => {
633
+ this.logger.error(`Error processing next batch for ${agentId}`, {
634
+ agentId,
635
+ error: error instanceof Error ? error.message : String(error),
636
+ });
637
+ this.startingAgents.delete(agentId);
638
+ });
639
+ }
640
+ else {
641
+ this.logger.debug(`Agent ${agentId} idle (no more messages)`, { agentId });
642
+ }
643
+ }
644
+ /**
645
+ * Handle agent errors
646
+ */
647
+ onAgentError(agentId, error) {
648
+ this.logger.error(`Agent ${agentId} failed`, {
649
+ agentId,
650
+ error: error.message,
651
+ });
652
+ // Mark agent as idle (failed, but can receive new messages)
653
+ this.setAgentStatus(agentId, 'idle');
654
+ // Remove from running
655
+ this.runningAgents.delete(agentId);
656
+ // Notify completion listeners with error
657
+ this.notifyCompletion(agentId, { success: false, error: error.message });
658
+ // Don't restart on error - let user handle it
659
+ // Messages stay in queue for manual intervention
660
+ }
661
+ /**
662
+ * Notify all completion listeners for an agent
663
+ * Called when agent completes (success or error)
664
+ */
665
+ notifyCompletion(agentId, result) {
666
+ const callbacks = this.completionCallbacks.get(agentId) || [];
667
+ callbacks.forEach(cb => cb(result));
668
+ this.completionCallbacks.delete(agentId); // One-time callbacks
669
+ if (callbacks.length > 0) {
670
+ this.logger.debug(`Notified ${callbacks.length} listener(s) for ${agentId}`, {
671
+ agentId,
672
+ listenersCount: callbacks.length,
673
+ success: result.success,
674
+ });
675
+ }
676
+ }
677
+ /**
678
+ * Interrupt a running agent and restart with new messages
679
+ */
680
+ async interruptAndRestart(agentId) {
681
+ const running = this.runningAgents.get(agentId);
682
+ if (!running)
683
+ return;
684
+ this.logger.info(`Sending interrupt to ${agentId}`, { agentId });
685
+ // Send interrupt signal
686
+ const instance = running.instance;
687
+ if (instance.interrupt) {
688
+ instance.interrupt();
689
+ }
690
+ // Wait for graceful stop (agent saves state)
691
+ try {
692
+ await running.promise;
693
+ }
694
+ catch {
695
+ // Interrupt causes early exit, that's expected
696
+ this.logger.debug(`Agent ${agentId} interrupted successfully`, { agentId });
697
+ }
698
+ // Remove from running
699
+ this.runningAgents.delete(agentId);
700
+ // Mark as starting and process next batch with new messages
701
+ if (!this.startingAgents.has(agentId)) {
702
+ this.startingAgents.add(agentId);
703
+ try {
704
+ await this.processNextBatch(agentId);
705
+ }
706
+ finally {
707
+ this.startingAgents.delete(agentId);
708
+ }
709
+ }
710
+ }
711
+ /**
712
+ * Add message to queue with priority + FIFO sorting
713
+ */
714
+ enqueue(message) {
715
+ if (!this.queues.has(message.to)) {
716
+ this.queues.set(message.to, []);
717
+ }
718
+ this.queues.get(message.to).push(message);
719
+ this.sortQueue(message.to);
720
+ // Emit message queued event
721
+ this.emit('message_queued', {
722
+ messageId: message.id,
723
+ from: message.from,
724
+ to: message.to,
725
+ priority: message.priority,
726
+ timestamp: message.timestamp,
727
+ queueSize: this.queues.get(message.to).length,
728
+ });
729
+ }
730
+ /**
731
+ * Sort queue: priority first, then FIFO within priority
732
+ */
733
+ sortQueue(agentId) {
734
+ const queue = this.queues.get(agentId);
735
+ if (!queue)
736
+ return;
737
+ queue.sort((a, b) => {
738
+ const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 };
739
+ const aPriority = priorityOrder[a.priority];
740
+ const bPriority = priorityOrder[b.priority];
741
+ if (aPriority !== bPriority) {
742
+ return aPriority - bPriority;
743
+ }
744
+ return a.timestamp - b.timestamp; // FIFO within same priority
745
+ });
746
+ }
747
+ /**
748
+ * Parse agent ID to determine ODU name
749
+ * Examples:
750
+ * - 'toolbox-ia' → 'toolbox'
751
+ * - 'toolbox-ea-abc123' → 'toolbox'
752
+ * - 'meta-ia' → 'meta'
753
+ */
754
+ getODUName(agentId) {
755
+ const parts = agentId.split('-');
756
+ if (parts.length < 2) {
757
+ throw new Error(`Invalid agent ID format: ${agentId}`);
758
+ }
759
+ // Regular instance: first part is ODU name
760
+ return parts[0];
761
+ }
762
+ /**
763
+ * Load session from Nexus session store (new format)
764
+ */
765
+ async loadSessionFromStore(storePath, agentId) {
766
+ try {
767
+ const sessionKey = `agent:${agentId}`;
768
+ const agentIdNormalized = normalizeAgentId(DEFAULT_AGENT_ID);
769
+ const session = await loadSession(agentIdNormalized, sessionKey);
770
+ if (!session) {
771
+ return null;
772
+ }
773
+ // Load history from new format
774
+ const history = session.history.map(turn => ({
775
+ role: turn.role,
776
+ content: turn.content,
777
+ timestamp: new Date(turn.timestamp).getTime(),
778
+ }));
779
+ return {
780
+ history,
781
+ };
782
+ }
783
+ catch (error) {
784
+ this.logger.error(`Failed to load session for ${agentId}`, {
785
+ agentId,
786
+ error: error instanceof Error ? error.message : String(error),
787
+ });
788
+ return null;
789
+ }
790
+ }
791
+ /**
792
+ * Register or update EA in session store (new format)
793
+ * EAs persist forever once created
794
+ */
795
+ async registerEA(storePath, agentId, taskName) {
796
+ try {
797
+ const sessionKey = `agent:${agentId}`;
798
+ const agentIdNormalized = normalizeAgentId(DEFAULT_AGENT_ID);
799
+ const now = Date.now();
800
+ // Check if EA already exists
801
+ const existing = await loadSession(agentIdNormalized, sessionKey);
802
+ if (existing) {
803
+ // Update last updated timestamp
804
+ await writeSessionMetadata(agentIdNormalized, sessionKey, {
805
+ ...existing.metadata,
806
+ updatedAt: now,
807
+ });
808
+ this.logger.debug(`Updated session for ${agentId}`, { agentId });
809
+ }
810
+ else {
811
+ // Create new EA registration
812
+ const displayName = this.getDisplayName(agentId);
813
+ const oduName = this.getODUName(agentId);
814
+ const newEntry = {
815
+ sessionId: crypto.randomUUID(),
816
+ updatedAt: now,
817
+ displayName: taskName || displayName,
818
+ chatType: 'direct',
819
+ };
820
+ await writeSessionMetadata(agentIdNormalized, sessionKey, {
821
+ ...newEntry,
822
+ created: new Date().toISOString(),
823
+ });
824
+ this.logger.info(`Registered new EA: ${agentId}`, { agentId, oduName, displayName });
825
+ }
826
+ }
827
+ catch (error) {
828
+ this.logger.error(`Failed to register EA ${agentId}`, {
829
+ agentId,
830
+ error: error instanceof Error ? error.message : String(error),
831
+ });
832
+ // Don't throw - registration failure shouldn't block agent execution
833
+ }
834
+ }
835
+ // ============================================================
836
+ // PUBLIC OBSERVABILITY METHODS (for GUI and monitoring)
837
+ // ============================================================
838
+ /**
839
+ * Get all registered IAs
840
+ * Returns array of IA metadata for GUI display
841
+ */
842
+ getRegisteredIAs() {
843
+ const ias = [];
844
+ for (const [id] of this.registeredIAs.entries()) {
845
+ ias.push({
846
+ id,
847
+ oduName: this.getODUName(id),
848
+ });
849
+ }
850
+ return ias;
851
+ }
852
+ /**
853
+ * Get all running EAs with their status
854
+ * Returns array of EA metadata for GUI display
855
+ */
856
+ getRunningAgents() {
857
+ const agents = [];
858
+ for (const [agentId, runningAgent] of this.runningAgents.entries()) {
859
+ agents.push({
860
+ agentId,
861
+ status: runningAgent.status,
862
+ startedAt: runningAgent.startedAt,
863
+ oduName: this.getODUName(agentId),
864
+ queueSize: this.getQueueSize(agentId),
865
+ });
866
+ }
867
+ return agents;
868
+ }
869
+ /**
870
+ * Get all queues with their sizes
871
+ * Returns map of agentId -> queue size
872
+ */
873
+ getAllQueues() {
874
+ const queueSizes = new Map();
875
+ for (const [agentId, queue] of this.queues.entries()) {
876
+ queueSizes.set(agentId, queue.length);
877
+ }
878
+ return queueSizes;
879
+ }
880
+ /**
881
+ * Get all agent IDs (both IAs and EAs) that the broker knows about
882
+ * Includes running, queued, and registered agents
883
+ */
884
+ getAllKnownAgents() {
885
+ const agents = [];
886
+ // Add all registered IAs
887
+ for (const [id] of this.registeredIAs.entries()) {
888
+ agents.push({
889
+ agentId: id,
890
+ type: 'ia',
891
+ status: this.getAgentStatus(id),
892
+ oduName: this.getODUName(id),
893
+ queueSize: this.getQueueSize(id),
894
+ isRunning: false, // IAs are always available, not "running" in the same sense
895
+ });
896
+ }
897
+ // Add all running EAs
898
+ for (const [id, runningAgent] of this.runningAgents.entries()) {
899
+ agents.push({
900
+ agentId: id,
901
+ type: 'ea',
902
+ status: runningAgent.status,
903
+ oduName: this.getODUName(id),
904
+ queueSize: this.getQueueSize(id),
905
+ isRunning: true,
906
+ });
907
+ }
908
+ // Add queued agents that aren't running
909
+ for (const [id, queue] of this.queues.entries()) {
910
+ if (!this.runningAgents.has(id) && !this.registeredIAs.has(id) && queue.length > 0) {
911
+ agents.push({
912
+ agentId: id,
913
+ type: 'ea',
914
+ status: 'idle',
915
+ oduName: this.getODUName(id),
916
+ queueSize: queue.length,
917
+ isRunning: false,
918
+ });
919
+ }
920
+ }
921
+ return agents;
922
+ }
923
+ /**
924
+ * Event emitter support for real-time updates
925
+ * Listeners can subscribe to broker events
926
+ */
927
+ eventListeners = new Map();
928
+ /**
929
+ * Subscribe to broker events
930
+ * Events: 'agent_started', 'agent_completed', 'agent_status_changed', 'message_queued'
931
+ */
932
+ on(event, callback) {
933
+ if (!this.eventListeners.has(event)) {
934
+ this.eventListeners.set(event, []);
935
+ }
936
+ this.eventListeners.get(event).push(callback);
937
+ }
938
+ /**
939
+ * Unsubscribe from broker events
940
+ */
941
+ off(event, callback) {
942
+ const listeners = this.eventListeners.get(event);
943
+ if (!listeners)
944
+ return;
945
+ const index = listeners.indexOf(callback);
946
+ if (index > -1) {
947
+ listeners.splice(index, 1);
948
+ }
949
+ }
950
+ /**
951
+ * Emit event to all subscribers
952
+ */
953
+ emit(event, data) {
954
+ const listeners = this.eventListeners.get(event);
955
+ if (!listeners)
956
+ return;
957
+ for (const callback of listeners) {
958
+ try {
959
+ callback(data);
960
+ }
961
+ catch (error) {
962
+ this.logger.error(`Error in event listener for ${event}`, {
963
+ event,
964
+ error: error instanceof Error ? error.message : String(error),
965
+ });
966
+ }
967
+ }
968
+ }
969
+ }