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,497 @@
1
+ // Bus drain loop — single consumer that processes all messages from the bus
2
+ // Replaces withLock/drainQueue. All channels push to bus, this loop processes sequentially.
3
+
4
+ import chalk from "chalk";
5
+ import { store } from "../store/index.js";
6
+ import { app } from "../app-state.js";
7
+ import { processMessage, handlePendingAction } from "../message-handler.js";
8
+ import { userMsgLine } from "../ui/header.js";
9
+ import * as bus from "./index.js";
10
+ import { createLogger } from "../logging/logger.js";
11
+ import { setSupervisorEnabled } from "../agent/supervisor.js";
12
+ import {
13
+ shouldContinue, isLearningOpportunity, setUserInterrupt, resetFlow,
14
+ FATAL_PROVIDER_REASONS, MAX_CONSECUTIVE_RATE_LIMITS, RATE_LIMIT_WAIT_MS, STOP_REASON_TEXT,
15
+ } from "../agent/flow-controller.js";
16
+ import { extractLearnings } from "../agent/learning.js";
17
+ import { config } from "../config.js";
18
+ import { bulkSetPermission, setUnattended } from "../tools/permissions.js";
19
+ import { getActivePlan, getActiveGoals } from "../tasks/queries.js";
20
+
21
+ const log = createLogger("bus-drain");
22
+
23
+ let _running = false;
24
+ let _drainResolvers = new Map(); // busId → { resolve, reject } for sync API callers
25
+ const _asyncResults = new Map(); // busId → { response, stats, error, ts } for async poll
26
+ const ASYNC_RESULT_TTL = 3600000; // 1 hour
27
+ let _continueTimer = null; // scheduled self-continue timer (clearable on flush)
28
+
29
+ // Autonomous no-progress guard — track tool-call signatures of the last N
30
+ // self-continue iterations. If N in a row are identical, the agent is stuck
31
+ // in a loop (typical pattern: repeatedly calling update_task with the same
32
+ // args because the server rejects it each time, hallucinating success in
33
+ // the response text). Halt autonomous when detected. Observed 2026-04-21:
34
+ // a probe run entered autonomous mode, then re-ran "update task N to
35
+ // in_progress" 50+ times burning $0.10+ before the user noticed.
36
+ const _autonomousSignatureHistory = []; // ring buffer, last N signatures
37
+ const AUTONOMOUS_NO_PROGRESS_THRESHOLD = 3;
38
+
39
+ // How long to wait before handing the run its next turn. Three seconds for a
40
+ // normal continue; a rate-limited turn waits out the provider's window instead.
41
+ const CONTINUE_DELAY_MS = 3000;
42
+
43
+ // Consecutive 429s. It lives here and not in flow-controller because
44
+ // resetFlow() runs at the top of every agent invocation, which would wipe it
45
+ // once per turn and it would never count past one.
46
+ let _rateLimitedInARow = 0;
47
+
48
+ function _toolCallSignature(toolCalls) {
49
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "::none::";
50
+ return toolCalls
51
+ .map((c) => `${c.name}:${JSON.stringify(c.arguments || {}).slice(0, 200)}`)
52
+ .join("|");
53
+ }
54
+
55
+ // RX-2 fix: track per-message abort controllers so we can cancel a specific
56
+ // in-flight message when its sync HTTP client disconnects. Prevents the
57
+ // "processing slot stuck forever" bug we hit on admin-008 during first live run.
58
+ const _processingControllers = new Map(); // busId → AbortController
59
+
60
+ /**
61
+ * Notify the drain loop that a new message is available.
62
+ * Call this after bus.push().
63
+ * Uses setImmediate to let Ink render the user's message first.
64
+ */
65
+ export function notify() {
66
+ if (!_running) {
67
+ // setImmediate ensures UI updates (user message line) render before processing starts
68
+ setImmediate(() => _tick());
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Wait for a specific bus message to be processed.
74
+ * Used by API server for sync responses.
75
+ * @param {number} busId
76
+ * @param {number} [timeoutMs=180000]
77
+ * @returns {Promise<{response: string, stats: object}|{error: string}>}
78
+ */
79
+ export function waitForResult(busId, timeoutMs = 180000) {
80
+ return new Promise((resolve, reject) => {
81
+ _drainResolvers.set(busId, { resolve, reject });
82
+ // Timeout safety
83
+ setTimeout(() => {
84
+ if (_drainResolvers.has(busId)) {
85
+ _drainResolvers.delete(busId);
86
+ resolve({ error: "timeout" });
87
+ }
88
+ }, timeoutMs);
89
+ });
90
+ }
91
+
92
+ /**
93
+ * RX-2 fix: Abort a specific in-flight message by bus ID.
94
+ *
95
+ * Called from the HTTP server when the sync client disconnects mid-request.
96
+ * Finds the AbortController for that message and aborts it. The agent loop
97
+ * catches the AbortError in its try/catch/finally and properly calls
98
+ * bus.fail() which releases the processing slot.
99
+ *
100
+ * Also wakes any waiting sync caller with an aborted result so the HTTP
101
+ * response handler can clean up (though the disconnected client will not
102
+ * see it — that's fine).
103
+ *
104
+ * @param {number} busId
105
+ * @returns {boolean} true if an abort was delivered, false if message not in flight
106
+ */
107
+ export function abortMessage(busId) {
108
+ const controller = _processingControllers.get(busId);
109
+ if (controller) {
110
+ try { controller.abort(new Error("client-disconnect")); } catch {}
111
+ }
112
+ const waiter = _drainResolvers.get(busId);
113
+ if (waiter) {
114
+ _drainResolvers.delete(busId);
115
+ try { waiter.resolve({ error: "aborted" }); } catch {}
116
+ }
117
+ return !!controller;
118
+ }
119
+
120
+ /**
121
+ * Abort ALL in-flight messages. Used by `/new` to stop any API traffic before
122
+ * clearing the screen, so the TUI does not render leftover tokens on top of
123
+ * the fresh banner.
124
+ *
125
+ * @returns {number} number of messages aborted
126
+ */
127
+ export function abortAll() {
128
+ let count = 0;
129
+ for (const [busId, controller] of _processingControllers.entries()) {
130
+ try { controller.abort(new Error("session-reset")); count++; } catch {}
131
+ const waiter = _drainResolvers.get(busId);
132
+ if (waiter) {
133
+ _drainResolvers.delete(busId);
134
+ try { waiter.resolve({ error: "aborted" }); } catch {}
135
+ }
136
+ }
137
+ return count;
138
+ }
139
+
140
+ /**
141
+ * Process one message from the bus.
142
+ */
143
+ async function _processOne(msg) {
144
+ store.getState().incrementQueue();
145
+ // Only show message line for non-TUI channels (TUI already displayed it in submit handler)
146
+ // A message that waited above the input is read now: into the history.
147
+ const waited = store.getState().takeQueuedInput?.(msg.id);
148
+ if (waited) userMsgLine(waited.display);
149
+ if (msg.channel !== "user") {
150
+ // No blank line here: the previous turn already ends with one, and two
151
+ // stacked up before every API message (owner, 2026-10-01).
152
+ userMsgLine(msg.content, msg.source);
153
+ }
154
+
155
+ // User interrupt detection: if a non-self-continue message arrives while
156
+ // there's an active plan with pending tasks, the user is taking control.
157
+ // Set the interrupt flag so shouldContinue() returns stop instead of
158
+ // generating another auto-Continue message. Permanent fix for an issue
159
+ // first seen in v1.0.0 where the user typing "stop" was ignored because the
160
+ // drain loop kept auto-continuing.
161
+ const isSelfContinue = msg.source === "self-continue";
162
+ if (!isSelfContinue && (app.autonomous || msg.channel === "api")) {
163
+ // Cancel any pending auto-continue timer immediately
164
+ if (_continueTimer) {
165
+ clearTimeout(_continueTimer);
166
+ _continueTimer = null;
167
+ log.info("drain: cancelled pending auto-continue (user message arrived)");
168
+ }
169
+ if (msg.channel === "api") {
170
+ // An API message is a new task, not an interruption of one: it starts
171
+ // with fresh run-level state and is allowed to self-continue.
172
+ resetFlow();
173
+ } else if (msg.source !== "auto") {
174
+ // A person typing during an autonomous run takes control. The /auto
175
+ // start message (source "auto") is the run itself, not an interrupt.
176
+ setUserInterrupt(true);
177
+ }
178
+ }
179
+ // Reset no-progress guard history on any non-self-continue message — the
180
+ // user (or another channel) has taken control, so any prior stuck-loop
181
+ // detection is no longer relevant.
182
+ if (!isSelfContinue) {
183
+ _autonomousSignatureHistory.length = 0;
184
+ }
185
+
186
+ let result = null;
187
+ let error = null;
188
+ // Auto-approve tools for API, agent, and autonomous channels
189
+ const isRemote = msg.channel === "api" || msg.channel === "agent" || msg.channel === "autonomous";
190
+ if (isRemote && config.apiAutoApprove) {
191
+ bulkSetPermission("allow");
192
+ }
193
+ // Auto-approve does not cover everything: a hook can still force a prompt for
194
+ // a dangerous command or a secret file, and there is nobody here to answer it.
195
+ setUnattended(isRemote);
196
+ // Auto-enable supervisor for API messages (mid-task detection, verify hints)
197
+ if (isRemote) {
198
+ setSupervisorEnabled(true);
199
+ }
200
+ // Context isolation: clear stale summary for API messages
201
+ // Each API message is an independent task — previous task summary shouldn't leak
202
+ if (msg.channel === "api" && msg.source !== "self-continue") {
203
+ store.getState().setLastSummary("");
204
+ }
205
+ // Eager tool loading: wait for MCP tools before processing
206
+ // Prevents agent from making decisions with incomplete tool set
207
+ if (isRemote && !app.mcpReady) {
208
+ const maxWait = 15000; // 15s max
209
+ const start = Date.now();
210
+ while (!app.mcpReady && Date.now() - start < maxWait) {
211
+ await new Promise(r => setTimeout(r, 500));
212
+ }
213
+ if (app.mcpReady) {
214
+ log.info("mcp-ready: tools loaded before processing API message");
215
+ } else {
216
+ log.warn("mcp-not-ready: proceeding with incomplete tools after %dms", maxWait);
217
+ }
218
+ }
219
+ // RX-2 fix: create per-message AbortController, register in map so
220
+ // abortMessage(busId) can cancel this specific in-flight message when
221
+ // its sync client disconnects.
222
+ const msgAbortController = new AbortController();
223
+ _processingControllers.set(msg.id, msgAbortController);
224
+
225
+ try {
226
+ const sender = isRemote ? msg.source : null;
227
+ result = await processMessage(msg.content, sender, { signal: msgAbortController.signal });
228
+ await handlePendingAction();
229
+ bus.complete(msg.id, result?.text?.slice(0, 500) || "ok");
230
+ } catch (err) {
231
+ error = err;
232
+ if (err.name !== "AbortError" && !err.message?.includes("borted")) {
233
+ store.getState().addLine(chalk.red(`Error: ${err.message}`));
234
+ bus.fail(msg.id, err.message);
235
+ } else {
236
+ bus.fail(msg.id, "aborted");
237
+ }
238
+ } finally {
239
+ // RX-2 fix: always clean up the processing controller so stale entries
240
+ // do not leak into the map across multiple messages.
241
+ _processingControllers.delete(msg.id);
242
+ // Restore default permissions after remote message
243
+ if (isRemote && config.apiAutoApprove) {
244
+ bulkSetPermission(null); // clear global override
245
+ }
246
+ setUnattended(false);
247
+ store.getState().decrementQueue();
248
+ }
249
+ // No blank line after the turn: userMsgLine opens the next message with
250
+ // one, and two stacked up between turns (owner, 2026-10-01).
251
+
252
+ // Store result for async poll
253
+ const stopReason = result?.stop_reason || (error ? "error" : "done");
254
+ const resultData = error
255
+ ? { error: error.message, ts: Date.now() }
256
+ // toolCalls belong here too. The sync waiter below has always returned them,
257
+ // the async poll path never did, so anything that measures Flint through
258
+ // POST /message + GET /message/:id saw an empty tool list and scored every
259
+ // tool-backed probe as a miss. The readiness harness is exactly that.
260
+ : { response: result?.text || "", stats: result?.stats || {}, stop_reason: stopReason,
261
+ toolCalls: result?.toolCalls || [], ts: Date.now() };
262
+ _asyncResults.set(msg.id, resultData);
263
+
264
+ // Resolve waiting API callers (sync mode)
265
+ const waiter = _drainResolvers.get(msg.id);
266
+ if (waiter) {
267
+ _drainResolvers.delete(msg.id);
268
+ if (error) {
269
+ waiter.resolve({ error: error.message });
270
+ } else {
271
+ waiter.resolve({ response: result?.text || "", stats: result?.stats || {}, stop_reason: stopReason, toolCalls: result?.toolCalls || [] });
272
+ }
273
+ }
274
+
275
+ // Track API goal: remember which goal this API task is working on
276
+ if (msg.channel === "api" && msg.source !== "self-continue") {
277
+ app.apiGoalId = null; // new API task — clear previous
278
+ }
279
+ // After any message: if API-scoped and no goal tracked yet, check if one was just created
280
+ if (app.apiGoalId === null && (msg.channel === "api" || (msg.channel === "autonomous" && msg.source === "self-continue"))) {
281
+ const plan = getActivePlan(store);
282
+ if (plan?.goalId) {
283
+ app.apiGoalId = plan.goalId;
284
+ log.info("api-goal-tracked", { goalId: plan.goalId });
285
+ }
286
+ }
287
+
288
+ // Flow control: decide whether to continue
289
+ if ((app.autonomous || isRemote) && !error && stopReason !== "budget") {
290
+ // A provider refusal is not a flow decision: no prompt changes it, so it is
291
+ // settled before shouldContinue is asked anything. It used to fall straight
292
+ // through, because the only guard here was for "budget" and shouldContinue
293
+ // does not look at stopReason at all. A plan with pending tasks therefore
294
+ // meant "keep going", and a run against a dead account spent three more
295
+ // turns failing identically until the no-progress guard halted it and told
296
+ // the operator the agent was stuck and to use /continue. Wrong cause, and a
297
+ // suggestion that could not work. Measured before the fix: five turns, and
298
+ // the word "provider" appearing nowhere.
299
+ if (FATAL_PROVIDER_REASONS.has(stopReason)) {
300
+ _rateLimitedInARow = 0;
301
+ _autonomousSignatureHistory.length = 0;
302
+ app.apiGoalId = null;
303
+ log.warn("flow: provider refused, stopping", { stopReason });
304
+ store.getState().addLine(chalk.yellow(
305
+ ` [flow] Stopped: ${STOP_REASON_TEXT[stopReason] || stopReason} (${stopReason}).`
306
+ ));
307
+ if (result?.text) store.getState().addLine(chalk.hex("#555")(` ${result.text.slice(0, 300)}`));
308
+ return;
309
+ }
310
+
311
+ // A 429 is survivable, and the same decision the /auto loop makes:
312
+ // wait out the window the provider asked for and take the turn again. Only
313
+ // a cap that will not lift ends the run.
314
+ let continueDelayMs = CONTINUE_DELAY_MS;
315
+ if (stopReason === "rate-limit") {
316
+ _rateLimitedInARow++;
317
+ if (_rateLimitedInARow > MAX_CONSECUTIVE_RATE_LIMITS) {
318
+ _rateLimitedInARow = 0;
319
+ _autonomousSignatureHistory.length = 0;
320
+ app.apiGoalId = null;
321
+ log.warn("flow: rate limit did not lift, stopping", { stopReason });
322
+ store.getState().addLine(chalk.yellow(
323
+ ` [flow] Stopped: ${STOP_REASON_TEXT["rate-limit"]} (rate-limit).`
324
+ ));
325
+ return;
326
+ }
327
+ continueDelayMs = result?.retryAfter != null ? result.retryAfter * 1000 : RATE_LIMIT_WAIT_MS;
328
+ store.getState().addLine(chalk.yellow(
329
+ ` [flow] Rate limited by the provider. Waiting ${Math.round(continueDelayMs / 1000)}s, ` +
330
+ `then retrying (${_rateLimitedInARow} of ${MAX_CONSECUTIVE_RATE_LIMITS}).`
331
+ ));
332
+ } else {
333
+ _rateLimitedInARow = 0;
334
+ }
335
+
336
+ const plan = getActivePlan(store);
337
+ const decision = shouldContinue({
338
+ stopReason,
339
+ plan,
340
+ isApiScoped: !!app.apiGoalId,
341
+ apiGoalId: app.apiGoalId,
342
+ });
343
+
344
+ if (decision.action === "stop") {
345
+ if (decision.goalComplete) {
346
+ // Learning: extract patterns from completed task
347
+ const plan = getActivePlan(store);
348
+ if (isLearningOpportunity({ plan, toolCallCount: store.getState().messages?.length || 0 })) {
349
+ try {
350
+ extractLearnings({
351
+ messages: store.getState().messages || [],
352
+ task: msg.content || "",
353
+ plan,
354
+ outcome: "success",
355
+ });
356
+ } catch (e) { log.warn("learning-failed", { error: e.message }); }
357
+ }
358
+ app.apiGoalId = null;
359
+ }
360
+ log.info("flow: stop", { reason: stopReason });
361
+ } else if (decision.action === "continue") {
362
+ // No-progress guard: track signatures of recent autonomous iterations.
363
+ // Reset the history whenever a non-self-continue message is processed
364
+ // (handled at the top of this function via _autonomousSignatureHistory.length = 0
365
+ // when msg.source !== "self-continue").
366
+ // A turn the provider refused is not evidence that the agent is stuck:
367
+ // it did nothing because it was not served. Counting those signatures
368
+ // made the no-progress guard fire first and take the blame for a rate
369
+ // limit.
370
+ if (msg.source === "self-continue" && stopReason !== "rate-limit") {
371
+ const sig = _toolCallSignature(result?.toolCalls);
372
+ _autonomousSignatureHistory.push(sig);
373
+ while (_autonomousSignatureHistory.length > AUTONOMOUS_NO_PROGRESS_THRESHOLD) {
374
+ _autonomousSignatureHistory.shift();
375
+ }
376
+ const stuck = _autonomousSignatureHistory.length === AUTONOMOUS_NO_PROGRESS_THRESHOLD
377
+ && _autonomousSignatureHistory.every((s) => s === _autonomousSignatureHistory[0]);
378
+ if (stuck) {
379
+ log.warn("autonomous: halt on no-progress", { signature: sig, count: _autonomousSignatureHistory.length });
380
+ store.getState().addLine(chalk.yellow(
381
+ ` [flow] Halted: ${AUTONOMOUS_NO_PROGRESS_THRESHOLD} identical autonomous iterations detected (no progress). Use /continue to resume.`
382
+ ));
383
+ _autonomousSignatureHistory.length = 0;
384
+ app.apiGoalId = null;
385
+ return;
386
+ }
387
+ }
388
+
389
+ _continueTimer = setTimeout(() => {
390
+ _continueTimer = null;
391
+ if (app.queueAborted) return; // abort happened while waiting
392
+ bus.push({ channel: "autonomous", content: decision.prompt, priority: bus.PRIORITY?.TASK || 5, source: "self-continue" });
393
+ store.getState().addLine(chalk.hex("#555")(" [flow] Continuing..."));
394
+ notify();
395
+ }, continueDelayMs);
396
+ }
397
+ }
398
+ }
399
+
400
+ // _scheduleAutoContinue logic moved to flow-controller.js shouldContinue()
401
+ /** Reset autonomous state (call on new autonomous run) */
402
+ export function resetAutonomous() {
403
+ if (_continueTimer) {
404
+ clearTimeout(_continueTimer);
405
+ _continueTimer = null;
406
+ }
407
+ _rateLimitedInARow = 0;
408
+ _autonomousSignatureHistory.length = 0;
409
+ // A new run starts with no interrupt and fresh retry/completion caps.
410
+ resetFlow();
411
+ }
412
+
413
+ /**
414
+ * Get async result for a processed message.
415
+ * Returns full response+stats (not truncated like bus DB result).
416
+ * @param {number} busId
417
+ * @returns {{ response?: string, stats?: object, error?: string } | null}
418
+ */
419
+ export function getAsyncResult(busId) {
420
+ const r = _asyncResults.get(busId);
421
+ if (!r) return null;
422
+ // Cleanup on read (one-time fetch is enough)
423
+ _asyncResults.delete(busId);
424
+ return r;
425
+ }
426
+
427
+ /** Periodic cleanup of stale async results */
428
+ setInterval(() => {
429
+ const now = Date.now();
430
+ for (const [id, r] of _asyncResults) {
431
+ if (now - r.ts > ASYNC_RESULT_TTL) _asyncResults.delete(id);
432
+ }
433
+ }, 300000); // every 5 min
434
+
435
+ /**
436
+ * Drain loop tick — process all pending messages, then stop.
437
+ * Re-triggered by notify() when new messages arrive.
438
+ */
439
+ async function _tick() {
440
+ if (_running) return; // already draining
441
+ _running = true;
442
+
443
+ try {
444
+ let msg;
445
+ while ((msg = bus.drain())) {
446
+ if (app.queueAborted) {
447
+ bus.fail(msg.id, "flushed");
448
+ continue;
449
+ }
450
+ log.debug("processing", { id: msg.id, channel: msg.channel });
451
+ await _processOne(msg);
452
+ }
453
+ } catch (err) {
454
+ log.error("drain loop error", { error: err.message });
455
+ } finally {
456
+ _running = false;
457
+ }
458
+
459
+ // Check if new messages arrived while we were processing
460
+ const remaining = bus.stats().pending;
461
+ if (remaining > 0) {
462
+ setImmediate(() => _tick().catch(e => log.error("drain re-entry error", { error: e.message })));
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Flush: fail all pending messages and abort current processing.
468
+ */
469
+ export function flush() {
470
+ app.queueAborted = true;
471
+ // Discarded messages leave the queue shown above the input too.
472
+ store.getState().clearQueuedInputs?.();
473
+ // Cancel pending self-continue timer
474
+ if (_continueTimer) {
475
+ clearTimeout(_continueTimer);
476
+ _continueTimer = null;
477
+ }
478
+ // Fail all pending bus messages
479
+ let msg;
480
+ while ((msg = bus.drain())) {
481
+ bus.fail(msg.id, "flushed");
482
+ const waiter = _drainResolvers.get(msg.id);
483
+ if (waiter) {
484
+ _drainResolvers.delete(msg.id);
485
+ waiter.resolve({ error: "flushed" });
486
+ }
487
+ }
488
+ // Reset abort flag after current lock chain settles
489
+ setTimeout(() => { app.queueAborted = false; }, 0);
490
+ }
491
+
492
+ /**
493
+ * Check if drain loop is currently processing.
494
+ */
495
+ export function isProcessing() {
496
+ return _running;
497
+ }