flint-agent 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,595 @@
1
+ import { spawn, execFileSync } from "node:child_process";
2
+ import { wrapCommand, getSandboxMode } from "../sandbox/backend.js";
3
+ import { ensureOwnEnv, withOwnEnv } from "./own-env.js";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { config } from "../config.js";
7
+ import { lastOutputLine } from "../ui/last-line.js";
8
+ import {
9
+ printProcessEnd, printProcessSummary,
10
+ } from "../ui/output.js";
11
+
12
+
13
+ // ── Child process registry for graceful shutdown ──
14
+ export const activeChildren = new Map(); // procId → ChildProcess
15
+
16
+ // First process id of the batch running now (see run_background_command).
17
+ let batchFrom = null;
18
+
19
+ // R0: abort signal propagation from agent loop to child processes
20
+ let _currentAbortSignal = null;
21
+ export function setProcessAbortSignal(signal) { _currentAbortSignal = signal; }
22
+ export function getProcessAbortSignal() { return _currentAbortSignal; }
23
+
24
+ // Global rate limit for background process chat output (1 line per 5s across ALL processes)
25
+ let _lastGlobalChatLine = 0;
26
+ const _GLOBAL_CHAT_INTERVAL = 5000;
27
+
28
+ /**
29
+ * Signal a process and everything under it, on Unix. SIGTERM to the shell
30
+ * alone leaves its commands running: `sleep 45; echo` kept going after the
31
+ * turn was stopped, held the output pipe open, and the stop waited for it
32
+ * (Linux, 2026-10-02). Children are found through ps and signalled before
33
+ * their parent, deepest first, so none is re-parented out of reach.
34
+ */
35
+ export function killUnixTree(pid, sig = "SIGTERM") {
36
+ if (!pid) return;
37
+ let rows = [];
38
+ try {
39
+ rows = execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8", timeout: 5000 })
40
+ .trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
41
+ } catch {}
42
+ const kids = new Map();
43
+ for (const [p, pp] of rows) {
44
+ if (!kids.has(pp)) kids.set(pp, []);
45
+ kids.get(pp).push(p);
46
+ }
47
+ const order = [];
48
+ const walk = (p) => { for (const c of kids.get(p) || []) { walk(c); order.push(c); } };
49
+ walk(pid);
50
+ order.push(pid);
51
+ for (const p of order) { try { process.kill(p, sig); } catch {} }
52
+ }
53
+
54
+ export function killAllChildren(timeoutMs = 3000) {
55
+ const running = [...activeChildren.entries()];
56
+ if (!running.length) return;
57
+
58
+ for (const [id, child] of running) {
59
+ try {
60
+ // Windows: taskkill /PID /T kills process tree
61
+ if (process.platform === "win32" && child.pid) {
62
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
63
+ } else {
64
+ killUnixTree(child.pid);
65
+ }
66
+ } catch {}
67
+ }
68
+
69
+ // Force kill after timeout
70
+ setTimeout(() => {
71
+ for (const [id, child] of activeChildren) {
72
+ try { child.kill("SIGKILL"); } catch {}
73
+ }
74
+ activeChildren.clear();
75
+ }, timeoutMs);
76
+ }
77
+
78
+ /**
79
+ * Kill every background process and its children now, before returning. For
80
+ * a process about to exit: killAllChildren's taskkill is spawned and not
81
+ * waited for, so an exit right after it left grandchildren running (a
82
+ * `ping -n 40` outlived the stdio-mode test that started it, 2026-10-02).
83
+ */
84
+ export function killAllChildrenSync() {
85
+ for (const [, child] of activeChildren) {
86
+ if (!child?.pid) continue;
87
+ try {
88
+ if (process.platform === "win32") {
89
+ execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", timeout: 5000 });
90
+ } else {
91
+ killUnixTree(child.pid);
92
+ }
93
+ } catch {}
94
+ }
95
+ activeChildren.clear();
96
+ }
97
+
98
+ export function fmtElapsed(ms) {
99
+ const sec = Math.floor(ms / 1000);
100
+ if (sec < 60) return `${sec}s`;
101
+ const min = Math.floor(sec / 60);
102
+ const s = sec % 60;
103
+ return `${min}m ${s}s`;
104
+ }
105
+
106
+ // ── Tool definitions ──
107
+
108
+ export const processToolDefs = [
109
+ {
110
+ type: "function",
111
+ function: {
112
+ name: "run_command",
113
+ description: "Execute a shell command and return its output. Use for git, npm, python, etc.",
114
+ parameters: {
115
+ type: "object",
116
+ properties: {
117
+ command: { type: "string", description: "The shell command to execute" },
118
+ timeout_seconds: { type: "number", description: "Kill the command after this many seconds (default 120, max 600). Raise it for test suites and builds." },
119
+ },
120
+ required: ["command"],
121
+ },
122
+ },
123
+ },
124
+ {
125
+ type: "function",
126
+ function: {
127
+ name: "run_background_command",
128
+ description: "Run a long-running shell command in the background. Returns immediately with a process ID. Output is kept for peek_process and the operator's /logs <id>; it is not printed into the chat. Use for: npm test, builds, servers, training, etc.",
129
+ parameters: {
130
+ type: "object",
131
+ properties: {
132
+ command: { type: "string", description: "The shell command to execute in background" },
133
+ label: { type: "string", description: "Short label for the process (optional)" },
134
+ },
135
+ required: ["command"],
136
+ },
137
+ },
138
+ },
139
+ {
140
+ type: "function",
141
+ function: {
142
+ name: "kill_process",
143
+ description: "Kill a background process. Pass either the process ID (small number like 1, 2, 3 from list_processes) or the system PID. Use list_processes first to see IDs.",
144
+ parameters: {
145
+ type: "object",
146
+ properties: {
147
+ process_id: { type: "number", description: "The process ID (1, 2, 3...) from list_processes output — NOT the system PID" },
148
+ },
149
+ required: ["process_id"],
150
+ },
151
+ },
152
+ },
153
+ {
154
+ type: "function",
155
+ function: {
156
+ name: "list_processes",
157
+ description: "List all background processes with their status, PID, and last output line.",
158
+ parameters: { type: "object", properties: {} },
159
+ },
160
+ },
161
+ {
162
+ type: "function",
163
+ function: {
164
+ name: "peek_process",
165
+ description: "Read the last N lines of output from a background process. Use to check progress.",
166
+ parameters: {
167
+ type: "object",
168
+ properties: {
169
+ process_id: { type: "number", description: "Process ID from list_processes" },
170
+ lines: { type: "number", description: "Number of lines to return (default: 10, max: 50)" },
171
+ },
172
+ required: ["process_id"],
173
+ },
174
+ },
175
+ },
176
+ ];
177
+
178
+ // ── Handler implementations ──
179
+
180
+ export function createProcessHandlers(store) {
181
+ return {
182
+ async kill_process({ process_id }) {
183
+ const id = Number(process_id);
184
+ // Try finding by process ID first, then fallback to PID
185
+ let proc = store.getState().processes.find((p) => p.id === id);
186
+ if (!proc) {
187
+ proc = store.getState().processes.find((p) => p.pid === id);
188
+ }
189
+ if (!proc) {
190
+ const all = store.getState().processes.map((p) => `[${p.id}] pid:${p.pid} ${p.cmd}`).join("\n");
191
+ return `Process ${id} not found. Available:\n${all || "(none)"}`;
192
+ }
193
+ if (proc.status !== "running") return `Process ${proc.id} (${proc.cmd}) is already ${proc.status}.`;
194
+ const actualId = proc.id;
195
+
196
+ // Kill via activeChildren map (has actual ChildProcess reference)
197
+ const child = activeChildren.get(actualId);
198
+ if (child) {
199
+ try {
200
+ if (process.platform === "win32") {
201
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
202
+ } else {
203
+ killUnixTree(child.pid);
204
+ }
205
+ } catch {}
206
+ activeChildren.delete(actualId);
207
+ } else {
208
+ // Fallback: kill by PID from store
209
+ store.getState().killProcess(actualId);
210
+ }
211
+ store.getState().finishProcess(actualId, null, "killed");
212
+ return `Killed process ${actualId} (${proc.cmd}), pid ${proc.pid}.`;
213
+ },
214
+
215
+ async list_processes() {
216
+ const procs = store.getState().processes;
217
+ if (!procs.length) return "No background processes.";
218
+ const lines = ["ID | Status | PID | Time | Command"];
219
+ for (const p of procs) {
220
+ const elapsed = fmtElapsed(Date.now() - p.startTime);
221
+ const last = p.output.length > 0 ? p.output[p.output.length - 1] : "";
222
+ const lastShort = last.length > 60 ? last.slice(0, 60) + "..." : last;
223
+ lines.push(`${p.id} | ${p.status.padEnd(7)} | ${String(p.pid).padEnd(5)} | ${elapsed.padEnd(6)} | ${p.cmd}`);
224
+ if (lastShort) lines.push(` \\ ${lastShort}`);
225
+ }
226
+ lines.push(`\nTo kill a process, use kill_process with the ID (first column), not the PID.`);
227
+ return lines.join("\n");
228
+ },
229
+
230
+ async peek_process({ process_id, lines }) {
231
+ const id = Number(process_id);
232
+ const n = Math.min(50, Math.max(1, lines || 10));
233
+ let proc = store.getState().processes.find((p) => p.id === id);
234
+ if (!proc) proc = store.getState().processes.find((p) => p.pid === id);
235
+ if (!proc) return `Process ${id} not found.`;
236
+ const output = proc.output.slice(-n);
237
+ if (!output.length) return `[${proc.cmd}] status: ${proc.status} | (no output yet)`;
238
+ return `[${proc.cmd}] status: ${proc.status} | ${output.length} lines:\n${output.join("\n")}`;
239
+ },
240
+
241
+ async run_command({ command, cwd: requestedCwd, timeout_seconds: timeoutSeconds }) {
242
+ // Enforce denied paths (e.g. during auto mode)
243
+ const { getDeniedPaths } = await import("./filesystem.js");
244
+ const denied = getDeniedPaths();
245
+
246
+ // Normalize /tmp/ paths for cross-platform consistency
247
+ const { normalizeTmpPath } = await import("./filesystem.js");
248
+ if (requestedCwd) requestedCwd = normalizeTmpPath(requestedCwd);
249
+
250
+ // Choose cwd: requested > auto-detect from command > projectRoot
251
+ let cwd = requestedCwd || config.baseDir || config.projectRoot;
252
+ // Auto-detect cwd for scripts outside projectRoot (e.g. /tmp/fcheck/bin/fcheck.js)
253
+ if (!requestedCwd) {
254
+ const scriptMatch = command.match(/node\s+["']?([^\s"']+)/);
255
+ if (scriptMatch) {
256
+ const scriptPath = normalizeTmpPath(scriptMatch[1]);
257
+ if (path.isAbsolute(scriptPath) && !scriptPath.startsWith(config.baseDir || config.projectRoot)) {
258
+ cwd = path.dirname(scriptPath);
259
+ }
260
+ }
261
+ }
262
+ if (denied.length) {
263
+ const resolved = path.resolve(cwd);
264
+ const inDenied = denied.some((dir) => resolved.startsWith(dir + path.sep) || resolved === dir);
265
+ if (inDenied) {
266
+ cwd = os.tmpdir();
267
+ }
268
+ // Also check if command references denied paths (prevent echo > src/file.js bypass)
269
+ const cmdNorm = command.replace(/\\/g, "/").toLowerCase();
270
+ for (const dir of denied) {
271
+ const dirNorm = dir.replace(/\\/g, "/").toLowerCase();
272
+ if (cmdNorm.includes(dirNorm)) {
273
+ return `Error: command references protected directory "${dir}" (self-modification blocked)`;
274
+ }
275
+ }
276
+ }
277
+
278
+ // Enforce allowed paths for cwd if configured
279
+ if (config.allowedPaths?.length) {
280
+ const ok = config.allowedPaths.some((dir) => cwd.startsWith(dir) || cwd === dir);
281
+ if (!ok) return `Error: working directory "${cwd}" is outside allowed paths`;
282
+ }
283
+ try {
284
+ // Filter sensitive env vars — allowlist approach
285
+ const safeEnv = {};
286
+ const ENV_ALLOWLIST = ["PATH", "HOME", "USER", "LANG", "LC_ALL", "TZ", "TERM",
287
+ "SHELL", "TMPDIR", "TEMP", "TMP", "NODE_ENV", "DISPLAY",
288
+ "XDG_RUNTIME_DIR", "SYSTEMROOT", "COMSPEC", "WINDIR", "APPDATA",
289
+ "PROGRAMFILES", "LOCALAPPDATA", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"];
290
+ for (const key of ENV_ALLOWLIST) {
291
+ if (process.env[key]) safeEnv[key] = process.env[key];
292
+ }
293
+ // Ensure Git Bash core utils are in PATH (mv, cp, find, etc.)
294
+ if (process.platform === "win32" && config.shell.includes("bash")) {
295
+ const gitUsrBin = path.dirname(config.shell); // e.g. C:\Program Files\Git\usr\bin
296
+ const gitMingw = path.resolve(gitUsrBin, "../../mingw64/bin");
297
+ const pathSep = ";";
298
+ const existing = safeEnv.PATH || "";
299
+ if (!existing.includes(gitUsrBin)) {
300
+ safeEnv.PATH = `${gitUsrBin}${pathSep}${gitMingw}${pathSep}${existing}`;
301
+ }
302
+ }
303
+ // Flint's own venv and npm prefix first, so an install lands where
304
+ // Flint may write (own-env.js). Host paths mean nothing inside the
305
+ // docker sandbox, so only locally.
306
+ if (getSandboxMode() !== "docker") {
307
+ await ensureOwnEnv();
308
+ Object.assign(safeEnv, withOwnEnv(safeEnv));
309
+ }
310
+ // Normalize /tmp/ in command for Windows (Git Bash /tmp/ != Node.js /tmp/)
311
+ let normalizedCommand = command;
312
+ if (process.platform === "win32") {
313
+ const osTmp = os.tmpdir().replace(/\\/g, "/");
314
+ // Replace /tmp/ with os.tmpdir() so bash finds files where write_file put them
315
+ normalizedCommand = command.replace(/\/tmp\//g, osTmp + "/");
316
+ }
317
+ // Route through sandbox backend (local = direct spawn, docker = docker exec)
318
+ const sandboxed = wrapCommand(normalizedCommand, cwd, { shell: config.shell, env: safeEnv });
319
+ const shell = sandboxed.shell;
320
+ const shellArgs = sandboxed.shellArgs;
321
+ if (sandboxed.cwd) cwd = sandboxed.cwd;
322
+ const MAX_OUTPUT = 1024 * 1024; // 1 MB max output
323
+ // 30s killed the project's own test suite (about 37s) on the
324
+ // 2026-09-26 repair bench; the agent could not run its fix and
325
+ // reverted it. Default 120s, and the model may ask for up to 600 when
326
+ // it knows the command is long.
327
+ const requested = Number(timeoutSeconds);
328
+ const defaultSec = parseInt(process.env.AGENT_COMMAND_TIMEOUT || "120", 10);
329
+ const COMMAND_TIMEOUT = Math.min(600, requested > 0 ? requested : defaultSec) * 1000;
330
+ const result = await new Promise((resolve, reject) => {
331
+ // R0 v2: we manage timeout ourselves (not via spawn timeout option)
332
+ // because spawn's timeout sends signal via process.kill() which only
333
+ // kills the parent shell, not the whole pipeline tree on Windows.
334
+ const child = spawn(shell, shellArgs, {
335
+ cwd,
336
+ env: safeEnv,
337
+ stdio: ["ignore", "pipe", "pipe"],
338
+ });
339
+ activeChildren.set("run_cmd_" + child.pid, child);
340
+
341
+ // Kill process tree (cross-platform)
342
+ const killTree = () => {
343
+ try {
344
+ if (process.platform === "win32" && child.pid) {
345
+ // /T = tree, /F = force — kills entire pipeline (bash + du + sort + head)
346
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
347
+ } else {
348
+ // The whole tree, then SIGKILL after a grace period for
349
+ // anything that ignored SIGTERM.
350
+ killUnixTree(child.pid);
351
+ setTimeout(() => { killUnixTree(child.pid, "SIGKILL"); }, 3000);
352
+ }
353
+ } catch {}
354
+ };
355
+
356
+ // Hard timeout: kill tree after COMMAND_TIMEOUT (30s default)
357
+ // plus safety resolve 5s later if stdio pipes remain open.
358
+ let timedOut = false;
359
+ let safetyResolveHandle = null;
360
+ const timeoutHandle = setTimeout(() => {
361
+ timedOut = true;
362
+ killTree();
363
+ // Safety resolve: if close event does not fire within 5s after kill
364
+ // (e.g. because stdio pipes are held by a cat in the pipeline),
365
+ // resolve the promise anyway so the caller does not hang.
366
+ safetyResolveHandle = setTimeout(() => {
367
+ try { child.stdout?.destroy(); } catch {}
368
+ try { child.stderr?.destroy(); } catch {}
369
+ activeChildren.delete("run_cmd_" + child.pid);
370
+ resolve({
371
+ code: -1,
372
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
373
+ stderr: "[killed by timeout after " + COMMAND_TIMEOUT + "ms]",
374
+ truncated: false,
375
+ timedOut: true,
376
+ });
377
+ }, 5000);
378
+ }, COMMAND_TIMEOUT);
379
+
380
+ child.on("close", () => {
381
+ clearTimeout(timeoutHandle);
382
+ if (safetyResolveHandle) clearTimeout(safetyResolveHandle);
383
+ activeChildren.delete("run_cmd_" + child.pid);
384
+ });
385
+
386
+ // R0 fix: propagate external abort signal to child process tree
387
+ const signal = _currentAbortSignal;
388
+ if (signal) {
389
+ const onAbort = () => killTree();
390
+ if (signal.aborted) {
391
+ onAbort();
392
+ } else {
393
+ signal.addEventListener("abort", onAbort, { once: true });
394
+ child.on("close", () => {
395
+ try { signal.removeEventListener("abort", onAbort); } catch {}
396
+ });
397
+ }
398
+ }
399
+
400
+ const stdoutChunks = [];
401
+ const stderrChunks = [];
402
+ let totalSize = 0;
403
+ const onData = (chunks) => (d) => {
404
+ totalSize += d.length;
405
+ if (totalSize > MAX_OUTPUT) {
406
+ // RX-3.1: this call used to reference a non-existent hardKill()
407
+ // which threw ReferenceError in a socket 'data' handler and
408
+ // crashed the whole Flint process when any command exceeded
409
+ // the 1 MB output cap (e.g. `tasklist` on a busy box).
410
+ killTree();
411
+ return;
412
+ }
413
+ chunks.push(d);
414
+ showProgress(d);
415
+ };
416
+ // The latest output line on the activity row, at most 4 times a
417
+ // second, so a long command shows progress and not only a clock.
418
+ let lastShownAt = 0;
419
+ const showProgress = (d) => {
420
+ const now = Date.now();
421
+ if (now - lastShownAt < 250) return;
422
+ const line = lastOutputLine(d);
423
+ if (!line) return;
424
+ lastShownAt = now;
425
+ store.getState().setActivityDetail?.(line);
426
+ };
427
+ child.stdout.on("data", onData(stdoutChunks));
428
+ child.stderr.on("data", onData(stderrChunks));
429
+ child.on("close", (code) => {
430
+ store.getState().setActivityDetail?.(null);
431
+ const truncated = totalSize > MAX_OUTPUT;
432
+ // When the kill lands, the child closes with an ordinary exit
433
+ // code (1 from taskkill on Windows) and this used to report it as
434
+ // a plain failure: the model could not tell "the tests are slow"
435
+ // from "the tests failed". The timeout is said in so many words.
436
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
437
+ resolve({
438
+ code,
439
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
440
+ stderr: timedOut
441
+ ? (stderr ? stderr + "\n" : "") + "[killed by timeout after " + COMMAND_TIMEOUT + "ms]"
442
+ : stderr,
443
+ truncated,
444
+ });
445
+ });
446
+ child.on("error", reject);
447
+ });
448
+ if (result.truncated) return "Error: output exceeded 1 MB limit — process killed.";
449
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
450
+ let output = stripAnsi(result.stdout || "");
451
+ if (result.stderr) output += (output ? "\n" : "") + "STDERR: " + stripAnsi(result.stderr);
452
+ if (result.code !== 0 && result.code !== null) {
453
+ return `Error (exit ${result.code}): ${output || "(no output)"}`;
454
+ }
455
+ return output || "(no output)";
456
+ } catch (err) {
457
+ return `Error: ${err.message}`;
458
+ }
459
+ },
460
+
461
+ async run_background_command({ command, label }) {
462
+ // Limit concurrent background processes
463
+ const MAX_BG = 5;
464
+ const running = [...activeChildren.keys()];
465
+ if (running.length >= MAX_BG) {
466
+ return `Error: too many background processes (${running.length}/${MAX_BG}). Kill some first.`;
467
+ }
468
+ // The model's label names the process for the model (list_processes,
469
+ // kill_process). The screen shows the command itself: the label ("ping302")
470
+ // made the start line and the end line name one process two ways
471
+ // (owner, 2026-10-01).
472
+ const displayCmd = label || command;
473
+ // A batch is the processes that ran at the same time; the summary counts
474
+ // only those, not every process this session ever started.
475
+ if (!store.getState().processes.some((p) => p.status === "running")) batchFrom = null;
476
+ const shell = config.shell;
477
+ const shellArgs = ["-c", command];
478
+
479
+ // Allowlist env vars for child processes
480
+ const bgEnv = {};
481
+ const BG_ENV_ALLOW = ["PATH", "HOME", "USER", "LANG", "LC_ALL", "TZ", "TERM",
482
+ "SHELL", "TMPDIR", "TEMP", "TMP", "NODE_ENV", "DISPLAY",
483
+ "XDG_RUNTIME_DIR", "SYSTEMROOT", "COMSPEC", "WINDIR", "APPDATA",
484
+ "PROGRAMFILES", "LOCALAPPDATA", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"];
485
+ for (const key of BG_ENV_ALLOW) {
486
+ if (process.env[key]) bgEnv[key] = process.env[key];
487
+ }
488
+ // Ensure Git Bash core utils are in PATH
489
+ if (process.platform === "win32" && config.shell.includes("bash")) {
490
+ const gitUsrBin = path.dirname(config.shell);
491
+ const gitMingw = path.resolve(gitUsrBin, "../../mingw64/bin");
492
+ const existing = bgEnv.PATH || "";
493
+ if (!existing.includes(gitUsrBin)) {
494
+ bgEnv.PATH = `${gitUsrBin};${gitMingw};${existing}`;
495
+ }
496
+ }
497
+ // Same own env as run_command: a server started here must see what was
498
+ // installed there.
499
+ await ensureOwnEnv();
500
+ Object.assign(bgEnv, withOwnEnv(bgEnv));
501
+ const BG_TIMEOUT = 5 * 60 * 1000; // 5 minutes max
502
+ const BG_MAX_OUTPUT = 5 * 1024 * 1024; // 5 MB max output
503
+ const child = spawn(shell, shellArgs, {
504
+ cwd: config.baseDir || config.projectRoot,
505
+ env: bgEnv,
506
+ stdio: ["ignore", "pipe", "pipe"],
507
+ detached: false,
508
+ });
509
+
510
+ const procId = store.getState().addProcess({
511
+ cmd: displayCmd,
512
+ command,
513
+ pid: child.pid,
514
+ });
515
+ if (batchFrom == null) batchFrom = procId;
516
+
517
+ // Track for graceful shutdown
518
+ activeChildren.set(procId, child);
519
+
520
+ // Register in TaskRegistry for Escape abort
521
+ const taskRegId = store.getState().registerTask({
522
+ type: "bg-process",
523
+ label: displayCmd.length > 40 ? displayCmd.slice(0, 40) + "..." : displayCmd,
524
+ pid: child.pid,
525
+ // Through the store, so a process stopped by Esc is recorded as
526
+ // killed (not failed) exactly as one stopped by /kill.
527
+ kill: () => {
528
+ if (store.getState().killProcess(procId)) return;
529
+ try {
530
+ if (process.platform === "win32" && child.pid) {
531
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
532
+ } else {
533
+ killUnixTree(child.pid);
534
+ }
535
+ } catch {}
536
+ },
537
+ });
538
+
539
+ // Auto-kill after timeout
540
+ const bgTimer = setTimeout(() => {
541
+ try { child.kill("SIGKILL"); } catch {}
542
+ store.getState().appendProcessOutput(procId, `[killed: exceeded ${BG_TIMEOUT / 1000}s timeout]`);
543
+ }, BG_TIMEOUT);
544
+
545
+
546
+ let bgOutputSize = 0;
547
+ const onData = (data) => {
548
+ bgOutputSize += data.length;
549
+ if (bgOutputSize > BG_MAX_OUTPUT) {
550
+ try { child.kill("SIGKILL"); } catch {}
551
+ store.getState().appendProcessOutput(procId, `[killed: output exceeded ${BG_MAX_OUTPUT / 1024 / 1024} MB]`);
552
+ return;
553
+ }
554
+ const lines = data.toString().split("\n");
555
+ for (const line of lines) {
556
+ if (line) {
557
+ // Kept for /logs and the dock; never printed into the chat.
558
+ store.getState().appendProcessOutput(procId, line);
559
+ }
560
+ }
561
+ };
562
+
563
+ child.stdout.on("data", onData);
564
+ child.stderr.on("data", onData);
565
+
566
+ child.on("close", (code) => {
567
+ clearTimeout(bgTimer);
568
+ activeChildren.delete(procId);
569
+ store.getState().unregisterTask(taskRegId);
570
+ store.getState().finishProcess(procId, code);
571
+ const proc = store.getState().processes.find((p) => p.id === procId);
572
+ const elapsed = fmtElapsed(Date.now() - (proc?.startTime || Date.now()));
573
+ printProcessEnd(procId, command, code, elapsed, proc?.status);
574
+
575
+ const allProcs = store.getState().processes.filter((p) => batchFrom == null || p.id >= batchFrom);
576
+ const running = allProcs.filter((p) => p.status === "running");
577
+ if (running.length === 0) {
578
+ if (allProcs.length > 1) {
579
+ const done = allProcs.filter((p) => p.status === "done").length;
580
+ const failed = allProcs.filter((p) => p.status === "failed").length;
581
+ const killed = allProcs.filter((p) => p.status === "killed").length;
582
+ printProcessSummary(allProcs.length, done, failed, killed);
583
+ }
584
+ }
585
+ });
586
+
587
+ child.on("error", (err) => {
588
+ store.getState().appendProcessOutput(procId, `Error: ${err.message}`);
589
+ store.getState().finishProcess(procId, 1, "failed");
590
+ });
591
+
592
+ return `Background process started (id: ${procId}, pid: ${child.pid}). Command: ${command}\nOutput: /logs ${procId}. Stop: /kill ${procId}.`;
593
+ },
594
+ };
595
+ }