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.
- package/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- 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
|
+
}
|
package/src/app-state.js
ADDED
|
@@ -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
|
+
}
|