@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.
- package/README.md +115 -233
- package/dist/config/queueableActions.d.ts +11 -0
- package/dist/config/queueableActions.d.ts.map +1 -0
- package/dist/config/queueableActions.js +22 -0
- package/dist/config/queueableActions.js.map +1 -0
- package/dist/daemon.d.ts +29 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +64 -0
- package/dist/daemon.js.map +1 -1
- package/dist/daemonConfig.d.ts +7 -0
- package/dist/daemonConfig.d.ts.map +1 -1
- package/dist/daemonConfig.js +9 -0
- package/dist/daemonConfig.js.map +1 -1
- package/dist/facades/definitions.d.ts.map +1 -1
- package/dist/facades/definitions.js +130 -33
- package/dist/facades/definitions.js.map +1 -1
- package/dist/httpQueueDetection.d.ts +16 -0
- package/dist/httpQueueDetection.d.ts.map +1 -0
- package/dist/httpQueueDetection.js +36 -0
- package/dist/httpQueueDetection.js.map +1 -0
- package/dist/httpServer.d.ts +4 -0
- package/dist/httpServer.d.ts.map +1 -1
- package/dist/httpServer.js +224 -97
- package/dist/httpServer.js.map +1 -1
- package/dist/kernel.d.ts +2 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +1 -0
- package/dist/kernel.js.map +1 -1
- package/dist/server.js +30 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/agentic/agentQueue.d.ts +50 -0
- package/dist/tools/agentic/agentQueue.d.ts.map +1 -0
- package/dist/tools/agentic/agentQueue.js +226 -0
- package/dist/tools/agentic/agentQueue.js.map +1 -0
- package/dist/tools/agentic/index.d.ts +2 -0
- package/dist/tools/agentic/index.d.ts.map +1 -1
- package/dist/tools/agentic/index.js +84 -0
- package/dist/tools/agentic/index.js.map +1 -1
- package/dist/tools/coordinator/coordinator.d.ts +14 -0
- package/dist/tools/coordinator/coordinator.d.ts.map +1 -1
- package/dist/tools/coordinator/coordinator.js +101 -0
- package/dist/tools/coordinator/coordinator.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts +2 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +2 -0
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/decibel/index.d.ts +3 -0
- package/dist/tools/decibel/index.d.ts.map +1 -0
- package/dist/tools/decibel/index.js +150 -0
- package/dist/tools/decibel/index.js.map +1 -0
- package/dist/tools/decibel.d.ts +13 -0
- package/dist/tools/decibel.d.ts.map +1 -0
- package/dist/tools/decibel.js +156 -0
- package/dist/tools/decibel.js.map +1 -0
- package/dist/tools/deck.d.ts +4 -4
- package/dist/tools/deck.d.ts.map +1 -1
- package/dist/tools/deck.js +1546 -78
- package/dist/tools/deck.js.map +1 -1
- package/dist/tools/designer/index.d.ts +6 -0
- package/dist/tools/designer/index.d.ts.map +1 -1
- package/dist/tools/designer/index.js +344 -1
- package/dist/tools/designer/index.js.map +1 -1
- package/dist/tools/designer.d.ts +143 -0
- package/dist/tools/designer.d.ts.map +1 -1
- package/dist/tools/designer.js +457 -0
- package/dist/tools/designer.js.map +1 -1
- package/dist/tools/guardian/index.d.ts +1 -0
- package/dist/tools/guardian/index.d.ts.map +1 -1
- package/dist/tools/guardian/index.js +40 -1
- package/dist/tools/guardian/index.js.map +1 -1
- package/dist/tools/guardian.d.ts +30 -0
- package/dist/tools/guardian.d.ts.map +1 -1
- package/dist/tools/guardian.js +96 -0
- package/dist/tools/guardian.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/mother.d.ts +11 -0
- package/dist/tools/mother.d.ts.map +1 -0
- package/dist/tools/mother.js +503 -0
- package/dist/tools/mother.js.map +1 -0
- package/dist/tools/swarm.d.ts +11 -0
- package/dist/tools/swarm.d.ts.map +1 -0
- package/dist/tools/swarm.js +701 -0
- package/dist/tools/swarm.js.map +1 -0
- package/dist/tools/terminal.d.ts +17 -0
- package/dist/tools/terminal.d.ts.map +1 -0
- package/dist/tools/terminal.js +968 -0
- package/dist/tools/terminal.js.map +1 -0
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +2 -0
- package/dist/transports/http.js.map +1 -1
- package/dist/transports/types.d.ts +4 -0
- package/dist/transports/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/docs.html +1084 -0
package/dist/httpServer.js
CHANGED
|
@@ -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
|
|
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(
|
|
67
|
+
const entry = this.windows.get(key);
|
|
62
68
|
if (!entry || now - entry.start > 60_000) {
|
|
63
|
-
this.windows.set(
|
|
69
|
+
this.windows.set(key, { count: 1, start: now });
|
|
64
70
|
return true;
|
|
65
71
|
}
|
|
66
72
|
entry.count++;
|
|
67
|
-
return entry.count <=
|
|
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 [
|
|
86
|
+
for (const [key, entry] of this.windows) {
|
|
77
87
|
if (now - entry.start > 60_000) {
|
|
78
|
-
this.windows.delete(
|
|
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(
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
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
|
-
|
|
156
|
-
|
|
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 · 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> ·
|
|
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:
|
|
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
|
-
|
|
464
|
-
|
|
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');
|