flint-agent 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,13 @@
1
+ // Lock utility for serializing command execution (e.g. /paste, /auto, /new)
2
+ // Message processing goes through bus drain loop (src/bus/drain-loop.js)
3
+ import { app } from "./app-state.js";
4
+
5
+ export function withLock(fn) {
6
+ const prev = app.lockChain;
7
+ let unlock;
8
+ app.lockChain = new Promise((r) => (unlock = r));
9
+ return prev.then(() => {
10
+ if (app.queueAborted) return;
11
+ return fn();
12
+ }).finally(unlock);
13
+ }
@@ -0,0 +1,123 @@
1
+ // What the operator sees must be what is sent.
2
+ //
3
+ // A pasted multi-line message looked cut and overwritten in the input box,
4
+ // though the agent received it whole. Nothing was wrong with what was sent,
5
+ // which is why it never showed up in a message log — the logs looked perfect.
6
+ //
7
+ // The cause is a character the terminal and the text disagree about. A paste
8
+ // on Windows (and from some terminals) arrives with the line breaks as CRLF,
9
+ // or as a bare CR. A terminal draws a CR as "back to column 0", so the next
10
+ // line is written *over* the one before it:
11
+ //
12
+ // paste: "line one\r\nline two\r\nline three" 30 chars, 2 CRs
13
+ // drawn: "\nline three" 1 line, the rest gone
14
+ //
15
+ // So the operator sees one line and sends three. They fix it by retyping the
16
+ // parts that appear to have vanished, which is the worst outcome: the
17
+ // correction is guesswork, and what finally reaches the agent is the
18
+ // retyped version, not what they pasted.
19
+ //
20
+ // The fix is here rather than in the component, because the disagreement is
21
+ // about the text, not about the drawing: normalize the line endings once, at
22
+ // the boundary, and everything downstream — the drawing, the cursor, the
23
+ // submitted message — then works on the same string. Deliberately narrow: no
24
+ // trimming, no rewrapping, no reflowing. A pasted diff, a stack trace or a
25
+ // JSON blob must arrive exactly as it was pasted, or the agent is reasoning
26
+ // about a document nobody sent.
27
+
28
+ /**
29
+ * Does this text contain a CR that is not part of a CRLF pair?
30
+ *
31
+ * \r\n is an ordinary line ending and is left alone. A bare \r is not — it is
32
+ * a control character the terminal acts on, which is the whole bug.
33
+ *
34
+ * @param {string} text
35
+ * @returns {boolean}
36
+ */
37
+ export function hasBareCR(text) {
38
+ if (!text) return false;
39
+ return /\r(?!\n)/.test(String(text));
40
+ }
41
+
42
+ /**
43
+ * The line endings a terminal cannot draw, turned into ones it can.
44
+ *
45
+ * CRLF first, then any remaining bare CR. Doing them in that order is what
46
+ * makes the result idempotent: after one pass there is no \r left at all, so
47
+ * a second pass is a no-op. That matters because the input normalizes on
48
+ * every keystroke.
49
+ *
50
+ * @param {string} text
51
+ * @returns {string}
52
+ */
53
+ export function normalizeInputText(text) {
54
+ if (text === null || text === undefined) return "";
55
+ const s = String(text);
56
+ if (!s) return "";
57
+ if (!s.includes("\r")) return s;
58
+ return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
59
+ }
60
+
61
+ /**
62
+ * The text as the operator will see it in the input box.
63
+ *
64
+ * What the terminal would draw, given text a terminal can draw. Exported
65
+ * because "what does the operator see" is the question this whole module
66
+ * exists to answer, and it should be answerable in a test without rendering
67
+ * a terminal.
68
+ *
69
+ * @param {string} text
70
+ * @returns {string}
71
+ */
72
+ export function displayTextFor(text) {
73
+ return expandTabs(normalizeInputText(text));
74
+ }
75
+
76
+ /**
77
+ * How many lines the operator can actually see, as opposed to how many the
78
+ * paste contained.
79
+ *
80
+ * Before the fix these two numbers differed, and the difference was the bug.
81
+ *
82
+ * @param {string} text
83
+ * @returns {number}
84
+ */
85
+ export function visibleLineCount(text) {
86
+ const shown = displayTextFor(text);
87
+ return shown ? shown.split("\n").length : 0;
88
+ }
89
+
90
+ // The tab stop the input renders at. A tab in the input is a jump to the next
91
+ // stop, so the operator sees a gap while the agent receives a 0x09; expanding
92
+ // it shows the character and the gap as the same thing.
93
+ const TAB_WIDTH = 4;
94
+
95
+ /**
96
+ * Tabs as spaces, so what is drawn is what was typed.
97
+ *
98
+ * @param {string} text
99
+ * @param {number} [tabWidth]
100
+ * @returns {string}
101
+ */
102
+ export function expandTabs(text, tabWidth = TAB_WIDTH) {
103
+ if (!text) return "";
104
+ const s = String(text);
105
+ if (!s.includes("\t")) return s;
106
+ let out = "";
107
+ for (const line of s.split("\n")) {
108
+ let col = 0;
109
+ for (const ch of line) {
110
+ if (ch === "\t") {
111
+ const pad = tabWidth - (col % tabWidth);
112
+ out += " ".repeat(pad);
113
+ col += pad;
114
+ } else {
115
+ out += ch;
116
+ col += 1;
117
+ }
118
+ }
119
+ out += "\n";
120
+ }
121
+ // split/join added a trailing newline that the input did not have.
122
+ return out.replace(/\n$/, "");
123
+ }
@@ -0,0 +1,129 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const projectRoot = path.resolve(__dirname, "..");
7
+ const userArgs = process.argv.slice(2);
8
+
9
+ // --version answers and exits here too, not only in bin/flint.js: a host
10
+ // that starts Flint with `npm start -- --version` or `node src/launcher.js
11
+ // --version` got a console instead of a version (node integration test,
12
+ // 2026-10-02).
13
+ if (userArgs.includes("--version") || userArgs.includes("-v")) {
14
+ const { readFileSync } = await import("node:fs");
15
+ console.log(`flint ${JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")).version}`);
16
+ process.exit(0);
17
+ }
18
+
19
+ const hasSessionArg = userArgs.some(a => a === "--last" || a === "--session" || a === "--list" || a === "--new");
20
+
21
+ const RESTART_CODE = 42;
22
+
23
+ // The session a restarting child asked to continue (src/restart.js). A
24
+ // restart names its own session, so the operator's --new/--last/--session
25
+ // from the first start are left out of the args then.
26
+ let resumeSessionId = null;
27
+ function withoutSessionArgs(args) {
28
+ const out = [];
29
+ for (let i = 0; i < args.length; i++) {
30
+ if (args[i] === "--new" || args[i] === "--last") continue;
31
+ if (args[i] === "--session") { i++; continue; }
32
+ out.push(args[i]);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ // The stdio mode (src/stdio) is driven by a program, not a person: no splash,
38
+ // no spinner, and the agent runs in the folder it was started from, which is
39
+ // the agent's own folder (its CLAUDE.md, .mcp.json and files).
40
+ const stdioMode = userArgs.includes("--stdio") || userArgs.some((a, i) =>
41
+ (a === "--input-format" || a === "--output-format") && userArgs[i + 1] === "stream-json");
42
+
43
+ // Clear screen + show the FLiNT mark (src/ui/splash.js) with a loading spinner
44
+ // on its second row. Raw escapes, no chalk: the launcher loads nothing it can
45
+ // avoid. Titanium is 256-colour 250, the spark yellow 220, grey 244.
46
+ const _mark0 = " \x1b[38;5;250m▀▀▀ █ \x1b[38;5;220m▀\x1b[38;5;250m █▄ █ ▀█▀\x1b[0m";
47
+ const _mark1 = " \x1b[38;5;250m█▀▀ █▄▄ █ █ ▀█ █\x1b[0m";
48
+ const _frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
49
+ if (!stdioMode) process.stderr.write(`\x1b[2J\x1b[H\n${_mark0}\n${_mark1} \x1b[38;5;244m${_frames[0]} loading...\x1b[0m`);
50
+ let _fi = 0;
51
+ let _released = false;
52
+ const _spinner = setInterval(() => {
53
+ _fi = (_fi + 1) % _frames.length;
54
+ if (_released) return;
55
+ process.stderr.write(`\r${_mark1} \x1b[38;5;244m${_frames[_fi]} loading...\x1b[0m`);
56
+ }, 120);
57
+
58
+ /**
59
+ * Hand the terminal over to the child.
60
+ *
61
+ * The spinner repaints with a bare `\r`, which the terminal reads as "back to
62
+ * column 0 of whatever line is current now". Once the child starts drawing,
63
+ * that is no longer the spinner's line: every tick reprints the FLINT AGENT
64
+ * banner on top of the child's own banner, and the startup banner with the
65
+ * first commands underneath it appears several times over.
66
+ *
67
+ * This used to be a fixed 500 ms after spawn, on the assumption that the child
68
+ * would be rendering by then. How long that takes depends on module load and on
69
+ * the machine, so a slow start got the spinner writing into a live UI, which is
70
+ * the reported glitch.
71
+ *
72
+ * The child is spawned with `stdio: "inherit"`, so it has no pipe handles to
73
+ * listen to: child.stdout and child.stderr are both null. An earlier attempt
74
+ * attached "release on first output" to those and did nothing at all, which
75
+ * would have left the spinner running forever while appearing to work. The
76
+ * streams stay inherited on purpose — piping them would cost Ink the TTY, and
77
+ * raw-mode key handling with it.
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
81
+ * than removed: a child that dies without saying anything must not hold the
82
+ * event loop open, and `_released` makes the remaining ticks no-ops.
83
+ */
84
+ function releaseTerminal() {
85
+ _released = true;
86
+ clearInterval(_spinner);
87
+ }
88
+
89
+ function start(extraArgs = []) {
90
+ const defaultArgs = (!hasSessionArg && extraArgs.length === 0 && !stdioMode) ? ["--new"] : [];
91
+ const baseArgs = extraArgs.length ? withoutSessionArgs(userArgs) : userArgs;
92
+ const args = [path.join(projectRoot, "src", "index.js"), ...baseArgs, ...defaultArgs, ...extraArgs];
93
+ const child = spawn(process.execPath, args, {
94
+ // The three standard streams stay inherited: piping them would cost Ink the
95
+ // TTY, and with it raw-mode key handling. The fourth slot is an IPC
96
+ // channel, which does not touch the terminal, and is how the child says
97
+ // "I have drawn" so the spinner stops repainting over it.
98
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
99
+ cwd: stdioMode ? process.cwd() : projectRoot,
100
+ });
101
+ if (stdioMode) releaseTerminal();
102
+
103
+ child.on("message", (msg) => {
104
+ if (msg && msg.type === "flint:ready") releaseTerminal();
105
+ if (msg && msg.type === "flint:restart" && typeof msg.sessionId === "string") resumeSessionId = msg.sessionId;
106
+ });
107
+
108
+ // `exit` always arrives, and it must stop the interval even if the child
109
+ // never said anything — otherwise it holds the event loop open.
110
+ child.on("exit", (code) => {
111
+ releaseTerminal();
112
+ if (code === RESTART_CODE && !stdioMode) {
113
+ // The same session when the child said which; a new one otherwise.
114
+ const id = resumeSessionId;
115
+ resumeSessionId = null;
116
+ start(id ? ["--session", id] : ["--new"]);
117
+ } else {
118
+ process.exit(code ?? 0);
119
+ }
120
+ });
121
+ child.on("error", releaseTerminal);
122
+
123
+ // Forward Ctrl+C to child gracefully
124
+ process.on("SIGINT", () => {
125
+ child.kill("SIGINT");
126
+ });
127
+ }
128
+
129
+ start();
@@ -0,0 +1,95 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { config } from "../config.js";
3
+ import path from "node:path";
4
+
5
+ export function logApiCall(sessionId, callNum, messages, tools, reply, usage) {
6
+ if (!sessionId) return;
7
+ const file = path.join(config.sessionsDir, `${sessionId}.api.log`);
8
+ const ts = new Date().toISOString().slice(11, 19);
9
+
10
+ try {
11
+ if (messages) {
12
+ const sep = "=".repeat(60);
13
+ let totalChars = 0;
14
+ const lines = [];
15
+
16
+ for (let i = 0; i < messages.length; i++) {
17
+ const m = messages[i];
18
+ const contentLen = (m.content || "").length;
19
+ totalChars += contentLen;
20
+
21
+ let desc = ` [${i}] ${m.role}: ${contentLen} chars`;
22
+ if (m.role === "assistant" && m.tool_calls?.length) {
23
+ const toolNames = m.tool_calls.map((tc) => tc.function?.name || "?").join(", ");
24
+ desc += ` + tools=[${toolNames}]`;
25
+ }
26
+ if (m.role === "tool") {
27
+ const name = m._toolName || "?";
28
+ desc += ` (${name})`;
29
+ if (m._compressed) desc += ` [${m._compressed}]`;
30
+ }
31
+ lines.push(desc);
32
+ }
33
+
34
+ const toolCount = tools?.length || 0;
35
+ const header = [
36
+ `${sep}`,
37
+ `[${ts}] API Call #${callNum}`,
38
+ `${sep}`,
39
+ `Messages: ${messages.length} (${totalChars} chars total content)`,
40
+ `Tools: ${toolCount} definitions`,
41
+ ].join("\n");
42
+
43
+ appendFileSync(file, `${header}\n${lines.join("\n")}\n`);
44
+
45
+ // Full message dump: write complete content for prompt analysis
46
+ const rawFile = path.join(config.sessionsDir, `${sessionId}.messages.jsonl`);
47
+ try {
48
+ const entry = {
49
+ ts, callNum,
50
+ messages: messages.map(m => ({
51
+ role: m.role,
52
+ content: m.content || "",
53
+ tool_calls: m.tool_calls,
54
+ tool_call_id: m.tool_call_id,
55
+ })),
56
+ toolCount,
57
+ };
58
+ appendFileSync(rawFile, JSON.stringify(entry) + "\n");
59
+ } catch {}
60
+ }
61
+
62
+ if (reply) {
63
+ const contentLen = (reply.content || "").length;
64
+ const toolCalls = reply.tool_calls || [];
65
+ let toolDesc = "none";
66
+ if (toolCalls.length) {
67
+ toolDesc = toolCalls
68
+ .map((tc) => {
69
+ const name = tc.function?.name || "?";
70
+ let argsShort = "";
71
+ try {
72
+ const parsed = JSON.parse(tc.function?.arguments || "{}");
73
+ argsShort = Object.entries(parsed)
74
+ .map(([k, v]) => {
75
+ const s = typeof v === "string" && v.length > 60 ? v.slice(0, 60) + "..." : String(v);
76
+ return `${k}="${s}"`;
77
+ })
78
+ .join(", ");
79
+ } catch {}
80
+ return `${name}(${argsShort})`;
81
+ })
82
+ .join("; ");
83
+ }
84
+
85
+ const usageStr = usage
86
+ ? `prompt=${usage.prompt_tokens || 0} completion=${usage.completion_tokens || 0} total=${(usage.prompt_tokens || 0) + (usage.completion_tokens || 0)}`
87
+ : "n/a";
88
+
89
+ appendFileSync(
90
+ file,
91
+ `Response: ${contentLen} chars, tool_calls: ${toolDesc}\nUsage: ${usageStr}\n\n`,
92
+ );
93
+ }
94
+ } catch {}
95
+ }
@@ -0,0 +1,113 @@
1
+ // The chat log must never stop writing, however long the session runs.
2
+ //
3
+ // Item 9: "chat.log silently stops after 1000 screen lines".
4
+ //
5
+ // The cause was a position tracked by array *length*. `addLine` caps the
6
+ // on-screen `lines` array (src/store/ui-slice.js) at maxDisplayLines — 1000 by
7
+ // default — by dropping the oldest entries. So the array stops growing once it
8
+ // is full, and the subscriber decided what was new with:
9
+ //
10
+ // if (lines.length > lastLineCount) {
11
+ // for (let i = lastLineCount; i < lines.length; i++) logChatLine(...);
12
+ // lastLineCount = lines.length;
13
+ // }
14
+ //
15
+ // At 1000 entries that condition is false forever. The log stops mid-session
16
+ // and stays stopped: no error, no message, and the file on disk simply ends
17
+ // where the screen filled up. Everything after that is lost — including the
18
+ // lines you would most want, the turn where it went wrong.
19
+ //
20
+ // The store already stamps every line with a monotonic `id` (`nextLineId`).
21
+ // That is the position which does not stop moving, and it is what this uses.
22
+ //
23
+ // The limit that cannot be engineered away: the log cannot be rebuilt from the
24
+ // array, because the array no longer holds the lines. A follower can only
25
+ // record what it saw as it went past. So this reports a gap when it starts
26
+ // late rather than pretending to be complete — a follower that silently skips
27
+ // what it missed is how this hid for so long.
28
+
29
+ /**
30
+ * Follow the on-screen lines and write each new one to the chat log exactly
31
+ * once, for as long as the session runs.
32
+ *
33
+ * @param {object} deps
34
+ * @param {() => object} deps.getState
35
+ * @param {(fn: Function) => Function} deps.subscribe
36
+ * @param {(sessionId: string, text: string) => void} deps.log
37
+ * @returns {{start: Function, stop: Function, written: number}}
38
+ */
39
+ export function createChatLogFollower({ getState, subscribe, log }) {
40
+ // The id of the last line written. Not the array length: the length stops
41
+ // moving at the cap, which is the entire bug.
42
+ let lastId = 0;
43
+ // clearScreen() resets nextLineId to 1, so ids restart. A single watermark
44
+ // would then read every later id as "already seen" and the log would die one
45
+ // screen in — the same silent failure, relocated. The generation counter is
46
+ // what makes the watermark per-page.
47
+ let generation = -1;
48
+ let unsubscribe = null;
49
+ let written = 0;
50
+
51
+ function currentGeneration(state) {
52
+ return state.clearCounter ?? 0;
53
+ }
54
+
55
+ function drain(state) {
56
+ const gen = currentGeneration(state);
57
+ if (gen !== generation) {
58
+ // A new page. Nothing is pending, and the id sequence restarts.
59
+ generation = gen;
60
+ lastId = 0;
61
+ }
62
+
63
+ // Log only what is newer than the watermark. Ids are not contiguous once
64
+ // the array has slid, so this is a comparison per line rather than a range.
65
+ for (const line of state.lines) {
66
+ if (line && typeof line.id === "number" && line.id > lastId) {
67
+ // A replayed line is already in this log (ui/replay.js).
68
+ if (!line.replay) {
69
+ log(state.sessionId ?? "", line.text);
70
+ written++;
71
+ }
72
+ lastId = line.id;
73
+ }
74
+ }
75
+ }
76
+
77
+ return {
78
+ /**
79
+ * Attach, and report anything that was already on screen.
80
+ *
81
+ * @returns {{skipped: number}} lines that existed before this ran
82
+ */
83
+ start() {
84
+ const state = getState();
85
+ generation = currentGeneration(state);
86
+ lastId = 0;
87
+
88
+ // Anything already on screen predates this follower. It cannot be
89
+ // logged honestly — it is not "skipped" by choice, it was never seen —
90
+ // so the count is returned rather than swallowed.
91
+ let skipped = 0;
92
+ for (const line of state.lines) {
93
+ if (line && typeof line.id === "number" && line.id > lastId) {
94
+ log(state.sessionId ?? "", line.text);
95
+ lastId = line.id;
96
+ written++;
97
+ skipped++;
98
+ }
99
+ }
100
+
101
+ unsubscribe = subscribe(() => drain(getState()));
102
+ return { skipped };
103
+ },
104
+
105
+ stop() {
106
+ if (unsubscribe) unsubscribe();
107
+ unsubscribe = null;
108
+ },
109
+
110
+ /** How many lines this follower has written. For tests and diagnostics. */
111
+ get written() { return written; },
112
+ };
113
+ }
@@ -0,0 +1,15 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { config } from "../config.js";
4
+
5
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
6
+ function stripAnsi(s) { return s.replace(ANSI_RE, ""); }
7
+
8
+ export function logChatLine(sessionId, text) {
9
+ if (!sessionId) return;
10
+ const file = path.join(config.sessionsDir, `${sessionId}.chat.log`);
11
+ const ts = new Date().toISOString().slice(11, 19);
12
+ try {
13
+ appendFileSync(file, `[${ts}] ${stripAnsi(text)}\n`);
14
+ } catch {}
15
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Log collector — background cleanup of old session files and stale registry entries.
3
+ *
4
+ * Runs once at startup (after a short delay) then periodically (default: every hour).
5
+ * Configurable via env:
6
+ * AGENT_LOG_RETENTION_DAYS — delete logs older than N days (default: 7)
7
+ * AGENT_SESSION_RETENTION_DAYS — delete session JSONs older than N days (default: 30)
8
+ * AGENT_CLEANUP_INTERVAL_MIN — interval in minutes (default: 60)
9
+ */
10
+
11
+ import { readdirSync, statSync, unlinkSync, existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { config } from "../config.js";
14
+ import { createLogger } from "./logger.js";
15
+ import { cleanup as busCleanupFn } from "../bus/index.js";
16
+
17
+ const log = createLogger("collector");
18
+
19
+ const LOG_RETENTION_DAYS = parseInt(process.env.AGENT_LOG_RETENTION_DAYS || "7", 10);
20
+ const SESSION_RETENTION_DAYS = parseInt(process.env.AGENT_SESSION_RETENTION_DAYS || "30", 10);
21
+ const CLEANUP_INTERVAL_MIN = parseInt(process.env.AGENT_CLEANUP_INTERVAL_MIN || "60", 10);
22
+
23
+ // File extensions considered "log files" (shorter retention)
24
+ const LOG_EXTENSIONS = new Set([".log"]);
25
+ // Session data files (longer retention)
26
+ const SESSION_EXTENSIONS = new Set([".json", ".hmac"]);
27
+
28
+ function isExpired(filePath, retentionDays) {
29
+ try {
30
+ const stat = statSync(filePath);
31
+ const ageMs = Date.now() - stat.mtimeMs;
32
+ return ageMs > retentionDays * 86400000;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function getExtGroup(filename) {
39
+ // e.g. "2026-03-13T03-56-39.api.log" → ".log"
40
+ // e.g. "2026-03-13T03-56-39.json" → ".json"
41
+ const ext = path.extname(filename); // last extension
42
+ if (LOG_EXTENSIONS.has(ext)) return "log";
43
+ if (SESSION_EXTENSIONS.has(ext)) return "session";
44
+ return null;
45
+ }
46
+
47
+ function cleanDirectory(dir) {
48
+ if (!existsSync(dir)) return { logsRemoved: 0, sessionsRemoved: 0 };
49
+
50
+ let logsRemoved = 0;
51
+ let sessionsRemoved = 0;
52
+
53
+ try {
54
+ const files = readdirSync(dir);
55
+ for (const file of files) {
56
+ // Skip special files
57
+ if (file.startsWith(".")) continue;
58
+
59
+ const group = getExtGroup(file);
60
+ if (!group) continue;
61
+
62
+ const filePath = path.join(dir, file);
63
+ const retention = group === "log" ? LOG_RETENTION_DAYS : SESSION_RETENTION_DAYS;
64
+
65
+ if (isExpired(filePath, retention)) {
66
+ try {
67
+ unlinkSync(filePath);
68
+ if (group === "log") logsRemoved++;
69
+ else sessionsRemoved++;
70
+ } catch {}
71
+ }
72
+ }
73
+ } catch (err) {
74
+ log.warn("Failed to read directory for cleanup", { dir, error: err.message });
75
+ }
76
+
77
+ return { logsRemoved, sessionsRemoved };
78
+ }
79
+
80
+ async function cleanRegistry() {
81
+ const { homedir } = await import("node:os");
82
+ const { join } = await import("node:path");
83
+ const registryFile = join(homedir(), ".flint", "agents.json");
84
+
85
+ if (!existsSync(registryFile)) return 0;
86
+
87
+ try {
88
+ const data = JSON.parse(readFileSync(registryFile, "utf-8"));
89
+ if (!Array.isArray(data)) return 0;
90
+
91
+ const before = data.length;
92
+ const alive = data.filter((entry) => {
93
+ try {
94
+ // Check if process is still running
95
+ process.kill(entry.pid, 0);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ });
101
+
102
+ if (alive.length < before) {
103
+ writeFileSync(registryFile, JSON.stringify(alive, null, 2));
104
+ }
105
+
106
+ return before - alive.length;
107
+ } catch {
108
+ return 0;
109
+ }
110
+ }
111
+
112
+ function runCollector() {
113
+ log.debug("Log collector running");
114
+
115
+ // Clean main sessions directory
116
+ const main = cleanDirectory(config.sessionsDir);
117
+
118
+ // Clean children sessions directory
119
+ const childrenDir = path.join(config.projectRoot, "sessions", "children");
120
+ const children = cleanDirectory(childrenDir);
121
+
122
+ // Clean stale registry entries
123
+ const registryCleaned = cleanRegistry();
124
+
125
+ // Clean old bus messages
126
+ let busCleanup = { done: 0, failed: 0 };
127
+ try {
128
+ busCleanup = busCleanupFn();
129
+ } catch {}
130
+
131
+ const totalLogs = main.logsRemoved + children.logsRemoved;
132
+ const totalSessions = main.sessionsRemoved + children.sessionsRemoved;
133
+ const totalBus = busCleanup.done + busCleanup.failed;
134
+
135
+ if (totalLogs > 0 || totalSessions > 0 || registryCleaned > 0 || totalBus > 0) {
136
+ log.info("Cleanup complete", {
137
+ logsRemoved: totalLogs,
138
+ sessionsRemoved: totalSessions,
139
+ registryCleaned,
140
+ busMessagesRemoved: totalBus,
141
+ });
142
+ } else {
143
+ log.debug("Cleanup complete — nothing to remove");
144
+ }
145
+ }
146
+
147
+ let _interval = null;
148
+
149
+ /**
150
+ * Start the background log collector.
151
+ * Runs immediately (after 5s delay), then every CLEANUP_INTERVAL_MIN minutes.
152
+ */
153
+ export function startLogCollector() {
154
+ // Initial run after 5 seconds (let the app finish starting up)
155
+ const initialTimer = setTimeout(runCollector, 5000);
156
+ initialTimer.unref();
157
+
158
+ // Periodic run
159
+ _interval = setInterval(runCollector, CLEANUP_INTERVAL_MIN * 60000);
160
+ _interval.unref(); // Don't keep process alive just for cleanup
161
+
162
+ log.info("Log collector started", {
163
+ logRetentionDays: LOG_RETENTION_DAYS,
164
+ sessionRetentionDays: SESSION_RETENTION_DAYS,
165
+ intervalMin: CLEANUP_INTERVAL_MIN,
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Stop the background log collector.
171
+ */
172
+ export function stopLogCollector() {
173
+ if (_interval) {
174
+ clearInterval(_interval);
175
+ _interval = null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Run collector once (for manual/command use).
181
+ */
182
+ export { runCollector };