@decibelsystems/tools 2.0.2 → 2.1.1

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 (96) hide show
  1. package/README.md +115 -233
  2. package/dist/config/queueableActions.d.ts +11 -0
  3. package/dist/config/queueableActions.d.ts.map +1 -0
  4. package/dist/config/queueableActions.js +22 -0
  5. package/dist/config/queueableActions.js.map +1 -0
  6. package/dist/daemon.d.ts +29 -0
  7. package/dist/daemon.d.ts.map +1 -1
  8. package/dist/daemon.js +64 -0
  9. package/dist/daemon.js.map +1 -1
  10. package/dist/daemonConfig.d.ts +7 -0
  11. package/dist/daemonConfig.d.ts.map +1 -1
  12. package/dist/daemonConfig.js +9 -0
  13. package/dist/daemonConfig.js.map +1 -1
  14. package/dist/facades/definitions.d.ts.map +1 -1
  15. package/dist/facades/definitions.js +130 -33
  16. package/dist/facades/definitions.js.map +1 -1
  17. package/dist/httpQueueDetection.d.ts +16 -0
  18. package/dist/httpQueueDetection.d.ts.map +1 -0
  19. package/dist/httpQueueDetection.js +36 -0
  20. package/dist/httpQueueDetection.js.map +1 -0
  21. package/dist/httpServer.d.ts +4 -0
  22. package/dist/httpServer.d.ts.map +1 -1
  23. package/dist/httpServer.js +224 -97
  24. package/dist/httpServer.js.map +1 -1
  25. package/dist/kernel.d.ts +2 -0
  26. package/dist/kernel.d.ts.map +1 -1
  27. package/dist/kernel.js +1 -0
  28. package/dist/kernel.js.map +1 -1
  29. package/dist/server.js +30 -1
  30. package/dist/server.js.map +1 -1
  31. package/dist/tools/agentic/agentQueue.d.ts +50 -0
  32. package/dist/tools/agentic/agentQueue.d.ts.map +1 -0
  33. package/dist/tools/agentic/agentQueue.js +226 -0
  34. package/dist/tools/agentic/agentQueue.js.map +1 -0
  35. package/dist/tools/agentic/index.d.ts +2 -0
  36. package/dist/tools/agentic/index.d.ts.map +1 -1
  37. package/dist/tools/agentic/index.js +84 -0
  38. package/dist/tools/agentic/index.js.map +1 -1
  39. package/dist/tools/coordinator/coordinator.d.ts +14 -0
  40. package/dist/tools/coordinator/coordinator.d.ts.map +1 -1
  41. package/dist/tools/coordinator/coordinator.js +101 -0
  42. package/dist/tools/coordinator/coordinator.js.map +1 -1
  43. package/dist/tools/coordinator/index.d.ts +2 -0
  44. package/dist/tools/coordinator/index.d.ts.map +1 -1
  45. package/dist/tools/coordinator/index.js +2 -0
  46. package/dist/tools/coordinator/index.js.map +1 -1
  47. package/dist/tools/decibel/index.d.ts +3 -0
  48. package/dist/tools/decibel/index.d.ts.map +1 -0
  49. package/dist/tools/decibel/index.js +150 -0
  50. package/dist/tools/decibel/index.js.map +1 -0
  51. package/dist/tools/decibel.d.ts +13 -0
  52. package/dist/tools/decibel.d.ts.map +1 -0
  53. package/dist/tools/decibel.js +156 -0
  54. package/dist/tools/decibel.js.map +1 -0
  55. package/dist/tools/deck.d.ts +4 -4
  56. package/dist/tools/deck.d.ts.map +1 -1
  57. package/dist/tools/deck.js +1546 -78
  58. package/dist/tools/deck.js.map +1 -1
  59. package/dist/tools/designer/index.d.ts +6 -0
  60. package/dist/tools/designer/index.d.ts.map +1 -1
  61. package/dist/tools/designer/index.js +344 -1
  62. package/dist/tools/designer/index.js.map +1 -1
  63. package/dist/tools/designer.d.ts +143 -0
  64. package/dist/tools/designer.d.ts.map +1 -1
  65. package/dist/tools/designer.js +457 -0
  66. package/dist/tools/designer.js.map +1 -1
  67. package/dist/tools/guardian/index.d.ts +1 -0
  68. package/dist/tools/guardian/index.d.ts.map +1 -1
  69. package/dist/tools/guardian/index.js +40 -1
  70. package/dist/tools/guardian/index.js.map +1 -1
  71. package/dist/tools/guardian.d.ts +30 -0
  72. package/dist/tools/guardian.d.ts.map +1 -1
  73. package/dist/tools/guardian.js +96 -0
  74. package/dist/tools/guardian.js.map +1 -1
  75. package/dist/tools/index.d.ts.map +1 -1
  76. package/dist/tools/index.js +7 -1
  77. package/dist/tools/index.js.map +1 -1
  78. package/dist/tools/mother.d.ts +11 -0
  79. package/dist/tools/mother.d.ts.map +1 -0
  80. package/dist/tools/mother.js +503 -0
  81. package/dist/tools/mother.js.map +1 -0
  82. package/dist/tools/swarm.d.ts +11 -0
  83. package/dist/tools/swarm.d.ts.map +1 -0
  84. package/dist/tools/swarm.js +701 -0
  85. package/dist/tools/swarm.js.map +1 -0
  86. package/dist/tools/terminal.d.ts +17 -0
  87. package/dist/tools/terminal.d.ts.map +1 -0
  88. package/dist/tools/terminal.js +968 -0
  89. package/dist/tools/terminal.js.map +1 -0
  90. package/dist/transports/http.d.ts.map +1 -1
  91. package/dist/transports/http.js +2 -0
  92. package/dist/transports/http.js.map +1 -1
  93. package/dist/transports/types.d.ts +4 -0
  94. package/dist/transports/types.d.ts.map +1 -1
  95. package/package.json +1 -1
  96. package/templates/docs.html +1084 -0
@@ -31,17 +31,20 @@ import { readFileSync } from 'fs';
31
31
  import { fileURLToPath } from 'url';
32
32
  import { dirname, join } from 'path';
33
33
  import { log } from './config.js';
34
- import { isSupabaseConfigured } from './lib/supabase.js';
34
+ import { isSupabaseConfigured, getSupabaseServiceClient } from './lib/supabase.js';
35
+ import { shouldQueueForAgent, parseToolCall } from './httpQueueDetection.js';
35
36
  import { getLicenseValidator } from './license.js';
36
37
  import { listProjects } from './projectRegistry.js';
37
38
  import { listEpics, listRepoIssues, isProjectResolutionError, } from './tools/sentinel.js';
38
39
  import { voiceInboxAdd, } from './tools/voice.js';
39
40
  import { generateImage, getImageStatus, meshyGenerate, getMeshyStatus, meshyDownload, tripoGenerate, getTripoStatus, tripoDownload, klingGenerateVideo, klingGenerateTextVideo, klingGenerateAvatar, getKlingStatus, listTasks, } from './tools/studio/index.js';
40
- // Module-level kernel reference — set by startHttpServer()
41
+ // Module-level references — set by startHttpServer()
41
42
  let kernel;
42
43
  let landingPageHtml = '';
43
44
  let startedAt = 0;
44
45
  let sseConnectionCount = 0;
46
+ let agentRegistry;
47
+ let daemonConfig;
45
48
  // ============================================================================
46
49
  // Security: Body Size Limit
47
50
  // ============================================================================
@@ -52,19 +55,26 @@ const MAX_BODY_BYTES = 1_048_576; // 1MB
52
55
  class RateLimiter {
53
56
  windows = new Map();
54
57
  maxRpm;
58
+ agentLimits = new Map();
55
59
  constructor(maxRpm) {
56
60
  this.maxRpm = maxRpm;
57
61
  }
58
- /** Returns true if the request should be allowed */
59
- check(ip) {
62
+ /** Returns true if the request should be allowed. Uses agent-scoped key when agentId is provided. */
63
+ check(ip, agentId) {
64
+ const key = agentId ? `agent:${agentId}` : `ip:${ip}`;
65
+ const limit = agentId ? (this.agentLimits.get(agentId) ?? this.maxRpm) : this.maxRpm;
60
66
  const now = Date.now();
61
- const entry = this.windows.get(ip);
67
+ const entry = this.windows.get(key);
62
68
  if (!entry || now - entry.start > 60_000) {
63
- this.windows.set(ip, { count: 1, start: now });
69
+ this.windows.set(key, { count: 1, start: now });
64
70
  return true;
65
71
  }
66
72
  entry.count++;
67
- return entry.count <= this.maxRpm;
73
+ return entry.count <= limit;
74
+ }
75
+ /** Set a per-agent RPM override */
76
+ setAgentLimit(agentId, rpm) {
77
+ this.agentLimits.set(agentId, rpm);
68
78
  }
69
79
  /** Update the max RPM (e.g. on config reload) */
70
80
  setMaxRpm(rpm) {
@@ -73,9 +83,9 @@ class RateLimiter {
73
83
  /** Periodic cleanup of expired windows */
74
84
  cleanup() {
75
85
  const now = Date.now();
76
- for (const [ip, entry] of this.windows) {
86
+ for (const [key, entry] of this.windows) {
77
87
  if (now - entry.start > 60_000) {
78
- this.windows.delete(ip);
88
+ this.windows.delete(key);
79
89
  }
80
90
  }
81
91
  }
@@ -116,91 +126,18 @@ const PKG = getVersion();
116
126
  /**
117
127
  * Generate landing page HTML from facade definitions.
118
128
  */
119
- function buildLandingPageHtml(facades) {
120
- const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
121
- const totalActions = facades.reduce((sum, f) => sum + f.actions.length, 0);
122
- const toolSections = facades
123
- .sort((a, b) => a.name.localeCompare(b.name))
124
- .map(f => {
125
- const rows = f.actions
126
- .sort()
127
- .map(action => {
128
- return ` <tr><td class="tool-name">${escHtml(f.name)}(${escHtml(action)})</td><td class="tool-desc"></td></tr>`;
129
- })
130
- .join('\n');
131
- return ` <div class="module-section">
132
- <h3>${escHtml(f.name)} <span class="tool-count">${f.actions.length} actions</span></h3>
133
- <p style="color:#888;font-size:0.85rem;margin-bottom:0.75rem">${escHtml(f.description.split('.')[0])}</p>
134
- <table>${rows}</table>
135
- </div>`;
136
- }).join('\n');
137
- return `<!DOCTYPE html>
138
- <html lang="en">
139
- <head>
140
- <meta charset="UTF-8">
141
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
142
- <title>Decibel Tools - ${facades.length} Facades</title>
143
- <style>
144
- * { box-sizing: border-box; margin: 0; padding: 0; }
145
- body {
146
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
147
- background: #0a0a0a;
148
- color: #e5e5e5;
149
- min-height: 100vh;
150
- display: flex;
151
- flex-direction: column;
152
- align-items: center;
153
- padding: 2rem;
129
+ function buildLandingPageHtml(_facades) {
130
+ // Load full API docs from template (uses module-level __dirname)
131
+ const templatePath = join(__dirname, '..', 'templates', 'docs.html');
132
+ try {
133
+ return readFileSync(templatePath, 'utf-8')
134
+ .replace(/\{\{VERSION\}\}/g, PKG.version)
135
+ .replace(/\{\{FACADE_COUNT\}\}/g, String(_facades.length))
136
+ .replace(/\{\{TOOL_COUNT\}\}/g, String(_facades.reduce((s, f) => s + f.actions.length, 0)));
154
137
  }
155
- .container { max-width: 900px; width: 100%; }
156
- h1 {
157
- font-size: 2.5rem;
158
- font-weight: 700;
159
- margin-bottom: 0.5rem;
160
- background: linear-gradient(135deg, #fff 0%, #888 100%);
161
- -webkit-background-clip: text;
162
- -webkit-text-fill-color: transparent;
138
+ catch {
139
+ return `<html><body><h1>Decibel Tools v${PKG.version}</h1><p>Docs template not found.</p></body></html>`;
163
140
  }
164
- .tagline { font-size: 1.25rem; color: #888; margin-bottom: 0.5rem; }
165
- .tool-total { font-size: 0.9rem; color: #555; margin-bottom: 2rem; }
166
- .features { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin-bottom: 2rem; }
167
- .feature { background: #111; border: 1px solid #222; border-radius: 8px; padding: 1.25rem; }
168
- .feature h3 { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.5rem; color: #fff; }
169
- .feature p { font-size: 0.85rem; color: #888; line-height: 1.5; }
170
- .module-section { background: #111; border: 1px solid #222; border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
171
- .module-section h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.75rem; color: #fff; text-transform: capitalize; }
172
- .tool-count { font-size: 0.75rem; color: #555; font-weight: 400; margin-left: 0.5rem; }
173
- table { width: 100%; border-collapse: collapse; }
174
- tr { border-bottom: 1px solid #1a1a1a; }
175
- tr:last-child { border-bottom: none; }
176
- td { padding: 0.4rem 0; vertical-align: top; font-size: 0.8rem; }
177
- .tool-name { color: #ccc; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; white-space: nowrap; padding-right: 1rem; width: 1%; }
178
- .tool-desc { color: #666; }
179
- footer { margin-top: 2rem; padding-top: 2rem; font-size: 0.8rem; color: #444; }
180
- footer a { color: #666; text-decoration: none; }
181
- footer a:hover { color: #888; }
182
- @media (max-width: 600px) { .features { grid-template-columns: 1fr; } }
183
- </style>
184
- </head>
185
- <body>
186
- <div class="container">
187
- <h1>Decibel Tools</h1>
188
- <p class="tagline">Project intelligence for AI coding agents</p>
189
- <p class="tool-total">${facades.length} facades, ${totalActions} actions &middot; v${PKG.version}</p>
190
- <div class="features">
191
- <div class="feature"><h3>Sentinel</h3><p>Track epics, issues, and incidents. Your agent knows what's in flight.</p></div>
192
- <div class="feature"><h3>Architect</h3><p>Record ADRs and decisions. Context persists across sessions.</p></div>
193
- <div class="feature"><h3>Dojo</h3><p>Incubate ideas with wishes, proposals, and experiments.</p></div>
194
- <div class="feature"><h3>Oracle</h3><p>Get AI-powered recommendations on what to work on next.</p></div>
195
- </div>
196
- ${toolSections}
197
- <footer>
198
- <a href="https://github.com/decibelsystems/decibel-tools-beta">GitHub</a> &middot;
199
- <a href="https://modelcontextprotocol.io">MCP Protocol</a>
200
- </footer>
201
- </div>
202
- </body>
203
- </html>`;
204
141
  }
205
142
  // ============================================================================
206
143
  // Response Helpers
@@ -277,8 +214,30 @@ async function parseBody(req) {
277
214
  async function executeTool(tool, args, req, tierOverride) {
278
215
  try {
279
216
  // Extract agent context from HTTP headers
217
+ const headerAgentId = req?.headers['x-agent-id'];
218
+ // Queue detection: if this is a write call from a remote agent, queue instead of execute
219
+ if (headerAgentId && shouldQueueForAgent(tool, args, headerAgentId)) {
220
+ const parsed = parseToolCall(tool, args);
221
+ const { action: _action, ...queueArgs } = args;
222
+ return await queueForAgent(parsed.facade, parsed.action, queueArgs, headerAgentId, args.projectId || 'default');
223
+ }
224
+ // Resolve allowedFacades: header > agent registry > agent config > none
225
+ let allowedFacades;
226
+ const facadesHeader = req?.headers['x-allowed-facades'];
227
+ if (facadesHeader) {
228
+ allowedFacades = facadesHeader.split(',').map(s => s.trim()).filter(Boolean);
229
+ }
230
+ else if (headerAgentId && agentRegistry) {
231
+ const registeredAgent = agentRegistry.get(headerAgentId);
232
+ if (registeredAgent?.allowedFacades) {
233
+ allowedFacades = registeredAgent.allowedFacades;
234
+ }
235
+ }
236
+ if (!allowedFacades && headerAgentId && daemonConfig?.agents?.[headerAgentId]?.allowed_facades) {
237
+ allowedFacades = daemonConfig.agents[headerAgentId].allowed_facades;
238
+ }
280
239
  const context = req ? {
281
- agentId: req.headers['x-agent-id'],
240
+ agentId: headerAgentId,
282
241
  runId: req.headers['x-run-id'],
283
242
  parentCallId: req.headers['x-parent-call-id'],
284
243
  scope: req.headers['x-scope'],
@@ -286,6 +245,7 @@ async function executeTool(tool, args, req, tierOverride) {
286
245
  userKey: req.headers['x-user-key'],
287
246
  requestId: req.headers['x-request-id'],
288
247
  tier: tierOverride,
248
+ allowedFacades,
289
249
  } : tierOverride ? { tier: tierOverride } : undefined;
290
250
  const toolResult = await kernel.dispatch(tool, args, context);
291
251
  const text = toolResult.content[0]?.text;
@@ -321,6 +281,35 @@ async function executeTool(tool, args, req, tierOverride) {
321
281
  return wrapError(message, 'EXECUTION_ERROR');
322
282
  }
323
283
  }
284
+ /**
285
+ * Queue a write operation for later local sync instead of executing immediately.
286
+ * Used when a remote agent calls a queueable write action.
287
+ */
288
+ async function queueForAgent(facade, action, args, agentId, projectId) {
289
+ if (!isSupabaseConfigured()) {
290
+ return wrapError('Agent queue requires Supabase configuration', 'QUEUE_UNAVAILABLE');
291
+ }
292
+ const supabase = getSupabaseServiceClient();
293
+ const { data, error } = await supabase
294
+ .from('agent_queue')
295
+ .insert({
296
+ project_id: projectId,
297
+ facade,
298
+ action,
299
+ arguments: args,
300
+ created_by: agentId,
301
+ })
302
+ .select('id')
303
+ .single();
304
+ if (error) {
305
+ return wrapError(`Failed to queue: ${error.message}`, 'QUEUE_ERROR');
306
+ }
307
+ return {
308
+ status: 'queued',
309
+ queue_id: data.id,
310
+ message: 'Queued for local sync. Use agentic queue_status to check result.',
311
+ };
312
+ }
324
313
  /**
325
314
  * Get list of available facades — public API for tool discovery
326
315
  */
@@ -384,14 +373,24 @@ export async function startHttpServer(server, kernelInstance, options) {
384
373
  timeoutMs = 120000, // 2 minute default timeout
385
374
  retryIntervalMs = 3000, // 3s retry for SSE clients
386
375
  rateLimitRpm = 100, // 100 req/min per IP default
387
- isDaemon = false, configLicenseKey, } = options;
376
+ isDaemon = false, configLicenseKey, agentRegistry: optAgentRegistry, daemonConfig: optDaemonConfig, } = options;
388
377
  // Set module-level references
389
378
  kernel = kernelInstance;
390
379
  startedAt = Date.now();
380
+ agentRegistry = optAgentRegistry;
381
+ daemonConfig = optDaemonConfig;
391
382
  log(`HTTP: Using kernel with ${kernel.toolCount} tools`);
392
383
  // Rate limiter (clean up stale entries every 60s)
393
384
  const rateLimiter = new RateLimiter(rateLimitRpm);
394
385
  const rateLimiterCleanup = setInterval(() => rateLimiter.cleanup(), 60_000);
386
+ // Apply per-agent rate limits from daemon config
387
+ if (daemonConfig?.agents) {
388
+ for (const [agentId, agentCfg] of Object.entries(daemonConfig.agents)) {
389
+ if (agentCfg.rate_limit_rpm) {
390
+ rateLimiter.setAgentLimit(agentId, agentCfg.rate_limit_rpm);
391
+ }
392
+ }
393
+ }
395
394
  // Build landing page from actual tool list
396
395
  landingPageHtml = buildLandingPageHtml(getAvailableTools());
397
396
  // Create transport in STATELESS mode (better for ChatGPT compatibility)
@@ -450,7 +449,7 @@ export async function startHttpServer(server, kernelInstance, options) {
450
449
  }
451
450
  }
452
451
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
453
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Accept');
452
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Accept, X-Agent-Id, X-Run-Id, X-License-Key, X-Allowed-Facades, X-Scope, X-Request-Id, X-Parent-Call-Id, X-Engagement-Mode, X-User-Key');
454
453
  res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
455
454
  // (a) Handle preflight OPTIONS requests
456
455
  if (req.method === 'OPTIONS') {
@@ -460,11 +459,16 @@ export async function startHttpServer(server, kernelInstance, options) {
460
459
  }
461
460
  // Rate limiting (check before auth to prevent brute force)
462
461
  const clientIp = (req.socket.remoteAddress || '127.0.0.1').replace('::ffff:', '');
463
- if (!rateLimiter.check(clientIp)) {
464
- log(`HTTP: Rate limited ${clientIp}`);
462
+ const reqAgentId = req.headers['x-agent-id'];
463
+ if (!rateLimiter.check(clientIp, reqAgentId)) {
464
+ log(`HTTP: Rate limited ${reqAgentId || clientIp}`);
465
465
  sendJson(res, 429, wrapError('Too many requests (rate limit exceeded)', 'RATE_LIMITED'));
466
466
  return;
467
467
  }
468
+ // Implicit heartbeat: any authenticated request with X-Agent-Id keeps the agent alive
469
+ if (reqAgentId && agentRegistry) {
470
+ agentRegistry.heartbeat(reqAgentId);
471
+ }
468
472
  // Track in-flight request
469
473
  activeRequests.add(req);
470
474
  res.on('finish', () => activeRequests.delete(req));
@@ -503,6 +507,8 @@ export async function startHttpServer(server, kernelInstance, options) {
503
507
  internal_tool_count: kernel.toolCount,
504
508
  connected_clients: activeSseConnections.size,
505
509
  active_requests: activeRequests.size,
510
+ connected_agents: agentRegistry?.count ?? 0,
511
+ agents: agentRegistry?.toJSON() ?? [],
506
512
  pro: licenseTier !== 'core',
507
513
  license_tier: licenseTier,
508
514
  supabase_configured: isSupabaseConfigured(),
@@ -629,6 +635,127 @@ export async function startHttpServer(server, kernelInstance, options) {
629
635
  }));
630
636
  return;
631
637
  }
638
+ // ========================================================================
639
+ // Multi-Agent Endpoints (ISS-0052)
640
+ // ========================================================================
641
+ // POST /connect — Agent handshake with daemon
642
+ if (path === '/connect' && req.method === 'POST') {
643
+ if (!agentRegistry) {
644
+ sendJson(res, 503, wrapError('Agent registry not available (not in daemon mode)', 'NOT_DAEMON'));
645
+ return;
646
+ }
647
+ try {
648
+ const body = await parseBody(req);
649
+ const agentId = body.agent_id;
650
+ if (!agentId) {
651
+ sendJson(res, 400, wrapError('Missing "agent_id" field', 'MISSING_AGENT_ID'));
652
+ return;
653
+ }
654
+ // Look up per-agent config
655
+ const agentCfg = daemonConfig?.agents?.[agentId];
656
+ const agent = agentRegistry.register({
657
+ id: agentId,
658
+ capabilities: body.capabilities || [],
659
+ allowedFacades: agentCfg?.allowed_facades,
660
+ tier: agentCfg?.tier,
661
+ });
662
+ // Apply per-agent rate limit if configured
663
+ if (agentCfg?.rate_limit_rpm) {
664
+ rateLimiter.setAgentLimit(agentId, agentCfg.rate_limit_rpm);
665
+ }
666
+ log(`HTTP: /connect agent=${agentId}`);
667
+ sendJson(res, 200, wrapSuccess({
668
+ agent_id: agent.id,
669
+ connected_at: agent.connectedAt,
670
+ capabilities: agent.capabilities,
671
+ allowed_facades: agent.allowedFacades || null,
672
+ tier: agent.tier || null,
673
+ rate_limit_rpm: agentCfg?.rate_limit_rpm || rateLimitRpm,
674
+ }));
675
+ }
676
+ catch (error) {
677
+ const message = error instanceof Error ? error.message : String(error);
678
+ sendJson(res, 400, wrapError(message, 'CONNECT_ERROR'));
679
+ }
680
+ return;
681
+ }
682
+ // POST /disconnect — Graceful agent disconnect
683
+ if (path === '/disconnect' && req.method === 'POST') {
684
+ if (!agentRegistry) {
685
+ sendJson(res, 503, wrapError('Agent registry not available (not in daemon mode)', 'NOT_DAEMON'));
686
+ return;
687
+ }
688
+ try {
689
+ const body = await parseBody(req);
690
+ const agentId = body.agent_id;
691
+ if (!agentId) {
692
+ sendJson(res, 400, wrapError('Missing "agent_id" field', 'MISSING_AGENT_ID'));
693
+ return;
694
+ }
695
+ const disconnected = agentRegistry.disconnect(agentId);
696
+ log(`HTTP: /disconnect agent=${agentId} (was_connected=${disconnected})`);
697
+ sendJson(res, 200, wrapSuccess({
698
+ agent_id: agentId,
699
+ disconnected,
700
+ }));
701
+ }
702
+ catch (error) {
703
+ const message = error instanceof Error ? error.message : String(error);
704
+ sendJson(res, 400, wrapError(message, 'DISCONNECT_ERROR'));
705
+ }
706
+ return;
707
+ }
708
+ // GET /agents — List connected agents
709
+ if (path === '/agents' && req.method === 'GET') {
710
+ if (!agentRegistry) {
711
+ sendJson(res, 200, wrapSuccess({ agents: [], count: 0 }));
712
+ return;
713
+ }
714
+ sendJson(res, 200, wrapSuccess({
715
+ agents: agentRegistry.toJSON(),
716
+ count: agentRegistry.count,
717
+ }));
718
+ return;
719
+ }
720
+ // GET /events/stream — SSE stream of real-time dispatch events
721
+ if (path === '/events/stream' && req.method === 'GET') {
722
+ // Filter params
723
+ const filterAgentId = url.searchParams.get('agent_id');
724
+ const filterFacade = url.searchParams.get('facade');
725
+ res.writeHead(200, {
726
+ 'Content-Type': 'text/event-stream',
727
+ 'Cache-Control': 'no-cache',
728
+ 'Connection': 'keep-alive',
729
+ });
730
+ res.write(': connected\n\n');
731
+ const listener = (evt) => {
732
+ // Apply filters
733
+ if (filterAgentId && evt.agentId !== filterAgentId)
734
+ return;
735
+ if (filterFacade && evt.facade !== filterFacade)
736
+ return;
737
+ if (!res.writableEnded) {
738
+ res.write(`data: ${JSON.stringify(evt)}\n\n`);
739
+ }
740
+ };
741
+ kernel.on('dispatch', listener);
742
+ kernel.on('result', listener);
743
+ kernel.on('error', listener);
744
+ // Track for keepalive
745
+ activeSseConnections.add(res);
746
+ log(`HTTP: /events/stream opened (${activeSseConnections.size} SSE connections)`);
747
+ // Cleanup on close
748
+ const cleanup = () => {
749
+ kernel.off('dispatch', listener);
750
+ kernel.off('result', listener);
751
+ kernel.off('error', listener);
752
+ activeSseConnections.delete(res);
753
+ log(`HTTP: /events/stream closed (${activeSseConnections.size} SSE connections)`);
754
+ };
755
+ res.on('close', cleanup);
756
+ res.on('error', cleanup);
757
+ return;
758
+ }
632
759
  // GET /facades - Facade registry for agent bootstrap
633
760
  if (path === '/facades' && req.method === 'GET') {
634
761
  const tier = (url.searchParams.get('tier') || 'full');