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,386 @@
1
+ /**
2
+ * Forge File Writer — generates tool file content via LLM.
3
+ * Does NOT write to disk — returns content strings for forge.js to preview and confirm.
4
+ *
5
+ * @module forge-file-writer
6
+ */
7
+
8
+ import { llmTurn } from './api-client.js';
9
+
10
+ // ── JSON extraction ────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Extract a JSON object from raw LLM response text.
14
+ * Tries ```json...``` fenced block first, then falls back to first `{` to
15
+ * its matching closing `}`.
16
+ *
17
+ * @param {string} text - Raw LLM response text
18
+ * @returns {object} Parsed JSON object
19
+ * @throws {Error} If no valid JSON object can be found or parsed
20
+ */
21
+ function extractJson(text) {
22
+ // Strategy 1: ```json ... ``` fenced block
23
+ const fenceMatch = text.match(/```json\s*([\s\S]*?)\s*```/);
24
+ if (fenceMatch) {
25
+ try {
26
+ return JSON.parse(fenceMatch[1]);
27
+ } catch (_) {
28
+ // Fenced block was malformed JSON — fall through to strategy 2
29
+ }
30
+ }
31
+
32
+ // Strategy 2: first `{` to its matching `}`
33
+ const start = text.indexOf('{');
34
+ if (start === -1) {
35
+ throw new Error('No JSON object found in LLM response');
36
+ }
37
+
38
+ let depth = 0;
39
+ let inString = false;
40
+ let escape = false;
41
+
42
+ for (let i = start; i < text.length; i++) {
43
+ const ch = text[i];
44
+
45
+ if (escape) {
46
+ escape = false;
47
+ continue;
48
+ }
49
+ if (ch === '\\' && inString) {
50
+ escape = true;
51
+ continue;
52
+ }
53
+ if (ch === '"') {
54
+ inString = !inString;
55
+ continue;
56
+ }
57
+ if (inString) continue;
58
+
59
+ if (ch === '{') depth++;
60
+ else if (ch === '}') {
61
+ depth--;
62
+ if (depth === 0) {
63
+ return JSON.parse(text.slice(start, i + 1));
64
+ }
65
+ }
66
+ }
67
+
68
+ throw new Error('Unbalanced JSON object in LLM response');
69
+ }
70
+
71
+ /**
72
+ * Validate that the parsed LLM output has the required shape.
73
+ *
74
+ * @param {unknown} obj
75
+ * @returns {{ toolFile: string, testFile: string, barrelLine: string|undefined }}
76
+ * @throws {Error} If required fields are missing or wrong type
77
+ */
78
+ function validateLlmOutput(obj) {
79
+ if (!obj || typeof obj !== 'object') {
80
+ throw new Error('LLM response did not parse to an object');
81
+ }
82
+ if (typeof obj.toolFile !== 'string' || obj.toolFile.trim() === '') {
83
+ throw new Error('LLM response missing required string field: toolFile');
84
+ }
85
+ if (!/mcpRouting/i.test(obj.toolFile)) {
86
+ throw new Error('toolFile must contain an mcpRouting declaration');
87
+ }
88
+ if (typeof obj.testFile !== 'string' || obj.testFile.trim() === '') {
89
+ throw new Error('LLM response missing required string field: testFile');
90
+ }
91
+ return {
92
+ toolFile: obj.toolFile,
93
+ testFile: obj.testFile,
94
+ barrelLine: typeof obj.barrelLine === 'string' ? obj.barrelLine : undefined
95
+ };
96
+ }
97
+
98
+ // ── Prompt builder ─────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Build the system prompt for the LLM code-generation request.
102
+ *
103
+ * @param {object} spec - Tool specification
104
+ * @param {string[]} existingTools - Names of already-registered tools
105
+ * @returns {string}
106
+ */
107
+ function buildSystemPrompt(spec, existingTools) {
108
+ const safeName = (spec.name || 'unnamed').replace(/[^a-zA-Z0-9_-]/g, '_');
109
+ const tagsStr = spec.tags?.join(', ') || '';
110
+ const dependsOnStr = spec.dependsOn?.join(', ') || '';
111
+ const triggersStr = spec.triggerPhrases?.join(', ') || '';
112
+ const schemaStr = JSON.stringify(spec.schema ?? {}, null, 2);
113
+ const existingStr = existingTools.length
114
+ ? existingTools.join(', ')
115
+ : '(none)';
116
+ const paramMapStr = JSON.stringify(spec.paramMap || {});
117
+
118
+ return `You are generating a JavaScript tool file for an Agent Tool Forge project. \
119
+ This tool generates the MCP routing layer — a tool definition that points to an internal API \
120
+ endpoint or external data source. Business logic lives behind that endpoint.
121
+
122
+ Tool spec:
123
+ - Name: ${spec.name}
124
+ - Description: ${spec.description}
125
+ - Category: ${spec.category}
126
+ - Consequence level: ${spec.consequenceLevel}
127
+ - Requires confirmation: ${spec.requiresConfirmation}
128
+ - Timeout: ${spec.timeout || 30000}
129
+ - Schema: ${schemaStr}
130
+ - Tags: ${tagsStr}
131
+ - Depends on: ${dependsOnStr}
132
+ - Trigger phrases: ${triggersStr}
133
+ - Endpoint target: ${spec.endpointTarget || 'https://your-api.example.com/...'}
134
+ - HTTP method: ${spec.httpMethod || 'GET'}
135
+ - Auth type: ${spec.authType || 'bearer'}
136
+ - Param map: ${paramMapStr}
137
+
138
+ Existing registered tools (for barrel import reference): ${existingStr}
139
+
140
+ --- TOOL FILE FORMAT ---
141
+ The file must be a JavaScript ESM module (.js) exporting a named const.
142
+ Follow this exact shape (adapt field values from the spec above):
143
+
144
+ /**
145
+ * ${spec.name} — <one-line description>
146
+ */
147
+
148
+ export const ${_camelName(spec.name)}Tool = {
149
+ name: '${spec.name}',
150
+ description: '<full description>',
151
+ schema: <schema as JS object literal — not JSON>,
152
+ category: '${spec.category}',
153
+ consequenceLevel: '${spec.consequenceLevel}',
154
+ requiresConfirmation: ${spec.requiresConfirmation},
155
+ timeout: ${spec.timeout || 30000},
156
+ version: '1.0.0',
157
+ status: 'active',
158
+ tags: ${JSON.stringify(spec.tags ?? [])},
159
+ dependsOn: ${JSON.stringify(spec.dependsOn ?? [])},
160
+ triggerPhrases: ${JSON.stringify(spec.triggerPhrases ?? [])},
161
+
162
+ mcpRouting: {
163
+ // EXTENSION POINT: configure your endpoint here
164
+ endpoint: '${spec.endpointTarget || 'https://your-api.example.com/...'}',
165
+ method: '${spec.httpMethod || 'GET'}',
166
+ auth: '${spec.authType || 'bearer'}',
167
+ paramMap: ${paramMapStr}
168
+ }
169
+ };
170
+
171
+ --- TEST FILE FORMAT ---
172
+ The test file must be a JavaScript ESM module using describe/it/expect (Jest or Vitest style).
173
+ It tests the exported tool object — NOT any execute() implementation.
174
+
175
+ import { ${_camelName(spec.name)}Tool } from '../${safeName}.tool.js';
176
+
177
+ describe('${spec.name}', () => {
178
+ it('has required fields', () => {
179
+ expect(${_camelName(spec.name)}Tool.name).toBe('${spec.name}');
180
+ expect(typeof ${_camelName(spec.name)}Tool.description).toBe('string');
181
+ expect(${_camelName(spec.name)}Tool.mcpRouting).toBeDefined();
182
+ });
183
+ it('schema matches spec', () => {
184
+ // assert each expected schema key is present
185
+ });
186
+ it('mcpRouting has required shape', () => {
187
+ expect(typeof ${_camelName(spec.name)}Tool.mcpRouting.endpoint).toBe('string');
188
+ expect(typeof ${_camelName(spec.name)}Tool.mcpRouting.method).toBe('string');
189
+ });
190
+ });
191
+
192
+ --- BARREL LINE FORMAT ---
193
+ The barrelLine must be a single ESM named re-export, e.g.:
194
+ export { ${_camelName(spec.name)}Tool } from './${safeName}.tool.js';
195
+
196
+ --- RESPONSE FORMAT ---
197
+ Respond ONLY with a JSON object — no prose, no markdown outside the JSON itself.
198
+ Required keys:
199
+ "toolFile" — full file content as a string (the complete .tool.js file)
200
+ "testFile" — full test file content as a string (the complete .tool.test.js file)
201
+ "barrelLine" — single export line to add to the barrel index (string)
202
+
203
+ Example response shape:
204
+ {
205
+ "toolFile": "/** ... */\\nexport const myTool = { ... };",
206
+ "testFile": "import { myTool } from '../my_tool.tool.js';\\ndescribe(...)",
207
+ "barrelLine": "export { myTool } from './my_tool.tool.js';"
208
+ }`;
209
+ }
210
+
211
+ // ── Camel-case helper ──────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Convert a snake_case tool name to camelCase for the exported const.
215
+ * e.g. "get_weather" → "getWeather"
216
+ *
217
+ * @param {string} name
218
+ * @returns {string}
219
+ */
220
+ function _camelName(name) {
221
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
222
+ }
223
+
224
+ // ── Main export ────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Generate tool file content via LLM.
228
+ *
229
+ * Does NOT write to disk. Returns content strings and computed paths so the
230
+ * caller (forge.js) can preview and confirm before writing.
231
+ *
232
+ * @param {object} opts
233
+ * @param {object} opts.spec - Tool specification
234
+ * @param {string} opts.spec.name - Snake_case tool name
235
+ * @param {string} opts.spec.description - Human-readable description
236
+ * @param {object} [opts.spec.schema] - Parameter schema
237
+ * @param {string} [opts.spec.category] - 'read' | 'write' | 'delete' | 'action'
238
+ * @param {string} [opts.spec.consequenceLevel] - 'low' | 'medium' | 'high'
239
+ * @param {boolean} [opts.spec.requiresConfirmation]
240
+ * @param {number} [opts.spec.timeout]
241
+ * @param {string[]} [opts.spec.tags]
242
+ * @param {string[]} [opts.spec.dependsOn]
243
+ * @param {string[]} [opts.spec.triggerPhrases]
244
+ * @param {object} opts.projectConfig - forge.config.json contents
245
+ * @param {string} opts.projectRoot - Absolute path to project root
246
+ * @param {object} opts.modelConfig - { provider, apiKey, model }
247
+ * @param {string} opts.modelConfig.provider - 'anthropic' | 'openai'
248
+ * @param {string} opts.modelConfig.apiKey
249
+ * @param {string} opts.modelConfig.model
250
+ * @param {string[]} [opts.existingTools] - Existing tool names for barrel example
251
+ *
252
+ * @returns {Promise<{
253
+ * toolFile: { path: string, content: string },
254
+ * testFile: { path: string, content: string },
255
+ * barrelDiff: { path: string, lineToAdd: string } | null
256
+ * }>}
257
+ *
258
+ * @throws {Error} If LLM returns invalid JSON after 2 retries
259
+ */
260
+ export async function generateToolFiles({
261
+ spec,
262
+ projectConfig,
263
+ projectRoot,
264
+ modelConfig,
265
+ existingTools = []
266
+ }) {
267
+ const toolsDir = projectConfig?.project?.toolsDir || 'example/tools';
268
+
269
+ // Resolve absolute base directory for path construction
270
+ const absToolsDir = toolsDir.startsWith('/')
271
+ ? toolsDir
272
+ : `${projectRoot}/${toolsDir}`;
273
+
274
+ const safeName = (spec.name || 'unnamed').replace(/[^a-zA-Z0-9_-]/g, '_');
275
+ const toolFilePath = `${absToolsDir}/${safeName}.tool.js`;
276
+ const testFilePath = `${absToolsDir}/__tests__/${safeName}.tool.test.js`;
277
+ const barrelPath = `${absToolsDir}/index.js`;
278
+
279
+ const systemPrompt = buildSystemPrompt(spec, existingTools);
280
+ const userMessage = `Generate the tool file, test file, and barrel line for the "${spec.name}" tool.`;
281
+
282
+ const messages = [{ role: 'user', content: userMessage }];
283
+
284
+ const MAX_RETRIES = 2;
285
+ let lastError;
286
+
287
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
288
+ let responseText;
289
+
290
+ try {
291
+ const turn = await llmTurn({
292
+ provider: modelConfig.provider,
293
+ apiKey: modelConfig.apiKey,
294
+ model: modelConfig.model,
295
+ system: systemPrompt,
296
+ messages,
297
+ maxTokens: 8192,
298
+ timeoutMs: 120_000
299
+ });
300
+
301
+ responseText = turn.text;
302
+ } catch (err) {
303
+ // Network / API errors propagate immediately — no retry benefit
304
+ throw new Error(
305
+ `LLM API call failed while generating tool "${spec.name}": ${err.message}`
306
+ );
307
+ }
308
+
309
+ if (!responseText || responseText.trim() === '') {
310
+ lastError = new Error(
311
+ `LLM returned an empty response on attempt ${attempt}/${MAX_RETRIES}`
312
+ );
313
+ // Append a nudge for the retry
314
+ messages.push({ role: 'assistant', content: responseText || '' });
315
+ messages.push({
316
+ role: 'user',
317
+ content:
318
+ 'Your response was empty. Please respond with ONLY a JSON object containing ' +
319
+ '"toolFile", "testFile", and "barrelLine" keys.'
320
+ });
321
+ continue;
322
+ }
323
+
324
+ let parsed;
325
+ try {
326
+ parsed = extractJson(responseText);
327
+ } catch (parseErr) {
328
+ lastError = new Error(
329
+ `Attempt ${attempt}/${MAX_RETRIES}: Could not extract JSON from LLM response — ` +
330
+ parseErr.message +
331
+ `\nRaw response (first 300 chars): ${responseText.slice(0, 300)}`
332
+ );
333
+ messages.push({ role: 'assistant', content: responseText });
334
+ messages.push({
335
+ role: 'user',
336
+ content:
337
+ 'Your previous response did not contain a valid JSON object. ' +
338
+ 'Respond ONLY with a JSON object with keys "toolFile", "testFile", and "barrelLine". ' +
339
+ 'Do not include any text outside the JSON.'
340
+ });
341
+ continue;
342
+ }
343
+
344
+ let validated;
345
+ try {
346
+ validated = validateLlmOutput(parsed);
347
+ } catch (validErr) {
348
+ lastError = new Error(
349
+ `Attempt ${attempt}/${MAX_RETRIES}: LLM JSON was missing required fields — ` +
350
+ validErr.message
351
+ );
352
+ messages.push({ role: 'assistant', content: responseText });
353
+ messages.push({
354
+ role: 'user',
355
+ content:
356
+ `The JSON you returned was invalid: ${validErr.message}. ` +
357
+ 'Please provide a JSON object with non-empty string fields "toolFile", "testFile", ' +
358
+ 'and optionally "barrelLine".'
359
+ });
360
+ continue;
361
+ }
362
+
363
+ // Success — build result
364
+ const barrelDiff = validated.barrelLine
365
+ ? { path: barrelPath, lineToAdd: validated.barrelLine }
366
+ : null;
367
+
368
+ return {
369
+ toolFile: {
370
+ path: toolFilePath,
371
+ content: validated.toolFile
372
+ },
373
+ testFile: {
374
+ path: testFilePath,
375
+ content: validated.testFile
376
+ },
377
+ barrelDiff
378
+ };
379
+ }
380
+
381
+ // Exhausted retries
382
+ throw new Error(
383
+ `generateToolFiles: failed to obtain valid LLM output for "${spec.name}" ` +
384
+ `after ${MAX_RETRIES} attempts. Last error: ${lastError?.message}`
385
+ );
386
+ }
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forge Service Client — CLI wrapper for the forge-service HTTP bridge.
4
+ * Used by the forge-tool skill (Claude) to interact with the queue.
5
+ *
6
+ * Usage:
7
+ * node cli/forge-service-client.js start
8
+ * node cli/forge-service-client.js health
9
+ * node cli/forge-service-client.js next # exits 0 + JSON or 1 on empty
10
+ * node cli/forge-service-client.js complete
11
+ * node cli/forge-service-client.js enqueue <json>
12
+ * node cli/forge-service-client.js shutdown
13
+ */
14
+
15
+ import { readFileSync, existsSync } from 'fs';
16
+ import { resolve, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { spawn } from 'child_process';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const PROJECT_ROOT = resolve(__dirname, '..');
22
+ const LOCK_FILE = resolve(PROJECT_ROOT, '.forge-service.lock');
23
+
24
+ function readLock() {
25
+ if (!existsSync(LOCK_FILE)) return null;
26
+ try {
27
+ return JSON.parse(readFileSync(LOCK_FILE, 'utf-8'));
28
+ } catch (_) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ async function httpRequest(method, path, body, timeoutMs = 35_000) {
34
+ const lock = readLock();
35
+ if (!lock) throw new Error('No forge service running (.forge-service.lock not found)');
36
+ const { port } = lock;
37
+
38
+ const { request } = await import('http');
39
+ return new Promise((res, rej) => {
40
+ const payload = body ? JSON.stringify(body) : undefined;
41
+ const options = {
42
+ hostname: '127.0.0.1',
43
+ port,
44
+ path,
45
+ method,
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {})
49
+ }
50
+ };
51
+
52
+ const req = request(options, (response) => {
53
+ let data = '';
54
+ response.on('data', (chunk) => { data += chunk; });
55
+ response.on('end', () => {
56
+ res({ statusCode: response.statusCode, body: data });
57
+ });
58
+ });
59
+
60
+ req.on('error', rej);
61
+ const timer = setTimeout(() => {
62
+ req.destroy(new Error('timeout'));
63
+ }, timeoutMs);
64
+
65
+ req.on('close', () => clearTimeout(timer));
66
+
67
+ if (payload) req.write(payload);
68
+ req.end();
69
+ });
70
+ }
71
+
72
+ async function cmdStart() {
73
+ const existing = readLock();
74
+ if (existing) {
75
+ // Verify it's actually alive
76
+ try {
77
+ const r = await httpRequest('GET', '/health', null, 3000);
78
+ if (r.statusCode === 200) {
79
+ const data = JSON.parse(r.body);
80
+ console.log(`Forge service already active on port ${existing.port}`);
81
+ console.log(JSON.stringify(data));
82
+ process.exit(0);
83
+ }
84
+ } catch (_) {
85
+ // Stale lock — remove it before spawning a new instance
86
+ }
87
+ try {
88
+ const { unlinkSync } = await import('fs');
89
+ unlinkSync(LOCK_FILE);
90
+ } catch (_) { /* ignore */ }
91
+ }
92
+
93
+ const child = spawn('node', [resolve(__dirname, 'forge-service.js')], {
94
+ detached: true,
95
+ stdio: ['ignore', 'pipe', 'pipe']
96
+ });
97
+
98
+ let output = '';
99
+ let stderrOutput = '';
100
+ child.stdout.on('data', (d) => { output += d.toString(); });
101
+ child.stderr.on('data', (d) => { stderrOutput += d.toString(); });
102
+
103
+ await new Promise((res, rej) => {
104
+ const timeout = setTimeout(() => {
105
+ const detail = stderrOutput.trim() ? `\nService stderr: ${stderrOutput.trim()}` : '';
106
+ rej(new Error(`Service start timeout${detail}`));
107
+ }, 10_000);
108
+ const poll = setInterval(() => {
109
+ if (readLock()) {
110
+ clearTimeout(timeout);
111
+ clearInterval(poll);
112
+ res();
113
+ }
114
+ }, 200);
115
+ child.on('error', (err) => { clearTimeout(timeout); clearInterval(poll); rej(err); });
116
+ });
117
+
118
+ child.unref();
119
+ const lock = readLock();
120
+ console.log(`Forge service started on port ${lock.port}`);
121
+ process.exit(0);
122
+ }
123
+
124
+ async function cmdHealth() {
125
+ const r = await httpRequest('GET', '/health');
126
+ console.log(r.body);
127
+ process.exit(r.statusCode === 200 ? 0 : 1);
128
+ }
129
+
130
+ async function cmdNext() {
131
+ // Long-poll /next — 30s server timeout + 35s client timeout
132
+ const r = await httpRequest('GET', '/next', null, 36_000);
133
+ if (r.statusCode === 200) {
134
+ console.log(r.body);
135
+ process.exit(0);
136
+ } else {
137
+ // 204 = nothing in queue after timeout
138
+ process.exit(1);
139
+ }
140
+ }
141
+
142
+ async function cmdComplete() {
143
+ const r = await httpRequest('POST', '/complete');
144
+ console.log(r.body);
145
+ process.exit(r.statusCode === 200 ? 0 : 1);
146
+ }
147
+
148
+ async function cmdEnqueue(jsonArg) {
149
+ if (!jsonArg) {
150
+ console.error('Usage: forge-service-client.js enqueue <json>');
151
+ process.exit(1);
152
+ }
153
+ let endpoint;
154
+ try {
155
+ endpoint = JSON.parse(jsonArg);
156
+ } catch (_) {
157
+ console.error('Invalid JSON argument');
158
+ process.exit(1);
159
+ }
160
+ const r = await httpRequest('POST', '/enqueue', { endpoint });
161
+ console.log(r.body);
162
+ process.exit(r.statusCode === 200 ? 0 : 1);
163
+ }
164
+
165
+ async function cmdShutdown() {
166
+ const r = await httpRequest('DELETE', '/shutdown');
167
+ console.log(r.body);
168
+ process.exit(0);
169
+ }
170
+
171
+ const [,, cmd, ...args] = process.argv;
172
+
173
+ const handlers = {
174
+ start: cmdStart,
175
+ health: cmdHealth,
176
+ next: cmdNext,
177
+ complete: cmdComplete,
178
+ enqueue: () => cmdEnqueue(args[0]),
179
+ shutdown: cmdShutdown
180
+ };
181
+
182
+ if (!cmd || !handlers[cmd]) {
183
+ console.error(`Usage: forge-service-client.js <start|health|next|complete|enqueue|shutdown>`);
184
+ process.exit(1);
185
+ }
186
+
187
+ handlers[cmd]().catch((err) => {
188
+ console.error(`Error: ${err.message}`);
189
+ process.exit(1);
190
+ });
@@ -0,0 +1,4 @@
1
+ // forge-service.js implements buildSidecarContext and createSidecarRouter.
2
+ // These functions are re-exported by sidecar.js and declared there to avoid duplication.
3
+ export { buildSidecarContext, createSidecarRouter } from './sidecar.js';
4
+ export type { SidecarContext, SidecarOptions } from './sidecar.js';