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,94 @@
1
+ // Whether the main model can look at images, and what it is told when it
2
+ // cannot.
3
+ //
4
+ // WHY: a tool that returns an image (a desktop screenshot, a browser observe,
5
+ // view_image) had its picture put into the conversation as image_url. A model
6
+ // with no image input then got the whole payload refused by the provider
7
+ // (OpenRouter: 404 "No endpoints found that support image input"), three
8
+ // retries of the same payload, and the turn died. 18 bench runs on 2026-09-26/27
9
+ // hit it; W-desk-013 never got past its first screenshot.
10
+ //
11
+ // The answer is known, not guessed. It comes from the provider's model list
12
+ // when the provider publishes input modalities, or from the provider refusing
13
+ // an image, which is the same fact learned the hard way. Until one of those
14
+ // arrives it is null: unknown, and images are sent as before.
15
+ //
16
+ // When the model cannot see, it is told so as a fact about the situation:
17
+ // where the image is saved and what text came with it. What to do about it,
18
+ // use the file, read the screen as text, look for another way to see, or tell
19
+ // the user, is the model's call. Naming a tool here would measure the hint.
20
+
21
+ import { createLogger } from "../logging/logger.js";
22
+
23
+ const log = createLogger("vision");
24
+
25
+ let seesImages = null;
26
+ let learnedFrom = null;
27
+
28
+ /** true, false, or null when nothing has told us yet. */
29
+ export function modelSeesImages() {
30
+ return seesImages;
31
+ }
32
+
33
+ /** Record what is known. `from` says how: "provider-metadata" or "provider-refusal". */
34
+ export function setModelSeesImages(value, from) {
35
+ if (value !== true && value !== false) return;
36
+ if (seesImages === value) return;
37
+ // A refusal is the model's own answer; a list saying otherwise is the
38
+ // stale one. The metadata fetch is async and can land after it.
39
+ if (learnedFrom === "provider-refusal" && from !== "provider-refusal") {
40
+ log.warn("model-sees-images-conflict", { kept: seesImages, ignored: value, from });
41
+ return;
42
+ }
43
+ seesImages = value;
44
+ learnedFrom = from;
45
+ log.info("model-sees-images", { value, from });
46
+ }
47
+
48
+ /** For tests and for a model switch: forget what was learned. */
49
+ export function resetVision() {
50
+ seesImages = null;
51
+ learnedFrom = null;
52
+ }
53
+
54
+ export function visionLearnedFrom() {
55
+ return learnedFrom;
56
+ }
57
+
58
+ // A client error whose text is about image input. Providers word it
59
+ // differently, so this matches the subject (images as input), not one
60
+ // provider's sentence; a 5xx or a rate limit is never this.
61
+ const IMAGE_REFUSAL = /image input|support(?:s|ed)? (?:for )?images?|image_url is only supported|does not support images?|images? (?:are|is) not supported/i;
62
+
63
+ export function isImageRefusal(err) {
64
+ const status = err?.statusCode;
65
+ if (!(status >= 400 && status < 500) || status === 429) return false;
66
+ return IMAGE_REFUSAL.test(String(err?.message || ""));
67
+ }
68
+
69
+ /** What the model reads in place of an image it cannot see. */
70
+ export function imageUnseenText(toolName, imagePath, caption) {
71
+ const where = imagePath ? `It is saved at ${imagePath}.` : "It was not saved to a file.";
72
+ const text = caption && caption.trim() ? ` Text that came with it: ${caption.trim()}` : "";
73
+ return `[The model you are running on cannot view images. ${toolName} returned an image. ${where}${text}]`;
74
+ }
75
+
76
+ /**
77
+ * Replace every image part in `messages` with the text the model can read.
78
+ * Used once the provider has refused an image, so the same payload is not sent
79
+ * again. Returns how many messages changed.
80
+ */
81
+ export function stripImages(messages) {
82
+ let changed = 0;
83
+ for (const m of messages) {
84
+ if (!Array.isArray(m.content) || !m.content.some((p) => p?.type === "image_url")) continue;
85
+ // A tool's image already has its caption and OCR in the tool result next
86
+ // to it; the text part here is only "[Tool result image ...]". An image
87
+ // from anywhere else keeps whatever text travelled with it.
88
+ const caption = m._isImage ? "" : m.content.filter((p) => p?.type === "text").map((p) => p.text).join("\n");
89
+ m.content = imageUnseenText(m._imageTool || "A message", m._imagePath || null, caption);
90
+ m._compressed = true;
91
+ changed++;
92
+ }
93
+ return changed;
94
+ }
@@ -0,0 +1,139 @@
1
+ // The first answer, and the clock on it.
2
+ //
3
+ // A call sent at 20:40:49 on 2026-09-29 never came back. The 10-minute timeout
4
+ // fired at 20:50:49, the call was retried, and for the whole ten minutes the
5
+ // console said `thinking #15 468s` — a number that is the age of the TASK, not
6
+ // of the call, so it looks equally alarming after ten seconds of normal work.
7
+ //
8
+ // Two separate faults, and the second is why this module exists:
9
+ //
10
+ // 1. The status line counted from the wrong clock (fixed in the UI layer).
11
+ // 2. Nothing gave up on the call. A provider that accepts the request and
12
+ // never answers is the one failure where the only useful move is to drop
13
+ // it and send it again, and Flint sat on it for the full 600s hard
14
+ // timeout, twice, before it did.
15
+ //
16
+ // So: the watchdog is armed on the wait for the FIRST answer, not the whole
17
+ // call. 90 seconds of silence on a request that has been sent is a hung
18
+ // connection, not a slow model. The attempt is dropped (the request is aborted,
19
+ // not merely abandoned — the socket stays open otherwise), the operator is told
20
+ // which attempt it is, and after the last attempt the turn stops with the
21
+ // reason and how to continue.
22
+
23
+ /** Silence before a hung connection is dropped. Env override for tests. */
24
+ export function firstTokenTimeoutMs() {
25
+ const v = parseInt(process.env.AGENT_FIRST_TOKEN_TIMEOUT_MS || "", 10);
26
+ return Number.isFinite(v) && v > 0 ? v : 90_000;
27
+ }
28
+
29
+ /** How many hung calls in a row before the turn stops. */
30
+ export function maxStallAttempts() {
31
+ const v = parseInt(process.env.AGENT_STALL_MAX_ATTEMPTS || "", 10);
32
+ return Number.isFinite(v) && v > 0 ? v : 3;
33
+ }
34
+
35
+ /**
36
+ * What the operator sees when one attempt is dropped.
37
+ *
38
+ * The attempt number is the whole point: "retrying" alone, once, looks like the
39
+ * loop hung.
40
+ *
41
+ * @param {number} seconds — silence before the drop
42
+ * @param {number} attempt — 1-based
43
+ * @param {number} max — total attempts allowed
44
+ * @returns {string}
45
+ */
46
+ export function stallNote(seconds, attempt, max) {
47
+ return `no answer for ${Math.round(seconds)}s, retrying, ${attempt} of ${max}`;
48
+ }
49
+
50
+ /**
51
+ * The line the turn ends on after the last hung call.
52
+ *
53
+ * @param {number} attempts
54
+ * @param {number} timeoutMs
55
+ * @returns {string}
56
+ */
57
+ export function stallStopNote(attempts, timeoutMs) {
58
+ const secs = Math.round((timeoutMs || 0) / 1000);
59
+ return `Stopped: the provider sent no answer for ${secs}s on ${attempts} attempt${attempts === 1 ? "" : "s"} in a row, ` +
60
+ `and Flint dropped each one. Nothing ran, and the work may be unfinished. ` +
61
+ `To continue, type: continue. If it keeps happening, switch the model with /model or the provider with /provider.`;
62
+ }
63
+
64
+ /**
65
+ * Run one model call under a watchdog on the FIRST answer.
66
+ *
67
+ * `run` receives `(onToken, signal)`. The signal aborts the in-flight request
68
+ * when the watchdog fires, because an abandoned promise still holds an open
69
+ * socket: on 2026-09-29 two of those were still open when the retry started.
70
+ *
71
+ * @param {(onToken: Function, signal: AbortSignal) => Promise<any>} run
72
+ * @param {object} opts - { timeoutMs, signal, onToken, onFirstToken, onStall }
73
+ * @returns {Promise<any>} the call's result
74
+ * @throws {Error} with `.isStall` set when the watchdog fired
75
+ */
76
+ export async function callWithStallWatchdog(run, { timeoutMs, signal, onToken, onFirstToken, onStall } = {}) {
77
+ const controller = new AbortController();
78
+ let timer = null;
79
+ let watched = false;
80
+
81
+ const onExternalAbort = () => controller.abort(signal?.reason);
82
+ if (signal) {
83
+ if (signal.aborted) controller.abort(signal.reason);
84
+ else signal.addEventListener("abort", onExternalAbort, { once: true });
85
+ }
86
+
87
+ // Armed until the first token or the answer itself. Once the model starts
88
+ // writing, there is no silence left to measure.
89
+ const disarm = () => {
90
+ watched = false;
91
+ if (timer) { clearTimeout(timer); timer = null; }
92
+ };
93
+
94
+ const forwardToken = (token) => {
95
+ if (watched) {
96
+ // The model has started writing: there is no silence left to measure.
97
+ // Told here rather than on the way out, because a call that streams for
98
+ // a minute and then fails should still have reported that it started.
99
+ disarm();
100
+ onFirstToken?.();
101
+ }
102
+ onToken?.(token);
103
+ };
104
+
105
+ let rejectStall = null;
106
+ const stalled = new Promise((_, reject) => { rejectStall = reject; });
107
+
108
+ const call = (async () => run(forwardToken, controller.signal))();
109
+ // The race decides; if the watchdog wins, the call's own later rejection is
110
+ // nobody's problem and must not become an unhandled rejection.
111
+ call.catch(() => {});
112
+
113
+ if (timeoutMs > 0) {
114
+ watched = true;
115
+ timer = setTimeout(() => {
116
+ const err = new Error(`no answer for ${timeoutMs}ms`);
117
+ err.isStall = true;
118
+ onStall?.(err);
119
+ controller.abort(err);
120
+ rejectStall(err);
121
+ }, timeoutMs);
122
+ // Never hold the process open for a watchdog on a call that already ended.
123
+ timer.unref?.();
124
+ }
125
+
126
+ try {
127
+ const out = await Promise.race([call, stalled]);
128
+ // A non-streaming call (stream: false) never fires onToken, so the
129
+ // watchdog is still armed here. Disarm it and report the first answer.
130
+ if (watched) {
131
+ disarm();
132
+ onFirstToken?.();
133
+ }
134
+ return out;
135
+ } finally {
136
+ disarm();
137
+ if (signal) signal.removeEventListener?.("abort", onExternalAbort);
138
+ }
139
+ }
@@ -0,0 +1,177 @@
1
+ // What a turn changed, read off the folders it works in rather than off the
2
+ // names of the tools it called.
3
+ //
4
+ // The count used to come from a list of file tools (write_file, edit_file, ...).
5
+ // A file made any other way did not exist for it: ffmpeg run through
6
+ // run_command, a Python script, an MCP server writing for the agent. 20 of 28
7
+ // answers in one benchmark run ended "[Nothing was changed on disk this turn.]"
8
+ // over a file the task had just produced, and each of those turns also paid for
9
+ // a model call asking why nothing had been done.
10
+ //
11
+ // A snapshot is path -> mtime and size for every file under the roots. Two
12
+ // snapshots differ exactly where something was written, whoever wrote it.
13
+
14
+ import { readdirSync, statSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+
17
+ // Past this many files a snapshot gives up and says so (null), and the caller
18
+ // makes no claim about the disk. Walking a big tree on every tool call would
19
+ // cost more than the answer is worth, and "unknown" is honest where a partial
20
+ // count would not be.
21
+ export const MAX_FILES = 20000;
22
+
23
+ // Stores that churn on their own or hold other people's code. A change there is
24
+ // not the work the operator asked for, and node_modules alone can exceed
25
+ // MAX_FILES in an ordinary project.
26
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
27
+
28
+ /**
29
+ * @param {string[]} roots - folders to read; duplicates and nesting are fine
30
+ * @param {{maxFiles?: number, skip?: string[]}} [opts] - skip: folders not to
31
+ * descend into, for Flint's own state. Flint writes its session log on every
32
+ * step, so when it runs from its own folder that log would count as the
33
+ * turn's work. A root inside a skipped folder (the session workspace sits
34
+ * under sessions/) is still read, because it was named.
35
+ * @returns {Map<string, string> | null} absolute path -> "mtimeMs:size", or null past maxFiles
36
+ */
37
+ export function snapshotWorkspace(roots, { maxFiles = MAX_FILES, skip = [] } = {}) {
38
+ const files = new Map();
39
+ const seenDirs = new Set(skip.filter(Boolean).map((s) => resolve(s)));
40
+ const stack = [...new Set(roots.filter(Boolean).map((r) => resolve(r)))];
41
+ for (const r of stack) seenDirs.delete(r);
42
+ while (stack.length) {
43
+ const dir = stack.pop();
44
+ if (seenDirs.has(dir)) continue;
45
+ seenDirs.add(dir);
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(dir, { withFileTypes: true });
49
+ } catch {
50
+ continue; // a root that does not exist yet, or a folder we may not read
51
+ }
52
+ for (const e of entries) {
53
+ const p = join(dir, e.name);
54
+ if (e.isDirectory()) {
55
+ if (!SKIP_DIRS.has(e.name)) stack.push(p);
56
+ continue;
57
+ }
58
+ if (!e.isFile()) continue;
59
+ let st;
60
+ try {
61
+ st = statSync(p);
62
+ } catch {
63
+ continue; // removed between readdir and stat
64
+ }
65
+ files.set(p, `${st.mtimeMs}:${st.size}`);
66
+ if (files.size > maxFiles) return null;
67
+ }
68
+ }
69
+ return files;
70
+ }
71
+
72
+ /**
73
+ * Paths that were added, modified or removed between two snapshots.
74
+ * @returns {string[] | null} null when either side is unknown
75
+ */
76
+ export function changedFiles(before, after) {
77
+ if (!before || !after) return null;
78
+ const out = [];
79
+ for (const [p, sig] of after) if (before.get(p) !== sig) out.push(p);
80
+ for (const p of before.keys()) if (!after.has(p)) out.push(p);
81
+ return out;
82
+ }
83
+
84
+ // Absolute paths in free text: a POSIX path not preceded by a URL's "//" or a
85
+ // word character, or a Windows drive path. Pseudo filesystems are left out:
86
+ // nothing the operator asked for lives there, and walking them is slow.
87
+ const POSIX_PATH = /(?<![\w:/.~-])\/(?:[\w.@%+~-]+\/?)+/g;
88
+ const WINDOWS_PATH = /\b[A-Za-z]:[\\/][^\s"'`;|&<>()*?]*/g;
89
+ const PSEUDO_FS = /^\/(proc|sys|dev)(\/|$)/;
90
+
91
+ /**
92
+ * The folders that absolute paths in `value` point into: the path itself when
93
+ * it is a folder, otherwise its nearest existing parent (a file the call is
94
+ * about to create does not exist yet). Strings are read anywhere inside
95
+ * `value`, so this does not know or care which tool the arguments belong to.
96
+ */
97
+ export function pathRootsIn(value) {
98
+ const strings = [];
99
+ (function collect(v) {
100
+ if (typeof v === "string") strings.push(v);
101
+ else if (Array.isArray(v)) v.forEach(collect);
102
+ else if (v && typeof v === "object") Object.values(v).forEach(collect);
103
+ })(value);
104
+ const roots = new Set();
105
+ for (const s of strings) {
106
+ for (const m of [...(s.match(POSIX_PATH) || []), ...(s.match(WINDOWS_PATH) || [])]) {
107
+ let p = resolve(m.replace(/[\\/.,:]+$/, "") || m);
108
+ if (PSEUDO_FS.test(p.replace(/\\/g, "/"))) continue;
109
+ for (;;) {
110
+ let st = null;
111
+ try { st = statSync(p); } catch {}
112
+ if (st?.isDirectory()) break;
113
+ const up = dirname(p);
114
+ if (up === p) break;
115
+ p = up;
116
+ }
117
+ // The filesystem root is not a place anyone works in; reading all of it
118
+ // would only hit MAX_FILES.
119
+ if (dirname(p) !== p) roots.add(p);
120
+ }
121
+ }
122
+ return [...roots];
123
+ }
124
+
125
+ /**
126
+ * Follow what a turn changes. The roots named at the start (the working folder
127
+ * and the session workspace) must be readable, or the whole answer is unknown.
128
+ * Folders met later in arguments are read once, before the call that names
129
+ * them runs; one too big to read is simply not watched, and not named.
130
+ *
131
+ * Two full readings per turn, at the start and when an answer is due, not one
132
+ * per tool call: on an 18k-file repository a reading takes about a second on
133
+ * Windows. The order of events (was anything run after the last change) comes
134
+ * from the files' own times instead.
135
+ */
136
+ export function createChangeTracker({ roots, skip = [], maxFiles = MAX_FILES }) {
137
+ const baseline = new Map();
138
+ const watched = new Set();
139
+ let unknown = false;
140
+
141
+ function watch(dirs, required) {
142
+ for (const r of dirs.filter(Boolean).map((d) => resolve(d))) {
143
+ if (watched.has(r)) continue;
144
+ const snap = snapshotWorkspace([r], { skip, maxFiles });
145
+ if (!snap) {
146
+ if (required) unknown = true;
147
+ continue;
148
+ }
149
+ watched.add(r);
150
+ for (const [p, sig] of snap) if (!baseline.has(p)) baseline.set(p, sig);
151
+ }
152
+ }
153
+ watch(roots, true);
154
+
155
+ return {
156
+ /** Start watching the folders any absolute path in `value` points into. */
157
+ watchPathsIn(value) {
158
+ watch(pathRootsIn(value), false);
159
+ },
160
+ /** @returns {{files: string[], newestMs: number} | null} null when unknown */
161
+ changes() {
162
+ if (unknown) return null;
163
+ const now = snapshotWorkspace([...watched], { skip, maxFiles });
164
+ const files = changedFiles(baseline, now);
165
+ if (!files) return null;
166
+ let newestMs = 0;
167
+ for (const f of files) {
168
+ const sig = now.get(f);
169
+ if (sig) newestMs = Math.max(newestMs, parseFloat(sig));
170
+ }
171
+ return { files, newestMs };
172
+ },
173
+ roots() {
174
+ return [...watched];
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,14 @@
1
+ // Where Flint's HTTP API listens, and the URL every caller uses to reach it.
2
+ //
3
+ // One address for both sides (2026-10-02). The server binds 127.0.0.1 only,
4
+ // while the callers (a parent asking its child agents, the agent registry,
5
+ // the orphan check) fetched http://localhost. On Node 22, which Flint needs,
6
+ // `localhost` resolves to ::1 first and nothing listens there, so every one of
7
+ // those calls failed with ECONNREFUSED; GitHub's Windows runners showed it.
8
+
9
+ export const API_HOST = "127.0.0.1";
10
+
11
+ /** http://127.0.0.1:<port><path> */
12
+ export function apiUrl(port, path = "") {
13
+ return `http://${API_HOST}:${port}${path}`;
14
+ }