flint-agent 1.14.0 → 1.14.2

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 CHANGED
@@ -72,7 +72,7 @@ INTENT_MODEL=openai/gpt-5.4-mini
72
72
 
73
73
  # Timeouts
74
74
  # AGENT_IDLE_TIMEOUT=0 # Auto-exit after N seconds idle (0=disabled)
75
- # AGENT_CHILD_IDLE_TIMEOUT=300 # Child agent idle timeout (seconds)
75
+ # AGENT_CHILD_IDLE_TIMEOUT=0 # Child agent idle timeout (seconds, 0 = lives as long as the parent)
76
76
  # AGENT_CHILD_CLEANUP_DELAY=5000 # Delay before cleaning up dead children (ms)
77
77
 
78
78
  # Context management
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ All notable changes to Flint are written here. The format follows
7
7
  Each version is a `## [x.y.z] - YYYY-MM-DD` heading: `/update` reads these
8
8
  headings to show what changed between your version and the newest one.
9
9
 
10
+ ## [1.14.2] - 2026-10-02
11
+
12
+ ### Fixed
13
+ - Child agents opened in their own window (`spawn_agent` with `visible`)
14
+ stay reachable. The process that opens the window exits at once, and Flint
15
+ took that for the agent's exit: it reported "exited", forgot the agent,
16
+ and `ask_agent` answered "has stopped", so every question started a new
17
+ agent. Whether such an agent is alive is now decided by its heartbeat, and
18
+ Esc stops the agent itself rather than the long-gone window launcher.
19
+ - The API key is no longer written in plain text to a temporary `.bat` file
20
+ when a visible child agent starts on Windows; it is passed in the
21
+ environment.
22
+ - A piece of the "loading..." splash no longer stays on the top line of the
23
+ console now and then: Flint waits until the splash has stopped before it
24
+ clears the screen.
25
+
26
+ ### Changed
27
+ - A child agent lives as long as its parent instead of stopping after 60
28
+ seconds idle, so you can talk to it across several questions. It still
29
+ stops about a minute after its parent is gone. `AGENT_CHILD_IDLE_TIMEOUT`
30
+ turns an idle limit back on.
31
+
32
+ ## [1.14.1] - 2026-10-02
33
+
34
+ ### Fixed
35
+ - Free mode no longer crashes the console. After `/model free` or
36
+ `/model free auto` chose a model, Flint stopped with React error #185, and
37
+ then on every start, because the chosen models are remembered. The status
38
+ line built its free-request counter as a new object on each check, so the
39
+ screen redrew without end.
40
+ - `bin` in package.json written the way npm expects, so publishing no longer
41
+ warns that it was corrected.
42
+
10
43
  ## [1.14.0] - 2026-10-02
11
44
 
12
45
  ### Added
package/FEATURES.md CHANGED
@@ -43,7 +43,7 @@
43
43
  ### 5. Multi-Agent
44
44
  - Spawn child agents with different models and profiles (`spawn_agent`)
45
45
  - `ask_agent` waits for the child's answer; `wait_tasks` waits for delegated work
46
- - Each child runs on its own port with its own data folder, and stops itself after an idle timeout
46
+ - Each child runs on its own port with its own data folder, and lives as long as its parent (an idle timeout can be turned on)
47
47
  - Child agents can push datasets back to the parent
48
48
 
49
49
  ### 6. Memory & Knowledge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flint-agent",
3
- "version": "1.14.0",
3
+ "version": "1.14.2",
4
4
  "description": "A lightweight AI agent for the terminal that works with any LLM, free tiers included: files, commands, web, MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -36,7 +36,7 @@
36
36
  "vitest": "^4.0.18"
37
37
  },
38
38
  "bin": {
39
- "flint": "./bin/flint.js"
39
+ "flint": "bin/flint.js"
40
40
  },
41
41
  "engines": {
42
42
  "node": ">=22.12.0"
package/src/api/server.js CHANGED
@@ -311,6 +311,10 @@ export function startServer(port, store, processMessage, opts = {}) {
311
311
  model: s.model,
312
312
  messages: s.messages.length,
313
313
  alive: true,
314
+ // A parent that opened this agent in its own window only knows the
315
+ // pid of the window launcher, which exits at once; it learns the
316
+ // real one here so it can stop the agent (tools/agent-tools.js).
317
+ pid: process.pid,
314
318
  // A bench harness cannot sum the per-turn figures and get the truth:
315
319
  // a turn it abandoned on a timeout still spent money it never saw.
316
320
  // The session total is the only honest number, so serve it, and the
@@ -112,7 +112,11 @@ export function App({ store, onSubmit, onAbort, onQuit, onClipboard, onRecallQue
112
112
  careLevel: s._careLevel || null,
113
113
  spendLevel: s._spendLevel || getSpendLevel(),
114
114
  // Free mode: requests made today with a free model, of the daily limit.
115
- freeQuota: config.freeChain ? { used: freeUsedToday(), limit: s._freeLimit || savedFreeLimit() } : null,
115
+ // Two plain numbers, not an object: the selector runs on every store
116
+ // check, and a fresh object each time never compares equal, so React
117
+ // re-rendered forever (error #185) as soon as free mode was on.
118
+ freeUsed: config.freeChain ? freeUsedToday() : null,
119
+ freeLimit: config.freeChain ? s._freeLimit || savedFreeLimit() : null,
116
120
  }));
117
121
  const { inputHistory } = ui;
118
122
 
@@ -221,7 +221,7 @@ export function statusText(state) {
221
221
  }
222
222
  parts.push(formatStatusLevel());
223
223
  if (state.spendLevel) parts.push(`spend: ${state.spendLevel}`);
224
- if (state.freeQuota) parts.push(`free ${state.freeQuota.used}/${state.freeQuota.limit}`);
224
+ if (state.freeLimit != null) parts.push(`free ${state.freeUsed}/${state.freeLimit}`);
225
225
  parts.push("/help");
226
226
  return ` ${parts.join(" | ")}`;
227
227
  }
package/src/config.js CHANGED
@@ -195,7 +195,7 @@ export const config = {
195
195
  .filter(Boolean)
196
196
  .map((p) => path.resolve(p)),
197
197
  maxChildAgents: parseInt(process.env.AGENT_MAX_CHILDREN || "5", 10),
198
- childIdleTimeout: parseInt(process.env.AGENT_CHILD_IDLE_TIMEOUT || "60", 10), // seconds
198
+ childIdleTimeout: parseInt(process.env.AGENT_CHILD_IDLE_TIMEOUT || "0", 10), // seconds, 0 = live as long as the parent
199
199
  childCleanupDelay: parseInt(process.env.AGENT_CHILD_CLEANUP_DELAY || "30000", 10), // ms before removing stopped agent from registry
200
200
  shell: process.env.AGENT_SHELL || (() => {
201
201
  if (process.platform !== "win32") return "/bin/bash";
package/src/index.js CHANGED
@@ -235,6 +235,36 @@ function handleAbort() {
235
235
  // Populate header lines BEFORE Ink render so first frame is complete
236
236
  printHeader();
237
237
 
238
+ // Longest wait for the launcher to confirm its spinner stopped. It answers in
239
+ // a few ms; the cap only matters for a launcher that never answers.
240
+ const LAUNCHER_RELEASE_WAIT_MS = 500;
241
+
242
+ // Stop the launcher's splash spinner BEFORE clearing the screen, and wait
243
+ // until it says it has stopped.
244
+ //
245
+ // The launcher spawns us with the three standard streams inherited, so it
246
+ // cannot see our output: there is no pipe to watch. Its spinner repaints with
247
+ // a bare \r every 120 ms. "flint:ready" used to be sent after Ink's first
248
+ // render, so a tick landing between the clear below and that message wrote
249
+ // "loading..." onto the top line of the cleared screen, where Ink then drew
250
+ // its border on the same row (owner, 2026-10-02, intermittent). The launcher
251
+ // stops the interval first and answers "flint:released" after, so once the
252
+ // answer is here no tick can follow. The wait is capped: an older launcher
253
+ // never answers, and a missing answer must not hold the start.
254
+ //
255
+ // Sent only when there is a channel: run directly (`node src/index.js`,
256
+ // tests) there is no parent IPC and process.send is undefined, which is a
257
+ // normal way to start Flint and must not throw.
258
+ if (process.send) {
259
+ await new Promise((resolve) => {
260
+ const done = () => { clearTimeout(timer); process.off("message", onMessage); resolve(); };
261
+ const onMessage = (msg) => { if (msg && msg.type === "flint:released") done(); };
262
+ const timer = setTimeout(done, LAUNCHER_RELEASE_WAIT_MS);
263
+ process.on("message", onMessage);
264
+ process.send({ type: "flint:ready" });
265
+ });
266
+ }
267
+
238
268
  // Clear entire screen (remove launcher splash) before Ink takes over
239
269
  if (cli.action !== "headless") {
240
270
  process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
@@ -245,23 +275,6 @@ if (inkInstance) {
245
275
  store.setState({ _inkClear: () => inkInstance.clear() });
246
276
  }
247
277
 
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
278
  // -- Graceful shutdown --
266
279
 
267
280
  function gracefulShutdown(signal) {
package/src/launcher.js CHANGED
@@ -76,8 +76,9 @@ const _spinner = setInterval(() => {
76
76
  * streams stay inherited on purpose — piping them would cost Ink the TTY, and
77
77
  * raw-mode key handling with it.
78
78
  *
79
- * So the child says when it has drawn, over an IPC channel that costs nothing
80
- * and is invisible to the terminal. The interval is kept as a ceiling rather
79
+ * So the child says when it is about to draw, over an IPC channel that costs
80
+ * nothing and is invisible to the terminal, and waits for "flint:released"
81
+ * before it clears the screen. The interval is kept as a ceiling rather
81
82
  * than removed: a child that dies without saying anything must not hold the
82
83
  * event loop open, and `_released` makes the remaining ticks no-ops.
83
84
  */
@@ -100,8 +101,13 @@ function start(extraArgs = []) {
100
101
  });
101
102
  if (stdioMode) releaseTerminal();
102
103
 
104
+ // On "flint:ready": stop first, answer after. Once the child has the answer
105
+ // no tick can follow, so it can clear the screen safely (src/index.js).
103
106
  child.on("message", (msg) => {
104
- if (msg && msg.type === "flint:ready") releaseTerminal();
107
+ if (msg && msg.type === "flint:ready") {
108
+ releaseTerminal();
109
+ try { child.send({ type: "flint:released" }); } catch {}
110
+ }
105
111
  if (msg && msg.type === "flint:restart" && typeof msg.sessionId === "string") resumeSessionId = msg.sessionId;
106
112
  });
107
113
 
@@ -20,6 +20,16 @@ const HEARTBEAT_INTERVAL = 15000; // 15s
20
20
  const MAX_MISSED_PINGS = 3;
21
21
  let _heartbeatTimer = null;
22
22
 
23
+ // Keep the pid a child reports in /status: for an agent in its own window it
24
+ // is the only handle on the real process (the launcher's pid is dead).
25
+ async function notePid(agent, res) {
26
+ if (!agent.visible || agent.agentPid || typeof res.json !== "function") return;
27
+ try {
28
+ const data = await res.json();
29
+ if (Number.isInteger(data?.pid)) agent.agentPid = data.pid;
30
+ } catch {}
31
+ }
32
+
23
33
  // Start heartbeat monitoring for child agents
24
34
  function startChildHeartbeat(store) {
25
35
  if (_heartbeatTimer) return;
@@ -31,6 +41,7 @@ function startChildHeartbeat(store) {
31
41
  signal: AbortSignal.timeout(3000),
32
42
  });
33
43
  if (res.ok) {
44
+ await notePid(agent, res);
34
45
  if (agent.missedPings > 0) {
35
46
  // Recovered
36
47
  const label = agent.profile !== "generic" ? agent.profile : null;
@@ -49,6 +60,12 @@ function startChildHeartbeat(store) {
49
60
  printChildEvent(port, `connection lost (${MAX_MISSED_PINGS} missed pings)`, label);
50
61
  store.getState().finishProcess(agent.procId, null, "lost");
51
62
  activeChildren.delete(agent.procId);
63
+ // A visible agent has no "close" of its own to clean up after it
64
+ // (spawn_agent), so the heartbeat does it.
65
+ if (agent.visible) {
66
+ if (agent.taskRegId) store.getState().unregisterTask(agent.taskRegId);
67
+ setTimeout(() => _childAgents.delete(port), config.childCleanupDelay);
68
+ }
52
69
  } else {
53
70
  log.debug(`Agent@${port} no response (${agent.missedPings}/${MAX_MISSED_PINGS})`);
54
71
  printChildEvent(port, `no response (${agent.missedPings}/${MAX_MISSED_PINGS})`, label);
@@ -179,8 +196,12 @@ export function createAgentHandlers(store) {
179
196
  childEnv.AGENT_PAIRING_SECRET = pairingSecret;
180
197
  // Parent port for orphan protection heartbeat
181
198
  childEnv.AGENT_PARENT_PORT = String(config.port);
182
- // Auto-exit after idle period (zombie fix)
183
- childEnv.AGENT_IDLE_TIMEOUT = String(config.childIdleTimeout || 60);
199
+ // Idle exit, off by default (0): a child lives as long as its parent,
200
+ // and the parent heartbeat in index.js stops it when the parent is
201
+ // gone, which is what the idle exit was first added for. A 60 s default
202
+ // stopped children between two questions, so a conversation with one
203
+ // was impossible (owner, 2026-10-02). AGENT_CHILD_IDLE_TIMEOUT turns it on.
204
+ childEnv.AGENT_IDLE_TIMEOUT = String(config.childIdleTimeout || 0);
184
205
  // Track agent depth for child-policy enforcement
185
206
  const currentDepth = parseInt(process.env.AGENT_DEPTH || "0", 10);
186
207
  childEnv.AGENT_DEPTH = String(currentDepth + 1);
@@ -201,35 +222,25 @@ export function createAgentHandlers(store) {
201
222
 
202
223
  let child;
203
224
  if (visible && isWin) {
204
- // Write a temp .bat launcher, then open it in a new window via Start-Process
205
- // This ensures complete process isolation — no shared console with parent
225
+ // A temp .bat opened in a new window with `start`, so the child has a
226
+ // console of its own. The environment (API key, pairing secret) goes
227
+ // to cmd.exe, and `start` hands it to the new window. It used to be
228
+ // written into the .bat as `set` lines, which left the API key in
229
+ // plain text in %TEMP% for good, while Flint stores keys encrypted
230
+ // everywhere else (found 2026-10-02).
206
231
  const os = await import("node:os");
207
232
  const fs = await import("node:fs");
208
233
  const batPath = path.join(os.tmpdir(), `flint-agent-${assignedPort}.bat`);
209
234
  const batLines = [
210
235
  "@echo off",
211
236
  `title Flint@${assignedPort}`,
212
- `set "AGENT_PORT=${assignedPort}"`,
213
- `set "AGENT_PAIRING_SECRET=${pairingSecret}"`,
214
- `set "AGENT_PARENT_PORT=${config.port}"`,
215
- `set "AGENT_DEPTH=${childEnv.AGENT_DEPTH}"`,
216
- `set "FLINT_PROVIDER=${config.provider}"`,
217
- `set "FLINT_DATA_DIR=${childEnv.FLINT_DATA_DIR}"`,
237
+ `cd /d "${config.projectRoot}"`,
238
+ cmd,
218
239
  ];
219
- // Pass the correct API key env var in bat file
220
- if (config.apiKey) {
221
- if (config.provider === "openrouter") batLines.push(`set "OPENROUTER_API_KEY=${config.apiKey}"`);
222
- else if (config.provider === "openai") batLines.push(`set "OPENAI_API_KEY=${config.apiKey}"`);
223
- else if (config.provider === "anthropic") batLines.push(`set "ANTHROPIC_API_KEY=${config.apiKey}"`);
224
- else batLines.push(`set "OPENROUTER_API_KEY=${config.apiKey}"`);
225
- }
226
- if (childEnv.AGENT_DENIED_PATHS) batLines.push(`set "AGENT_DENIED_PATHS=${childEnv.AGENT_DENIED_PATHS}"`);
227
- if (task_id) batLines.push(`set "AGENT_TASK_ID=${task_id}"`);
228
- batLines.push(`cd /d "${config.projectRoot}"`);
229
- batLines.push(cmd);
230
240
  fs.writeFileSync(batPath, batLines.join("\r\n") + "\r\n");
231
241
 
232
242
  child = spawn("cmd.exe", ["/c", "start", `"Flint@${assignedPort}"`, batPath], {
243
+ env: childEnv,
233
244
  stdio: "ignore",
234
245
  detached: true,
235
246
  windowsHide: true,
@@ -297,7 +308,13 @@ export function createAgentHandlers(store) {
297
308
  port: assignedPort,
298
309
  kill: () => {
299
310
  try {
300
- if (process.platform === "win32" && child.pid) {
311
+ // A visible agent's own pid comes from its /status; child.pid is
312
+ // the window launcher, long gone.
313
+ const agentPid = _childAgents.get(assignedPort)?.agentPid;
314
+ if (visible && agentPid) {
315
+ if (process.platform === "win32") spawn("taskkill", ["/PID", String(agentPid), "/T", "/F"], { stdio: "ignore" });
316
+ else process.kill(agentPid, "SIGTERM");
317
+ } else if (process.platform === "win32" && child.pid) {
301
318
  spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
302
319
  } else {
303
320
  child.kill("SIGTERM");
@@ -305,6 +322,7 @@ export function createAgentHandlers(store) {
305
322
  } catch {}
306
323
  },
307
324
  });
325
+ _childAgents.get(assignedPort).taskRegId = taskRegId;
308
326
 
309
327
  // Start heartbeat monitoring
310
328
  startChildHeartbeat(store);
@@ -325,6 +343,14 @@ export function createAgentHandlers(store) {
325
343
  }
326
344
 
327
345
  child.on("close", (code) => {
346
+ // A visible agent runs in a window that `start` (Windows), tmux,
347
+ // screen or a terminal emulator opened; the process spawned here only
348
+ // opens it and exits at once with 0. Taking that for the agent's exit
349
+ // marked a live agent stopped and forgot it 5 s later, so ask_agent
350
+ // answered "has stopped" and the model spawned a new agent for every
351
+ // question (owner, 2026-10-02). The heartbeat decides when a visible
352
+ // agent is gone; only a launcher that failed counts here.
353
+ if (visible && code === 0) return;
328
354
  activeChildren.delete(procId);
329
355
  store.getState().unregisterTask(taskRegId);
330
356
  store.getState().finishProcess(procId, code);
@@ -354,7 +380,10 @@ export function createAgentHandlers(store) {
354
380
  });
355
381
  if (res.ok) {
356
382
  const agent = _childAgents.get(assignedPort);
357
- if (agent) agent.status = "running";
383
+ if (agent) {
384
+ agent.status = "running";
385
+ await notePid(agent, res);
386
+ }
358
387
  // Send the task (authenticated with pairing secret)
359
388
  const taskRes = await fetch(apiUrl(assignedPort, "/message"), {
360
389
  method: "POST",
@@ -385,7 +414,7 @@ export function createAgentHandlers(store) {
385
414
  async ask_agent({ port, message }) {
386
415
  const agent = _childAgents.get(port);
387
416
  if (!agent) return `No agent on port ${port}. Use list_agents() to see available agents.`;
388
- if (agent.status === "stopped") return `Agent@${port} has stopped. Spawn a new one.`;
417
+ if (agent.status === "stopped" || agent.status === "lost") return `Agent@${port} has stopped. Spawn a new one.`;
389
418
 
390
419
  // Wait for agent to be ready if it's still starting
391
420
  if (agent.status === "starting") {
@@ -393,7 +422,7 @@ export function createAgentHandlers(store) {
393
422
  await new Promise((r) => setTimeout(r, 1000));
394
423
  try {
395
424
  const check = await fetch(apiUrl(port, "/status"), { signal: AbortSignal.timeout(2000) });
396
- if (check.ok) { agent.status = "running"; break; }
425
+ if (check.ok) { agent.status = "running"; await notePid(agent, check); break; }
397
426
  } catch {}
398
427
  }
399
428
  if (agent.status !== "running") return `Agent@${port} failed to start within 30s.`;