mindweave 2.5.0 → 3.0.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 (255) hide show
  1. package/README.md +81 -145
  2. package/dist/cli/App.js +579 -304
  3. package/dist/cli/App.js.map +1 -1
  4. package/dist/cli/analytics.js +27 -18
  5. package/dist/cli/analytics.js.map +1 -1
  6. package/dist/cli/attachments.js +34 -0
  7. package/dist/cli/attachments.js.map +1 -1
  8. package/dist/cli/bannerFit.js +70 -0
  9. package/dist/cli/bannerFit.js.map +1 -0
  10. package/dist/cli/bootstrap.js +41 -3
  11. package/dist/cli/bootstrap.js.map +1 -1
  12. package/dist/cli/commands.js +4 -1
  13. package/dist/cli/commands.js.map +1 -1
  14. package/dist/cli/components/ApprovalBox.js +3 -2
  15. package/dist/cli/components/ApprovalBox.js.map +1 -1
  16. package/dist/cli/components/BlockView.js +27 -6
  17. package/dist/cli/components/BlockView.js.map +1 -1
  18. package/dist/cli/components/FirstRunFrame.js +2 -1
  19. package/dist/cli/components/FirstRunFrame.js.map +1 -1
  20. package/dist/cli/components/KeyManager.js +5 -4
  21. package/dist/cli/components/KeyManager.js.map +1 -1
  22. package/dist/cli/components/KeySetup.js +29 -13
  23. package/dist/cli/components/KeySetup.js.map +1 -1
  24. package/dist/cli/components/MarathonBox.js +64 -0
  25. package/dist/cli/components/MarathonBox.js.map +1 -0
  26. package/dist/cli/components/McpMinitabs.js +8 -7
  27. package/dist/cli/components/McpMinitabs.js.map +1 -1
  28. package/dist/cli/components/Picker.js +4 -3
  29. package/dist/cli/components/Picker.js.map +1 -1
  30. package/dist/cli/components/PromptInput.js +47 -8
  31. package/dist/cli/components/PromptInput.js.map +1 -1
  32. package/dist/cli/components/PulseDot.js +42 -0
  33. package/dist/cli/components/PulseDot.js.map +1 -0
  34. package/dist/cli/components/SubagentView.js +9 -6
  35. package/dist/cli/components/SubagentView.js.map +1 -1
  36. package/dist/cli/components/TipLine.js +2 -1
  37. package/dist/cli/components/TipLine.js.map +1 -1
  38. package/dist/cli/components/ToolGroup.js +65 -49
  39. package/dist/cli/components/ToolGroup.js.map +1 -1
  40. package/dist/cli/components/ToolLine.js +20 -19
  41. package/dist/cli/components/ToolLine.js.map +1 -1
  42. package/dist/cli/components/TrustGate.js +2 -1
  43. package/dist/cli/components/TrustGate.js.map +1 -1
  44. package/dist/cli/components/minitabs.js +3 -2
  45. package/dist/cli/components/minitabs.js.map +1 -1
  46. package/dist/cli/desktopApp.js +45 -0
  47. package/dist/cli/desktopApp.js.map +1 -0
  48. package/dist/cli/help.js +1 -0
  49. package/dist/cli/help.js.map +1 -1
  50. package/dist/cli/inkOutputPatch.js +285 -0
  51. package/dist/cli/inkOutputPatch.js.map +1 -0
  52. package/dist/cli/inkSpeed.js +29 -0
  53. package/dist/cli/inkSpeed.js.map +1 -0
  54. package/dist/cli/inkSpeedLoader.js +47 -0
  55. package/dist/cli/inkSpeedLoader.js.map +1 -0
  56. package/dist/cli/inputText.js +30 -0
  57. package/dist/cli/inputText.js.map +1 -0
  58. package/dist/cli/keyManager.js +2 -1
  59. package/dist/cli/keyManager.js.map +1 -1
  60. package/dist/cli/keyPrefs.js +90 -0
  61. package/dist/cli/keyPrefs.js.map +1 -0
  62. package/dist/cli/keySetup.js +5 -2
  63. package/dist/cli/keySetup.js.map +1 -1
  64. package/dist/cli/marathonUi.js +113 -0
  65. package/dist/cli/marathonUi.js.map +1 -0
  66. package/dist/cli/markdown.js +63 -11
  67. package/dist/cli/markdown.js.map +1 -1
  68. package/dist/cli/modes.js +3 -3
  69. package/dist/cli/modes.js.map +1 -1
  70. package/dist/cli/prodEnv.js +43 -0
  71. package/dist/cli/prodEnv.js.map +1 -0
  72. package/dist/cli/revealPace.js +5 -5
  73. package/dist/cli/revealPace.js.map +1 -1
  74. package/dist/cli/revealQueue.js +120 -0
  75. package/dist/cli/revealQueue.js.map +1 -0
  76. package/dist/cli/theme.js +47 -0
  77. package/dist/cli/theme.js.map +1 -0
  78. package/dist/cli/toolDisplay.js +112 -18
  79. package/dist/cli/toolDisplay.js.map +1 -1
  80. package/dist/cli/transcript.js +1 -1
  81. package/dist/cli/transcript.js.map +1 -1
  82. package/dist/cli/virtualWindow.js +14 -2
  83. package/dist/cli/virtualWindow.js.map +1 -1
  84. package/dist/cli/wheelAccel.js +73 -0
  85. package/dist/cli/wheelAccel.js.map +1 -0
  86. package/dist/cli/widestLinePatch.js +48 -0
  87. package/dist/cli/widestLinePatch.js.map +1 -0
  88. package/dist/cli/workingVerb.js +88 -28
  89. package/dist/cli/workingVerb.js.map +1 -1
  90. package/dist/cli/wrap.js +1 -1
  91. package/dist/cli/wrap.js.map +1 -1
  92. package/dist/core/contextSettings.js +84 -0
  93. package/dist/core/contextSettings.js.map +1 -0
  94. package/dist/core/limitsView.js +40 -0
  95. package/dist/core/limitsView.js.map +1 -0
  96. package/dist/core/mcpServers.js +164 -0
  97. package/dist/core/mcpServers.js.map +1 -0
  98. package/dist/core/permissions.js +209 -0
  99. package/dist/core/permissions.js.map +1 -0
  100. package/dist/core/providerKeys.js +187 -0
  101. package/dist/core/providerKeys.js.map +1 -0
  102. package/dist/core/rulesSkills.js +142 -0
  103. package/dist/core/rulesSkills.js.map +1 -0
  104. package/dist/core/spendView.js +90 -0
  105. package/dist/core/spendView.js.map +1 -0
  106. package/dist/core/turnRunner.js +759 -0
  107. package/dist/core/turnRunner.js.map +1 -0
  108. package/dist/drivers/anthropic/client.js +101 -26
  109. package/dist/drivers/anthropic/client.js.map +1 -1
  110. package/dist/drivers/anthropic/manifest.js +43 -13
  111. package/dist/drivers/anthropic/manifest.js.map +1 -1
  112. package/dist/drivers/cerebras/manifest.js +13 -3
  113. package/dist/drivers/cerebras/manifest.js.map +1 -1
  114. package/dist/drivers/deepseek/manifest.js +24 -34
  115. package/dist/drivers/deepseek/manifest.js.map +1 -1
  116. package/dist/drivers/glm/manifest.js +21 -6
  117. package/dist/drivers/glm/manifest.js.map +1 -1
  118. package/dist/drivers/groq/manifest.js +5 -2
  119. package/dist/drivers/groq/manifest.js.map +1 -1
  120. package/dist/drivers/kimi/manifest.js +13 -10
  121. package/dist/drivers/kimi/manifest.js.map +1 -1
  122. package/dist/drivers/meta/manifest.js +21 -16
  123. package/dist/drivers/meta/manifest.js.map +1 -1
  124. package/dist/drivers/minimax/manifest.js +15 -12
  125. package/dist/drivers/minimax/manifest.js.map +1 -1
  126. package/dist/drivers/ollama/catalog.js +71 -0
  127. package/dist/drivers/ollama/catalog.js.map +1 -0
  128. package/dist/drivers/ollama/client.js +164 -0
  129. package/dist/drivers/ollama/client.js.map +1 -0
  130. package/dist/drivers/ollama/endpoint.js +42 -0
  131. package/dist/drivers/ollama/endpoint.js.map +1 -0
  132. package/dist/drivers/ollama/index.js +5 -0
  133. package/dist/drivers/ollama/index.js.map +1 -0
  134. package/dist/drivers/ollama/manifest.js +64 -0
  135. package/dist/drivers/ollama/manifest.js.map +1 -0
  136. package/dist/drivers/openai/client.js +10 -4
  137. package/dist/drivers/openai/client.js.map +1 -1
  138. package/dist/drivers/openai/manifest.js +69 -18
  139. package/dist/drivers/openai/manifest.js.map +1 -1
  140. package/dist/drivers/qwen/manifest.js +19 -6
  141. package/dist/drivers/qwen/manifest.js.map +1 -1
  142. package/dist/drivers/registry.js +11 -0
  143. package/dist/drivers/registry.js.map +1 -1
  144. package/dist/drivers/retryPolicy.js +26 -1
  145. package/dist/drivers/retryPolicy.js.map +1 -1
  146. package/dist/drivers/xai/client.js +12 -10
  147. package/dist/drivers/xai/client.js.map +1 -1
  148. package/dist/drivers/xai/manifest.js +62 -25
  149. package/dist/drivers/xai/manifest.js.map +1 -1
  150. package/dist/dynamo/auxModel.js +54 -0
  151. package/dist/dynamo/auxModel.js.map +1 -0
  152. package/dist/dynamo/engine.js +407 -99
  153. package/dist/dynamo/engine.js.map +1 -1
  154. package/dist/dynamo/guard.js +10 -0
  155. package/dist/dynamo/guard.js.map +1 -1
  156. package/dist/dynamo/liveMeter.js +25 -6
  157. package/dist/dynamo/liveMeter.js.map +1 -1
  158. package/dist/dynamo/marathon.js +409 -0
  159. package/dist/dynamo/marathon.js.map +1 -0
  160. package/dist/dynamo/marathonPrompt.js +45 -0
  161. package/dist/dynamo/marathonPrompt.js.map +1 -0
  162. package/dist/dynamo/model.js +6 -2
  163. package/dist/dynamo/model.js.map +1 -1
  164. package/dist/dynamo/toolBatches.js +58 -0
  165. package/dist/dynamo/toolBatches.js.map +1 -0
  166. package/dist/dynamo/usageLimits.js +446 -0
  167. package/dist/dynamo/usageLimits.js.map +1 -0
  168. package/dist/governor/freshness.js +10 -7
  169. package/dist/governor/freshness.js.map +1 -1
  170. package/dist/governor/index.js +76 -9
  171. package/dist/governor/index.js.map +1 -1
  172. package/dist/governor/write.js +43 -14
  173. package/dist/governor/write.js.map +1 -1
  174. package/dist/index.js +18 -151
  175. package/dist/index.js.map +1 -1
  176. package/dist/main.js +162 -0
  177. package/dist/main.js.map +1 -0
  178. package/dist/mcp/manager.js +18 -0
  179. package/dist/mcp/manager.js.map +1 -1
  180. package/dist/memory/allMetas.js +69 -0
  181. package/dist/memory/allMetas.js.map +1 -0
  182. package/dist/memory/compaction.js +70 -15
  183. package/dist/memory/compaction.js.map +1 -1
  184. package/dist/memory/earlier.js +15 -0
  185. package/dist/memory/earlier.js.map +1 -0
  186. package/dist/memory/profile.js +93 -0
  187. package/dist/memory/profile.js.map +1 -0
  188. package/dist/memory/rewind.js +262 -0
  189. package/dist/memory/rewind.js.map +1 -0
  190. package/dist/memory/session.js +42 -10
  191. package/dist/memory/session.js.map +1 -1
  192. package/dist/memory/sessionMemory.js +48 -13
  193. package/dist/memory/sessionMemory.js.map +1 -1
  194. package/dist/memory/sessionMemoryCompact.js +1 -1
  195. package/dist/memory/sessionMemoryCompact.js.map +1 -1
  196. package/dist/memory/sessionTitle.js +50 -0
  197. package/dist/memory/sessionTitle.js.map +1 -0
  198. package/dist/memory/store.js +42 -1
  199. package/dist/memory/store.js.map +1 -1
  200. package/dist/memory/types.js.map +1 -1
  201. package/dist/tools/askUser.js +14 -0
  202. package/dist/tools/askUser.js.map +1 -1
  203. package/dist/tools/backgroundShells.js +208 -5
  204. package/dist/tools/backgroundShells.js.map +1 -1
  205. package/dist/tools/browser.js +176 -0
  206. package/dist/tools/browser.js.map +1 -0
  207. package/dist/tools/cdp.js +165 -0
  208. package/dist/tools/cdp.js.map +1 -0
  209. package/dist/tools/checkpoints.js +208 -5
  210. package/dist/tools/checkpoints.js.map +1 -1
  211. package/dist/tools/deferredNative.js +2 -0
  212. package/dist/tools/deferredNative.js.map +1 -1
  213. package/dist/tools/detail.js +9 -6
  214. package/dist/tools/detail.js.map +1 -1
  215. package/dist/tools/edit.js +2 -1
  216. package/dist/tools/edit.js.map +1 -1
  217. package/dist/tools/exitPlan.js +4 -0
  218. package/dist/tools/exitPlan.js.map +1 -1
  219. package/dist/tools/hiddenDesktop.js +273 -0
  220. package/dist/tools/hiddenDesktop.js.map +1 -0
  221. package/dist/tools/listeningPorts.js +134 -0
  222. package/dist/tools/listeningPorts.js.map +1 -0
  223. package/dist/tools/mcpSearch.js +8 -0
  224. package/dist/tools/mcpSearch.js.map +1 -1
  225. package/dist/tools/mindweaveStatus.js +1 -1
  226. package/dist/tools/mindweaveStatus.js.map +1 -1
  227. package/dist/tools/registry.js +3 -0
  228. package/dist/tools/registry.js.map +1 -1
  229. package/dist/tools/replaceSymbol.js +2 -1
  230. package/dist/tools/replaceSymbol.js.map +1 -1
  231. package/dist/tools/runCommand.js +113 -9
  232. package/dist/tools/runCommand.js.map +1 -1
  233. package/dist/tools/screenshot.js +4 -6
  234. package/dist/tools/screenshot.js.map +1 -1
  235. package/dist/tools/screenshotWin.js +26 -9
  236. package/dist/tools/screenshotWin.js.map +1 -1
  237. package/dist/tools/stateCheckpoint.js +128 -0
  238. package/dist/tools/stateCheckpoint.js.map +1 -0
  239. package/dist/tools/todo.js +6 -0
  240. package/dist/tools/todo.js.map +1 -1
  241. package/dist/tools/ui.js +1295 -0
  242. package/dist/tools/ui.js.map +1 -0
  243. package/dist/tools/uiHide.js +356 -0
  244. package/dist/tools/uiHide.js.map +1 -0
  245. package/dist/tools/uiPage.js +861 -0
  246. package/dist/tools/uiPage.js.map +1 -0
  247. package/dist/tools/uiWin.js +506 -0
  248. package/dist/tools/uiWin.js.map +1 -0
  249. package/dist/tools/webFetch.js +32 -25
  250. package/dist/tools/webFetch.js.map +1 -1
  251. package/dist/tools/webSearch.js +12 -0
  252. package/dist/tools/webSearch.js.map +1 -1
  253. package/dist/tools/writeFile.js +2 -1
  254. package/dist/tools/writeFile.js.map +1 -1
  255. package/package.json +78 -75
@@ -13,8 +13,11 @@
13
13
  * the read-only tool exactly like any other tool call — so this whole function
14
14
  * can later move to a server unchanged, with tools executing on the client.
15
15
  */
16
+ import { readProfile, profilePrompt } from "../memory/profile.js";
17
+ import { narrationShown, noteReads } from "../cli/toolDisplay.js";
16
18
  import { activeDriver, ensureDriver, manifestForModel } from "../drivers/registry.js";
17
19
  import { summarizeTask, taskLimitReason } from "./pricing.js";
20
+ import { limitGateReason, noteUsage, refreshUsageLimits, takeLimitWarnings } from "./usageLimits.js";
18
21
  import { addTurn, emptySpend } from "./spend.js";
19
22
  import { mutationNeedsVerification, isVerification, reScopeCheck, isBackgroundPollStep, stepFailureSignature, repeatFailureStep, repeatFailureNudge, failedActionLabel, firstErrorLine, sameFileEditCounts, overusedSingleEdits, batchEditNudge, narrationFault, narrationNudge, unknownToolError, replyFault, replyRewrite, VERIFY_NUDGE } from "./verify.js";
20
23
  import { guardOptions, GUARD_REFUSAL, GUARD_REFUSAL_INPUT, guardRefusalWith, guardQuestion, guardDetail, interpretGuardChoice } from "./guard.js";
@@ -23,19 +26,24 @@ import { findTool, toolSchemas, TOOLS } from "../tools/registry.js";
23
26
  import { deferredToolsIndex } from "../tools/deferredNative.js";
24
27
  import { prefixPrint, diffPrefix, cacheCallLine, writeCacheLog } from "./cacheBreak.js";
25
28
  import { commandShellLabel } from "../tools/runCommand.js";
29
+ import { uiLiveState } from "../tools/ui.js";
30
+ import { isAgenticOnlyRefusal, withAuxModel } from "./auxModel.js";
31
+ import { marathonBlock } from "./marathonPrompt.js";
26
32
  import { basePrompt } from "./prompt.js";
27
33
  import { basename } from "node:path";
28
34
  import { randomUUID } from "node:crypto";
29
35
  import { promises as fsp } from "node:fs";
30
36
  import { relativize, resolvePath, rootLabel, rootsOf } from "../tools/paths.js";
31
37
  import { renderRules, renderSkillCatalog, reloadGovernance, governanceStamp, rescope } from "../governor/index.js";
32
- import { forkSession, reloadProjectMemory } from "../memory/session.js";
38
+ import { writesAgentState, snapshotAgentState, recordAgentStateChanges } from "../tools/stateCheckpoint.js";
39
+ import { forkSession, reconcileInterruptedTools, reloadProjectMemory, repairToolCallOrder } from "../memory/session.js";
33
40
  import { selectActiveFiles } from "../memory/workingSet.js";
34
41
  import { directoryNotesFor } from "../memory/projectNotes.js";
35
42
  import { rippleCheck } from "../tools/editRipple.js";
36
43
  import { fullReadPaths } from "../memory/presence.js";
37
44
  import { RESTORE_MAX_TOKENS_PER_FILE, renderRestored, restoreBudgetFor, selectForRestore, } from "../memory/restore.js";
38
- import { KEEP_LAST_N, KEEP_LAST_N_BOUNDARY, summaryRequest, SUMMARY_SYSTEM_PROMPT, dropOldestRounds, estimateEntriesTokens, estimateTokens, estimateTokensForChars, formatTranscriptForSummary, isContinuation, microcompact, spliceSummary, usableSummary, } from "../memory/compaction.js";
45
+ import { KEEP_LAST_N, KEEP_LAST_N_BOUNDARY, summaryRequest, SUMMARY_SYSTEM_PROMPT, dropOldestRounds, estimateEntriesTokens, estimateTokens, estimateTokensForChars, formatTranscriptForSummary, isContinuation, microcompact, spliceSummary, summaryForWire, summaryIsHistory, usableSummary, } from "../memory/compaction.js";
46
+ import { MAX_PARALLEL_CALLS, partitionCalls, runLimited } from "./toolBatches.js";
39
47
  import { loadPlanArtifact, completePlanArtifact, renderPlanBlock, planDivergenceStop } from "./planArtifact.js";
40
48
  import { autoCompactThreshold, microCompactThreshold, cacheLikelyCold, clearIsWorthIt, measuredOverhead, sharpContextWindow, } from "./contextWindow.js";
41
49
  import { renderSessionMemory, shouldUpdateSessionMemory, updateSessionMemory } from "../memory/sessionMemory.js";
@@ -43,6 +51,7 @@ import { compactFromSessionMemory } from "../memory/sessionMemoryCompact.js";
43
51
  import { isContextOverflowError } from "../drivers/contextOverflow.js";
44
52
  import { detailOf, providerMessage } from "../drivers/providerError.js";
45
53
  import { transcriptPath } from "../memory/store.js";
54
+ import { replaceTranscript } from "../memory/earlier.js";
46
55
  /** Stop retrying autocompact after this many consecutive failures in a session, so a
47
56
  * transcript that's irrecoverably over the limit can't hammer the summarizer each turn
48
57
  * (a circuit-breaker for runaway retry loops, which can otherwise pile up thousands of doomed retries). */
@@ -55,14 +64,19 @@ const MAX_COMPACT_FAILURES = 3;
55
64
  // line we hold is the thin-prompt boundary: rich on what the harness owns, but
56
65
  // we still do NOT teach engineering judgment (how to debug, how to write code) —
57
66
  // that is the model's job.
58
- export function staticSystemPrompt(projectContext, projectMemory, memoryDir, memoryIndex, governance, workspace, priorSessions = 0) {
67
+ export function staticSystemPrompt(projectContext, projectMemory, memoryDir, memoryIndex, governance, workspace, priorSessions = 0, aboutUser = "") {
59
68
  let prompt = basePrompt(commandShellLabel());
69
+ // The user's own profile (memory/profile.ts): name, experience level, reply style.
70
+ if (aboutUser)
71
+ prompt += `
72
+
73
+ ${aboutUser}`;
60
74
  if (workspace) {
61
- prompt += `
62
-
63
- This session spans more than one root folder. Each file is addressed as \`label/path\`; search tools cover every root unless you pass a specific \`path\`. The roots are:
64
- <workspace>
65
- ${workspace}
75
+ prompt += `
76
+
77
+ This session spans more than one root folder. Each file is addressed as \`label/path\`; search tools cover every root unless you pass a specific \`path\`. The roots are:
78
+ <workspace>
79
+ ${workspace}
66
80
  </workspace>`;
67
81
  }
68
82
  // NOTE: the user's standing rules are deliberately NOT rendered here. They live
@@ -73,41 +87,41 @@ ${workspace}
73
87
  // skills are a reference catalog), so they alone get the salience boost. Keeping
74
88
  // them out of the prefix also stops a mid-session `remember_rule` from busting it.
75
89
  if (governance.forbidden) {
76
- prompt += `
77
-
78
- You are FORBIDDEN from modifying these paths — never write, edit, or run a command that changes them. The tools also enforce this and will refuse, but do not even try:
79
- <forbidden>
80
- ${governance.forbidden}
90
+ prompt += `
91
+
92
+ You are FORBIDDEN from modifying these paths — never write, edit, or run a command that changes them. The tools also enforce this and will refuse, but do not even try:
93
+ <forbidden>
94
+ ${governance.forbidden}
81
95
  </forbidden>`;
82
96
  }
83
97
  if (governance.forbiddenCommands) {
84
- prompt += `
85
-
86
- You are FORBIDDEN from running these commands (or any command that contains one) — run_command will refuse them and only the user can lift that. Do not attempt them or a workaround:
87
- <forbidden_commands>
88
- ${governance.forbiddenCommands}
98
+ prompt += `
99
+
100
+ You are FORBIDDEN from running these commands (or any command that contains one) — run_command will refuse them and only the user can lift that. Do not attempt them or a workaround:
101
+ <forbidden_commands>
102
+ ${governance.forbiddenCommands}
89
103
  </forbidden_commands>`;
90
104
  }
91
105
  if (governance.skills) {
92
- prompt += `
93
-
94
- You have project skills available — named procedures you can run. To run one, call use_skill with its name; its full steps are loaded then (you only see the summary here). Use one when its description fits the task:
95
- <available_skills>
96
- ${governance.skills}
106
+ prompt += `
107
+
108
+ You have project skills available — named procedures you can run. To run one, call use_skill with its name; its full steps are loaded then (you only see the summary here). Use one when its description fits the task:
109
+ <available_skills>
110
+ ${governance.skills}
97
111
  </available_skills>`;
98
112
  }
99
113
  if (projectContext) {
100
- prompt += `
101
-
102
- The following describes the project and machine you're working in, captured at the start of this session (a snapshot — use tools for anything current or deeper):
114
+ prompt += `
115
+
116
+ The following describes the project and machine you're working in, captured at the start of this session (a snapshot — use tools for anything current or deeper):
103
117
  ${projectContext}`;
104
118
  }
105
119
  if (projectMemory) {
106
- prompt += `
107
-
108
- The project provides this context in its MINDWEAVE.md — treat it as background facts about this codebase:
109
- <project_memory>
110
- ${projectMemory}
120
+ prompt += `
121
+
122
+ The project provides this context in its MINDWEAVE.md — treat it as background facts about this codebase:
123
+ <project_memory>
124
+ ${projectMemory}
111
125
  </project_memory>`;
112
126
  }
113
127
  // Its own past work in this project. The COUNT goes in the prompt (so the model
@@ -117,16 +131,16 @@ ${projectMemory}
117
131
  // and a question about past work gets a real answer instead of a deflection.
118
132
  if (priorSessions > 0) {
119
133
  const s = priorSessions === 1 ? "" : "s";
120
- prompt += `
121
-
134
+ prompt += `
135
+
122
136
  You have worked in this project before: ${priorSessions} earlier session${s} of yours are saved, and you can read them. When the user refers to earlier work — "last session", "what did we do", "the bug we fixed" — call \`sessions\` to list them, then \`sessions\` again with an id to read the one they mean, and answer from what you find. It is not in your tool list until you load it with find_tools. Do not say you cannot see your past sessions, and do not guess from the project files instead. \`/continue\` is for the user to RESUME a session; it is not a substitute for you looking. Never present another tool's saved conversations as your own.`;
123
137
  }
124
138
  if (memoryDir) {
125
- prompt += `
126
-
127
- Your cross-session memory for this project lives in \`${memoryDir}\` (read or grep the topic files there for the full text of any entry). Its index:
128
- <memory_index>
129
- ${memoryIndex || "(empty — nothing has been saved to memory yet)"}
139
+ prompt += `
140
+
141
+ Your cross-session memory for this project lives in \`${memoryDir}\` (read or grep the topic files there for the full text of any entry). Its index:
142
+ <memory_index>
143
+ ${memoryIndex || "(empty — nothing has been saved to memory yet)"}
130
144
  </memory_index>`;
131
145
  }
132
146
  // The deferred pool's index. Roughly forty tokens standing in for several hundred of
@@ -134,8 +148,8 @@ ${memoryIndex || "(empty — nothing has been saved to memory yet)"}
134
148
  // missing feature, and the model routes around a capability it actually has.
135
149
  const deferred = deferredToolsIndex();
136
150
  if (deferred) {
137
- prompt += `
138
-
151
+ prompt += `
152
+
139
153
  ${deferred}`;
140
154
  }
141
155
  return prompt;
@@ -158,7 +172,9 @@ export function volatileContext(rules, planMode, sessionMemory, approvedPlan = "
158
172
  /** Notes for the folders being worked in right now (see memory/projectNotes.ts). */
159
173
  directoryNotes = [],
160
174
  /** Where the previous turn left the shell, when this turn started back at the root. */
161
- cwdResetFrom = "") {
175
+ cwdResetFrom = "",
176
+ /** A running Marathon's standing block (see marathonPrompt.ts), or "". */
177
+ marathon = "") {
162
178
  const parts = [];
163
179
  // Each turn starts at the project root, and a model that `cd`-ed into a subfolder last
164
180
  // turn does not know that unless it is told. It was not, and a real session ran
@@ -195,6 +211,10 @@ cwdResetFrom = "") {
195
211
  "you following that plan. Do not write the plan out as an ordinary reply and stop — prose between steps is " +
196
212
  "shortened before the user sees it, so a plan presented that way reaches them in pieces.");
197
213
  }
214
+ // A running Marathon is standing instruction like the approved plan: rendered fresh
215
+ // every request so compaction cannot lose what the run is for.
216
+ if (marathon)
217
+ parts.push(marathon);
198
218
  // The maintained session state — first in the volatile tail so the model reads
199
219
  // "here's where we are" before the map/task list. Survives compaction.
200
220
  const memBlock = renderSessionMemory(sessionMemory);
@@ -451,6 +471,12 @@ function workspaceText(session) {
451
471
  function modelSeesImages(session) {
452
472
  return manifestForModel(session.modelConfig.model).acceptsImages?.(session.modelConfig.model) ?? false;
453
473
  }
474
+ /** "JPG and PNG" from `["image/png", "image/jpeg"]` — the names a user knows the files by. */
475
+ export function imageTypeNames(types) {
476
+ const names = types.map((t) => ({ "image/jpeg": "JPG", "image/png": "PNG", "image/gif": "GIF", "image/webp": "WebP" })[t] ?? t);
477
+ const sorted = [...new Set(names)].sort();
478
+ return sorted.length <= 1 ? (sorted[0] ?? "") : `${sorted.slice(0, -1).join(", ")} and ${sorted[sorted.length - 1]}`;
479
+ }
454
480
  async function loadImagePayloads(session) {
455
481
  // Nothing to load for a model that cannot look at one. This is the /provider switch
456
482
  // case: a picture attached while a vision model was running stays in the transcript,
@@ -476,12 +502,19 @@ async function loadImagePayloads(session) {
476
502
  }));
477
503
  return out;
478
504
  }
505
+ /** What the wire carries in place of an assistant reply that had no words (see buildRequest). */
506
+ export const EMPTY_REPLY_PLACEHOLDER = "(no reply)";
479
507
  function buildRequest(session, tools, imagePayloads = new Map(),
480
508
  /** Notes for the folders in play, resolved by the caller (it has to read disk). */
481
509
  directoryNotes = []) {
482
510
  const canSeeImages = modelSeesImages(session);
511
+ // The formats it takes, when narrower than everything core attaches. A manifest fact,
512
+ // like vision itself; absent means every type.
513
+ const imageTypes = canSeeImages
514
+ ? manifestForModel(session.modelConfig.model).imageTypes?.(session.modelConfig.model)
515
+ : undefined;
483
516
  const messages = [];
484
- for (const e of session.transcript) {
517
+ for (const [i, e] of session.transcript.entries()) {
485
518
  if (e.role === "user" || e.role === "summary") {
486
519
  // Attached images ride with the message, but only while their payload is still
487
520
  // live: microcompaction drops the refs once the turn is old, and a file deleted
@@ -491,11 +524,16 @@ directoryNotes = []) {
491
524
  // A message that arrived mid-turn is framed HERE rather than being stored framed,
492
525
  // so the transcript keeps what the person typed and only the wire carries the
493
526
  // explanation. Deterministic from the entry, so the cached prefix is unaffected.
494
- const said = e.role === "user" && e.arrival ? arrivalNote(e.arrival, e.content) : e.content;
527
+ // A summary is sent as background once the user has written after it, so its
528
+ // hand-off "next step" cannot keep overriding what they actually asked.
529
+ const said = e.role === "summary"
530
+ ? summaryForWire(e.content, summaryIsHistory(e, session.transcript.slice(i + 1)))
531
+ : e.arrival ? arrivalNote(e.arrival, e.content) : e.content;
495
532
  if (refs && refs.length > 0) {
496
533
  const images = [];
497
534
  const missing = [];
498
535
  const unseen = [];
536
+ const wrongType = [];
499
537
  for (const ref of refs) {
500
538
  // Told, never silently dropped. A message that mentions a screenshot and
501
539
  // carries nothing reads to the model as a picture it failed to notice; the
@@ -504,6 +542,12 @@ directoryNotes = []) {
504
542
  unseen.push(basename(ref.path));
505
543
  continue;
506
544
  }
545
+ // A format the provider would reject: held back the same way, so the model
546
+ // can tell the user which formats work instead of the whole request failing.
547
+ if (imageTypes && !imageTypes.includes(ref.mediaType)) {
548
+ wrongType.push(basename(ref.path));
549
+ continue;
550
+ }
507
551
  const data = imagePayloads.get(ref.path);
508
552
  if (data)
509
553
  images.push({ path: ref.path, mediaType: ref.mediaType, data });
@@ -514,10 +558,17 @@ directoryNotes = []) {
514
558
  ...(unseen.length > 0
515
559
  ? [`${unseen.join(", ")} was attached, but the model now running cannot see images`]
516
560
  : []),
561
+ ...(wrongType.length > 0 && imageTypes
562
+ ? [
563
+ `${wrongType.join(", ")} was attached but not sent: the model now running ` +
564
+ `accepts only ${imageTypeNames(imageTypes)} images, so tell the user to ` +
565
+ `convert it or switch models`,
566
+ ]
567
+ : []),
517
568
  ...(missing.length > 0 ? [`${missing.join(", ")} could not be read from disk`] : []),
518
569
  ];
519
- const content = notes.length > 0 ? `${said}
520
-
570
+ const content = notes.length > 0 ? `${said}
571
+
521
572
  [${notes.join("; ")}]` : said;
522
573
  messages.push({ role: "user", content, ...(images.length > 0 ? { images } : {}) });
523
574
  continue;
@@ -525,10 +576,15 @@ directoryNotes = []) {
525
576
  messages.push({ role: "user", content: said });
526
577
  }
527
578
  else if (e.role === "assistant") {
579
+ const calls = e.toolCalls && e.toolCalls.length > 0;
528
580
  messages.push({
529
581
  role: "assistant",
530
- content: e.content,
531
- ...(e.toolCalls && e.toolCalls.length > 0 ? { tool_calls: toWire(e.toolCalls) } : {}),
582
+ // A reply with no words and no tool calls (an empty completion, a filtered one) is
583
+ // stored as it came, but several providers reject an empty assistant message and it
584
+ // would be sent on every later request. A placeholder goes on the wire instead; a
585
+ // message that carries tool calls may have no words and is left alone.
586
+ content: calls || e.content.trim() !== "" ? e.content : EMPTY_REPLY_PLACEHOLDER,
587
+ ...(calls ? { tool_calls: toWire(e.toolCalls) } : {}),
532
588
  });
533
589
  }
534
590
  else {
@@ -542,7 +598,7 @@ directoryNotes = []) {
542
598
  // END of the system prompt. The end, because everything before it is the prefix every
543
599
  // session shares and caches against; appending leaves that untouched.
544
600
  const agentPrompt = session.toolContext.agentPrompt;
545
- const base = staticSystemPrompt(session.projectContext, session.projectMemory, session.memoryDir, session.memoryIndex, gov, workspaceText(session), session.priorSessions);
601
+ const base = staticSystemPrompt(session.projectContext, session.projectMemory, session.memoryDir, session.memoryIndex, gov, workspaceText(session), session.priorSessions, profilePrompt(readProfile()));
546
602
  return {
547
603
  system: agentPrompt ? `${base}\n\n${agentPrompt}` : base,
548
604
  messages,
@@ -557,6 +613,11 @@ directoryNotes = []) {
557
613
  // the command's own result says where it is now.
558
614
  session.toolContext.cwdResetFrom && session.toolContext.cwd === session.cwd
559
615
  ? relativize(session.toolContext, session.toolContext.cwdResetFrom)
616
+ : "",
617
+ // Lead agent only: a fork inherits `marathon` by reference, and a verifier told to
618
+ // "keep working toward the goal" would stop being an independent check.
619
+ session.marathon?.status === "running" && (session.toolContext.subagentDepth ?? 0) === 0
620
+ ? marathonBlock(session.marathon.goal, session.marathon.turnsSpent === 0 ? "plan" : "run")
560
621
  : ""),
561
622
  tools,
562
623
  model: session.modelConfig,
@@ -582,14 +643,27 @@ async function backgroundEventNotes(session) {
582
643
  * app", which blamed the user for the agent's own restart and gave the model a second,
583
644
  * contradictory account of the same event.
584
645
  */
585
- export function backgroundEventNote({ info, kind, tail, wake, }) {
646
+ export function backgroundEventNote({ info, kind, tail, wake, ports, }) {
586
647
  // It came up. This is the only positive event a server ever produces, and it is
587
648
  // what lets the model actually deliver the "I'll tell you when it's running" it
588
649
  // was told to say. Nothing has gone wrong, so there is nothing to fix.
650
+ // Something it started opened a port after it was already running: typically the app
651
+ // a build was producing has opened (its window, its debugging port), which is what an
652
+ // agent that ended its turn to "wait for the app" was waiting for.
653
+ if (kind === "opened") {
654
+ const list = (ports ?? []).join(", ");
655
+ return (`[Background shell #${info.id} (\`${info.command}\`) opened port ${list}.]\n` +
656
+ `Recent output:\n${tail || "(no output)"}\n\n` +
657
+ `Something it started is now listening there: usually the app or server you were waiting for has ` +
658
+ `opened. If you were waiting for it, carry on now (for an app's debugging port, call ui with that ` +
659
+ `port). If you were not, there is nothing to do. Nothing is wrong: do not restart it.`);
660
+ }
589
661
  if (kind === "ready") {
590
- return (`[Background shell #${info.id} (\`${info.command}\`) is up and running.]\n` +
662
+ const on = info.listening?.length ? ` (listening on port ${info.listening.join(", ")})` : "";
663
+ return (`[Background shell #${info.id} (\`${info.command}\`) is up and running${on}.]\n` +
591
664
  `Recent output:\n${tail || "(no output)"}\n\n` +
592
- `Tell the user in one short line that it's running. Nothing is wrong — do not investigate, ` +
665
+ `Tell the user in one short line that it's running, and do not repeat anything you already said ` +
666
+ `about it. Nothing is wrong — do not investigate, ` +
593
667
  `do not restart it, and do not change any files because of this. Running only means the ` +
594
668
  `process started: do not describe what it shows or say a change is visible unless you ` +
595
669
  `have actually looked.`);
@@ -677,7 +751,7 @@ export function callIsConcurrencySafe(tool, args) {
677
751
  * an unreadable rules directory must not take the turn down with it — on any failure the
678
752
  * session simply keeps the governance it already had.
679
753
  */
680
- async function refreshGovernance(session, force = false) {
754
+ export async function refreshGovernance(session, force = false) {
681
755
  try {
682
756
  const stamp = await governanceStamp(session.toolContext.cwd);
683
757
  // Skipping when the stamp is unchanged is what keeps our OWN writes from causing a
@@ -738,13 +812,13 @@ export async function respond(session, options = {}) {
738
812
  function implementFromScratch(priorPath, plan) {
739
813
  const path = priorPath;
740
814
  const where = path
741
- ? `
742
-
815
+ ? `
816
+
743
817
  If you need something exact from the planning that produced this — a snippet, an ` +
744
818
  `error message, a path — the full conversation is at: ${path}`
745
819
  : "";
746
- return `Implement the following plan:
747
-
820
+ return `Implement the following plan:
821
+
748
822
  ${plan}${where}`;
749
823
  }
750
824
  /**
@@ -781,11 +855,23 @@ async function respondTurn(session, options = {}) {
781
855
  // every subsequent turn, and it keeps `activeDriver()` safe to call synchronously
782
856
  // from here down (including from inside a tool).
783
857
  await ensureDriver(session.modelConfig.model);
858
+ // Bring the user's usage limits up to date, once per top-level turn. Cheap when they are off
859
+ // (one small file read); a sub-agent's turn is inside this one and does not repeat it.
860
+ if ((session.toolContext.subagentDepth ?? 0) === 0)
861
+ await refreshUsageLimits().catch(() => { });
784
862
  // Pick up a governance file the USER edited by hand since the last turn. One stat
785
863
  // pass over a few small directories, taken here because a turn is the only moment
786
864
  // governance is consulted — so it is fresh exactly where it is used, with no watcher
787
865
  // to own, poll or tear down. See refreshGovernance.
788
866
  await refreshGovernance(session);
867
+ // A history the provider would reject is healed here, before anything is sent. The usual
868
+ // cause is a turn that ended with tool calls still unanswered (a tool that threw, a crash
869
+ // between the call and its result): every later request would then be refused, and the only
870
+ // cure used to be restarting, which repairs the same thing on load. Both repairs return the
871
+ // same array when there is nothing to do, so a healthy session pays one scan per turn.
872
+ const healed = reconcileInterruptedTools(repairToolCallOrder(session.transcript), "the turn ended before this tool returned");
873
+ if (healed !== session.transcript)
874
+ session.transcript = healed;
789
875
  // Resume an approved plan from disk, once per session (undefined = unchecked).
790
876
  // A plan approved last session is still the agreed scope this session — that is
791
877
  // the point of it being an artifact — and the user deleting the file (or its
@@ -840,7 +926,13 @@ async function respondTurn(session, options = {}) {
840
926
  // Session): fork a scoped child, forward the child's usage to this turn's meter,
841
927
  // and share this turn's abort signal so Esc stops a sub-agent too.
842
928
  session.toolContext.forkChild = (task, opts) => forkSession(session, task, opts);
843
- session.toolContext.reportUsage = (u) => options.onEvent?.({ type: "usage", ...u });
929
+ session.toolContext.reportUsage = (u) => {
930
+ options.onEvent?.({ type: "usage", ...u });
931
+ // A sub-agent's calls arrive here as events, but its own turn already counted them toward the
932
+ // limits; only a bare usage (a page-summarising call, say) is new.
933
+ if (!("type" in u))
934
+ countUsage(session, u, options);
935
+ };
844
936
  // The raw event sink, so spawn_subagent can surface a child's nested activity
845
937
  // (its lifecycle + tagged tool calls) up this same stream instead of running dark.
846
938
  session.toolContext.emitEvent = options.onEvent;
@@ -916,6 +1008,7 @@ async function respondTurn(session, options = {}) {
916
1008
  // Narration budget: one nudge per turn, and the turn's earlier prose to compare against.
917
1009
  let narrationNudged = false;
918
1010
  const narratedBefore = [];
1011
+ const readThisTurn = new Set(); // files read so far, for narrationShown
919
1012
  // Judged next to the prose, pushed after the tool results — see the gate below.
920
1013
  let pendingNarrationFault = null;
921
1014
  let lastFailSig = null;
@@ -932,6 +1025,12 @@ async function respondTurn(session, options = {}) {
932
1025
  // no matter how the turn ends (finish, pause, interrupt, throw). Labeled with
933
1026
  // the request that drove it. No-op when nothing was edited.
934
1027
  const turnLabel = lastUserText(session);
1028
+ // The message that opened this turn, stamped now if the save that normally stamps it
1029
+ // has not run yet. The checkpoint below carries that stamp, which is how a rewind to
1030
+ // this message (or any earlier one) finds the file changes to take back with it.
1031
+ const opener = turnOpener(session);
1032
+ if (opener && opener.ts === undefined)
1033
+ opener.ts = Date.now();
935
1034
  // Fold this turn's cost into the session total on the way out, however the turn ends.
936
1035
  // In the `finally` rather than the success path on purpose: an interrupted or failed
937
1036
  // turn still spent the tokens it spent, and a spend figure that quietly omits the
@@ -968,7 +1067,7 @@ async function respondTurn(session, options = {}) {
968
1067
  finally {
969
1068
  recordSpend();
970
1069
  const before = session.toolContext.checkpoints?.list().length ?? 0;
971
- session.toolContext.checkpoints?.seal(turnLabel);
1070
+ session.toolContext.checkpoints?.seal(turnLabel, opener?.ts);
972
1071
  // Say that a restore point exists. It was made silently, so `/undo` was a feature
973
1072
  // you had to already know about — and the moment to learn it is the moment there is
974
1073
  // something to undo, not after you have lost it.
@@ -982,11 +1081,16 @@ async function respondTurn(session, options = {}) {
982
1081
  for (let step = 0; stepLimit === undefined || step < stepLimit; step++) {
983
1082
  if (options.signal?.aborted)
984
1083
  return interrupted(session);
1084
+ // The user's own usage limit: a step that began under it has finished, so this is the
1085
+ // boundary. Nothing is lost; the work carries on once the window reopens.
1086
+ const heldBack = (session.toolContext.subagentDepth ?? 0) === 0 ? limitGateReason() : null;
1087
+ if (heldBack)
1088
+ return pauseTask(session, options, heldBack, "limit");
985
1089
  // Stop before another (billable) call if a cost/time ceiling is hit — pause
986
1090
  // losslessly, exactly like the step budget, so the user can raise it and resume.
987
1091
  const limitReason = taskLimitReason(summarizeTask(usages, session.modelConfig.model), Date.now() - startedAt, limits);
988
1092
  if (limitReason)
989
- return pauseTask(session, options, `hit the ${limitReason}`);
1093
+ return pauseTask(session, options, `hit the ${limitReason}`, "costTimeLimit");
990
1094
  await maybeCompact(session, options);
991
1095
  // NO working-set block is built or sent. It used to be: the current contents of
992
1096
  // every active file, rebuilt each step and injected at the tail — up to 12K tokens
@@ -1086,7 +1190,7 @@ async function respondTurn(session, options = {}) {
1086
1190
  if (!shed)
1087
1191
  return false;
1088
1192
  overflowRecovered = true;
1089
- session.transcript = shed;
1193
+ replaceTranscript(session, shed);
1090
1194
  await options.persist?.();
1091
1195
  options.onActivity?.("conversation was too long — dropped the oldest turns and retried", {
1092
1196
  context: true,
@@ -1119,6 +1223,7 @@ async function respondTurn(session, options = {}) {
1119
1223
  // may span several calls across tool rounds, and the UI sums them.
1120
1224
  emitUsage(result, options);
1121
1225
  if (result.usage) {
1226
+ countUsage(session, result.usage, options);
1122
1227
  usages.push(result.usage);
1123
1228
  usageTimes.push(Date.now());
1124
1229
  writeCacheLog(cacheCallLine({
@@ -1155,7 +1260,7 @@ async function respondTurn(session, options = {}) {
1155
1260
  if (content.trim())
1156
1261
  session.transcript.push({ role: "assistant", content });
1157
1262
  await options.persist?.();
1158
- return pauseTask(session, options, note);
1263
+ return pauseTask(session, options, note, result.stop);
1159
1264
  }
1160
1265
  // No tool calls → the model is done. Record the reply.
1161
1266
  if (toolCalls.length === 0) {
@@ -1221,6 +1326,13 @@ async function respondTurn(session, options = {}) {
1221
1326
  // Durable BEFORE running the tools: if the machine dies mid-tool, the resume path
1222
1327
  // sees these dangling tool_calls and reconciles them (reconcileInterruptedTools).
1223
1328
  await options.persist?.();
1329
+ // Whether the words above lead to anything on screen, before any tool is announced.
1330
+ {
1331
+ const calls = toolCalls.map((c) => ({ name: c.name, args: parseArgs(c.arguments) }));
1332
+ if (content.trim())
1333
+ options.onEvent?.({ type: "narration", shown: narrationShown(calls, readThisTurn) });
1334
+ noteReads(calls, readThisTurn);
1335
+ }
1224
1336
  // Narration gate, part one: JUDGE here, where this message's prose and the turn's
1225
1337
  // earlier prose are both in hand. Do NOT push anything yet — an assistant message
1226
1338
  // carrying tool_calls must be followed immediately by a tool message per call, and
@@ -1234,11 +1346,20 @@ async function respondTurn(session, options = {}) {
1234
1346
  // Announce every tool the model chose, in its order, BEFORE running any —
1235
1347
  // the UI's reveal queue paces them and a slow tool (test/run) can show a live
1236
1348
  // "running" state until its end event lands.
1237
- for (const call of toolCalls) {
1349
+ // A row is announced when its tool actually STARTS, not up front for the whole step.
1350
+ // Announcing every call at once showed a command as running while a Sentinel prompt
1351
+ // for an earlier call was still waiting, and kept rows appearing behind an open
1352
+ // question: work on screen that nobody had approved. A call that never runs (declined,
1353
+ // refused, interrupted) is announced together with its end, so it still gets its row.
1354
+ const announced = new Set();
1355
+ const announce = (call) => {
1356
+ if (announced.has(call.id))
1357
+ return;
1358
+ announced.add(call.id);
1238
1359
  options.onEvent?.({ type: "tool", phase: "start", id: call.id, name: call.name, args: parseArgs(call.arguments) });
1239
- }
1240
- // Concurrency-safe calls run in PARALLEL; the rest run one at a time, in order
1241
- // (parallel edits to one file race, and an edit must see the last write). A call
1360
+ };
1361
+ // Consecutive concurrency-safe calls run in PARALLEL; every other call runs alone, in
1362
+ // order (parallel edits to one file race, and an edit must see the last write). A call
1242
1363
  // is concurrency-safe when the tool says so for THESE args (isConcurrencySafe) —
1243
1364
  // e.g. a read-only sub-agent, which lets the model fan out research — otherwise
1244
1365
  // the default is: read-only ⇒ safe, mutating ⇒ serial.
@@ -1246,8 +1367,9 @@ async function respondTurn(session, options = {}) {
1246
1367
  const tool = lookup(call.name);
1247
1368
  return tool ? callIsConcurrencySafe(tool, parseArgs(call.arguments)) : false;
1248
1369
  };
1249
- const parallelCalls = toolCalls.filter(concurrencySafe);
1250
- const serialCalls = toolCalls.filter((call) => !concurrencySafe(call));
1370
+ // Batches keep the model's ORDER (toolBatches.ts): a read written after an edit has to
1371
+ // see the edit, so read-only calls are only grouped with their neighbours.
1372
+ const batches = partitionCalls(toolCalls, concurrencySafe);
1251
1373
  const runCall = async (call) => {
1252
1374
  // Esc: once the turn is aborted, no further tool may START. The step loop only
1253
1375
  // re-checks BETWEEN steps, so without this gate the rest of a batch still runs
@@ -1320,7 +1442,10 @@ async function respondTurn(session, options = {}) {
1320
1442
  // uniformly — including subagent edits. Fails safe: no approval channel, or an
1321
1443
  // unclear answer, refuses rather than runs.
1322
1444
  const ctx = session.toolContext;
1323
- if (!tool.readOnly && ctx.guarded && !ctx.guardAllowed?.has(call.name)) {
1445
+ // Two kinds of standing "yes": one given during this session (guardAllowed), and
1446
+ // one the user saved in their permissions, for this project or every project.
1447
+ const savedYes = ctx.governance?.sentinelAllow?.includes(call.name) === true;
1448
+ if (!tool.readOnly && ctx.guarded && !ctx.guardAllowed?.has(call.name) && !savedYes) {
1324
1449
  const args = parseArgs(call.arguments);
1325
1450
  // The question is one line; WHAT is about to happen rides as detail, which the
1326
1451
  // CLI prints into the transcript. A gate the user cannot read is a gate they
@@ -1365,21 +1490,61 @@ async function respondTurn(session, options = {}) {
1365
1490
  // Zero threw — every one returned an error result. So this catches nothing today
1366
1491
  // and is deliberately defence in depth: it removes a single point of failure
1367
1492
  // rather than fixing an observed bug, and the next tool added inherits it.
1493
+ announce(call); // approved (or needing no approval): now it is really running
1494
+ // A command waits until its row is actually on screen before it starts, when the caller
1495
+ // paces the screen. Otherwise the screen, which shows one thing at a time, replays a
1496
+ // command that finished long ago and its "running" state is never real. Only commands:
1497
+ // reads and edits take milliseconds, and holding them back would only slow the turn.
1498
+ if (tool.liveRow && options.beforeLiveTool) {
1499
+ await options.beforeLiveTool(call.id);
1500
+ if (options.signal?.aborted) {
1501
+ return {
1502
+ call,
1503
+ output: "Not run: the turn was interrupted before this tool started.",
1504
+ summary: "interrupted",
1505
+ isError: true,
1506
+ detail: undefined,
1507
+ };
1508
+ }
1509
+ }
1368
1510
  let result;
1369
1511
  try {
1370
1512
  // A per-call channel, so a tool that runs for minutes can say what it is doing.
1371
1513
  // Scoped to THIS call rather than hung on the shared context, which every tool in
1372
1514
  // the turn holds the same instance of — with two commands in flight there would be
1373
1515
  // no way to tell whose output was whose.
1374
- result = await tool.execute(parseArgs(call.arguments), session.toolContext, {
1375
- progress: (text) => options.onEvent?.({ type: "tool", phase: "progress", id: call.id, text }),
1376
- });
1516
+ // Memories, rules, skills, permissions and MCP servers are written by their own
1517
+ // helpers, not the edit tools; snapshotting around the call puts them in the same
1518
+ // undo net as project files, so /undo and a rewind take them back too.
1519
+ const stateBefore = session.toolContext.checkpoints && writesAgentState(call.name)
1520
+ ? await snapshotAgentState(session.toolContext)
1521
+ : null;
1522
+ try {
1523
+ result = await tool.execute(parseArgs(call.arguments), session.toolContext, {
1524
+ progress: (text) => options.onEvent?.({ type: "tool", phase: "progress", id: call.id, text }),
1525
+ });
1526
+ }
1527
+ finally {
1528
+ if (stateBefore)
1529
+ await recordAgentStateChanges(session.toolContext, stateBefore);
1530
+ }
1377
1531
  }
1378
1532
  catch (error) {
1379
- // An abort is the user, not a fault: let it travel so the loop's own handling
1380
- // reports an interruption instead of a broken tool.
1381
- if (isAbort(error))
1382
- throw error;
1533
+ // An abort is the user, not a fault, so it is NOT reported as a broken tool. But it
1534
+ // must not travel out of the loop either: the assistant message carrying these calls
1535
+ // is already saved, so a throw here leaves calls with no results, and every later
1536
+ // request in the live session is then malformed until the app is restarted. It is
1537
+ // recorded as an interrupted call instead; when the turn really was stopped the next
1538
+ // pass through the loop sees the abort and ends the turn cleanly.
1539
+ if (isAbort(error)) {
1540
+ return {
1541
+ call,
1542
+ output: "Not completed: this call was interrupted before it finished, so its effect is unknown. Check the current state before relying on it.",
1543
+ summary: "interrupted",
1544
+ isError: true,
1545
+ detail: undefined,
1546
+ };
1547
+ }
1383
1548
  return { call, ...toolFailureResult(call.name, error), detail: undefined };
1384
1549
  }
1385
1550
  return {
@@ -1388,10 +1553,13 @@ async function respondTurn(session, options = {}) {
1388
1553
  summary: result.summary,
1389
1554
  isError: result.isError,
1390
1555
  detail: result.detail,
1556
+ detailFull: result.detailFull,
1391
1557
  detailKind: result.detailKind,
1392
1558
  quiet: result.quiet,
1393
1559
  fullContentOf: result.fullContentOf,
1394
1560
  images: result.images,
1561
+ web: result.web,
1562
+ ui: result.ui,
1395
1563
  displayKind: result.displayKind,
1396
1564
  displayName: result.displayName,
1397
1565
  awaitsModel: result.awaitsModel,
@@ -1403,6 +1571,7 @@ async function respondTurn(session, options = {}) {
1403
1571
  // the model's call order (the sort below); only the UI events go out eagerly.
1404
1572
  const runAndEmit = async (call) => {
1405
1573
  const r = await runCall(call);
1574
+ announce(r.call); // one that never ran gets its start now, right before its end
1406
1575
  options.onEvent?.({
1407
1576
  type: "tool",
1408
1577
  phase: "end",
@@ -1411,16 +1580,23 @@ async function respondTurn(session, options = {}) {
1411
1580
  summary: r.summary ?? r.call.name,
1412
1581
  error: r.isError ?? false,
1413
1582
  detail: r.detail,
1583
+ ...("detailFull" in r && r.detailFull ? { detailFull: r.detailFull } : {}),
1414
1584
  ...(r.detailKind ? { detailKind: r.detailKind } : {}),
1415
1585
  ...(r.quiet ? { quiet: true } : {}),
1416
1586
  ...(r.displayKind ? { displayKind: r.displayKind } : {}),
1417
1587
  ...(r.displayName ? { displayName: r.displayName } : {}),
1588
+ ...(r.images?.length ? { images: r.images.map((i) => i.path) } : {}),
1589
+ ...(r.web ? { web: r.web } : {}),
1590
+ ...(r.ui ? { ui: r.ui } : {}),
1418
1591
  });
1419
1592
  return r;
1420
1593
  };
1421
- const results = await Promise.all(parallelCalls.map(runAndEmit));
1422
- for (const call of serialCalls) {
1423
- results.push(await runAndEmit(call));
1594
+ const results = [];
1595
+ for (const batch of batches) {
1596
+ if (batch.parallel)
1597
+ results.push(...(await runLimited(batch.calls, MAX_PARALLEL_CALLS, runAndEmit)));
1598
+ else
1599
+ results.push(await runAndEmit(batch.calls[0]));
1424
1600
  }
1425
1601
  // Hand results back in the model's original call order (start events were emitted
1426
1602
  // in that order too), no matter which lane each call ran in.
@@ -1465,6 +1641,9 @@ async function respondTurn(session, options = {}) {
1465
1641
  ...(result.quiet ? { quiet: true } : {}),
1466
1642
  ...(result.displayName ? { displayName: result.displayName } : {}),
1467
1643
  ...(result.displayKind ? { displayKind: result.displayKind } : {}),
1644
+ ...(result.images?.length ? { imagePaths: result.images.map((i) => i.path) } : {}),
1645
+ ...(result.web ? { web: result.web } : {}),
1646
+ ...(result.ui ? { ui: result.ui } : {}),
1468
1647
  ...(result.isError ? { isError: true } : {}),
1469
1648
  // Presence, as recorded by the tool that knows: this result IS the whole
1470
1649
  // content of that file. Not display — the presence derivation reads it.
@@ -1734,10 +1913,19 @@ async function respondTurn(session, options = {}) {
1734
1913
  // transcript, task list, and working set are all intact, so telling Mindweave to
1735
1914
  // continue resumes exactly here with nothing lost, and the user stays in control of
1736
1915
  // the spend.
1737
- return pauseTask(session, options, `reached the step budget of ${stepLimit} tool steps in one turn`);
1916
+ return pauseTask(session, options, `reached the step budget of ${stepLimit} tool steps in one turn`, "stepBudget");
1738
1917
  }
1739
1918
  }
1740
1919
  /** The most recent user request in the transcript, clipped — labels a checkpoint. */
1920
+ /** The last message the person sent, which is what this turn is answering. */
1921
+ function turnOpener(session) {
1922
+ for (let i = session.transcript.length - 1; i >= 0; i--) {
1923
+ const e = session.transcript[i];
1924
+ if (e.role === "user" && !e.synthetic)
1925
+ return e;
1926
+ }
1927
+ return undefined;
1928
+ }
1741
1929
  function lastUserText(session) {
1742
1930
  for (let i = session.transcript.length - 1; i >= 0; i--) {
1743
1931
  const e = session.transcript[i];
@@ -1769,6 +1957,7 @@ function endTurnWith(session, options, msg) {
1769
1957
  * one in the same turn (the re-scope guard) — a natural checkpoint to let the user
1770
1958
  * steer instead of the model taking on scope it wasn't asked for. */
1771
1959
  function pauseReScope(session, options) {
1960
+ options.onPause?.("reScope");
1772
1961
  return endTurnWith(session, options, "(I've finished the task list for what you asked. I have ideas for taking it " +
1773
1962
  "further, but I've stopped here so you can steer — rather than piling on new scope " +
1774
1963
  'on my own. Tell me which direction you want, or say "keep going" to continue.)');
@@ -1778,6 +1967,7 @@ function pauseReScope(session, options) {
1778
1967
  * to do but wait — end the turn cleanly instead of looping "still running" checks.
1779
1968
  * Deliberately worded as a status line to the user, not a "paused" apology. */
1780
1969
  function pauseForBackgroundPoll(session, options) {
1970
+ options.onPause?.("backgroundPoll");
1781
1971
  return endTurnWith(session, options, "It's still running in the background. I'll stop checking and let you know as soon " +
1782
1972
  "as it finishes — no need to keep watching.");
1783
1973
  }
@@ -1786,13 +1976,22 @@ function pauseForBackgroundPoll(session, options) {
1786
1976
  * count, and its real working directory, and it still hasn't moved — so hand the wheel
1787
1977
  * to the user rather than spend more steps on it. */
1788
1978
  function pauseForRepeatedFailure(session, options, errorOutput) {
1979
+ options.onPause?.("repeatedFailure");
1789
1980
  return endTurnWith(session, options, `I've hit the same failure several times in a row and I'm not making progress, so I've ` +
1790
1981
  `stopped rather than retry the same thing again. The error was:\n\n${firstErrorLine(errorOutput)}\n\n` +
1791
1982
  `Tell me how you'd like to proceed, or I can try a different approach.`);
1792
1983
  }
1793
1984
  /** Record and return a clean, lossless pause reply (well-formed transcript) when a
1794
- * guard trips — step budget or a cost/time ceiling. Saying "continue" resumes. */
1795
- function pauseTask(session, options, reason) {
1985
+ * guard trips — step budget or a cost/time ceiling. Saying "continue" resumes.
1986
+ * `tag` tells the two apart for `onPause`; the prose reason stays a free string since
1987
+ * it already carries the exact number ("the step budget of 40 tool steps"). */
1988
+ function pauseTask(session, options, reason, tag) {
1989
+ options.onPause?.(tag);
1990
+ // A usage limit is not something to argue with by saying "continue": it lifts on its own.
1991
+ if (tag === "limit") {
1992
+ return endTurnWith(session, options, `(Paused — ${reason}. The task isn't finished, but nothing is lost: your progress, edits, and task ` +
1993
+ `list are saved. Carry on once it opens.)`);
1994
+ }
1796
1995
  return endTurnWith(session, options, `(Paused — ${reason}. The task isn't finished, but nothing is lost: your progress, ` +
1797
1996
  `edits, and task list are saved. Say "continue" to pick up exactly where I left off.)`);
1798
1997
  }
@@ -1847,6 +2046,18 @@ export function toCallRecord(u, model, at = Date.now()) {
1847
2046
  model,
1848
2047
  };
1849
2048
  }
2049
+ /**
2050
+ * A finished model call counts toward the user's limits, and a window crossing 80% or 95%
2051
+ * says so once, as a line in the conversation. Only the top-level turn speaks: a sub-agent's
2052
+ * call is counted, and the parent's next call is what reports where things stand.
2053
+ */
2054
+ function countUsage(session, u, options) {
2055
+ noteUsage(session.modelConfig.model, u);
2056
+ if ((session.toolContext.subagentDepth ?? 0) > 0)
2057
+ return;
2058
+ for (const line of takeLimitWarnings())
2059
+ options.onActivity?.(`Usage limit: ${line}`);
2060
+ }
1850
2061
  /** Report a turn's token usage to the UI, if the provider returned it. */
1851
2062
  function emitUsage(result, options) {
1852
2063
  if (result.usage) {
@@ -1881,7 +2092,7 @@ async function sweepSessionMemory(session, options) {
1881
2092
  const grown = shouldUpdateSessionMemory(estimateEntriesTokens(session.transcript), session.sessionMemoryTokens ?? 0, session.sessionMemoryInit ?? false);
1882
2093
  if (!grown)
1883
2094
  return;
1884
- await updateSessionMemory(session);
2095
+ await updateSessionMemory(session, options.signal);
1885
2096
  await options.persist?.(); // durable: the notes sidecar is written by the persister
1886
2097
  }
1887
2098
  /**
@@ -1911,12 +2122,39 @@ export function contextUsed(session) {
1911
2122
  : (session.toolContext.mcp?.estimatedTokens() ?? 0);
1912
2123
  return estimateEntriesTokens(session.transcript) + overhead;
1913
2124
  }
2125
+ /**
2126
+ * The auto-compaction bar actually in force for this session: the user's own override
2127
+ * (Settings > Usage > Context, or `/context limit`) if they set one — project first,
2128
+ * then all-projects, per governor/index.ts's context.json — else Mindweave's own
2129
+ * model-anchored default. The env var still wins over either, same as before this
2130
+ * existed: it is the sandbox/CI escape hatch, not a user-facing setting.
2131
+ */
2132
+ export function effectiveAutoCompactThreshold(session) {
2133
+ // Optional chaining: a real session always has governance (loadGovernance runs at
2134
+ // session start), but a hand-built test fixture may skip it, and a pure accessor
2135
+ // shouldn't crash on that rather than just falling through to the model default.
2136
+ const base = session.governance?.contextAutoCompactTokens ?? autoCompactThreshold(session.modelConfig.model);
2137
+ return envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", base);
2138
+ }
2139
+ /**
2140
+ * How full the context is, against the bar auto-compaction fires at: what a front end
2141
+ * draws as the context meter. The same numbers `maybeCompact` decides on, so a full meter
2142
+ * and a compaction cannot disagree.
2143
+ */
2144
+ export function contextFill(session) {
2145
+ return {
2146
+ used: contextUsed(session),
2147
+ limit: effectiveAutoCompactThreshold(session),
2148
+ window: sharpContextWindow(session.modelConfig.model),
2149
+ };
2150
+ }
1914
2151
  async function maybeCompact(session, options) {
1915
2152
  const model = session.modelConfig.model;
1916
- // Model-anchored bars (env still overrides), so the thresholds are right per model
1917
- // instead of a fixed number — and a longer/stronger model automatically gets more room.
2153
+ // Model-anchored bars (env, then the user's own override, still win), so the
2154
+ // thresholds are right per model instead of a fixed number — and a longer/stronger
2155
+ // model automatically gets more room.
1918
2156
  const microBar = envInt("MINDWEAVE_MICROCOMPACT_TOKENS", microCompactThreshold(model));
1919
- const autoBar = envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(model));
2157
+ const autoBar = effectiveAutoCompactThreshold(session);
1920
2158
  // MCP tool schemas are sent on every turn but live OUTSIDE the transcript, so the bars
1921
2159
  // could not see them: a 30K-token catalog meant the model was 30K deeper into its real
1922
2160
  // context than this arithmetic believed, and every threshold fired that much too late.
@@ -2021,6 +2259,16 @@ export async function compactNow(session, options = {}) {
2021
2259
  async function autocompact(session, options) {
2022
2260
  if (session.transcript.length === 0)
2023
2261
  return;
2262
+ options.onCompactionStart?.();
2263
+ try {
2264
+ await summarizeAndSplice(session, options);
2265
+ }
2266
+ finally {
2267
+ options.onCompactionEnd?.();
2268
+ }
2269
+ }
2270
+ /** The body of `autocompact`: the notes when they cover the prefix, else the summarizer. */
2271
+ async function summarizeAndSplice(session, options) {
2024
2272
  // Measured BEFORE the summarizer runs, with the same arithmetic the thresholds use,
2025
2273
  // so the bar the user sees is the number the system actually acted on.
2026
2274
  const before = contextUsed(session);
@@ -2042,9 +2290,9 @@ async function autocompact(session, options) {
2042
2290
  // summarizer is about to be paid to produce. When they are current enough to cover
2043
2291
  // the prefix being dropped, spending a model call buys something already owned.
2044
2292
  // Declines rather than approximates: stale or empty notes fall through.
2045
- const fromNotes = compactFromSessionMemory(session.transcript, session.sessionMemory, session.sessionMemoryEntries, envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(session.modelConfig.model)), contextUsed(session) - estimateEntriesTokens(session.transcript));
2293
+ const fromNotes = compactFromSessionMemory(session.transcript, session.sessionMemory, session.sessionMemoryEntries, effectiveAutoCompactThreshold(session), contextUsed(session) - estimateEntriesTokens(session.transcript));
2046
2294
  if (fromNotes) {
2047
- session.transcript = fromNotes.entries;
2295
+ replaceTranscript(session, fromNotes.entries);
2048
2296
  // The notes now describe everything before the tail they were spliced in front of.
2049
2297
  session.sessionMemoryEntries = 1;
2050
2298
  session.sessionMemoryTokens = estimateEntriesTokens(session.transcript);
@@ -2054,15 +2302,31 @@ async function autocompact(session, options) {
2054
2302
  let summary;
2055
2303
  try {
2056
2304
  // Summaries don't need reasoning — use the chosen model with thinking off.
2057
- const turn = await activeDriver().toolTurn({
2058
- system: SUMMARY_SYSTEM_PROMPT,
2059
- messages: [
2060
- {
2061
- role: "user",
2062
- content: `${formatTranscriptForSummary(session.transcript)}\n\n${summaryRequest(options.compactFocus)}`,
2063
- },
2064
- ],
2065
- model: { ...session.modelConfig, thinking: false },
2305
+ // With reasoning off where the model allows it; see auxModel.ts.
2306
+ //
2307
+ // `ensureDriver` here, not just at the top of the turn: a sub-agent (or another
2308
+ // background aux call) can run its OWN model in between and leaves `activeDriver()`
2309
+ // pointed at THAT provider, which is a plain global, not scoped to this session. A
2310
+ // compaction landing after one then handed this session's model string to the
2311
+ // wrong provider's API, which correctly refused it as a model it had never heard
2312
+ // of ("Unknown Model, please check the model code.") — same failure shape as the
2313
+ // model call sites in respondTurn, which is why only THIS one call was missing it.
2314
+ //
2315
+ // `withTools`: a real (read-only) tool set, attached only if the model already
2316
+ // refused a bare call — see auxModel.ts for why some free models require it.
2317
+ const turn = await withAuxModel(session.modelConfig, async (model, withTools) => {
2318
+ await ensureDriver(model.model);
2319
+ return activeDriver().toolTurn({
2320
+ system: SUMMARY_SYSTEM_PROMPT,
2321
+ messages: [
2322
+ {
2323
+ role: "user",
2324
+ content: `${formatTranscriptForSummary(session.transcript)}\n\n${summaryRequest(options.compactFocus)}`,
2325
+ },
2326
+ ],
2327
+ model,
2328
+ ...(withTools ? { tools: toolSchemas({ readOnlyOnly: true }) } : {}),
2329
+ }, { signal: options.signal });
2066
2330
  });
2067
2331
  // The reply is untrusted: a cut-off or all-scratchpad summary must not be allowed
2068
2332
  // to replace the conversation. See usableSummary.
@@ -2070,21 +2334,38 @@ async function autocompact(session, options) {
2070
2334
  // is what keeps the meter honest: a turn that happened to trip the bar spends
2071
2335
  // a whole extra summarisation call, and leaving that out made the figure short
2072
2336
  // by exactly the work nobody could see.
2073
- if (turn.usage)
2337
+ if (turn.usage) {
2074
2338
  options.onEvent?.({ type: "usage", ...turn.usage });
2339
+ countUsage(session, turn.usage, options);
2340
+ }
2075
2341
  const usable = usableSummary(turn.content, turn.stop);
2076
2342
  if (!usable)
2077
2343
  return void fail(turn.stop && turn.stop !== "end" ? `the summary came back ${turn.stop}` : "the summary was unusable");
2078
2344
  summary = usable;
2079
2345
  }
2080
2346
  catch (error) {
2347
+ // Stopped by the user: not a failed compaction, so nothing to count or report.
2348
+ if (options.signal?.aborted)
2349
+ return;
2350
+ // Some free OpenRouter models gate on the CALLING APP's identity, not the shape of
2351
+ // any one request — real tool schemas (withAuxModel's retry, above) genuinely fix a
2352
+ // model that just needs a non-empty `tools` array, but this specific refusal was
2353
+ // confirmed live to survive that retry too: OpenRouter's own error names the cause
2354
+ // as `"failed_routing_step":"Gate Free Endpoints by Agentic Harness"`, which nothing
2355
+ // Mindweave sends in a request can satisfy. Say so plainly instead of surfacing the
2356
+ // provider's raw JSON, which reads as a Mindweave bug rather than a model limit.
2357
+ if (isAgenticOnlyRefusal(error)) {
2358
+ return void fail(`${session.modelConfig.model} won't serve Mindweave's background calls — some free OpenRouter models restrict ` +
2359
+ `themselves to specific recognised apps, and switching what a request sends can't change that. Pick a ` +
2360
+ `different model for this project, or expect compaction and session notes to stay off on this one.`);
2361
+ }
2081
2362
  return void fail(providerMessage(detailOf(error)) || "the summarizer call failed");
2082
2363
  }
2083
2364
  // A summary replaces the transcript prefix, so file contents read before it are gone.
2084
2365
  // Nothing re-injects them: the working-set block that used to do so was removed for
2085
2366
  // costing up to 12K per model call. The model re-reads what it still needs, which
2086
2367
  // read_file allows because the summary also clears the presence set the dedup checks.
2087
- session.transcript = spliceSummary(session.transcript, summary, KEEP_LAST_N);
2368
+ replaceTranscript(session, spliceSummary(session.transcript, summary, KEEP_LAST_N));
2088
2369
  // The notes no longer describe the transcript they were measured against, and the
2089
2370
  // summary now covers everything before the kept tail.
2090
2371
  session.sessionMemoryEntries = 1;
@@ -2103,6 +2384,12 @@ async function autocompact(session, options) {
2103
2384
  async function finishCompaction(session, options, before) {
2104
2385
  session.compactFailures = 0; // a clean compaction resets the breaker
2105
2386
  await restoreAfterCompaction(session);
2387
+ // What is still running, said right after the summary: the summary is history, and
2388
+ // may not mention a dev server that is still up or an app still open for testing,
2389
+ // and a model that does not know starts them again.
2390
+ const live = liveStateNote(session.toolContext.backgroundShells?.running() ?? [], uiLiveState(session.toolContext));
2391
+ if (live)
2392
+ session.transcript.splice(1, 0, { role: "user", content: live, synthetic: true });
2106
2393
  // Re-read the governor unconditionally here. The prompt is being rebuilt from scratch
2107
2394
  // at this point, so it is the natural moment to rebuild what it is made of — and it is
2108
2395
  // the one path that does not depend on the stat check being right about anything.
@@ -2126,6 +2413,27 @@ async function finishCompaction(session, options, before) {
2126
2413
  // the moment to pick the file back up, and it costs nothing extra here.
2127
2414
  await reloadProjectMemory(session).catch(() => { });
2128
2415
  }
2416
+ /**
2417
+ * The note put after a compaction about what is still running, or null when nothing is
2418
+ * (pure). Background commands are listed with the ports they are listening on, so a
2419
+ * model that has lost the history does not start a second dev server; an app open for
2420
+ * testing is named, with the instruction to look before acting.
2421
+ */
2422
+ export function liveStateNote(shells, ui, now = Date.now()) {
2423
+ const lines = [];
2424
+ for (const sh of shells) {
2425
+ const mins = Math.max(0, Math.round((now - sh.startedAt) / 60_000));
2426
+ const ports = sh.listening?.length ? sh.listening : sh.port ? [sh.port] : [];
2427
+ lines.push(`- Background shell #${sh.id} (\`${sh.command}\`): running for ${mins < 1 ? "under a minute" : `${mins} min`}` +
2428
+ `${ports.length ? `, listening on port ${ports.join(", ")}` : ""}.`);
2429
+ }
2430
+ if (ui)
2431
+ lines.push(`- ${ui}`);
2432
+ if (lines.length === 0)
2433
+ return null;
2434
+ return (`[Still running after the compaction. This is the live state now, not history.]\n${lines.join("\n")}\n` +
2435
+ `Do not start any of these again: they are already running. Use shells to check one, kill_shell to stop it.`);
2436
+ }
2129
2437
  /**
2130
2438
  * Reconcile the read ledger with the transcript, and put the working files back.
2131
2439
  *
@@ -2152,7 +2460,7 @@ async function restoreAfterCompaction(session) {
2152
2460
  // earlier fix copied every path forward to re-derive the same answer every step.
2153
2461
  // The correctness half. Unconditional, and before anything that can throw.
2154
2462
  reads.clear();
2155
- const budget = restoreBudgetFor(envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(session.modelConfig.model)));
2463
+ const budget = restoreBudgetFor(effectiveAutoCompactThreshold(session));
2156
2464
  if (budget <= 0)
2157
2465
  return;
2158
2466
  // What the kept tail still shows. Re-sending a file the model can already see costs