agent-tool-forge 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. package/widget/forge-chat.js +789 -0
@@ -0,0 +1,655 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forge Service — Local HTTP bridge between the TUI (Terminal A) and
4
+ * the forge-tool skill running in Claude (Terminal B).
5
+ *
6
+ * Usage: node lib/forge-service.js
7
+ *
8
+ * Lock file: .forge-service.lock → { port, pid, startedAt }
9
+ * Endpoints:
10
+ * GET /health → { status, queueLength, working, uptime }
11
+ * POST /enqueue → body: { endpoint } → { queued: true, position }
12
+ * GET /next → long-poll (30s timeout), returns first item or 204
13
+ * POST /complete → pops queue[0], sets working=false, notifies waiters
14
+ * DELETE /shutdown → cleanup + exit
15
+ *
16
+ * Library exports:
17
+ * buildSidecarContext(config, db, env) — construct sidecar context object
18
+ * createSidecarRouter(ctx, options) — HTTP request handler for sidecar routes
19
+ */
20
+
21
+ import { createServer } from 'net';
22
+ import { createServer as createHttpServer } from 'http';
23
+ import { writeFileSync, unlinkSync, existsSync, readFileSync, realpathSync, statSync } from 'fs';
24
+ import { resolve, dirname, sep } from 'path';
25
+ import { timingSafeEqual } from 'crypto';
26
+ import { fileURLToPath } from 'url';
27
+ import { getDb } from './db.js';
28
+ import { createMcpServer } from './mcp-server.js';
29
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
30
+ import { mergeDefaults } from './config-schema.js';
31
+ import { createAuth } from './auth.js';
32
+ import { makePromptStore } from './prompt-store.js';
33
+ import { makePreferenceStore } from './preference-store.js';
34
+ import { makeConversationStore } from './conversation-store.js';
35
+ import { makeHitlEngine } from './hitl-engine.js';
36
+ import { requireDependency } from './dep-check.js';
37
+ import { sendJson } from './http-utils.js';
38
+ import { handleChat } from './handlers/chat.js';
39
+ import { handleChatSync } from './handlers/chat-sync.js';
40
+ import { handleChatResume } from './handlers/chat-resume.js';
41
+ import { handleAdminConfig } from './handlers/admin.js';
42
+ import { handleGetPreferences, handlePutPreferences } from './handlers/preferences.js';
43
+ import { createDriftMonitor } from './drift-background.js';
44
+ import { VerifierRunner } from './verifier-runner.js';
45
+ import { makeAgentRegistry } from './agent-registry.js';
46
+ import { handleAgents } from './handlers/agents.js';
47
+ import { handleConversations } from './handlers/conversations.js';
48
+ import { handleToolsList } from './handlers/tools-list.js';
49
+ import { makeRateLimiter } from './rate-limiter.js';
50
+
51
+ const __dirname = dirname(fileURLToPath(import.meta.url));
52
+ const PROJECT_ROOT = resolve(__dirname, '..');
53
+
54
+ // ── Exported library functions ─────────────────────────────────────────────
55
+
56
+ /**
57
+ * Build the sidecar context object from config, database, and environment.
58
+ * This is the shared state passed to all sidecar request handlers.
59
+ *
60
+ * Creates Redis/Postgres clients based on config when needed.
61
+ * Returns _redisClient and _pgPool for cleanup on shutdown.
62
+ *
63
+ * @param {object} config — merged config (after mergeDefaults)
64
+ * @param {import('better-sqlite3').Database} db
65
+ * @param {Record<string, string>} env — environment variables
66
+ * @returns {Promise<{ auth, promptStore, preferenceStore, conversationStore, hitlEngine, verifierRunner, agentRegistry, db, config, env, _redisClient, _pgPool }>}
67
+ */
68
+ export async function buildSidecarContext(config, db, env = {}, opts = {}) {
69
+ const auth = createAuth(config.auth);
70
+
71
+ let redisClient = null;
72
+ let pgPool = null;
73
+ const storeType = config?.conversation?.store ?? 'sqlite';
74
+
75
+ if (storeType === 'redis') {
76
+ await requireDependency('redis');
77
+ const { createClient } = await import('redis');
78
+ const url = config?.conversation?.redis?.url ?? 'redis://localhost:6379';
79
+ redisClient = createClient({ url });
80
+ redisClient.on('error', err => process.stderr.write(`[sidecar] Redis: ${err.message}\n`));
81
+ await redisClient.connect();
82
+ }
83
+
84
+ if (storeType === 'postgres' || config?.database?.type === 'postgres') {
85
+ await requireDependency('pg');
86
+ const pg = await import('pg');
87
+ const Pool = pg.default?.Pool ?? pg.Pool;
88
+ const rawUrl = config?.database?.url;
89
+ let connStr = rawUrl;
90
+ if (rawUrl?.startsWith('${') && rawUrl.endsWith('}')) {
91
+ const SAFE_ENV_VAR_NAME = /^[A-Z_][A-Z0-9_]*$/;
92
+ const varName = rawUrl.slice(2, -1);
93
+ if (!SAFE_ENV_VAR_NAME.test(varName)) {
94
+ throw new Error(`Invalid env var reference in config: "\${${varName}}" — only uppercase letters, digits, and underscores allowed`);
95
+ }
96
+ connStr = env[varName];
97
+ }
98
+ pgPool = new Pool({ connectionString: connStr ?? undefined });
99
+ }
100
+
101
+ // Select store backends (Postgres if configured, SQLite otherwise)
102
+ let promptStore, preferenceStore, agentRegistry;
103
+ if (pgPool && config?.database?.type === 'postgres') {
104
+ const { PostgresPromptStore, PostgresPreferenceStore, PostgresAgentRegistry } = await import('./postgres-store.js');
105
+ promptStore = new PostgresPromptStore(pgPool);
106
+ preferenceStore = new PostgresPreferenceStore(pgPool, config);
107
+ agentRegistry = new PostgresAgentRegistry(config, pgPool);
108
+ } else {
109
+ promptStore = makePromptStore(config, db);
110
+ preferenceStore = makePreferenceStore(config, db);
111
+ agentRegistry = makeAgentRegistry(config, db);
112
+ }
113
+
114
+ const conversationStore = makeConversationStore(config, db, pgPool);
115
+ const hitlEngine = makeHitlEngine(config, db, redisClient, pgPool);
116
+ const verifierRunner = new VerifierRunner(db, config);
117
+ const rateLimiter = makeRateLimiter(config, redisClient);
118
+
119
+ // configPath — used by admin handler to persist overlay changes.
120
+ // Defaults to process.cwd() so library consumers write config to their own
121
+ // project directory, not into the installed package.
122
+ const configPath = opts?.configPath ?? resolve(process.cwd(), 'forge.config.json');
123
+
124
+ return {
125
+ auth, promptStore, preferenceStore, conversationStore, hitlEngine, verifierRunner,
126
+ agentRegistry, db, config, env, rateLimiter, configPath,
127
+ _redisClient: redisClient, _pgPool: pgPool
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Serve a static file from the widget directory.
133
+ * Validates the resolved path stays within widgetDir to prevent path traversal.
134
+ * Shared by createSidecarRouter and createDirectServer.
135
+ *
136
+ * @param {import('http').IncomingMessage} req
137
+ * @param {import('http').ServerResponse} res
138
+ * @param {string} widgetDir — absolute path to the widget directory
139
+ * @param {function} errorFn — send-error helper: (res, status, body) => void
140
+ */
141
+ function serveWidgetFile(req, res, widgetDir, errorFn) {
142
+ const urlPath = new URL(req.url, 'http://localhost').pathname;
143
+ const relativePath = urlPath.replace(/^\/widget\//, '');
144
+ if (!relativePath || relativePath.includes('..')) {
145
+ errorFn(res, 400, { error: 'Invalid path' });
146
+ return;
147
+ }
148
+ const filePath = resolve(widgetDir, relativePath);
149
+ try {
150
+ const realPath = realpathSync(filePath);
151
+ const realWidget = realpathSync(widgetDir);
152
+ if (!realPath.startsWith(realWidget + sep)) {
153
+ errorFn(res, 403, { error: 'Forbidden' });
154
+ return;
155
+ }
156
+ const content = readFileSync(realPath);
157
+ const ext = realPath.split('.').pop();
158
+ const mimeTypes = {
159
+ js: 'application/javascript',
160
+ css: 'text/css',
161
+ html: 'text/html',
162
+ json: 'application/json',
163
+ svg: 'image/svg+xml',
164
+ png: 'image/png',
165
+ ico: 'image/x-icon',
166
+ };
167
+ const mtime = statSync(realPath).mtime.toUTCString();
168
+ res.writeHead(200, {
169
+ 'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
170
+ 'Content-Length': content.length,
171
+ 'Cache-Control': 'public, max-age=3600',
172
+ 'ETag': `"${mtime}"`,
173
+ });
174
+ res.end(content);
175
+ } catch (err) {
176
+ if (err.code === 'ENOENT') { errorFn(res, 404, { error: 'Not found' }); return; }
177
+ errorFn(res, 500, { error: 'Internal error' });
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Create an HTTP request handler for all sidecar routes.
183
+ *
184
+ * @param {object} ctx — sidecar context from buildSidecarContext()
185
+ * @param {object} [options]
186
+ * @param {string} [options.widgetDir] — directory for /widget/* static files (defaults to <project>/widget)
187
+ * @param {function} [options.mcpHandler] — optional async (req, res) handler for /mcp route
188
+ * @returns {function(import('http').IncomingMessage, import('http').ServerResponse): Promise<void>}
189
+ */
190
+ export function createSidecarRouter(ctx, options = {}) {
191
+ const widgetDir = options.widgetDir || resolve(__dirname, '..', 'widget');
192
+ const mcpHandler = options.mcpHandler || null;
193
+
194
+ return async (req, res) => {
195
+ const url = new URL(req.url, 'http://localhost');
196
+
197
+ // ── /mcp route (optional) ──────────────────────────────────────────────
198
+ if (mcpHandler && url.pathname.startsWith('/mcp')) {
199
+ return mcpHandler(req, res);
200
+ }
201
+
202
+ // ── /health ────────────────────────────────────────────────────────────
203
+ if (req.method === 'GET' && url.pathname === '/health') {
204
+ sendJson(res, 200, { status: 'ok' });
205
+ return;
206
+ }
207
+
208
+ // ── Sidecar API routes ─────────────────────────────────────────────────
209
+ // Normalise /agent-api/v1/* → /agent-api/* so versioned paths hit
210
+ // the same handlers without a proxy rewrite rule.
211
+ const sidecarPath = url.pathname.startsWith('/agent-api/v1/')
212
+ ? '/agent-api/' + url.pathname.slice('/agent-api/v1/'.length)
213
+ : url.pathname;
214
+
215
+ if (sidecarPath === '/agent-api/chat' && req.method === 'POST') {
216
+ return handleChat(req, res, ctx);
217
+ }
218
+ if (sidecarPath === '/agent-api/chat-sync' && req.method === 'POST') {
219
+ return handleChatSync(req, res, ctx);
220
+ }
221
+ if (sidecarPath === '/agent-api/chat/resume' && req.method === 'POST') {
222
+ return handleChatResume(req, res, ctx);
223
+ }
224
+ if (sidecarPath === '/agent-api/user/preferences') {
225
+ if (req.method === 'GET') return handleGetPreferences(req, res, ctx);
226
+ if (req.method === 'PUT') return handlePutPreferences(req, res, ctx);
227
+ else { sendJson(res, 405, { error: 'Method not allowed' }); return; }
228
+ }
229
+ if (sidecarPath.startsWith('/agent-api/conversations')) {
230
+ return handleConversations(req, res, ctx);
231
+ }
232
+ if (sidecarPath === '/agent-api/tools' && req.method === 'GET') {
233
+ return handleToolsList(req, res, ctx);
234
+ }
235
+ if (url.pathname.startsWith('/forge-admin/agents')) {
236
+ return handleAgents(req, res, ctx);
237
+ }
238
+ if (url.pathname.startsWith('/forge-admin/config')) {
239
+ return handleAdminConfig(req, res, ctx);
240
+ }
241
+
242
+ // ── Widget static file serving ─────────────────────────────────────────
243
+ if (url.pathname.startsWith('/widget/')) {
244
+ serveWidgetFile(req, res, widgetDir, sendJson);
245
+ return;
246
+ }
247
+
248
+ // ── 404 fallback ───────────────────────────────────────────────────────
249
+ sendJson(res, 404, { error: 'not found' });
250
+ };
251
+ }
252
+
253
+ // ── Direct-run internals (TUI bridge mode) ──────────────────────────────────
254
+
255
+ const LOCK_FILE = resolve(PROJECT_ROOT, '.forge-service.lock');
256
+ const NEXT_TIMEOUT_MS = 30_000;
257
+ const WATCHDOG_INTERVAL_MS = 30_000;
258
+ const INACTIVITY_TIMEOUT_MS = 90_000;
259
+
260
+ const startedAt = Date.now();
261
+ let lastActivity = Date.now();
262
+
263
+ const queue = [];
264
+ let working = false;
265
+ const waiters = []; // pending /next long-poll response objects
266
+
267
+ // MCP runtime state — initialized in main() after config and lock are ready
268
+ let forgeMcpKey = null; // null = unset = fail-closed
269
+ let mcpDb = null;
270
+ let mcpConfig = null;
271
+
272
+ // Sidecar context — initialized when sidecar mode is active
273
+ let sidecarCtx = null;
274
+
275
+ // Sidecar mode: --mode=sidecar disables watchdog, binds 0.0.0.0
276
+ const sidecarMode = process.argv.includes('--mode=sidecar');
277
+
278
+ /**
279
+ * Parse a .env file into a key=value object.
280
+ * Skips blank lines and comments. Strips surrounding quotes from values.
281
+ * @param {string} envPath
282
+ * @returns {Record<string, string>}
283
+ */
284
+ function loadDotEnv(envPath) {
285
+ if (!existsSync(envPath)) return {};
286
+ const lines = readFileSync(envPath, 'utf-8').split('\n');
287
+ const out = {};
288
+ for (const line of lines) {
289
+ const trimmed = line.trim();
290
+ if (!trimmed || trimmed.startsWith('#')) continue;
291
+ const eqIdx = trimmed.indexOf('=');
292
+ if (eqIdx === -1) continue;
293
+ const key = trimmed.slice(0, eqIdx).trim();
294
+ let val = trimmed.slice(eqIdx + 1).trim();
295
+ if ((val.startsWith('"') && val.endsWith('"')) ||
296
+ (val.startsWith("'") && val.endsWith("'"))) {
297
+ val = val.slice(1, -1);
298
+ } else {
299
+ // Strip inline comments (# preceded by space) for unquoted values
300
+ val = val.split(/\s+#/)[0].trim();
301
+ }
302
+ out[key] = val;
303
+ }
304
+ return out;
305
+ }
306
+
307
+ /**
308
+ * Read forge.config.json from project root. Returns {} on any error.
309
+ * @returns {object}
310
+ */
311
+ function loadConfig() {
312
+ const configPath = resolve(PROJECT_ROOT, 'forge.config.json');
313
+ if (!existsSync(configPath)) return {};
314
+ try {
315
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
316
+ } catch (err) {
317
+ process.stderr.write(`[forge-service] Could not parse forge.config.json: ${err.message}\n`);
318
+ return {};
319
+ }
320
+ }
321
+
322
+ function getPort() {
323
+ return new Promise((res, rej) => {
324
+ const srv = createServer();
325
+ srv.listen(0, '127.0.0.1', () => {
326
+ const { port } = srv.address();
327
+ srv.close(() => res(port));
328
+ });
329
+ srv.on('error', rej);
330
+ });
331
+ }
332
+
333
+ function writeLock(port) {
334
+ writeFileSync(LOCK_FILE, JSON.stringify({ port, pid: process.pid, startedAt: new Date().toISOString() }), 'utf-8');
335
+ }
336
+
337
+ function removeLock() {
338
+ if (existsSync(LOCK_FILE)) {
339
+ try { unlinkSync(LOCK_FILE); } catch (_) { /* ignore */ }
340
+ }
341
+ }
342
+
343
+ const MAX_BODY_SIZE = 1_048_576; // 1 MB
344
+
345
+ function readBody(req) {
346
+ return new Promise((res, rej) => {
347
+ let data = '';
348
+ let size = 0;
349
+ req.on('data', (chunk) => {
350
+ size += chunk.length;
351
+ if (size > MAX_BODY_SIZE) {
352
+ req.destroy();
353
+ rej(new Error('Request body too large'));
354
+ return;
355
+ }
356
+ data += chunk;
357
+ });
358
+ req.on('end', () => {
359
+ try { res(data ? JSON.parse(data) : {}); } catch (_) { res({}); }
360
+ });
361
+ req.on('error', () => res({}));
362
+ });
363
+ }
364
+
365
+ function json(res, statusCode, body) {
366
+ const payload = JSON.stringify(body);
367
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
368
+ res.end(payload);
369
+ }
370
+
371
+ /**
372
+ * Drain waiters: if there is a pending /next and something is available.
373
+ */
374
+ function drainWaiters() {
375
+ while (waiters.length > 0 && queue.length > 0 && !working) {
376
+ const { res, timer } = waiters.shift();
377
+ clearTimeout(timer);
378
+ working = true;
379
+ json(res, 200, queue[0]);
380
+ }
381
+ }
382
+
383
+ let server;
384
+ let watchdog;
385
+
386
+ function createDirectServer() {
387
+ server = createHttpServer(async (req, res) => {
388
+ const url = new URL(req.url, 'http://localhost');
389
+
390
+ // ── /mcp route — MCP protocol via StreamableHTTP ────────────────────────
391
+ if (url.pathname.startsWith('/mcp')) {
392
+ const authHeader = req.headers['authorization'] || '';
393
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
394
+ // Fail-closed: unset key, empty key, missing token, or wrong token → 401
395
+ const tokenBuf = Buffer.from(token || '');
396
+ const keyBuf = Buffer.from(forgeMcpKey || '');
397
+ if (!forgeMcpKey || !token || tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
398
+ json(res, 401, { error: 'Unauthorized' });
399
+ return;
400
+ }
401
+ if (!mcpDb) {
402
+ json(res, 503, { error: 'MCP server not initialized' });
403
+ return;
404
+ }
405
+ const mcpServer = createMcpServer(mcpDb, mcpConfig, sidecarCtx);
406
+ try {
407
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
408
+ await mcpServer.connect(transport);
409
+ const parsedBody = await readBody(req);
410
+ res.on('close', () => {
411
+ transport.close();
412
+ mcpServer.close();
413
+ });
414
+ await transport.handleRequest(req, res, parsedBody);
415
+ } catch (err) {
416
+ process.stderr.write(`[forge-service] MCP handler error: ${err.message}\n`);
417
+ if (!res.headersSent) json(res, 500, { error: 'Internal error' });
418
+ }
419
+ return;
420
+ }
421
+
422
+ if (req.method === 'GET' && url.pathname === '/health') {
423
+ json(res, 200, {
424
+ status: 'ok',
425
+ queueLength: queue.length,
426
+ working,
427
+ waiting: waiters.length,
428
+ uptime: Math.floor((Date.now() - startedAt) / 1000)
429
+ });
430
+ return;
431
+ }
432
+
433
+ if (req.method === 'POST' && url.pathname === '/enqueue') {
434
+ const body = await readBody(req);
435
+ if (!body.endpoint) {
436
+ json(res, 400, { error: 'endpoint required' });
437
+ return;
438
+ }
439
+ queue.push(body.endpoint);
440
+ lastActivity = Date.now();
441
+ json(res, 200, { queued: true, position: queue.length });
442
+ drainWaiters();
443
+ return;
444
+ }
445
+
446
+ if (req.method === 'GET' && url.pathname === '/next') {
447
+ lastActivity = Date.now();
448
+
449
+ if (queue.length > 0 && !working) {
450
+ working = true;
451
+ json(res, 200, queue[0]);
452
+ return;
453
+ }
454
+
455
+ // Long-poll: wait up to NEXT_TIMEOUT_MS
456
+ const timer = setTimeout(() => {
457
+ const idx = waiters.findIndex((w) => w.res === res);
458
+ if (idx !== -1) waiters.splice(idx, 1);
459
+ res.writeHead(204);
460
+ res.end();
461
+ }, NEXT_TIMEOUT_MS);
462
+
463
+ waiters.push({ res, timer });
464
+ return;
465
+ }
466
+
467
+ if (req.method === 'POST' && url.pathname === '/complete') {
468
+ if (queue.length > 0) queue.shift();
469
+ working = false;
470
+ lastActivity = Date.now();
471
+ json(res, 200, { ok: true, remaining: queue.length });
472
+ drainWaiters();
473
+ return;
474
+ }
475
+
476
+ if (req.method === 'DELETE' && url.pathname === '/shutdown') {
477
+ json(res, 200, { ok: true });
478
+ shutdown();
479
+ return;
480
+ }
481
+
482
+ // ── Sidecar routes (only when context is initialized) ───────────────────
483
+ if (sidecarCtx) {
484
+ const sidecarPath = url.pathname.startsWith('/agent-api/v1/')
485
+ ? '/agent-api/' + url.pathname.slice('/agent-api/v1/'.length)
486
+ : url.pathname;
487
+
488
+ if (sidecarPath === '/agent-api/chat' && req.method === 'POST') {
489
+ return handleChat(req, res, sidecarCtx);
490
+ }
491
+ if (sidecarPath === '/agent-api/chat-sync' && req.method === 'POST') {
492
+ return handleChatSync(req, res, sidecarCtx);
493
+ }
494
+ if (sidecarPath === '/agent-api/chat/resume' && req.method === 'POST') {
495
+ return handleChatResume(req, res, sidecarCtx);
496
+ }
497
+ if (sidecarPath === '/agent-api/user/preferences') {
498
+ if (req.method === 'GET') return handleGetPreferences(req, res, sidecarCtx);
499
+ if (req.method === 'PUT') return handlePutPreferences(req, res, sidecarCtx);
500
+ else { json(res, 405, { error: 'Method not allowed' }); return; }
501
+ }
502
+ if (sidecarPath.startsWith('/agent-api/conversations')) {
503
+ return handleConversations(req, res, sidecarCtx);
504
+ }
505
+ if (sidecarPath === '/agent-api/tools' && req.method === 'GET') {
506
+ return handleToolsList(req, res, sidecarCtx);
507
+ }
508
+ if (url.pathname.startsWith('/forge-admin/agents')) {
509
+ return handleAgents(req, res, sidecarCtx);
510
+ }
511
+ if (url.pathname.startsWith('/forge-admin/config')) {
512
+ return handleAdminConfig(req, res, sidecarCtx);
513
+ }
514
+ }
515
+
516
+ // ── Widget static file serving ───────────────────────────────────────────
517
+ if (url.pathname.startsWith('/widget/')) {
518
+ const directWidgetDir = resolve(PROJECT_ROOT, 'widget');
519
+ // Use json() as the error helper since createDirectServer uses its own json()
520
+ serveWidgetFile(req, res, directWidgetDir, (r, status, body) => json(r, status, body));
521
+ return;
522
+ }
523
+
524
+ json(res, 404, { error: 'not found' });
525
+ });
526
+
527
+ return server;
528
+ }
529
+
530
+ function shutdown() {
531
+ // Drain waiters with 204
532
+ for (const { res, timer } of waiters) {
533
+ clearTimeout(timer);
534
+ try { res.writeHead(204); res.end(); } catch (_) { /* ignore */ }
535
+ }
536
+ waiters.length = 0;
537
+ removeLock();
538
+ server.close(() => process.exit(0));
539
+ // Force-close lingering keep-alive connections (Node 18.2.0+)
540
+ if (typeof server.closeAllConnections === 'function') {
541
+ server.closeAllConnections();
542
+ }
543
+ setTimeout(() => process.exit(0), 2000);
544
+ }
545
+
546
+ async function main() {
547
+ createDirectServer();
548
+
549
+ // Watchdog: self-terminate after inactivity
550
+ watchdog = setInterval(() => {
551
+ if (Date.now() - lastActivity > INACTIVITY_TIMEOUT_MS) {
552
+ process.stderr.write('[forge-service] No activity for 90s — self-terminating\n');
553
+ shutdown();
554
+ }
555
+ }, WATCHDOG_INTERVAL_MS);
556
+ watchdog.unref();
557
+
558
+ process.on('SIGTERM', shutdown);
559
+ process.on('SIGINT', shutdown);
560
+
561
+ const rawConfig = loadConfig();
562
+ const config = mergeDefaults(rawConfig);
563
+ const port = sidecarMode
564
+ ? (config.sidecar?.port ?? 8001)
565
+ : await getPort();
566
+ const bindHost = sidecarMode ? '0.0.0.0' : '127.0.0.1';
567
+
568
+ // Load .env and initialize DB/sidecar context BEFORE server.listen() so that
569
+ // any async errors (Redis connect, bad config, etc.) propagate to main().catch()
570
+ // rather than being swallowed inside the listen callback (M10).
571
+ const envFile = loadDotEnv(resolve(PROJECT_ROOT, '.env'));
572
+ const env = { ...envFile, ...process.env };
573
+
574
+ // FORGE_MCP_KEY: process.env takes precedence (set by Docker, CI, test harness),
575
+ // then .env file. Empty string is treated as unset (fail-closed).
576
+ const rawKey = process.env.FORGE_MCP_KEY ?? envFile.FORGE_MCP_KEY ?? '';
577
+ forgeMcpKey = rawKey.trim() || null;
578
+
579
+ // Initialize DB; if it fails, log and continue without MCP
580
+ try {
581
+ const dbPath = resolve(PROJECT_ROOT, config.dbPath || 'forge.db');
582
+ mcpDb = getDb(dbPath);
583
+ mcpConfig = config;
584
+ process.stdout.write('[forge-service] MCP server initialized\n');
585
+
586
+ // Build sidecar context using the exported function (async — awaited here
587
+ // inside main() so unhandled-rejection is impossible)
588
+ sidecarCtx = await buildSidecarContext(config, mcpDb, env);
589
+
590
+ // Seed agents from config.agents[] if defined
591
+ try {
592
+ await sidecarCtx.agentRegistry.seedFromConfig();
593
+ const allAgents = await sidecarCtx.agentRegistry.getAllAgents();
594
+ if (allAgents.length > 0) {
595
+ process.stdout.write(`[forge-service] Agent registry seeded: ${allAgents.length} agent(s)\n`);
596
+ }
597
+ } catch (err) {
598
+ process.stderr.write(`[forge-service] Agent seeding failed: ${err.message}\n`);
599
+ }
600
+ process.stdout.write('[forge-service] Sidecar context initialized\n');
601
+ } catch (err) {
602
+ process.stderr.write(`[forge-service] MCP server init failed (MCP disabled): ${err.message}\n`);
603
+ mcpDb = null;
604
+ mcpConfig = null;
605
+ }
606
+
607
+ // In sidecar mode: disable watchdog, enable WAL, start drift monitor
608
+ if (sidecarMode) {
609
+ clearInterval(watchdog);
610
+ if (mcpDb) {
611
+ try {
612
+ mcpDb.pragma('journal_mode = WAL');
613
+ process.stdout.write('[forge-service] SQLite WAL mode enabled\n');
614
+ } catch (err) {
615
+ process.stderr.write(`[forge-service] WAL mode failed: ${err.message}\n`);
616
+ }
617
+ const driftMonitor = createDriftMonitor(config, mcpDb);
618
+ driftMonitor.start();
619
+ process.stdout.write('[forge-service] Background drift monitor started\n');
620
+ }
621
+ }
622
+
623
+ // Now start listening — callback is synchronous-only (no awaits inside)
624
+ server.on('error', (err) => {
625
+ process.stderr.write(`forge-service listen error: ${err.message}\n`);
626
+ process.exit(1);
627
+ });
628
+ await new Promise((res, rej) => {
629
+ server.once('error', rej);
630
+ server.listen(port, bindHost, () => {
631
+ server.removeListener('error', rej);
632
+ writeLock(port);
633
+ process.stdout.write(`forge-service started on ${bindHost}:${port}${sidecarMode ? ' (sidecar mode)' : ''}\n`);
634
+ if (sidecarMode) {
635
+ process.stdout.write(`[forge-service] Sidecar ready on ${bindHost}:${port}\n`);
636
+ }
637
+ res();
638
+ });
639
+ });
640
+ }
641
+
642
+ // Guard: only auto-execute when run directly (not when imported as a library)
643
+ let isDirectRun = false;
644
+ try {
645
+ isDirectRun = Boolean(process.argv[1]) &&
646
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
647
+ } catch {
648
+ // broken symlink or unusual runner — treat as library import
649
+ }
650
+ if (isDirectRun) {
651
+ main().catch((err) => {
652
+ process.stderr.write(`forge-service failed: ${err.message}\n`);
653
+ process.exit(1);
654
+ });
655
+ }