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
package/src/index.js ADDED
@@ -0,0 +1,1111 @@
1
+ // First: in the stdio mode it moves every stray write off stdout before any
2
+ // other module can print (stdio/guard.js).
3
+ import { stdioArgs, protocolWrite } from "./stdio/guard.js";
4
+ // `node src/index.js --version`: the version, and nothing started.
5
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
6
+ const { readFileSync: readPkg } = await import("node:fs");
7
+ console.log(`flint ${JSON.parse(readPkg(new URL("../package.json", import.meta.url), "utf8")).version}`);
8
+ process.exit(0);
9
+ }
10
+ // Then: React and Ink load their production builds (production-env.js).
11
+ import { restoreNodeEnv } from "./production-env.js";
12
+ import { execSync, execFileSync } from "node:child_process";
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import os from "node:os";
15
+ import { format } from "node:util";
16
+ import chalk from "chalk";
17
+ import { apiUrl } from "./api/address.js";
18
+
19
+ // Fix Cyrillic/Unicode display in Windows console
20
+ if (process.platform === "win32") {
21
+ try { execSync("chcp 65001", { stdio: "ignore" }); } catch {}
22
+ }
23
+
24
+ // Ink + readline + our handlers add multiple SIGINT listeners -- raise limit
25
+ process.setMaxListeners(20);
26
+
27
+ // Emergency kill: Ctrl+C (x3 in 2s) = force exit
28
+ let _sigintCount = 0;
29
+ let _sigintReset = null;
30
+ process.on("SIGINT", () => {
31
+ _sigintCount++;
32
+ if (_sigintCount >= 3) {
33
+ process.stderr.write("\n[FORCE EXIT] Triple Ctrl+C\n");
34
+ process.exit(1);
35
+ }
36
+ if (_sigintReset) clearTimeout(_sigintReset);
37
+ _sigintReset = setTimeout(() => { _sigintCount = 0; }, 2000);
38
+ _sigintReset?.unref?.();
39
+ });
40
+
41
+ // Startup watchdog: if app doesn't finish init within 30s, exit. Paused while
42
+ // a first-run question waits for the operator (see startup-watchdog.js).
43
+ import { startStartupWatchdog, clearStartupWatchdog, whileWaitingForOperator } from "./startup-watchdog.js";
44
+ startStartupWatchdog();
45
+
46
+ // Patch stdin only when truly needed (non-TTY fallback for piped/CI). Not in
47
+ // the stdio mode: there stdin is the protocol, and a stdin that claims to be
48
+ // a terminal would make the first-run questions wait on it.
49
+ if (!process.stdin.isTTY && !stdioArgs) {
50
+ if (!process.stdin.setRawMode) process.stdin.setRawMode = () => process.stdin;
51
+ if (!process.stdin.ref) process.stdin.ref = () => process.stdin;
52
+ if (!process.stdin.unref) process.stdin.unref = () => process.stdin;
53
+ process.stdin.isTTY = true;
54
+ }
55
+
56
+ import React from "react";
57
+ import { render } from "ink";
58
+ import { config, needsFirstRunSetup } from "./config.js";
59
+ import { store } from "./store/index.js";
60
+ import { app, sessionData } from "./app-state.js";
61
+ import { parseCLI, runListSessions, migrateKeys, runFirstRunSetup } from "./cli.js";
62
+ import { bootstrap } from "./bootstrap.js";
63
+ import { processMessage, handlePendingAction } from "./message-handler.js";
64
+ import { withLock } from "./input-handler.js"; // serializes /commands, /paste, /auto
65
+ import * as bus from "./bus/index.js";
66
+ import { notify as busPush, flush as busFlush, waitForResult, isProcessing } from "./bus/drain-loop.js";
67
+ import { loadPlugins, stopPlugins } from "./bus/plugins.js";
68
+ import { printHeader, userMsgLine, addIndented, INDENT, ANSI_RE } from "./ui/header.js";
69
+ import { initCommands, tryHandleCommand, isSlashCommand } from "./commands/registry.js";
70
+ import { setSupervisorEnabled } from "./agent/supervisor.js";
71
+ import { runAutoMode } from "./agent/auto.js";
72
+ import { initPermissions, bulkSetPermission } from "./tools/permissions.js";
73
+ import { initSecurity } from "./security/index.js";
74
+ import { fetchModelInfo } from "./api/client.js";
75
+ import { startServer } from "./api/server.js";
76
+ import { logChatLine } from "./logging/chat-log.js";
77
+ import { createChatLogFollower } from "./logging/chat-log-follower.js";
78
+ import { setInkActive, createLogger } from "./logging/logger.js";
79
+ import { startLogCollector } from "./logging/log-collector.js";
80
+ import { killAllChildren } from "./tools/system.js";
81
+ import { pushInbox } from "./memory/inbox.js";
82
+ import { getActiveGoal, getTaskStats, syncPlanToStore, abandonGoal, getDueReminders, fireReminder, claimTask, getTask as getTaskById, completeTaskWithResult, gcStaleSessions } from "./tasks/queries.js";
83
+ import { closeDb } from "./tasks/db.js";
84
+ import { registerAgent, unregisterAgent } from "./registry.js";
85
+ import {
86
+ generateSessionId,
87
+ saveSession,
88
+ loadSession,
89
+ listSessions,
90
+ } from "./sessions.js";
91
+ import { RENDER_OPTIONS } from "./ui/render-options.js";
92
+ import { stripTimeStamp } from "./agent/time-stamp.js";
93
+ import { App } from "./components/App.js";
94
+ import { initOutput, printSystem, printWarning, printConfirmResult } from "./ui/output.js";
95
+
96
+ const { createElement: h } = React;
97
+
98
+ // Every module has loaded and React has picked its build; commands the agent
99
+ // runs get the NODE_ENV the operator started Flint with.
100
+ restoreNodeEnv();
101
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
102
+
103
+ // -- CLI --
104
+
105
+ const cli = stdioArgs ? { action: "stdio", ...stdioArgs } : parseCLI();
106
+
107
+ if (cli.action === "list") {
108
+ await runListSessions();
109
+ }
110
+
111
+ // -- Auto-migrate env keys + first-run wizard --
112
+
113
+ await migrateKeys();
114
+ if (needsFirstRunSetup && cli.action !== "list" && cli.action !== "stdio") {
115
+ await whileWaitingForOperator(() => runFirstRunSetup(cli));
116
+ }
117
+
118
+ // Resolve API key from encrypted storage
119
+ await config.resolveApiKey();
120
+
121
+ // Free mode survives a restart when its primary is still the model
122
+ // (docs/free-mode.md); its notices go to the console.
123
+ {
124
+ const { loadFreeChain } = await import("./free-models.js");
125
+ const { setFreeNoticeSink } = await import("./api/client.js");
126
+ const chain = loadFreeChain();
127
+ if (chain && chain[0] === config.model) config.freeChain = chain;
128
+ setFreeNoticeSink((line) => store.getState().addLine(chalk.yellow(` ${line}`)));
129
+ }
130
+
131
+ // Set provider in store
132
+ store.setState({ provider: config.provider });
133
+
134
+ // -- stdio mode: the host's instructions and MCP servers, before bootstrap
135
+ // builds the system prompt and connects the servers --
136
+ if (cli.action === "stdio") {
137
+ const { hostPromptFrom, mcpConfigPath } = await import("./stdio/session.js");
138
+ const { mcpJsonServers, parseServerConfig } = await import("./mcp-client.js");
139
+ // Paths are the agent's folder's, as an agent CLI would: relative reads and
140
+ // writes land there, not in a per-session workspace (a test agent could not
141
+ // find junk.txt beside its own CLAUDE.md, 2026-10-02).
142
+ if (!config.workdirBase) config.workdirBase = process.cwd();
143
+ config.baseDir = process.cwd();
144
+ try {
145
+ app.hostPrompt = hostPromptFrom(cli);
146
+ const mcpFile = mcpConfigPath(cli);
147
+ if (mcpFile) {
148
+ const fromFile = mcpJsonServers(JSON.parse(readFileSync(mcpFile, "utf-8")));
149
+ config.mcpServers = [...parseServerConfig(config.mcpServers), ...fromFile];
150
+ }
151
+ } catch (err) {
152
+ process.stderr.write(`[flint] ${err.message}
153
+ `);
154
+ process.exit(2);
155
+ }
156
+ }
157
+
158
+ // -- Bootstrap all subsystems --
159
+
160
+ const { log } = await bootstrap(cli, pkg);
161
+
162
+ // -- Subscribe to lines[] for full chat log --
163
+ //
164
+ // This tracked its position with `lines.length`, which stops moving once
165
+ // addLine's 1000-line cap drops the oldest entries: the condition
166
+ // `lines.length > lastLineCount` is then false forever and chat.log stops
167
+ // mid-session with no error (#9). The follower tracks the store's monotonic
168
+ // line id instead, which keeps advancing, and reports a gap if it started
169
+ // after lines were already on screen.
170
+ const chatLogFollower = createChatLogFollower({
171
+ getState: () => store.getState(),
172
+ subscribe: (fn) => store.subscribe(fn),
173
+ log: (sessionId, text) => logChatLine(sessionId, text),
174
+ });
175
+ const { skipped } = chatLogFollower.start();
176
+ if (skipped > 0) {
177
+ logChatLine(store.getState().sessionId ?? "",
178
+ `[chat-log] ${skipped} line(s) were on screen before the log follower started and are not in this file.`);
179
+ }
180
+
181
+ // -- Override console.log to go to store --
182
+
183
+ console.log = (...args) => store.getState().addLine(format(...args));
184
+ console.error = (...args) => store.getState().addLine(chalk.red(format(...args)));
185
+
186
+ // -- stdio mode: no UI, no server, no bus. Runs until stdin closes. --
187
+ if (cli.action === "stdio") {
188
+ clearStartupWatchdog();
189
+ const { runStdio } = await import("./stdio/run.js");
190
+ await runStdio({ opts: cli, write: protocolWrite, version: pkg.version });
191
+ }
192
+
193
+ // -- Start ink UI --
194
+
195
+ function handleAbort() {
196
+ const s = store.getState();
197
+ // Esc ends the agent task. It does not discard the queue.
198
+ //
199
+ // This used to call busFlush() on an agent-loop hit, which failed every
200
+ // pending message as "flushed" while also killing the running task. In
201
+ // session 2026-09-30T00-37-42 those are the two log lines 19 ms apart: the
202
+ // question typed at 19:59:52 discarded unread, and the task killed with it.
203
+ // The only way to ask a question mid-work cost the operator both.
204
+ //
205
+ // The API's /stop still aborts the whole loop, and flush() is still what
206
+ // /new and an explicit API abort use -- discarding the queue is the request
207
+ // there. Esc is not one of those.
208
+ //
209
+ // What changed on 2026-09-30 (item 6 follow-up): Esc stopped meaning "end
210
+ // the step" for the operator who never asked for that distinction. Pressed
211
+ // ten times against one running task, it printed ten [Step stopped] lines
212
+ // and restarted the same step ten times -- a key that stops nothing and
213
+ // says it stopped something. Esc means the whole agent task now, and the
214
+ // queue survives it: a message typed mid-work is still delivered, a message
215
+ // the operator asks for afterwards is still their own.
216
+ const result = s.escAbort();
217
+ if (!result) return; // nothing running, nothing said
218
+ // One short line per press: what stopped, and what the next press would stop.
219
+ const left = store.getState()._taskRegistry.filter((t) => t.type === "bg-process").length;
220
+ const next = left ? ` · ${left} background process${left === 1 ? "" : "es"} running, Esc stops the newest` : "";
221
+ if (result.type === "agent-loop") {
222
+ const detail = s.currentTool ? ` while ${s.currentTool}` : "";
223
+ // The queue is still kept (see escAbort).
224
+ s.addLine(chalk.yellow(` [stopped the turn${detail}]`) + chalk.dim(next));
225
+ s.setStreamText("");
226
+ s.setAgentStatus("idle");
227
+ } else if (result.type === "bg-process") {
228
+ // Nothing here: the process's own "sh killed" ledger line says it, with
229
+ // what is still running (ui/output.js printProcessEnd).
230
+ } else {
231
+ s.addLine(chalk.yellow(` [stopped ${result.type}: ${result.label}]`) + chalk.dim(next));
232
+ }
233
+ }
234
+
235
+ // Populate header lines BEFORE Ink render so first frame is complete
236
+ printHeader();
237
+
238
+ // Clear entire screen (remove launcher splash) before Ink takes over
239
+ if (cli.action !== "headless") {
240
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
241
+ }
242
+ const inkInstance = cli.action !== "headless" ? render(h(App, { store, onSubmit: handleInput, onAbort: handleAbort, onQuit: () => gracefulShutdown("ctrl-c"), onClipboard: readClipboardForInput, onRecallQueued: recallQueuedInput }), RENDER_OPTIONS) : null;
243
+ if (inkInstance) {
244
+ setInkActive(true); // suppress stderr writes that corrupt Ink layout
245
+ store.setState({ _inkClear: () => inkInstance.clear() });
246
+ }
247
+
248
+ // Tell the launcher we are drawing now, so it stops repainting its splash
249
+ // spinner over this frame.
250
+ //
251
+ // The launcher spawns us with the three standard streams inherited, so it
252
+ // cannot see our output: there is no pipe to watch. Its spinner repaints with
253
+ // a bare \r, which the terminal reads as "back to column 0 of whatever line is
254
+ // current now" — and once this frame exists, that is this line. The result was
255
+ // the startup banner and the first few commands printed several times over.
256
+ //
257
+ // The message is sent only when there is a channel to send it on: run directly
258
+ // (`node src/index.js`, tests, headless) there is no parent IPC and
259
+ // process.send is undefined, which is a normal way to start Flint and must not
260
+ // throw.
261
+ if (process.send) {
262
+ process.send({ type: "flint:ready" });
263
+ }
264
+
265
+ // -- Graceful shutdown --
266
+
267
+ function gracefulShutdown(signal) {
268
+ if (app.shuttingDown) return;
269
+ app.shuttingDown = true;
270
+ // Abort all registered tasks
271
+ const aborted = store.getState().abortAll();
272
+ if (aborted.length > 0) {
273
+ process.stderr.write(`\n[Flint] Aborted ${aborted.length} task(s): ${aborted.map(t => t.label).join(", ")}\n`);
274
+ }
275
+ process.stderr.write("[Flint] Shutting down...\n");
276
+ stopPlugins().catch(() => {});
277
+ killAllChildren(2000);
278
+ // Task-driven child agent: complete task on exit
279
+ if (_agentTaskId) {
280
+ try {
281
+ const task = getTaskById(_agentTaskId);
282
+ if (task && task.status === "in_progress") {
283
+ completeTaskWithResult(_agentTaskId, "Agent completed task");
284
+ }
285
+ } catch {}
286
+ }
287
+ unregisterAgent(process.pid);
288
+ // Cleanup sandbox container if running in docker mode
289
+ try {
290
+ import("./sandbox/backend.js").then(m => m.cleanupSandbox()).catch(() => {});
291
+ } catch {}
292
+ closeDb();
293
+ const ss = store.getState();
294
+ saveSession(ss.sessionId, sessionData(store)).finally(() => {
295
+ setTimeout(() => process.exit(0), 500);
296
+ });
297
+ }
298
+
299
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
300
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
301
+ process.on("exit", () => { if (!app.shuttingDown) killAllChildren(0); });
302
+
303
+ // -- Replay chat history on session load --
304
+ //
305
+ // The end of the session as it was on screen, from its chat log
306
+ // (ui/replay.js): the last lines, not the whole conversation, and not written
307
+ // to the log again. The message-based replay below is the fallback for a
308
+ // session without a chat log.
309
+
310
+ let replayedFromLog = 0;
311
+ if (store.getState().messages.length > 1) {
312
+ try {
313
+ const { replaySessionTail } = await import("./ui/replay.js");
314
+ replayedFromLog = replaySessionTail(store, store.getState().sessionId);
315
+ } catch {}
316
+ }
317
+ if (store.getState().messages.length > 1 && !replayedFromLog) {
318
+ const { addLine } = store.getState();
319
+ for (const m of store.getState().messages) {
320
+ if (m.role === "user") {
321
+ let text = typeof m.content === "string" ? stripTimeStamp(m.content) : "";
322
+ if (!text) continue;
323
+ // Strip injected plan/image registry blocks for cleaner display
324
+ text = text.replace(/\[CURRENT PLAN\][\s\S]*?\n/g, "");
325
+ text = text.replace(/\[Available pasted images:[\s\S]*?\]/g, "");
326
+ text = text.trim();
327
+ if (!text) continue;
328
+ const short = text.length > 200 ? text.slice(0, 200) + "..." : text;
329
+ userMsgLine(chalk.dim(short));
330
+ } else if (m.role === "assistant" && m.content && !m.tool_calls?.length) {
331
+ const text = m.content;
332
+ const short = text.length > 300 ? text.slice(0, 300) + "..." : text;
333
+ for (const line of short.split("\n")) {
334
+ addIndented(INDENT, chalk.dim(line));
335
+ }
336
+ }
337
+ }
338
+ addLine(chalk.gray(" ---"));
339
+ addLine("");
340
+ }
341
+
342
+
343
+
344
+
345
+ // -- Clipboard --
346
+
347
+ function readClipboardImage() {
348
+ try {
349
+ const ps = [
350
+ "$ProgressPreference='SilentlyContinue';",
351
+ "Add-Type -AssemblyName System.Windows.Forms;",
352
+ "$i = [System.Windows.Forms.Clipboard]::GetImage();",
353
+ "if ($i) {",
354
+ " $m = New-Object System.IO.MemoryStream;",
355
+ " $i.Save($m, [System.Drawing.Imaging.ImageFormat]::Png);",
356
+ " [Convert]::ToBase64String($m.ToArray())",
357
+ "} else { Write-Output 'NO_IMAGE' }",
358
+ ].join(" ");
359
+ const encoded = Buffer.from(ps, "utf16le").toString("base64");
360
+ const result = execFileSync("powershell", ["-NoProfile", "-EncodedCommand", encoded], {
361
+ encoding: "utf-8",
362
+ timeout: 10000,
363
+ stdio: ["pipe", "pipe", "ignore"],
364
+ }).trim();
365
+ if (result === "NO_IMAGE" || !result) return null;
366
+ return result;
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+
372
+ function readClipboard() {
373
+ const base64 = readClipboardImage();
374
+ if (base64) {
375
+ const sizeKB = Math.round((base64.length * 3) / 4 / 1024);
376
+ return { type: "image", data: base64, tag: `[img:${sizeKB}KB]` };
377
+ }
378
+ try {
379
+ const text = execFileSync("powershell", ["-NoProfile", "-command", "$ProgressPreference='SilentlyContinue'; Get-Clipboard"], {
380
+ encoding: "utf-8",
381
+ timeout: 3000,
382
+ stdio: ["pipe", "pipe", "ignore"],
383
+ }).trim();
384
+ if (text) {
385
+ const bytes = Buffer.byteLength(text);
386
+ const display = bytes >= 1024 ? `${Math.round(bytes / 1024)}KB` : `${bytes}B`;
387
+ return { type: "text", data: text, tag: `[text:${display}]` };
388
+ }
389
+ } catch {}
390
+ return null;
391
+ }
392
+
393
+ // -- Clipboard into the input (Ctrl+V) --
394
+
395
+ /**
396
+ * The clipboard for the input: a picture is saved to a temp file and numbered
397
+ * (the input shows "[Image #n]"), text is returned as text.
398
+ */
399
+ function readClipboardForInput() {
400
+ const clip = readClipboard();
401
+ if (!clip) return null;
402
+ if (clip.type !== "image") return { type: "text", data: clip.data };
403
+ const path = `${os.tmpdir()}/flint_clipboard_${Date.now()}.png`;
404
+ try { writeFileSync(path, Buffer.from(clip.data, "base64")); } catch {}
405
+ const pasted = store.getState().pastedImages;
406
+ const index = pasted.length + 1;
407
+ store.setState({ pastedImages: [...pasted, { path, timestamp: Date.now(), index }] });
408
+ return { type: "image", index, path, data: clip.data, bytes: Math.round((clip.data.length * 3) / 4) };
409
+ }
410
+
411
+ /** A message with pasted images: inlined up to 2 MB each, by path beyond that. */
412
+ async function sendWithImages(text, images) {
413
+ const MAX_IMAGE_INLINE = 2 * 1024 * 1024;
414
+ const parts = [];
415
+ const notes = [];
416
+ for (const img of images) {
417
+ if (img.bytes <= MAX_IMAGE_INLINE) {
418
+ parts.push({ type: "image_url", image_url: { url: `data:image/png;base64,${img.data}` } });
419
+ notes.push(`[IMAGE #${img.index} saved to: ${img.path}]`);
420
+ } else {
421
+ notes.push(`[IMAGE #${img.index} saved to: ${img.path} (${(img.bytes / 1024 / 1024).toFixed(1)} MB). Too large to send inline; the file is on disk.]`);
422
+ }
423
+ }
424
+ parts.push({ type: "text", text: [text, "", ...notes].join("\n") });
425
+ const content = parts.length === 1 ? parts[0].text : parts;
426
+ store.getState().incrementQueue();
427
+ await withLock(async () => {
428
+ store.getState().decrementQueue();
429
+ try {
430
+ await processMessage(content, null);
431
+ await handlePendingAction();
432
+ } catch (err) {
433
+ if (err.name !== "AbortError") store.getState().addLine(chalk.red(`Error: ${err.message}`));
434
+ }
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Esc on an unread message: the newest one leaves the queue and comes back to
440
+ * the input for editing (owner, 2026-10-01). Returns its text, or null when
441
+ * the agent has already taken it.
442
+ */
443
+ function recallQueuedInput() {
444
+ const list = store.getState().queuedInputs || [];
445
+ const last = list[list.length - 1];
446
+ if (!last) return null;
447
+ store.getState().takeQueuedInput(last.id);
448
+ if (!bus.cancelPending(last.id)) return null;
449
+ return last.edit || last.display;
450
+ }
451
+
452
+ // -- Handle user input --
453
+
454
+ async function handleInput(input, opts = {}) {
455
+ const trimmed = input.trim();
456
+ if (!trimmed) return;
457
+ // What the history shows: the input with its paste/image tokens, when the
458
+ // App expanded them for the model (ui/paste-tokens.js).
459
+ const display = (opts.display || trimmed).trim();
460
+
461
+ // Intercept permission confirmation responses
462
+ const pending = store.getState().pendingConfirmation;
463
+ if (pending) {
464
+ const lower = trimmed.toLowerCase();
465
+ if (lower === "y" || lower === "yes") {
466
+ printConfirmResult("allow", pending.toolName, pending.argsText);
467
+ pending.resolve("yes");
468
+ } else if (lower === "a" || lower === "always") {
469
+ printConfirmResult("always", pending.toolName, pending.argsText);
470
+ pending.resolve("always");
471
+ } else {
472
+ printConfirmResult("deny", pending.toolName, pending.argsText);
473
+ pending.resolve("no");
474
+ }
475
+ return;
476
+ }
477
+
478
+ // Intercept pairing cancellation
479
+ const pairingPending = store.getState().pendingPairing;
480
+ if (pairingPending && trimmed.toLowerCase() === "c") {
481
+ store.getState().clearPendingPairing();
482
+ printSystem("Pairing cancelled.");
483
+ return;
484
+ }
485
+
486
+ // "stop" -- abort current execution
487
+ const lower = trimmed.toLowerCase();
488
+ if (lower === "stop" || lower === "/stop") {
489
+ if (app.abortController) {
490
+ app.abortController.abort();
491
+ const pending = bus.stats().pending;
492
+ if (pending > 0) busFlush();
493
+ store.getState().addLine(chalk.yellow(`\n [Aborted by user${pending > 0 ? `, ${pending} queued dropped` : ""}]\n`));
494
+ } else {
495
+ store.getState().addLine(chalk.gray(" Nothing running."));
496
+ }
497
+ return;
498
+ }
499
+
500
+ const isBusy = isProcessing() || store.getState().processingCount > 0;
501
+
502
+ // While busy, a message to the agent waits above the input until it is read
503
+ // (takeQueuedMessages / the drain loop move it into the history then).
504
+ // Commands and images still run at once, so they are echoed now.
505
+ const deferEcho = isBusy && !isSlashCommand(trimmed) && !opts.images?.length;
506
+ if (!deferEcho) userMsgLine(isBusy ? chalk.dim(display) : display);
507
+
508
+ // Save to input history: the tokens, which the App expands again on send.
509
+ if (trimmed) {
510
+ store.getState().pushInputHistory((opts.history || display).trim());
511
+ }
512
+
513
+ // Images pasted with Ctrl+V travel with the message, so it cannot go
514
+ // through the bus (which stores text); sent directly, as /paste did.
515
+ if (opts.images?.length) {
516
+ await sendWithImages(trimmed, opts.images);
517
+ return;
518
+ }
519
+
520
+ // Supervisor toggle
521
+ if (lower === "/supervisor" || lower === "/supervisor on") {
522
+ setSupervisorEnabled(true);
523
+ printSystem("Supervisor ON — watching tool calls, will inject hints");
524
+ return;
525
+ }
526
+ if (lower === "/supervisor off") {
527
+ setSupervisorEnabled(false);
528
+ printSystem("Supervisor OFF");
529
+ return;
530
+ }
531
+
532
+ // Exit
533
+ if (lower === "exit" || lower === "/exit" || lower === "/quit") {
534
+ await saveSession(store.getState().sessionId, sessionData(store));
535
+ store.getState().addLine(chalk.gray(`Session saved: ${store.getState().sessionId}`));
536
+ store.getState().addLine(chalk.gray("Bye!"));
537
+ setTimeout(() => process.exit(0), 100);
538
+ return;
539
+ }
540
+
541
+ // Handle /paste command (needs access to processMessage, so handled here not in commands.js)
542
+ if (lower === "/paste" || lower.startsWith("/paste ")) {
543
+ const text = trimmed.slice(6).trim() || "What is in this image?";
544
+ store.getState().addLine(chalk.gray(" reading clipboard..."));
545
+ const clip = readClipboard();
546
+ if (!clip) {
547
+ store.getState().addLine(chalk.red(" Clipboard is empty."));
548
+ return;
549
+ }
550
+ store.getState().addLine(chalk.gray(` ${clip.tag}, sending...`));
551
+ let msgContent;
552
+ if (clip.type === "image") {
553
+ const imgPath = `${(await import("node:os")).tmpdir()}/flint_clipboard_${Date.now()}.png`;
554
+ const imgBytes = Math.round((clip.data.length * 3) / 4);
555
+ const MAX_IMAGE_INLINE = 2 * 1024 * 1024; // 2 MB — inline as base64
556
+ try {
557
+ const { mkdirSync, writeFileSync } = await import("node:fs");
558
+ // tmpdir already exists
559
+ writeFileSync(imgPath, Buffer.from(clip.data, "base64"));
560
+ } catch {}
561
+ const pastedImages = store.getState().pastedImages;
562
+ const imgIndex = pastedImages.length + 1;
563
+ store.setState({ pastedImages: [...pastedImages, { path: imgPath, timestamp: Date.now(), index: imgIndex }] });
564
+
565
+ if (imgBytes > MAX_IMAGE_INLINE) {
566
+ // Large image: send only file path, do not inline base64
567
+ const sizeMB = (imgBytes / 1024 / 1024).toFixed(1);
568
+ store.getState().addLine(chalk.yellow(` large image (${sizeMB} MB), saved to file instead of inlining`));
569
+ msgContent = `${text}\n\n[IMAGE #${imgIndex} saved to: ${imgPath} (${sizeMB} MB). Image is too large to send inline. The file is on disk if you need to reference it.]`;
570
+ } else {
571
+ // Normal image: inline as base64
572
+ msgContent = [
573
+ { type: "image_url", image_url: { url: `data:image/png;base64,${clip.data}` } },
574
+ { type: "text", text: `${text}\n\n[IMAGE #${imgIndex} saved to: ${imgPath}]` },
575
+ ];
576
+ }
577
+ } else {
578
+ const textLines = clip.data.split("\n").length;
579
+ const textBytes = Buffer.byteLength(clip.data);
580
+ const isLarge = textLines >= 50 || textBytes >= 2048;
581
+
582
+ if (isLarge) {
583
+ // Large text: save to temp file, let model use read_file
584
+ const tmpPath = `${(await import("node:os")).tmpdir()}/flint_clipboard_${Date.now()}.txt`;
585
+ try {
586
+ const { mkdirSync, writeFileSync } = await import("node:fs");
587
+ // tmpdir already exists
588
+ writeFileSync(tmpPath, clip.data, "utf-8");
589
+ } catch (e) {
590
+ store.getState().addLine(chalk.red(` Failed to save clipboard: ${e.message}`));
591
+ return;
592
+ }
593
+ const sizeDisplay = textBytes >= 1024 ? `${Math.round(textBytes / 1024)} KB` : `${textBytes} B`;
594
+ store.getState().addLine(chalk.gray(` saved to ${tmpPath} (${sizeDisplay}, ${textLines} lines)`));
595
+ const userNote = text !== "What is in this image?" ? text + "\n\n" : "";
596
+ msgContent = `${userNote}[User pasted text from clipboard, saved as ${tmpPath} (${sizeDisplay}, ${textLines} lines). Use read_file to see the content.]`;
597
+ } else {
598
+ // Small text: inline as before
599
+ msgContent = text !== "What is in this image?" ? text + "\n\n" + clip.data : clip.data;
600
+ }
601
+ }
602
+ store.getState().incrementQueue();
603
+ await withLock(async () => {
604
+ store.getState().decrementQueue();
605
+ try {
606
+ await processMessage(msgContent, null);
607
+ await handlePendingAction();
608
+ } catch (err) {
609
+ if (err.name !== "AbortError") {
610
+ store.getState().addLine(chalk.red(`Error: ${err.message}`));
611
+ }
612
+ }
613
+ store.getState().addLine("");
614
+ });
615
+ return;
616
+ }
617
+
618
+ // Handle /auto command (needs processMessage access)
619
+ if (lower.startsWith("/auto ")) {
620
+ const autoTask = trimmed.slice(6).trim();
621
+ if (!autoTask) {
622
+ store.getState().addLine(chalk.red(' Usage: /auto "build a REST API"'));
623
+ return;
624
+ }
625
+ store.getState().addLine(chalk.cyan(`\n [AUTO MODE] ${autoTask}\n`));
626
+ const autoMaxIter = config.autoMaxIterations;
627
+ const autoMaxCost = config.autoMaxCost;
628
+ await withLock(async () => {
629
+ try {
630
+ await runAutoMode(autoTask, {
631
+ processMessage,
632
+ getStore: () => store,
633
+ printSystem: (msg) => store.getState().addLine(chalk.cyan(` ${msg}`)),
634
+ printWarning: (msg) => store.getState().addLine(chalk.yellow(` ${msg}`)),
635
+ maxIterations: autoMaxIter,
636
+ maxCost: autoMaxCost,
637
+ isAborted: () => app.abortController?.signal?.aborted || false,
638
+ });
639
+ } catch (err) {
640
+ if (err.name !== "AbortError") {
641
+ store.getState().addLine(chalk.red(` [AUTO] Error: ${err.message}`));
642
+ }
643
+ }
644
+ store.getState().addLine("");
645
+ });
646
+ return;
647
+ }
648
+
649
+ // Handle /continue — resume auto mode on existing plan
650
+ if (lower === "/continue") {
651
+ const activeGoal = getActiveGoal();
652
+ if (!activeGoal) {
653
+ store.getState().addLine(chalk.gray(" No active plan to continue. Use /auto <task> to start one."));
654
+ return;
655
+ }
656
+ const stats = getTaskStats(activeGoal.id);
657
+ if (stats.pending === 0 && stats.in_progress === 0) {
658
+ if (stats.total === 0) {
659
+ // Empty plan (0/0) — model failed to create tasks. Re-send goal as regular message.
660
+ store.getState().addLine(chalk.yellow(` Plan has 0 tasks — re-sending goal as message.`));
661
+ abandonGoal(activeGoal.id);
662
+ await withLock(async () => {
663
+ await processMessage(activeGoal.title);
664
+ });
665
+ return;
666
+ }
667
+ store.getState().addLine(chalk.gray(` Plan "${activeGoal.title}" is already complete (${stats.done}/${stats.total} done).`));
668
+ return;
669
+ }
670
+ store.getState().addLine(chalk.cyan(`\n [CONTINUE] Resuming: ${activeGoal.title}\n`));
671
+ await withLock(async () => {
672
+ try {
673
+ await runAutoMode(activeGoal.title, {
674
+ processMessage,
675
+ getStore: () => store,
676
+ printSystem: (msg) => store.getState().addLine(chalk.cyan(` ${msg}`)),
677
+ printWarning: (msg) => store.getState().addLine(chalk.yellow(` ${msg}`)),
678
+ maxIterations: config.autoMaxIterations,
679
+ maxCost: config.autoMaxCost,
680
+ isAborted: () => app.abortController?.signal?.aborted || false,
681
+ resume: true,
682
+ });
683
+ } catch (err) {
684
+ if (err.name !== "AbortError") {
685
+ store.getState().addLine(chalk.red(` [AUTO] Error: ${err.message}`));
686
+ }
687
+ }
688
+ store.getState().addLine("");
689
+ });
690
+ return;
691
+ }
692
+
693
+ // Try slash commands (through lock to prevent race with concurrent API messages)
694
+ if (isSlashCommand(trimmed)) {
695
+ await withLock(async () => {
696
+ const handled = await tryHandleCommand(trimmed, store);
697
+ if (!handled) {
698
+ const cmd = trimmed.split(/\s/)[0];
699
+ store.getState().addLine(chalk.red(` Unknown command: ${cmd}`));
700
+ store.getState().addLine(chalk.gray(" Type /help for available commands."));
701
+ }
702
+ });
703
+ return;
704
+ }
705
+
706
+ // All messages go through bus — drain loop processes them sequentially
707
+ const { id: busId } = bus.push({ channel: "user", content: trimmed, priority: bus.PRIORITY.USER, source: "tui", sessionId: store.getState().sessionId });
708
+ if (isBusy) {
709
+ // `edit` is what Esc puts back in the input: the tokens, not the preview.
710
+ store.getState().addQueuedInput({ id: busId, display, edit: (opts.history || trimmed).trim() });
711
+ } else {
712
+ store.getState().setAgentStatus("thinking"); // show spinner immediately
713
+ }
714
+ busPush(); // notify drain loop
715
+ }
716
+
717
+ // -- Cleanup old clipboard temp files (older than 1 hour) --
718
+ try {
719
+ const { readdirSync, statSync, unlinkSync } = await import("node:fs");
720
+ const { tmpdir } = await import("node:os");
721
+ const tmpDir = tmpdir();
722
+ const now = Date.now();
723
+ for (const f of readdirSync(tmpDir).filter((n) => n.startsWith("flint_clipboard_") && (n.endsWith(".txt") || n.endsWith(".png")))) {
724
+ try {
725
+ const fp = `${tmpDir}/${f}`;
726
+ if (now - statSync(fp).mtimeMs > 3600000) unlinkSync(fp);
727
+ } catch {}
728
+ }
729
+ } catch {}
730
+
731
+ // -- Start HTTP server --
732
+
733
+ const serverResult = startServer(config.port, store, async (content, name, sender) => {
734
+ // All API messages go through bus — drain loop processes them
735
+ const { id: busId } = bus.push({ channel: "api", content, priority: bus.PRIORITY.API, source: sender || name, sessionId: store.getState().sessionId });
736
+ busPush(); // notify drain loop
737
+ return waitForResult(busId);
738
+ }, {
739
+ getAbortController: () => app.abortController,
740
+ onStop: busFlush,
741
+ getPlan: () => store.getState().plan,
742
+ authMiddleware: app.securityApi?.authMiddleware || null,
743
+ // --port means that port. See config.portExplicit.
744
+ strictPort: config.portExplicit,
745
+ onPairingRequest({ sessionId, pin, fromAddress, agentName, expiresAt }) {
746
+ const safeName = agentName ? agentName.replace(/\s+/g, "_") : null;
747
+ store.getState().setPendingPairing({ sessionId, pin, fromAddress, agentName: safeName, expiresAt });
748
+ const who = safeName ? `${safeName}@${fromAddress}` : fromAddress;
749
+ printWarning(`Pairing request from ${who} -- PIN: ${pin}`);
750
+ // Auto-clear after expiry
751
+ setTimeout(() => {
752
+ const current = store.getState().pendingPairing;
753
+ if (current && current.sessionId === sessionId) {
754
+ store.getState().clearPendingPairing();
755
+ printSystem("Pairing session expired.");
756
+ }
757
+ }, expiresAt - Date.now());
758
+ },
759
+ onPairingResult({ success, fromAddress, agentName, error }) {
760
+ store.getState().clearPendingPairing();
761
+ const safeName = agentName ? agentName.replace(/\s+/g, "_") : null;
762
+ const who = safeName ? `${safeName}@${fromAddress}` : fromAddress;
763
+ if (success) {
764
+ printSystem(`Connection established with ${who}`);
765
+ } else {
766
+ printWarning(`Pairing failed from ${who}: ${error}`);
767
+ }
768
+ },
769
+ onCommand: async (command) => {
770
+ // /new, /clear, etc. — delegate to tryHandleCommand (same codepath as TUI)
771
+ if (command.toLowerCase() === "/new") {
772
+ await withLock(async () => {
773
+ await tryHandleCommand("/new", store);
774
+ });
775
+ return "new session started";
776
+ }
777
+ // Handle /supervisor via HTTP
778
+ if (command.toLowerCase() === "/supervisor on" || command.toLowerCase() === "/supervisor") {
779
+ setSupervisorEnabled(true);
780
+ return "Supervisor ON -- watching tool calls, will inject hints";
781
+ }
782
+ if (command.toLowerCase() === "/supervisor off") {
783
+ setSupervisorEnabled(false);
784
+ return "Supervisor OFF";
785
+ }
786
+
787
+ // Handle /clear via HTTP
788
+ if (command.toLowerCase() === "/clear") {
789
+ await withLock(async () => {
790
+ const s = store.getState();
791
+ s.resetSession(s.sessionId, [app.systemMessage]);
792
+ s.clearDatasets();
793
+ await saveSession(s.sessionId, sessionData(store));
794
+ });
795
+ return "context cleared";
796
+ }
797
+ // Handle /continue via HTTP
798
+ if (command.toLowerCase() === "/continue") {
799
+ const activeGoal = getActiveGoal();
800
+ if (!activeGoal) return "no active plan to continue";
801
+ const gStats = getTaskStats(activeGoal.id);
802
+ if (gStats.pending === 0 && gStats.in_progress === 0) return "plan already complete";
803
+ store.getState().addLine(chalk.cyan(`\n [CONTINUE] Resuming: ${activeGoal.title}\n`));
804
+ await withLock(async () => {
805
+ await runAutoMode(activeGoal.title, {
806
+ processMessage,
807
+ getStore: () => store,
808
+ printSystem: (msg) => store.getState().addLine(chalk.cyan(` ${msg}`)),
809
+ printWarning: (msg) => store.getState().addLine(chalk.yellow(` ${msg}`)),
810
+ maxIterations: config.autoMaxIterations,
811
+ maxCost: config.autoMaxCost,
812
+ isAborted: () => app.abortController?.signal?.aborted || false,
813
+ resume: true,
814
+ });
815
+ });
816
+ return "continue completed";
817
+ }
818
+ // Handle /auto via HTTP
819
+ if (command.toLowerCase().startsWith("/auto ")) {
820
+ const autoTask = command.slice(6).trim();
821
+ if (!autoTask) return "usage: /auto <task>";
822
+ store.getState().addLine(chalk.cyan(`\n [AUTO MODE] ${autoTask}\n`));
823
+ await withLock(async () => {
824
+ await runAutoMode(autoTask, {
825
+ processMessage,
826
+ getStore: () => store,
827
+ printSystem: (msg) => store.getState().addLine(chalk.cyan(` ${msg}`)),
828
+ printWarning: (msg) => store.getState().addLine(chalk.yellow(` ${msg}`)),
829
+ maxIterations: config.autoMaxIterations,
830
+ maxCost: config.autoMaxCost,
831
+ isAborted: () => app.abortController?.signal?.aborted || false,
832
+ });
833
+ });
834
+ return "auto mode completed";
835
+ }
836
+ return withLock(async () => {
837
+ const handled = await tryHandleCommand(command, store);
838
+ return handled ? "ok" : "unknown command";
839
+ });
840
+ },
841
+ });
842
+
843
+ // Update port if server picked a different one (startServer returns a Promise)
844
+ // The banner is drawn before this resolves, so tell it now what was asked for.
845
+ // It used to print the default 3000 on an instance started with --port 3001,
846
+ // which is how a bench run reported a port it was not on.
847
+ app.actualPort = config.port;
848
+
849
+ if (serverResult && typeof serverResult.then === "function") {
850
+ serverResult.then((result) => {
851
+ if (result && result.error) {
852
+ // Asked for a specific port and did not get it. Carrying on would mean
853
+ // answering on a port nobody was told about, or not answering at all.
854
+ process.stderr.write(`[flint] ${result.error}\n`);
855
+ log.error("explicit port unavailable, exiting", { port: config.port });
856
+ process.exit(1);
857
+ }
858
+ if (result && result.port) {
859
+ app.actualPort = result.port;
860
+ if (app.actualPort !== config.port) {
861
+ store.getState().addLine(chalk.yellow(` Port ${config.port} busy, using ${app.actualPort}`));
862
+ }
863
+ store.setState({ _port: app.actualPort });
864
+ }
865
+ });
866
+ } else if (serverResult && serverResult.port) {
867
+ app.actualPort = serverResult.port;
868
+ store.setState({ _port: app.actualPort });
869
+ }
870
+
871
+ // -- Is there a newer Flint? (docs/self-update.md) --
872
+ // In the background, at most once a day, never in the headless mode; one
873
+ // line when there is. FLINT_UPDATE_CHECK=0 turns it off.
874
+ if (cli.action !== "headless" && process.env.FLINT_UPDATE_CHECK !== "0") {
875
+ setTimeout(async () => {
876
+ try {
877
+ const { checkForUpdate, installKind, updateNotice } = await import("./update.js");
878
+ const dataDir = process.env.FLINT_DATA_DIR || (await import("node:path")).join(os.homedir(), ".flint");
879
+ const root = config.projectRoot;
880
+ const check = await checkForUpdate({
881
+ root, current: pkg.version, kind: installKind(root),
882
+ cacheFile: (await import("node:path")).join(dataDir, "update-check.json"),
883
+ });
884
+ const notice = updateNotice(check);
885
+ if (notice) store.getState().addLine(chalk.yellow(` ${notice}`));
886
+ } catch (err) {
887
+ log.debug("update check failed", { error: err.message });
888
+ }
889
+ }, 4000).unref?.();
890
+ }
891
+
892
+ // Startup complete -- clear watchdog
893
+ clearStartupWatchdog();
894
+ log.info("Startup complete", { port: app.actualPort, pid: process.pid });
895
+
896
+ // Start background log collector (cleanup old sessions/logs)
897
+ startLogCollector();
898
+
899
+ // Register internal timers for Processes tab visibility
900
+ const CLEANUP_INTERVAL = parseInt(process.env.AGENT_CLEANUP_INTERVAL_MIN || "60", 10) * 60000;
901
+ store.getState().registerTimer({ id: "log-collector", name: "Log collector", intervalMs: CLEANUP_INTERVAL });
902
+ store.getState().registerTimer({ id: "schedule-watcher", name: "Schedule watcher", intervalMs: 30000 });
903
+
904
+ // -- Headless mode: skip UI, run task, exit --
905
+ if (cli.action === "headless") {
906
+ clearStartupWatchdog();
907
+ config.headless = true;
908
+ bulkSetPermission("allow"); // auto-approve all tools in headless mode
909
+ if (cli.cwd) {
910
+ try { process.chdir(cli.cwd); } catch (e) {
911
+ process.stderr.write("[headless] Cannot chdir to " + cli.cwd + ": " + e.message + "\n");
912
+ process.exit(1);
913
+ }
914
+ // Route filesystem WRITE tools to the target cwd instead of the default
915
+ // session workspace. Without this, `write_file`/`edit_file` end up in
916
+ // .../sessions/<ts>/workspace/ and the `--cwd` dir is untouched — which
917
+ // breaks headless workflows like SWE-bench that need the agent to edit
918
+ // an external checkout. See filesystem.js:resolveWritePath.
919
+ config.workdir = cli.cwd;
920
+ config.projectRoot = cli.cwd;
921
+ }
922
+ if (!cli.task) {
923
+ process.stderr.write("[headless] --task is required\n");
924
+ process.exit(1);
925
+ }
926
+ // Apply budget override for headless mode
927
+ if (cli.budget) { config.maxCostPerAction = cli.budget; }
928
+ // Redirect store output to stderr (stdout reserved for structured result)
929
+ const _origAddLine = store.getState().addLine.bind(store.getState());
930
+ store.getState().addLine = (text) => {
931
+ const raw = typeof text === "string" ? text.replace(/\x1b\[[0-9;]*m/g, "") : String(text);
932
+ if (raw.trim()) process.stderr.write(raw.trim() + "\n");
933
+ return _origAddLine(text);
934
+ };
935
+ try {
936
+ let result = await processMessage(cli.task, null);
937
+
938
+ // Auto-verify: if no files changed, give agent one more chance
939
+ try {
940
+ const diff = execSync("git diff --stat 2>/dev/null", { encoding: "utf-8", timeout: 5000 }).trim();
941
+ if (!diff && result.text && !result.text.includes("Budget limit")) {
942
+ result = await processMessage(
943
+ "Your previous edit produced NO changes (git diff is empty). " +
944
+ "edit_file likely failed due to wrong old_text. " +
945
+ "Re-read the file, find the exact text, and try again.",
946
+ null
947
+ );
948
+ }
949
+ } catch {}
950
+ const output = {
951
+ response: result.text || "",
952
+ cost: result.stats?.cost || 0,
953
+ tokens: (result.stats?.promptTokens || 0) + (result.stats?.completionTokens || 0),
954
+ };
955
+ process.stdout.write(JSON.stringify(output) + "\n");
956
+ process.exit(0);
957
+ } catch (err) {
958
+ process.stderr.write("[headless] Error: " + err.message + "\n");
959
+ process.exit(1);
960
+ }
961
+ }
962
+
963
+ // printHeader() already called before Ink render
964
+
965
+ // -- Message Bus: setup; this session's half-done messages back, leftovers of
966
+ // earlier runs expired (see prepareQueueAtStart) --
967
+ store._bus = bus; // expose to message-handler for onCheckQueue
968
+ {
969
+ const { recovered, expired } = bus.prepareQueueAtStart({ sessionId: store.getState().sessionId });
970
+ if (recovered > 0) {
971
+ store.getState().addLine(chalk.cyan(` [bus] Recovered ${recovered} message(s) a crash left half done`));
972
+ }
973
+ if (expired > 0) {
974
+ store.getState().addLine(chalk.gray(` [bus] ${expired} message(s) left in the queue by an earlier run were not run`));
975
+ }
976
+ }
977
+
978
+ // -- Load channel plugins --
979
+ loadPlugins(bus).catch(() => {});
980
+
981
+ // -- Schedule watcher: check for fired reminders every 30s --
982
+
983
+ const _scheduleWatcher = setInterval(() => {
984
+ try {
985
+ store.getState().updateTimerRun("schedule-watcher");
986
+ // Update bus stats for StatusBar
987
+ try { store.setState({ _busPending: bus.stats().pending }); } catch {}
988
+ const due = getDueReminders();
989
+ for (const task of due) {
990
+ fireReminder(task.id, task.repeat);
991
+ const repeatTag = task.repeat ? ` [repeats: ${task.repeat}]` : "";
992
+ store.getState().addLine(chalk.cyan(` [reminder #${task.id}] ${task.title}${repeatTag}`));
993
+ // Push to both inbox (for prompt hint) and bus (for processing)
994
+ pushInbox("reminder", task.title, "schedule");
995
+ bus.push({ channel: "scheduler", content: task.title, priority: bus.PRIORITY.TASK, source: "schedule", metadata: { taskId: task.id, repeat: task.repeat } });
996
+ }
997
+ } catch {}
998
+ // GC stale sessions every ~30 min (counter resets each run)
999
+ if (!_scheduleWatcher._gcCounter) _scheduleWatcher._gcCounter = 0;
1000
+ if (++_scheduleWatcher._gcCounter >= 60) { // 60 * 30s = 30 min
1001
+ _scheduleWatcher._gcCounter = 0;
1002
+ try {
1003
+ const orphaned = gcStaleSessions(7);
1004
+ if (orphaned > 0) {
1005
+ log.info(`GC: orphaned ${orphaned} tasks from stale sessions`);
1006
+ }
1007
+ } catch {}
1008
+ }
1009
+ }, 30000);
1010
+ _scheduleWatcher.unref();
1011
+
1012
+ // -- Register in agent registry --
1013
+
1014
+ registerAgent({
1015
+ port: app.actualPort,
1016
+ sessionId: store.getState().sessionId,
1017
+ model: config.model,
1018
+ provider: config.provider,
1019
+ profile: app.activeProfile,
1020
+ pid: process.pid,
1021
+ });
1022
+
1023
+ // -- Orphan protection: if spawned by parent, monitor parent health --
1024
+
1025
+ const parentPort = process.env.AGENT_PARENT_PORT;
1026
+ if (parentPort && process.env.AGENT_PAIRING_SECRET) {
1027
+ const PARENT_CHECK_INTERVAL = 20000; // 20s
1028
+ const MAX_PARENT_MISSES = 3;
1029
+ let parentMisses = 0;
1030
+
1031
+ const parentHeartbeat = setInterval(async () => {
1032
+ try {
1033
+ const res = await fetch(apiUrl(parentPort, "/status"), {
1034
+ signal: AbortSignal.timeout(3000),
1035
+ });
1036
+ if (res.ok) {
1037
+ parentMisses = 0;
1038
+ return;
1039
+ }
1040
+ } catch {}
1041
+
1042
+ parentMisses++;
1043
+ if (parentMisses >= MAX_PARENT_MISSES) {
1044
+ clearInterval(parentHeartbeat);
1045
+ store.getState().addLine(chalk.red(`\n Parent agent (port ${parentPort}) unreachable -- shutting down (orphan protection)\n`));
1046
+ await saveSession(store.getState().sessionId, sessionData(store));
1047
+ setTimeout(() => process.exit(0), 1000);
1048
+ } else {
1049
+ store.getState().addLine(chalk.yellow(` Parent agent (port ${parentPort}) no response (${parentMisses}/${MAX_PARENT_MISSES})`));
1050
+ }
1051
+ }, PARENT_CHECK_INTERVAL);
1052
+ }
1053
+
1054
+ // -- Task-driven child agent: claim task from SQLite, update on exit --
1055
+ const _agentTaskId = process.env.AGENT_TASK_ID ? parseInt(process.env.AGENT_TASK_ID, 10) : null;
1056
+ if (_agentTaskId) {
1057
+ try {
1058
+ const task = getTaskById(_agentTaskId);
1059
+ if (task) {
1060
+ claimTask(_agentTaskId, `agent@${app.actualPort}`, app.actualPort);
1061
+ log.info(`Child agent claimed task #${_agentTaskId}: ${task.title}`);
1062
+ store.getState().addLine(chalk.cyan(` [task #${_agentTaskId}] ${task.title}`));
1063
+ } else {
1064
+ log.warn(`Task #${_agentTaskId} not found in SQLite`);
1065
+ }
1066
+ } catch (err) {
1067
+ log.error(`Failed to claim task #${_agentTaskId}: ${err.message}`);
1068
+ }
1069
+ }
1070
+
1071
+ // -- Idle timeout: child agents auto-exit after inactivity --
1072
+ const idleTimeoutSec = parseInt(process.env.AGENT_IDLE_TIMEOUT || "0", 10);
1073
+ const { childBusy } = await import("./child-idle.js");
1074
+ const isChildBusy = () => childBusy({
1075
+ processing: isProcessing(),
1076
+ processingCount: store.getState().processingCount,
1077
+ agentStatus: store.getState().agentStatus,
1078
+ abortController: app.abortController,
1079
+ });
1080
+ if (idleTimeoutSec > 0) {
1081
+ let _idleTimer = null;
1082
+ const resetIdle = () => {
1083
+ if (_idleTimer) clearTimeout(_idleTimer);
1084
+ _idleTimer = setTimeout(async () => {
1085
+ // Working is not idle. The timer was reset only when the store's
1086
+ // message count changed, and a turn writes its messages back at the
1087
+ // end: a child doing a research task shut itself down one minute in,
1088
+ // between two browser calls (2026-10-02, agent@3010). While a turn or
1089
+ // queued work is running, look again later.
1090
+ if (isChildBusy()) { resetIdle(); return; }
1091
+ store.getState().addLine(chalk.gray(`\n Idle timeout (${idleTimeoutSec}s) — shutting down\n`));
1092
+ // Task-driven child agent: complete task on idle exit
1093
+ if (_agentTaskId) {
1094
+ try {
1095
+ const task = getTaskById(_agentTaskId);
1096
+ if (task && task.status === "in_progress") {
1097
+ completeTaskWithResult(_agentTaskId, "Agent completed task (idle exit)");
1098
+ }
1099
+ } catch {}
1100
+ }
1101
+ await saveSession(store.getState().sessionId, sessionData(store));
1102
+ setTimeout(() => process.exit(0), 500);
1103
+ }, idleTimeoutSec * 1000);
1104
+ };
1105
+ // Reset on every message processed, and whenever the agent's status moves
1106
+ // (a turn starting or ending counts as activity).
1107
+ store.subscribe((state, prev) => {
1108
+ if (state.messages.length !== prev.messages.length || state.agentStatus !== prev.agentStatus) resetIdle();
1109
+ });
1110
+ resetIdle(); // start initial timer
1111
+ }