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