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
+ // The end of a session as it was on screen, shown again when the session is
2
+ // continued (/resume, /load, a restart, --session, --last).
3
+ //
4
+ // Owner, 2026-10-02: after a resume the screen was empty, and the only way to
5
+ // see where the conversation had got to was to ask the agent. The session's
6
+ // chat log has every line exactly as it was shown (answers, ledger lines,
7
+ // receipts), so its last lines are put back. They are for the eyes only: the
8
+ // model's context is the session's saved messages, which a resume restores
9
+ // whether or not anything is shown.
10
+
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import path from "node:path";
13
+ import chalk from "chalk";
14
+ import { config } from "../config.js";
15
+
16
+ /** How many lines of a continued session are shown again. */
17
+ export const RESUME_TAIL_LINES = 80;
18
+
19
+ const STAMP_RE = /^\[\d\d:\d\d:\d\d\] ?/;
20
+
21
+ // Lines about the session rather than in it: leaving and loading it, and the
22
+ // start banner a restart prints into the same log. Owner, 2026-10-02: the
23
+ // first resume showed "> exit", "Session saved", "Bye!" and "Loaded
24
+ // session" in the middle of the conversation.
25
+ const HOUSEKEEPING_RE = [
26
+ /^\s*> (exit|quit)\s*$/,
27
+ /^\s*> \/\S*(\s.*)?$/, // slash commands
28
+ /^Session saved: /,
29
+ /^Bye!\s*$/,
30
+ /^\s*Loaded session \S+ \(/,
31
+ /^\s*\\ \/\s*$/, // banner up to 1.13 (the mascot)
32
+ /FLINT AGENT v\d/,
33
+ /^\s*▀▀▀ █ ▀ █▄ █ ▀█▀\s*$/, // banner since 1.14: the FLiNT mark
34
+ /^\s*█▀▀ █▄▄ █ █ ▀█ █\s+v\d/,
35
+ /^ (model|session|api|token|mcp):\s/,
36
+ /^ type \/help for commands/,
37
+ /^\s*mcp: \S+ (connected \(|-- )/,
38
+ /^\s*Restarting agent/,
39
+ ];
40
+
41
+ /** A line the operator typed, as the chat log has it ("> text", "api@host > text"). */
42
+ const USER_LINE_RE = /^\s*(\S+@\S+ )?> \S/;
43
+
44
+ /**
45
+ * The conversation part of a chat log's lines: housekeeping dropped, runs of
46
+ * blank lines collapsed to one.
47
+ */
48
+ export function conversationLines(lines) {
49
+ const out = [];
50
+ for (const line of lines) {
51
+ if (HOUSEKEEPING_RE.some((re) => re.test(line))) continue;
52
+ if (!line.trim() && (!out.length || !out[out.length - 1].trim())) continue;
53
+ out.push(line);
54
+ }
55
+ while (out.length && !out[out.length - 1].trim()) out.pop();
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * The last `n` lines of a session's chat log, without their time stamps or
61
+ * housekeeping, starting at a message the operator typed so the tail does not
62
+ * open in the middle of an answer. Empty when there is no log.
63
+ */
64
+ export function chatLogTail(sessionId, n = RESUME_TAIL_LINES, dir = config.sessionsDir) {
65
+ if (!sessionId || !dir) return [];
66
+ const file = path.join(dir, `${sessionId}.chat.log`);
67
+ if (!existsSync(file)) return [];
68
+ let text;
69
+ try {
70
+ text = readFileSync(file, "utf8");
71
+ } catch {
72
+ return [];
73
+ }
74
+ const lines = conversationLines(text.split(/\r?\n/).map((l) => l.replace(STAMP_RE, "")));
75
+ const tail = lines.slice(-n);
76
+ // Open at the first message typed within the tail; a tail without one (a
77
+ // single long answer) is kept as it is.
78
+ const first = tail.findIndex((l) => USER_LINE_RE.test(l));
79
+ return first > 0 ? tail.slice(first) : tail;
80
+ }
81
+
82
+ /**
83
+ * Show the end of a continued session in the history, between two dim rules.
84
+ * Returns how many lines were shown.
85
+ */
86
+ export function replaySessionTail(store, sessionId, n = RESUME_TAIL_LINES, dir = config.sessionsDir) {
87
+ const tail = chatLogTail(sessionId, n, dir);
88
+ if (!tail.length) return 0;
89
+ const add = (t) => store.getState().addLine(t, { replay: true });
90
+ add(chalk.dim(` ── the end of session ${sessionId}, as it was (last ${tail.length} lines) ──`));
91
+ for (const line of tail) add(line);
92
+ add(chalk.dim(" ── resumed here ──"));
93
+ return tail.length;
94
+ }
@@ -0,0 +1,49 @@
1
+ import chalk from "chalk";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ // The FLiNT mark (owner, 2026-10-02), after the designer's wordmark: an F
5
+ // whose top bar stands apart, titanium letters, one yellow spark over the i, the
6
+ // version beside it. It replaced the -[°^°]- mascot. Two text rows of half
7
+ // blocks, so it renders in any monospace font.
8
+ export const MARK = [
9
+ "▀▀▀ █ ▀ █▄ █ ▀█▀",
10
+ "█▀▀ █▄▄ █ █ ▀█ █",
11
+ ];
12
+ const MARK_ACCENT = [[8], []]; // column of the dot over the i, per row
13
+
14
+ // 256-colour codes, the same the launcher's loading line uses, so the mark
15
+ // does not change colour between loading and the header: titanium 250,
16
+ // spark yellow 220, grey 244. Yellow, not orange (owner and designer,
17
+ // 2026-10-02): warm orange is Claude Code's colour.
18
+ const SPARK = 220;
19
+ const TITANIUM = 250;
20
+ const DIM = 244;
21
+
22
+ /** The mark's two rows, coloured; `tail` goes after the second row. */
23
+ export function markLines(tail = "") {
24
+ return MARK.map((row, r) => {
25
+ let s = "";
26
+ for (let i = 0; i < row.length; i++) {
27
+ s += MARK_ACCENT[r].includes(i) ? chalk.ansi256(SPARK)(row[i]) : chalk.ansi256(TITANIUM)(row[i]);
28
+ }
29
+ return " " + s + (r === MARK.length - 1 && tail ? " " + tail : "");
30
+ });
31
+ }
32
+
33
+ export function printSplash(version = "0.0.0", returnString = false) {
34
+ const lines = markLines(chalk.ansi256(DIM)(`v${version}`));
35
+ if (returnString) return lines.join("\n");
36
+ console.log("");
37
+ for (const l of lines) console.log(l);
38
+ console.log("");
39
+ }
40
+
41
+ if (process.argv[1]?.endsWith("splash.js")) {
42
+ // When run directly (node src/ui/splash.js), read the live version
43
+ // from package.json — previously hardcoded "0.9.3" which decayed.
44
+ let version = "0.0.0";
45
+ try {
46
+ version = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")).version;
47
+ } catch {}
48
+ printSplash(version);
49
+ }
@@ -0,0 +1,36 @@
1
+ // The security level, for the status bar.
2
+ //
3
+ // A level that is chosen and then invisible is a level the operator cannot
4
+ // check themselves against. The onboarding question is asked exactly once, so
5
+ // for the whole life of the install the only evidence of which posture Flint is
6
+ // running at is this string — and when something asks that ought not to, the
7
+ // first question is whether the level is set to a stranger value than they
8
+ // think. It is not printed in the output stream (that is the conversation), it
9
+ // is shown in the status bar, which is where the numbers live that get read
10
+ // without being read on purpose.
11
+
12
+ import { getOnboardingAnswer } from "../tools/permissions.js";
13
+
14
+ /** The level, for display. Null when nobody has chosen one. */
15
+ export function currentLevel() {
16
+ return getOnboardingAnswer();
17
+ }
18
+
19
+ /**
20
+ * The status-bar fragment naming the level.
21
+ *
22
+ * Says "not set" rather than defaulting silently. The spec's default level
23
+ * applies until the question is answered, and that is the right behaviour to
24
+ * have — but "the right behaviour" and "the operator chose this" are different
25
+ * claims, and a status bar that reports a default as if it were a choice is
26
+ * reporting something it does not know. Before the question is answered this
27
+ * says so, and the user can go and answer it.
28
+ *
29
+ * No chalk, deliberately: the status bar composes its own colouring, and this
30
+ * is a fragment rather than a line.
31
+ */
32
+ export function formatStatusLevel() {
33
+ const level = currentLevel();
34
+ if (!level) return "care: not set (/allow-all or answer the question to choose)";
35
+ return `care: ${level}`;
36
+ }
@@ -0,0 +1,203 @@
1
+ // The tool ledger: one line per tool call in history, and the receipt that
2
+ // closes a turn. Flint's own look (owner, 2026-10-01): a category tag, a verb,
3
+ // the argument, and the measured result right-aligned, like a ledger:
4
+ //
5
+ // fs read docs/guide.md 24 KB 12ms
6
+ // sh run ping -n 5 127.0.0.1 exit 0 4.1s
7
+ // web search node lts 2 KB 0.8s
8
+ //
9
+ // ── turn 7 · 4 tools · changed fib.js · 6.2s · $0.0000 ──
10
+
11
+ import chalk from "chalk";
12
+ import stringWidth from "string-width";
13
+
14
+ // name -> [category, verb]. Anything else falls back by prefix, then "tool".
15
+ const TOOLS = {
16
+ read_file: ["fs", "read"], write_file: ["fs", "write"], edit_file: ["fs", "edit"],
17
+ delete_file: ["fs", "delete"], copy_file: ["fs", "copy"], move_file: ["fs", "move"],
18
+ create_directory: ["fs", "mkdir"], list_directory: ["fs", "list"], glob: ["fs", "glob"],
19
+ search_in_files: ["fs", "grep"], view_image: ["fs", "view"],
20
+ run_command: ["sh", "run"], run_background_command: ["sh", "start"],
21
+ kill_process: ["sh", "kill"], list_processes: ["sh", "ps"], peek_process: ["sh", "peek"],
22
+ web_search: ["web", "search"], web_fetch: ["web", "fetch"],
23
+ memory_write: ["mem", "write"], memory_search: ["mem", "search"], memory_get: ["mem", "get"],
24
+ memory_delete: ["mem", "delete"], memory_expand: ["mem", "expand"],
25
+ skill_add: ["mem", "skill+"], skill_update: ["mem", "skill"], skill_remove: ["mem", "skill-"],
26
+ create_plan: ["plan", "create"], update_task: ["plan", "update"], add_task: ["plan", "add"],
27
+ list_tasks: ["plan", "list"], create_subtask: ["plan", "sub"], add_task_note: ["plan", "note"],
28
+ link_task_file: ["plan", "link"], list_goals: ["plan", "goals"], focus_goal: ["plan", "focus"],
29
+ task_stats: ["plan", "stats"], today: ["plan", "today"],
30
+ spawn_agent: ["agent", "spawn"], ask_agent: ["agent", "ask"], list_agents: ["agent", "list"],
31
+ wait_tasks: ["agent", "wait"],
32
+ mesh_search: ["mesh", "search"], mesh_add: ["mesh", "add"], mesh_recent: ["mesh", "recent"],
33
+ };
34
+
35
+ /** [category, verb] for a tool name. */
36
+ export function toolCategory(name) {
37
+ const n = String(name || "");
38
+ if (TOOLS[n]) return TOOLS[n];
39
+ if (n.startsWith("mcp_") || n.includes("__")) {
40
+ // mcp_<server>_<tool>: the server is the useful part.
41
+ const parts = n.replace(/^mcp_/, "").split(/__|_/);
42
+ return ["mcp", parts[0] || "call"];
43
+ }
44
+ if (n.startsWith("desktop_")) return ["desk", n.slice(8)];
45
+ return ["sys", n.replace(/_/g, " ")];
46
+ }
47
+
48
+ /** The one argument worth showing, as a single line. */
49
+ export function toolArgument(args) {
50
+ const a = args && typeof args === "object" ? args : {};
51
+ const v = a.command ?? a.path ?? a.file_path ?? a.pattern ?? a.query ?? a.url ?? a.content ?? a.name ?? a.goal ?? a.title ?? "";
52
+ return String(typeof v === "string" ? v : JSON.stringify(v)).replace(/\s+/g, " ").trim();
53
+ }
54
+
55
+ function bytes(n) {
56
+ if (n < 1024) return `${n} B`;
57
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`;
58
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
59
+ }
60
+
61
+ /** The measured result: exit code, size, error or denial. */
62
+ const lineCount = (t) => (t ? String(t).split("\n").length : 0);
63
+
64
+ export function toolOutcome(name, result, denied, args = {}) {
65
+ if (denied) return { text: "denied", bad: true };
66
+ const s = typeof result === "string" ? result : (result && result._table ? `${result.rows?.length || 0} rows` : String(result ?? ""));
67
+ if (result && result._table) return { text: s, bad: false };
68
+ const exit = s.match(/^Error \(exit (\d+)\)/);
69
+ if (exit) return { text: `exit ${exit[1]}`, bad: true };
70
+ if (/^Error\b/.test(s)) return { text: "error", bad: true };
71
+ if (name === "run_command") return { text: "exit 0", bad: false };
72
+ // A write or an edit is measured by what it put in the file, not by its
73
+ // one-line reply: "fs write ... 51 B" was the length of "File written:
74
+ // <path>" for a 7.8 KB document (owner, 2026-10-02).
75
+ if (name === "write_file" && typeof args?.content === "string") {
76
+ return { text: bytes(Buffer.byteLength(args.content, "utf8")), bad: false };
77
+ }
78
+ if (name === "edit_file" && typeof args?.new_text === "string") {
79
+ return { text: `-${lineCount(args.old_text)} +${lineCount(args.new_text)}`, bad: false };
80
+ }
81
+ // A started background process is named by its id, which /logs and /kill use.
82
+ const bg = s.match(/^Background process started \(id: (\d+)/);
83
+ if (bg) return { text: `bg ${bg[1]}`, bad: false };
84
+ return { text: bytes(Buffer.byteLength(s, "utf8")), bad: false };
85
+ }
86
+
87
+ export function formatDuration(ms) {
88
+ if (ms == null || ms < 0) return "";
89
+ if (ms < 1000) return `${Math.round(ms)}ms`;
90
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
91
+ const m = Math.floor(ms / 60000);
92
+ return `${m}m${String(Math.round((ms % 60000) / 1000)).padStart(2, "0")}s`;
93
+ }
94
+
95
+ function fit(text, width) {
96
+ if (width <= 0) return "";
97
+ if (stringWidth(text) <= width) return text + " ".repeat(width - stringWidth(text));
98
+ let out = "";
99
+ for (const ch of text) {
100
+ if (stringWidth(out + ch) > width - 1) break;
101
+ out += ch;
102
+ }
103
+ return out + "…" + " ".repeat(Math.max(0, width - stringWidth(out) - 1));
104
+ }
105
+
106
+ /** Cut from the middle, so a path keeps its start and its file name. */
107
+ export function fitMiddle(text, width) {
108
+ const s = String(text);
109
+ if (width <= 0) return "";
110
+ if (stringWidth(s) <= width) return s + " ".repeat(width - stringWidth(s));
111
+ const keepEnd = Math.max(1, Math.floor((width - 1) * 0.6));
112
+ const keepStart = Math.max(1, width - 1 - keepEnd);
113
+ const chars = [...s];
114
+ let head = "";
115
+ for (const ch of chars) {
116
+ if (stringWidth(head + ch) > keepStart) break;
117
+ head += ch;
118
+ }
119
+ let tail = "";
120
+ for (let i = chars.length - 1; i >= 0; i--) {
121
+ if (stringWidth(chars[i] + tail) > keepEnd) break;
122
+ tail = chars[i] + tail;
123
+ }
124
+ const out = `${head}…${tail}`;
125
+ return out + " ".repeat(Math.max(0, width - stringWidth(out)));
126
+ }
127
+
128
+ // The ledger is secondary to the conversation (owner, 2026-10-01): indented,
129
+ // uncoloured, and no wider than 60% of the window, with fixed columns so the
130
+ // result and the time sit in the same place on every line.
131
+ const INDENT = " ";
132
+ const CAT_COL = 6;
133
+ const VERB_COL = 8;
134
+ const OUT_COL = 9;
135
+ const DUR_COL = 7;
136
+
137
+ /** The width the ledger and the receipt use: 60% of the window, within reason. */
138
+ export function ledgerWidth(columns = process.stdout.columns || 80) {
139
+ return Math.min(columns - 1, Math.max(56, Math.floor(columns * 0.6)));
140
+ }
141
+
142
+ /** One ledger line, dim, left-aligned columns, fixed width. */
143
+ export function ledgerLine({ name, args, result, denied, ms, columns = process.stdout.columns || 80 }) {
144
+ const [cat, verb] = toolCategory(name);
145
+ const outcome = toolOutcome(name, result, denied, args);
146
+ const width = ledgerWidth(columns);
147
+ const argCol = Math.max(10, width - INDENT.length - CAT_COL - VERB_COL - OUT_COL - DUR_COL - 1);
148
+ const line = INDENT
149
+ + fit(cat, CAT_COL)
150
+ + fit(verb, VERB_COL)
151
+ + fitMiddle(toolArgument(args), argCol) + " "
152
+ + fit(outcome.text, OUT_COL)
153
+ + formatDuration(ms);
154
+ return chalk.dim(line.trimEnd());
155
+ }
156
+
157
+ /**
158
+ * A background process ending, in the same columns as the ledger:
159
+ * sh done ping -n 180 127.0.0.1 bg 6 3m 2s
160
+ * `verb` is done, killed or "exit N"; `elapsed` is already formatted.
161
+ */
162
+ export function processLedgerLine({ verb, cmd, procId, elapsed, columns = process.stdout.columns || 80 }) {
163
+ const width = ledgerWidth(columns);
164
+ const argCol = Math.max(10, width - INDENT.length - CAT_COL - VERB_COL - OUT_COL - DUR_COL - 1);
165
+ const line = INDENT
166
+ + fit("sh", CAT_COL)
167
+ + fit(verb, VERB_COL)
168
+ + fitMiddle(String(cmd || ""), argCol) + " "
169
+ + fit(`bg ${procId}`, OUT_COL)
170
+ + String(elapsed || "");
171
+ return chalk.dim(line.trimEnd());
172
+ }
173
+
174
+ /**
175
+ * The line that closes a turn. `files` is the list the agent loop read off the
176
+ * disk (null when it could not tell), so the receipt never claims more than
177
+ * that.
178
+ */
179
+ function tokenCount(n) {
180
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
181
+ }
182
+
183
+ export function receiptLine({ turn, tools, files, ms, cost, tokensIn, tokensOut, sessionCost, estimated, stopped, columns = process.stdout.columns || 80 }) {
184
+ const parts = [`turn ${turn}`];
185
+ parts.push(`${tools} tool${tools === 1 ? "" : "s"}`);
186
+ if (Array.isArray(files) && files.length) {
187
+ const names = files.map((f) => String(f).split(/[\\/]/).pop());
188
+ const shown = names.slice(0, 3).join(", ") + (names.length > 3 ? ` +${names.length - 3}` : "");
189
+ parts.push(`changed ${shown}`);
190
+ }
191
+ if (stopped) parts.push(stopped);
192
+ // Tokens for the whole turn, every call counted (owner, 2026-10-01: on a
193
+ // free model the cost is always $0 and the token count is the real figure).
194
+ if (tokensIn || tokensOut) parts.push(`${tokenCount(tokensIn || 0)} in / ${tokenCount(tokensOut || 0)} out tok`);
195
+ parts.push(formatDuration(ms));
196
+ const money = `${estimated ? "~" : ""}$${(cost || 0).toFixed(4)}`;
197
+ parts.push(sessionCost != null ? `${money} (session $${sessionCost.toFixed(4)})` : money);
198
+ const body = ` ${parts.join(" · ")} `;
199
+ const width = ledgerWidth(columns);
200
+ const left = 2;
201
+ const right = Math.max(2, width - INDENT.length - left - stringWidth(body));
202
+ return chalk.dim(INDENT + "─".repeat(left) + body + "─".repeat(right));
203
+ }
@@ -0,0 +1,150 @@
1
+ // Window title: the one place Flint is visible when the window is not in front.
2
+ //
3
+ // Backlog item 18, owner 2026-09-29 19:02: mid-build the console window title
4
+ // just said "bash", so from another window, from the taskbar or an Alt+Tab list
5
+ // there was no sign of anything running. The owner had to switch back to find
6
+ // out whether Flint had died. The item asks for a spinner in the window title
7
+ // while Flint works, so the entry you can see without focusing the window
8
+ // carries the signal.
9
+ //
10
+ // The same reasoning as the attention bell in prompt-attention.js, extended from
11
+ // "Flint needs you" to "Flint is working". This is the module that owns the
12
+ // spinner; prompt-attention.js still owns the urgent case and reaches for it
13
+ // through hold()/release() so a spinner cannot overwrite a prompt, and so the
14
+ // turn-end cost line at the end of a turn is not wiped by the next tick.
15
+
16
+ import { defaultSetTitle } from "./prompt-attention.js";
17
+
18
+ /**
19
+ * Spinner frames: a gear and a spark, alternating (owner, 2026-10-02). The
20
+ * title bar is drawn in the system UI font, which has both; the console keeps
21
+ * braille dots, because several monospace fonts lack these two. The classic
22
+ * "|/-\" set reads as a blinking cursor at title-bar size, which looks like a
23
+ * crash.
24
+ */
25
+ export const TITLE_FRAMES = ["⛭", "✲"];
26
+
27
+ /**
28
+ * How often the title changes.
29
+ *
30
+ * The owner ruled out 200ms explicitly ("not every 200ms; something a person can
31
+ * read at a glance"). Faster than roughly a third of a second and the frames are
32
+ * indistinguishable, so the title bar flickers instead of animating — and a
33
+ * flicker in a peripheral position reads as noise rather than life. 500ms is
34
+ * two slow frames a second: readable, and still obviously moving.
35
+ */
36
+ export const TITLE_INTERVAL_MS = 500;
37
+
38
+ /** Shown when Flint is up and nothing is happening. */
39
+ export const IDLE_TITLE = "Flint";
40
+
41
+ /**
42
+ * Write the title to the terminal.
43
+ *
44
+ * Delegated to prompt-attention's defaultSetTitle rather than reimplemented, so
45
+ * there is exactly one place in Flint that knows how to set a window title. That
46
+ * function already prefers process.stdout.title where it exists — which on
47
+ * Windows is what makes the taskbar entry flash — and falls back to the OSC 0
48
+ * escape on POSIX terminals. A second OSC writer here would have been a second
49
+ * platform-specific guess at the same thing, and the two would disagree on
50
+ * Windows.
51
+ */
52
+ export function writeTitle(title, stream = process.stdout) {
53
+ if (stream === process.stdout) {
54
+ defaultSetTitle(title);
55
+ return;
56
+ }
57
+ stream.write(`\x1b]0;${title}\x07`);
58
+ }
59
+
60
+ /**
61
+ * Start animating the window title.
62
+ *
63
+ * @param {object} [opts]
64
+ * @param {(title: string) => void} [opts.setTitle] injected for tests
65
+ * @param {boolean} [opts.isTTY] injected for tests; real runs read the stream
66
+ * @returns {{isRunning: boolean, hold: (t: string) => void, release: () => void, stop: () => void}}
67
+ */
68
+ export function startAliveTitle({ setTitle = writeTitle, isTTY = process.stdout.isTTY } = {}) {
69
+ let frame = 0;
70
+ let timer = null;
71
+ let held = null;
72
+
73
+ // Not a terminal means no title: in a pipe, a log file or a test, an OSC 0
74
+ // sequence is a stray control character in captured output, which is worse
75
+ // than no title at all. Same guard the bell uses.
76
+ let running = isTTY !== false && typeof setTitle === "function";
77
+
78
+ const write = (title) => {
79
+ if (!running) return;
80
+ try {
81
+ setTitle(title);
82
+ } catch {
83
+ // A window title is decoration. A terminal that rejects the sequence, or a
84
+ // sink that throws, must not take the agent loop down with it — and the
85
+ // animation keeps trying, because the condition may be momentary.
86
+ }
87
+ };
88
+
89
+ const current = () => `${TITLE_FRAMES[frame]} ${IDLE_TITLE}`;
90
+
91
+ const tick = () => {
92
+ if (held !== null) return;
93
+ frame = (frame + 1) % TITLE_FRAMES.length;
94
+ write(current());
95
+ };
96
+
97
+ const begin = () => {
98
+ if (timer) return;
99
+ timer = setInterval(tick, TITLE_INTERVAL_MS);
100
+ timer.unref?.();
101
+ };
102
+
103
+ if (running) {
104
+ // Set something immediately: an Alt+Tab entry that is only correct after
105
+ // half a second is an entry that is briefly still wrong.
106
+ write(current());
107
+ begin();
108
+ }
109
+
110
+ return {
111
+ get isRunning() {
112
+ return running;
113
+ },
114
+
115
+ /**
116
+ * Show a title the operator has to read instead of the spinner, until
117
+ * release(). An approval prompt, or the cost line at the end of a turn.
118
+ */
119
+ hold(title) {
120
+ held = typeof title === "string" ? title : null;
121
+ if (held !== null) write(held);
122
+ },
123
+
124
+ /** Give the held title back to the animation. */
125
+ release() {
126
+ if (held === null) return;
127
+ held = null;
128
+ if (!running) return;
129
+ write(current());
130
+ begin();
131
+ },
132
+
133
+ /**
134
+ * Stop animating, leaving a title that does not claim to be busy. A stopped
135
+ * Flint that keeps spinning looks like a hung one.
136
+ */
137
+ stop() {
138
+ if (!running) return;
139
+ if (timer) {
140
+ clearInterval(timer);
141
+ timer = null;
142
+ }
143
+ // Written while `running` is still true, because write() refuses to emit
144
+ // once it is false. A stop that leaves the spinner in the title bar is a
145
+ // stopped Flint that still looks busy — which is what this is avoiding.
146
+ write(held !== null ? held : IDLE_TITLE);
147
+ running = false;
148
+ },
149
+ };
150
+ }