flint-agent 1.14.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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,535 @@
1
+ import http from "node:http";
2
+ import { createPairingSession, verifyPin } from "../security/pairing.js";
3
+ import { createLogger } from "../logging/logger.js";
4
+ import { app } from "../app-state.js";
5
+ import { API_HOST } from "./address.js";
6
+
7
+ const log = createLogger("server");
8
+
9
+ function readBody(req) {
10
+ return new Promise((resolve) => {
11
+ const chunks = [];
12
+ req.on("data", (chunk) => chunks.push(chunk));
13
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
14
+ });
15
+ }
16
+
17
+ // Server reads from store + uses processMessage callback for messages
18
+ export function startServer(port, store, processMessage, opts = {}) {
19
+ log.info(`Starting server, requested port: ${port}`);
20
+ const { getAbortController, onStop, getPlan, onCommand, authMiddleware, onPairingRequest, onPairingResult, strictPort } = opts;
21
+
22
+ const server = http.createServer(async (req, res) => {
23
+ // Dynamic CORS: allow localhost with any port
24
+ const origin = req.headers.origin || "";
25
+ const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
26
+ res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : "http://localhost");
27
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
28
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
29
+
30
+ if (req.method === "OPTIONS") {
31
+ res.writeHead(204);
32
+ res.end();
33
+ return;
34
+ }
35
+
36
+ // Auth check (after CORS headers are set)
37
+ if (authMiddleware) {
38
+ const authorized = authMiddleware(req, res);
39
+ if (!authorized) return; // response already sent by middleware
40
+ }
41
+
42
+ const url = new URL(req.url, `http://localhost:${port}`);
43
+ const json = (code, data) => {
44
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
45
+ res.end(JSON.stringify(data));
46
+ };
47
+
48
+ function parseJson(raw) {
49
+ try { return JSON.parse(raw); }
50
+ catch { return null; }
51
+ }
52
+
53
+ try {
54
+ // POST /pair/request -- initiate pairing
55
+ if (url.pathname === "/pair/request" && req.method === "POST") {
56
+ const body = await readBody(req);
57
+ const parsed = body ? JSON.parse(body) : {};
58
+ const fromAddress = req.socket.remoteAddress || "unknown";
59
+ const agentName = parsed.agentName || parsed.name || null;
60
+ const result = createPairingSession(fromAddress);
61
+ if (result.error) {
62
+ json(429, { error: result.error });
63
+ return;
64
+ }
65
+ if (onPairingRequest) {
66
+ onPairingRequest({
67
+ sessionId: result.sessionId,
68
+ pin: result.pin,
69
+ fromAddress,
70
+ agentName,
71
+ expiresAt: result.expiresAt,
72
+ });
73
+ }
74
+ json(200, { sessionId: result.sessionId, expiresAt: result.expiresAt });
75
+ return;
76
+ }
77
+
78
+ // POST /pair/confirm -- verify PIN and get token
79
+ if (url.pathname === "/pair/confirm" && req.method === "POST") {
80
+ const body = parseJson(await readBody(req));
81
+ if (!body) { json(400, { error: "invalid JSON" }); return; }
82
+ if (!body.sessionId || !body.pin) {
83
+ json(400, { error: "sessionId and pin are required" });
84
+ return;
85
+ }
86
+ const fromAddress = req.socket.remoteAddress || "unknown";
87
+ // Get agent name from the pending pairing state
88
+ const pendingPairing = store.getState().pendingPairing;
89
+ const agentName = pendingPairing?.agentName || null;
90
+ const result = verifyPin(body.sessionId, body.pin, { name: agentName, address: fromAddress });
91
+ if (!result.valid) {
92
+ if (onPairingResult) onPairingResult({ success: false, fromAddress, agentName, error: result.error });
93
+ json(403, { error: result.error });
94
+ return;
95
+ }
96
+ if (onPairingResult) onPairingResult({ success: true, fromAddress, agentName });
97
+ json(200, { token: result.token });
98
+ return;
99
+ }
100
+
101
+ // POST /message -- send a message to the agent
102
+ // Modes:
103
+ // ?sync=true — wait for result (old behavior, 180s timeout)
104
+ // default — async: return messageId immediately, poll GET /message/:id
105
+ if (url.pathname === "/message" && req.method === "POST") {
106
+ const body = parseJson(await readBody(req));
107
+ if (!body) { json(400, { error: "invalid JSON" }); return; }
108
+ const content = body.content;
109
+ const name = body.name || "api";
110
+ const fromAddress = req.socket.remoteAddress || "";
111
+ const sender = name + (fromAddress ? `@${fromAddress}` : "");
112
+ if (!content) {
113
+ json(400, { error: "content is required" });
114
+ return;
115
+ }
116
+
117
+ // Encoding sanity check — reject mojibake / invalid-UTF-8 input with
118
+ // a clear 400 so the caller notices and retries, rather than letting
119
+ // the classifier hallucinate meaning out of garbled bytes. Triggered
120
+ // when the message contains a high density of U+FFFD replacement
121
+ // characters (Node puts these where a byte sequence was not decodable
122
+ // as UTF-8 — typical when a client sends cp1251/windows-1252 bytes
123
+ // with Content-Type: application/json but no charset). See failure
124
+ // 2026-04-21 where Windows bash + curl -d sent mojibake and Flint
125
+ // invented "mark task as in progress" for a Russian "delete the task".
126
+ {
127
+ const s = String(content);
128
+ const replacementCount = (s.match(/\uFFFD/g) || []).length;
129
+ const ratio = replacementCount / Math.max(s.length, 1);
130
+ if (replacementCount >= 3 && ratio > 0.05) {
131
+ json(400, {
132
+ error: "content appears encoding-corrupted",
133
+ detail: `${replacementCount} U+FFFD replacement chars (${Math.round(ratio * 100)}% of input). Send the body as UTF-8 — the client is likely encoding Russian/non-ASCII text as cp1251 or latin-1.`,
134
+ replacement_count: replacementCount,
135
+ });
136
+ return;
137
+ }
138
+ }
139
+ // Enable autonomous mode via API
140
+ if (body.autonomous) {
141
+ app.autonomous = true;
142
+ try {
143
+ const { resetAutonomous } = await import("../bus/drain-loop.js");
144
+ resetAutonomous();
145
+ } catch {}
146
+ }
147
+
148
+ // Reserved slash commands — intercept BEFORE the bus, mirroring the
149
+ // TUI path at src/index.js:508. Without this, messages like /mcp or
150
+ // /restart sent via the API land in the classifier+LLM pipeline and
151
+ // are interpreted as natural-language tasks. See
152
+ // the 2026-04-21 autonomous-loop incident where a user's /mcp was
153
+ // swallowed by a self-continue loop. Bus PRIORITY already declares
154
+ // USER=0 "TUI input, /commands" as highest priority — this makes
155
+ // the API path consistent with that declaration.
156
+ const trimmedContent = String(content).trim();
157
+ const { isSlashCommand, tryHandleCommand } = await import("../commands/registry.js");
158
+ if (isSlashCommand(trimmedContent)) {
159
+ try {
160
+ const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "");
161
+ const stateBefore = store.getState();
162
+ const fromLineId = stateBefore.nextLineId || 0;
163
+ const handled = await tryHandleCommand(trimmedContent, store);
164
+ if (handled) {
165
+ const stateAfter = store.getState();
166
+ const newLines = (stateAfter.lines || []).filter((l) => l.id >= fromLineId);
167
+ const output = newLines.map((l) => stripAnsi(l.text || "").trimEnd()).join("\n");
168
+ const cmd = trimmedContent.split(/\s/)[0];
169
+ json(200, { status: "done", handled_as: "slash_command", command: cmd, output });
170
+ return;
171
+ }
172
+ // Unrecognised slash — match TUI "Unknown command" behaviour
173
+ // and short-circuit. Don't forward to the LLM.
174
+ const cmd = trimmedContent.split(/\s/)[0];
175
+ json(200, {
176
+ status: "done",
177
+ handled_as: "unknown_command",
178
+ command: cmd,
179
+ output: `Unknown command: ${cmd}. Type /help for available commands.`,
180
+ });
181
+ return;
182
+ } catch (err) {
183
+ // If the command infrastructure isn't initialised (e.g. server
184
+ // started before commands registered), fall through to the bus
185
+ // so the message isn't lost.
186
+ }
187
+ }
188
+
189
+ const syncMode = url.searchParams.get("sync") === "true" || body.sync === true;
190
+ const streamMode = url.searchParams.get("stream") === "true" || body.stream === true;
191
+
192
+ // All API messages go through bus — sync and async use the same path
193
+ try {
194
+ const busMod = await import("../bus/index.js");
195
+ const drainLoop = await import("../bus/drain-loop.js");
196
+ const { id: messageId } = busMod.push({
197
+ channel: "api",
198
+ content,
199
+ priority: busMod.PRIORITY.API,
200
+ source: sender,
201
+ metadata: syncMode ? JSON.stringify({ sync: true, autonomous: body.autonomous }) : undefined,
202
+ sessionId: store.getState().sessionId,
203
+ });
204
+ drainLoop.notify();
205
+
206
+ if (streamMode) {
207
+ // SSE streaming: open event-stream, forward tokens in real time
208
+ try {
209
+ const { createPipe, pipeToResponse, closePipe } = await import("./stream-pipe.js");
210
+ const pipe = createPipe(messageId);
211
+ pipeToResponse(pipe, res, messageId);
212
+ // Wait for result, then close pipe
213
+ const result = await drainLoop.waitForResult(messageId, 300000); // 5min for streaming
214
+ pipe.emit("event", { type: "result", data: result });
215
+ closePipe(messageId);
216
+ } catch (err) {
217
+ try { res.end(); } catch {}
218
+ }
219
+ return;
220
+ }
221
+
222
+ if (syncMode) {
223
+ // Sync: wait for drain loop to process.
224
+ // RX-2 fix: on client disconnect, call drainLoop.abortMessage(id)
225
+ // which aborts the in-flight processing controller. The agent loop
226
+ // then throws AbortError, drain loop's try/catch/finally properly
227
+ // calls bus.fail(), and the processing slot is released.
228
+ let clientGone = false;
229
+ const onClose = () => {
230
+ clientGone = true;
231
+ try { drainLoop.abortMessage(messageId); } catch {}
232
+ };
233
+ req.on("close", onClose);
234
+
235
+ try {
236
+ const result = await drainLoop.waitForResult(messageId, 180000);
237
+ if (clientGone) return; // client gone, do not write response
238
+ if (result.error) {
239
+ json(result.error === "timeout" ? 408 : 500, { error: result.error });
240
+ } else {
241
+ json(200, result);
242
+ }
243
+ } catch (err) {
244
+ if (!clientGone) {
245
+ json(500, { error: err.message });
246
+ }
247
+ } finally {
248
+ try { req.off("close", onClose); } catch {}
249
+ }
250
+ } else {
251
+ // Async: return messageId immediately, client polls GET /message/:id
252
+ json(202, { messageId, status: "pending" });
253
+ }
254
+ } catch (err) {
255
+ json(500, { error: err.message });
256
+ }
257
+ return;
258
+ }
259
+
260
+ // GET /message/:id -- poll for async message result
261
+ if (url.pathname.startsWith("/message/") && req.method === "GET") {
262
+ const id = parseInt(url.pathname.split("/")[2], 10);
263
+ if (!id || isNaN(id)) {
264
+ json(400, { error: "invalid message id" });
265
+ return;
266
+ }
267
+ try {
268
+ const busMod = await import("../bus/index.js");
269
+ const { getAsyncResult } = await import("../bus/drain-loop.js");
270
+ const msg = busMod.getMessage(id);
271
+ if (!msg) {
272
+ json(404, { error: "message not found" });
273
+ return;
274
+ }
275
+ const response = {
276
+ messageId: msg.id,
277
+ status: msg.status,
278
+ channel: msg.channel,
279
+ createdAt: msg.created_at,
280
+ processedAt: msg.processed_at,
281
+ };
282
+ if (msg.status === "done" || msg.status === "failed") {
283
+ // Try full result from memory first, fall back to DB (truncated)
284
+ const asyncResult = getAsyncResult(id);
285
+ if (asyncResult) {
286
+ response.response = asyncResult.response;
287
+ response.stats = asyncResult.stats;
288
+ // A poller cannot tell a finished turn from one that died on a 429
289
+ // or an expired key without stop_reason, and cannot tell what the
290
+ // agent actually did without toolCalls. Both were being dropped here.
291
+ response.stop_reason = asyncResult.stop_reason;
292
+ response.toolCalls = asyncResult.toolCalls || [];
293
+ if (asyncResult.error) response.error = asyncResult.error;
294
+ } else {
295
+ // Fallback: DB result (may be truncated to 500 chars)
296
+ response.result = msg.result;
297
+ if (msg.status === "failed") response.error = msg.result;
298
+ }
299
+ }
300
+ json(200, response);
301
+ } catch (err) {
302
+ json(500, { error: err.message });
303
+ }
304
+ return;
305
+ }
306
+
307
+ // GET /status -- session info
308
+ if (url.pathname === "/status" && req.method === "GET") {
309
+ const s = store.getState();
310
+ json(200, {
311
+ model: s.model,
312
+ messages: s.messages.length,
313
+ alive: true,
314
+ // A bench harness cannot sum the per-turn figures and get the truth:
315
+ // a turn it abandoned on a timeout still spent money it never saw.
316
+ // The session total is the only honest number, so serve it, and the
317
+ // per-source split with it, so "what is that made of" has an answer
318
+ // without reading the session file.
319
+ usage: {
320
+ cost: s.sessionCost,
321
+ estimated: s.sessionCostEstimated,
322
+ promptTokens: s.sessionPromptTokens,
323
+ completionTokens: s.sessionCompletionTokens,
324
+ cachedTokens: s.sessionCachedTokens,
325
+ bySource: s.sessionUsageBySource,
326
+ },
327
+ });
328
+ return;
329
+ }
330
+
331
+ // POST /restart -- graceful restart (same as /restart TUI command).
332
+ // Mirrors src/commands/commands.js:"/restart" — saves session then
333
+ // exits with code 42 so the launcher (src/launcher.js) brings the
334
+ // agent back up. Token-protected via authMiddleware above; no
335
+ // request body required.
336
+ if (url.pathname === "/restart" && req.method === "POST") {
337
+ try {
338
+ const s = store.getState();
339
+ const { saveSession } = await import("../sessions.js");
340
+ // Mirror the /restart TUI command: do NOT persist `plan` or
341
+ // pendingAction across restart — that caused autonomous-loop
342
+ // stickiness after 2026-04-21 (plan resumed in fresh process).
343
+ await saveSession(s.sessionId, {
344
+ messages: s.messages,
345
+ model: s.model,
346
+ profile: s._profile,
347
+ inputHistory: s.inputHistory,
348
+ lastSummary: s.lastSummary,
349
+ pastedImages: s.pastedImages,
350
+ // The restart continues this session now, cost included.
351
+ provider: s.provider,
352
+ sessionCost: s.sessionCost,
353
+ sessionPromptTokens: s.sessionPromptTokens,
354
+ sessionCompletionTokens: s.sessionCompletionTokens,
355
+ });
356
+ } catch {}
357
+ json(202, { status: "restarting", exitCode: 42, sessionId: store.getState().sessionId });
358
+ // Delay so the HTTP response actually reaches the client.
359
+ const { restartKeepingSession } = await import("../restart.js");
360
+ restartKeepingSession(store.getState().sessionId, 150);
361
+ return;
362
+ }
363
+
364
+ // GET /bus/log -- recent bus events for monitoring
365
+ if (url.pathname === "/bus/log" && req.method === "GET") {
366
+ const { recentEvents, stats } = await import("../bus/index.js");
367
+ const limit = parseInt(url.searchParams?.get("limit") || "50", 10);
368
+ json(200, { events: recentEvents(limit), stats: stats() });
369
+ return;
370
+ }
371
+
372
+ // GET /bus/stats -- bus queue statistics
373
+ if (url.pathname === "/bus/stats" && req.method === "GET") {
374
+ const { stats } = await import("../bus/index.js");
375
+ const { isProcessing } = await import("../bus/drain-loop.js");
376
+ json(200, { ...stats(), drainRunning: isProcessing() });
377
+ return;
378
+ }
379
+
380
+ // POST /dataset -- child agents push datasets
381
+ if (url.pathname === "/dataset" && req.method === "POST") {
382
+ const body = parseJson(await readBody(req));
383
+ if (!body) { json(400, { error: "invalid JSON" }); return; }
384
+ const { label, columns, rows } = body;
385
+ if (!columns || !rows) { json(400, { error: "columns and rows required" }); return; }
386
+ const id = store.getState().addDataset({ label: label || "remote", columns, rows, source: "api" });
387
+ json(201, { ok: true, id });
388
+ return;
389
+ }
390
+
391
+ // GET /datasets -- list active datasets
392
+ if (url.pathname === "/datasets" && req.method === "GET") {
393
+ const datasets = store.getState().datasets;
394
+ const list = Object.values(datasets).map((ds) => ({
395
+ id: ds.id, label: ds.label, rows: ds.rows.length,
396
+ page: ds.page, totalPages: Math.ceil(ds.rows.length / ds.pageSize),
397
+ }));
398
+ json(200, { datasets: list });
399
+ return;
400
+ }
401
+
402
+ // GET /history -- conversation history (supports ?limit=N&offset=M)
403
+ if (url.pathname === "/history" && req.method === "GET") {
404
+ const messages = store.getState().messages;
405
+ const limit = parseInt(url.searchParams.get("limit")) || messages.length;
406
+ const offset = parseInt(url.searchParams.get("offset")) || 0;
407
+ const slice = messages.slice(offset, offset + limit);
408
+ json(200, { messages: slice, total: messages.length, offset, limit });
409
+ return;
410
+ }
411
+
412
+ // GET /plan -- current task plan
413
+ if (url.pathname === "/plan" && req.method === "GET") {
414
+ json(200, { plan: getPlan ? getPlan() : null });
415
+ return;
416
+ }
417
+
418
+ // GET /model -- current model and pricing
419
+ if (url.pathname === "/model" && req.method === "GET") {
420
+ const s = store.getState();
421
+ json(200, {
422
+ model: s.model,
423
+ pricing: s.pricing
424
+ ? {
425
+ prompt: +(s.pricing.prompt * 1e6).toFixed(2),
426
+ completion: +(s.pricing.completion * 1e6).toFixed(2),
427
+ }
428
+ : null,
429
+ });
430
+ return;
431
+ }
432
+
433
+ // GET /queue -- list pending bus messages
434
+ if (url.pathname === "/queue" && req.method === "GET") {
435
+ const { pending, stats } = await import("../bus/index.js");
436
+ json(200, { queue: pending(), stats: stats() });
437
+ return;
438
+ }
439
+
440
+ // DELETE /queue -- flush bus queue
441
+ if (url.pathname === "/queue" && req.method === "DELETE") {
442
+ const { flush } = await import("../bus/drain-loop.js");
443
+ const { stats } = await import("../bus/index.js");
444
+ const before = stats().pending;
445
+ flush();
446
+ json(200, { ok: true, flushed: before });
447
+ return;
448
+ }
449
+
450
+ // POST /continue -- resume unfinished auto work (shortcut for POST /command {command:"/continue"})
451
+ if (url.pathname === "/continue" && req.method === "POST") {
452
+ if (onCommand) {
453
+ const result = await onCommand("/continue");
454
+ json(200, { ok: true, result });
455
+ } else {
456
+ json(501, { error: "commands not supported" });
457
+ }
458
+ return;
459
+ }
460
+
461
+ // POST /command -- execute REPL command (/clear, /new, etc.)
462
+ if (url.pathname === "/command" && req.method === "POST") {
463
+ const body = parseJson(await readBody(req));
464
+ if (!body) { json(400, { error: "invalid JSON" }); return; }
465
+ const command = body.command;
466
+ if (!command) {
467
+ json(400, { error: "command is required" });
468
+ return;
469
+ }
470
+ if (onCommand) {
471
+ const result = await onCommand(command);
472
+ json(200, { ok: true, result });
473
+ } else {
474
+ json(501, { error: "commands not supported" });
475
+ }
476
+ return;
477
+ }
478
+
479
+ // POST /stop -- abort current agent execution (outside the lock)
480
+ if (url.pathname === "/stop" && req.method === "POST") {
481
+ const ac = getAbortController?.();
482
+ if (ac) {
483
+ ac.abort();
484
+ onStop?.();
485
+ json(200, { ok: true, message: "aborted" });
486
+ } else {
487
+ json(200, { ok: true, message: "nothing running" });
488
+ }
489
+ return;
490
+ }
491
+
492
+ json(404, { error: "not found" });
493
+ } catch (err) {
494
+ json(500, { error: err.message });
495
+ }
496
+ });
497
+
498
+ // Try configured port, then scan up to find a free one, UNLESS the caller
499
+ // named the port on purpose. Scanning is right for a person starting a
500
+ // second Flint by hand and wrong for anything driven by another program: a
501
+ // bench subject asked for 3001 and silently answering on 3003 means the
502
+ // harness talked to whatever else was listening. Reported through `error` so
503
+ // the caller can say so and exit rather than run on the wrong port.
504
+ return new Promise((resolve) => {
505
+ let tryPort = port;
506
+ const maxAttempts = strictPort ? 0 : 20;
507
+
508
+ server.on("error", (err) => {
509
+ process.stderr.write(`[server] Port ${tryPort} error: ${err.code} ${err.message}\n`);
510
+ if (strictPort) {
511
+ log.error(`Port ${port} was requested explicitly and is not available`, { code: err.code });
512
+ resolve({ server: null, port: null, error: `port ${port} unavailable (${err.code})` });
513
+ return;
514
+ }
515
+ if ((err.code === "EADDRINUSE" || err.code === "EACCES") && tryPort - port < maxAttempts) {
516
+ log.debug(`Port ${tryPort} unavailable (${err.code}), trying next`);
517
+ tryPort++;
518
+ server.listen(tryPort, API_HOST);
519
+ } else {
520
+ log.error(`Failed to bind (tried ports ${port}-${tryPort})`, { code: err.code, message: err.message });
521
+ resolve({ server, port });
522
+ }
523
+ });
524
+ server.on("listening", () => {
525
+ const actualPort = server.address().port;
526
+ if (actualPort !== port) {
527
+ log.warn(`Port ${port} unavailable, using ${actualPort}`);
528
+ } else {
529
+ log.info(`Listening on port ${actualPort}`);
530
+ }
531
+ resolve({ server, port: actualPort });
532
+ });
533
+ server.listen(tryPort, API_HOST);
534
+ });
535
+ }
@@ -0,0 +1,113 @@
1
+ // Stream pipe — per-message event emitter for SSE streaming.
2
+ //
3
+ // When a message is processed with stream=true, the drain loop attaches
4
+ // callbacks (onToken, onToolStart, onToolResult) that emit events here.
5
+ // The HTTP server subscribes and forwards as Server-Sent Events.
6
+ //
7
+ // Internal task reference removed.
8
+
9
+ import { EventEmitter } from "node:events";
10
+
11
+ const _pipes = new Map(); // messageId → EventEmitter
12
+
13
+ /**
14
+ * Create a stream pipe for a message.
15
+ * @param {string} messageId
16
+ * @returns {EventEmitter}
17
+ */
18
+ export function createPipe(messageId) {
19
+ const emitter = new EventEmitter();
20
+ emitter.setMaxListeners(5);
21
+ _pipes.set(messageId, emitter);
22
+ return emitter;
23
+ }
24
+
25
+ /**
26
+ * Get existing pipe for a message.
27
+ * @param {string} messageId
28
+ * @returns {EventEmitter|null}
29
+ */
30
+ export function getPipe(messageId) {
31
+ return _pipes.get(messageId) || null;
32
+ }
33
+
34
+ /**
35
+ * Close and remove a pipe.
36
+ * @param {string} messageId
37
+ */
38
+ export function closePipe(messageId) {
39
+ const pipe = _pipes.get(messageId);
40
+ if (pipe) {
41
+ pipe.emit("end");
42
+ pipe.removeAllListeners();
43
+ _pipes.delete(messageId);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Create SSE callback handlers for agent loop.
49
+ * Attach to processMessage options to pipe events to SSE client.
50
+ * @param {string} messageId
51
+ * @returns {{ onToken, onToolStart, onToolResult, onStreamEnd }}
52
+ */
53
+ export function createStreamCallbacks(messageId) {
54
+ const pipe = getPipe(messageId);
55
+ if (!pipe) return {};
56
+
57
+ return {
58
+ onToken: (token) => {
59
+ pipe.emit("event", { type: "token", data: token });
60
+ },
61
+ onToolStart: (toolName, args) => {
62
+ pipe.emit("event", { type: "tool_start", data: { tool: toolName, args } });
63
+ },
64
+ onToolResult: (toolName, result) => {
65
+ const short = typeof result === "string" ? result.slice(0, 500) : JSON.stringify(result).slice(0, 500);
66
+ pipe.emit("event", { type: "tool_result", data: { tool: toolName, result: short } });
67
+ },
68
+ onStreamEnd: () => {
69
+ pipe.emit("event", { type: "stream_end", data: {} });
70
+ },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Write SSE events to HTTP response from a pipe.
76
+ * @param {EventEmitter} pipe
77
+ * @param {http.ServerResponse} res
78
+ * @param {string} messageId
79
+ */
80
+ export function pipeToResponse(pipe, res, messageId) {
81
+ // SSE headers
82
+ res.writeHead(200, {
83
+ "Content-Type": "text/event-stream",
84
+ "Cache-Control": "no-cache",
85
+ "Connection": "keep-alive",
86
+ "X-Message-Id": messageId,
87
+ });
88
+
89
+ const onEvent = ({ type, data }) => {
90
+ const payload = JSON.stringify({ type, data, ts: Date.now() });
91
+ res.write(`event: ${type}\ndata: ${payload}\n\n`);
92
+ };
93
+
94
+ const onEnd = () => {
95
+ res.write(`event: done\ndata: ${JSON.stringify({ type: "done", ts: Date.now() })}\n\n`);
96
+ res.end();
97
+ cleanup();
98
+ };
99
+
100
+ const cleanup = () => {
101
+ pipe.off("event", onEvent);
102
+ pipe.off("end", onEnd);
103
+ };
104
+
105
+ pipe.on("event", onEvent);
106
+ pipe.on("end", onEnd);
107
+
108
+ // Client disconnect cleanup
109
+ res.on("close", () => {
110
+ cleanup();
111
+ closePipe(messageId);
112
+ });
113
+ }
@@ -0,0 +1,39 @@
1
+ // Shared mutable application state.
2
+ // All modules import this object and read/write its properties directly.
3
+
4
+ import { config } from "./config.js";
5
+
6
+ export const app = {
7
+ abortController: null,
8
+ systemMessage: null,
9
+ activeProfile: "generic",
10
+ profileConfig: null,
11
+ mcpStatusList: [],
12
+ actualPort: 3000,
13
+ securityApi: null,
14
+ inkInstance: null,
15
+ shuttingDown: false,
16
+ lockChain: Promise.resolve(),
17
+ queueAborted: false,
18
+ autonomous: false, // autonomous mode: self-continue when plan has pending tasks
19
+ apiGoalId: null, // goal ID created by current API task — stop when this goal completes
20
+ };
21
+
22
+ // Session data snapshot -- used by multiple modules for saving
23
+ // Must be called with store as argument to avoid circular imports
24
+ export function sessionData(store) {
25
+ const ss = store.getState();
26
+ return {
27
+ messages: ss.messages,
28
+ model: config.model,
29
+ provider: config.provider,
30
+ profile: app.activeProfile,
31
+ plan: ss.plan,
32
+ inputHistory: ss.inputHistory,
33
+ pastedImages: ss.pastedImages,
34
+ lastSummary: ss.lastSummary,
35
+ sessionCost: ss.sessionCost,
36
+ sessionPromptTokens: ss.sessionPromptTokens,
37
+ sessionCompletionTokens: ss.sessionCompletionTokens,
38
+ };
39
+ }