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,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
|
+
}
|