micro-models-agent 0.63.3 → 1.1.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 (185) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/dist/cli/cache-line.js +30 -0
  3. package/dist/cli/command-suggest.js +38 -0
  4. package/dist/cli/commands.js +285 -60
  5. package/dist/cli/completer.js +16 -16
  6. package/dist/cli/json-payload.js +32 -0
  7. package/dist/cli/main.js +165 -77
  8. package/dist/cli/plugin-commands.js +5 -4
  9. package/dist/cli/relaunch.js +37 -0
  10. package/dist/cli/repl-commands.js +441 -307
  11. package/dist/cli/repl.js +360 -83
  12. package/dist/cli/run-result.js +12 -6
  13. package/dist/cli/security-commands.js +64 -60
  14. package/dist/cli/setup-order.js +57 -0
  15. package/dist/cli/setup-prompt.js +49 -0
  16. package/dist/cli/setup.js +52 -48
  17. package/dist/config/budget.js +48 -0
  18. package/dist/config/config.js +132 -70
  19. package/dist/config/defaults.js +37 -11
  20. package/dist/config/domains.js +9 -50
  21. package/dist/config/utils.js +56 -0
  22. package/dist/core/agent/audit-gate.js +49 -0
  23. package/dist/core/agent/compaction.js +89 -0
  24. package/dist/core/agent/constants.js +61 -0
  25. package/dist/core/agent/context-renderer.js +40 -0
  26. package/dist/core/agent/hallucination-gate.js +87 -0
  27. package/dist/core/agent/loop-state.js +53 -0
  28. package/dist/core/agent/prefix-monitor.js +101 -0
  29. package/dist/core/agent/reasoning-resolver.js +56 -0
  30. package/dist/core/agent/token-tracker.js +96 -0
  31. package/dist/core/agent/tool-batch.js +237 -0
  32. package/dist/core/agent/tool-output.js +62 -0
  33. package/dist/core/agent-moe.js +214 -69
  34. package/dist/core/agent.js +506 -546
  35. package/dist/core/bootstrap.js +297 -98
  36. package/dist/core/crash-handler.js +2 -1
  37. package/dist/core/prompt-builder.js +3 -0
  38. package/dist/core/prompt-overflow.js +307 -0
  39. package/dist/core/session-logger.js +34 -2
  40. package/dist/i18n/en.json +7 -4
  41. package/dist/i18n/ru.json +7 -4
  42. package/dist/index.js +5 -1
  43. package/dist/llm/cache-usage.js +76 -0
  44. package/dist/llm/image-utils.js +20 -16
  45. package/dist/llm/llm-errors.js +41 -0
  46. package/dist/llm/model-loader.js +30 -0
  47. package/dist/llm/openai-compat.js +287 -101
  48. package/dist/llm/orchestrator.js +140 -68
  49. package/dist/llm/provider-budget.js +68 -0
  50. package/dist/llm/provider.js +0 -1
  51. package/dist/llm/stream-state.js +26 -0
  52. package/dist/llm/token-counter.js +28 -0
  53. package/dist/logger/app-logger.js +12 -15
  54. package/dist/main.js +1606 -800
  55. package/dist/migration/detect.js +3 -1
  56. package/dist/modules/browser/actions.js +0 -3
  57. package/dist/modules/browser/bridge-client.js +2 -0
  58. package/dist/modules/browser/driver.js +46 -4
  59. package/dist/modules/certification/cli.js +85 -42
  60. package/dist/modules/certification/loader.js +15 -1
  61. package/dist/modules/certification/manifest.js +126 -15
  62. package/dist/modules/certification/runner.js +4 -26
  63. package/dist/modules/certification/scenarios.js +184 -5
  64. package/dist/modules/certification/syntax-scenarios.js +51 -0
  65. package/dist/modules/context/chunk-query.js +25 -5
  66. package/dist/modules/context/fact-extractor.js +6 -2
  67. package/dist/modules/context/manager.js +23 -7
  68. package/dist/modules/execution/audit-runners.js +7 -1
  69. package/dist/modules/execution/auditor.js +3 -3
  70. package/dist/modules/execution/execution-plugin.js +22 -15
  71. package/dist/modules/execution/input-from.js +46 -0
  72. package/dist/modules/execution/module.js +107 -18
  73. package/dist/modules/execution/moe-executor.js +166 -54
  74. package/dist/modules/execution/plan-actions.js +524 -0
  75. package/dist/modules/execution/plan-steps.js +23 -0
  76. package/dist/modules/execution/plan-store.js +15 -3
  77. package/dist/modules/execution/plan-tool.js +6 -488
  78. package/dist/modules/execution/plan-validator.js +24 -0
  79. package/dist/modules/execution/stuck-detector.js +3 -18
  80. package/dist/modules/execution/tracker.js +14 -5
  81. package/dist/modules/execution/transient-error.js +30 -0
  82. package/dist/modules/execution/verifier.js +94 -7
  83. package/dist/modules/execution/windows-commands.js +11 -0
  84. package/dist/modules/hallucination/confidence.js +36 -23
  85. package/dist/modules/hallucination/consistency.js +3 -0
  86. package/dist/modules/hallucination/detector.js +8 -3
  87. package/dist/modules/hallucination/factual.js +26 -7
  88. package/dist/modules/hallucination/llm-judge.js +12 -2
  89. package/dist/modules/indexer/map-command.js +35 -0
  90. package/dist/modules/indexer/map-select.js +87 -0
  91. package/dist/modules/indexer/module.js +34 -22
  92. package/dist/modules/indexer/symbols.js +189 -0
  93. package/dist/modules/indexer/walker.js +96 -42
  94. package/dist/modules/lsp/check-tool.js +2 -1
  95. package/dist/modules/lsp/client.js +49 -32
  96. package/dist/modules/lsp/config.js +55 -2
  97. package/dist/modules/lsp/module.js +38 -5
  98. package/dist/modules/lsp/probe.js +4 -3
  99. package/dist/modules/lsp/project-root.js +41 -1
  100. package/dist/modules/lsp/startup-check.js +12 -4
  101. package/dist/modules/mcp/client.js +153 -104
  102. package/dist/modules/mcp/module.js +165 -41
  103. package/dist/modules/memory/module.js +4 -3
  104. package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
  105. package/dist/modules/plugins/manager.js +47 -84
  106. package/dist/modules/pricing/index.js +17 -7
  107. package/dist/modules/pricing/prices.js +30 -12
  108. package/dist/modules/processes/index.js +1 -0
  109. package/dist/modules/processes/kill-tree.js +56 -0
  110. package/dist/modules/processes/registry.js +2 -54
  111. package/dist/modules/providers/cache.js +23 -0
  112. package/dist/modules/providers/factory.js +28 -0
  113. package/dist/modules/providers/fallback.js +7 -5
  114. package/dist/modules/providers/health.js +2 -1
  115. package/dist/modules/providers/index.js +1 -0
  116. package/dist/modules/providers/manager.js +17 -2
  117. package/dist/modules/providers/presets.js +79 -6
  118. package/dist/modules/reasoning/policy.js +40 -0
  119. package/dist/modules/reasoning/probe.js +111 -0
  120. package/dist/modules/security/audit-notifier.js +42 -27
  121. package/dist/modules/security/command-validator.js +25 -20
  122. package/dist/modules/security/encryption.js +6 -12
  123. package/dist/modules/security/network-validator.js +76 -5
  124. package/dist/modules/security/path-validator.js +77 -34
  125. package/dist/modules/security/rate-limiter.js +11 -0
  126. package/dist/modules/security/security-policies.js +1 -1
  127. package/dist/modules/security/session-encryption.js +13 -2
  128. package/dist/modules/security/session-isolation.js +2 -9
  129. package/dist/modules/session/manager.js +11 -0
  130. package/dist/modules/session/module.js +11 -3
  131. package/dist/modules/session/store.js +41 -5
  132. package/dist/modules/skills/loader.js +7 -1
  133. package/dist/modules/skills/module.js +2 -1
  134. package/dist/modules/updater/changelog-reader.js +94 -0
  135. package/dist/modules/updater/dev-detect.js +17 -0
  136. package/dist/modules/updater/index.js +1 -0
  137. package/dist/modules/updater/module.js +14 -3
  138. package/dist/output/bus.js +32 -0
  139. package/dist/output/channel.js +233 -0
  140. package/dist/output/format.js +14 -0
  141. package/dist/output/index.js +7 -0
  142. package/dist/output/json-sink.js +22 -0
  143. package/dist/output/machine.js +8 -0
  144. package/dist/output/session-sink.js +27 -0
  145. package/dist/output/types.js +1 -0
  146. package/dist/tools/approve.js +6 -2
  147. package/dist/tools/attach-image.js +11 -11
  148. package/dist/tools/auto-fixer.js +198 -0
  149. package/dist/tools/bash.js +142 -89
  150. package/dist/tools/chunk-query.js +10 -6
  151. package/dist/tools/download-file.js +1 -1
  152. package/dist/tools/edit-file.js +20 -2
  153. package/dist/tools/executor.js +54 -9
  154. package/dist/tools/glob-tool.js +7 -0
  155. package/dist/tools/grep-tool.js +15 -1
  156. package/dist/tools/index.js +3 -1
  157. package/dist/tools/list-dir.js +3 -1
  158. package/dist/tools/load-skill.js +2 -1
  159. package/dist/tools/mcp-call.js +1 -1
  160. package/dist/tools/move-file.js +5 -4
  161. package/dist/tools/path-utils.js +7 -0
  162. package/dist/tools/pipeline-run.js +1 -1
  163. package/dist/tools/prompt-io.js +28 -0
  164. package/dist/tools/question.js +12 -12
  165. package/dist/tools/scope-request.js +91 -0
  166. package/dist/tools/session-info.js +44 -0
  167. package/dist/tools/set-thinking.js +71 -0
  168. package/dist/tools/subagent.js +50 -9
  169. package/dist/tools/syntax-validator.js +177 -0
  170. package/dist/tools/user-input.js +16 -9
  171. package/dist/tools/write-file.js +17 -1
  172. package/dist/ui/diff.js +10 -0
  173. package/dist/ui/line-editor.js +179 -26
  174. package/dist/ui/line-math.js +20 -3
  175. package/dist/ui/md-formatter.js +100 -10
  176. package/dist/ui/output.js +5 -4
  177. package/dist/ui/plan-view.js +2 -7
  178. package/dist/ui/renderer.js +89 -85
  179. package/dist/ui/spinner.js +14 -4
  180. package/dist/utils/error.js +4 -0
  181. package/dist/utils/index.js +4 -0
  182. package/dist/utils/retry.js +17 -0
  183. package/dist/utils/sleep.js +23 -0
  184. package/dist/utils/truncate.js +9 -0
  185. package/package.json +1 -1
package/dist/cli/repl.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as readline from "readline";
2
2
  import { pc } from "../ui/colors";
3
3
  import { LineEditor } from "../ui/line-editor";
4
- import { existsSync, readFileSync, writeFileSync } from "fs";
5
- import { join } from "path";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { dirname, join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { Completer, SlashCommandProvider, SessionNameProvider, SubcommandProvider, SkillNameProvider, ProviderArgProvider, ModelArgProvider, } from "./completer";
8
8
  import { Renderer } from "../ui/renderer";
@@ -12,11 +12,15 @@ import { registerAllCommands } from "./repl-commands";
12
12
  import { probeLspServers } from "../modules/lsp/probe";
13
13
  import { FileOnlyLogger } from "../modules/lsp/client";
14
14
  import { DEFAULT_LSP_CONFIG } from "../modules/lsp/config";
15
+ import { errMsg } from "../utils";
15
16
  import { SessionLogger } from "../core/session-logger";
16
17
  import { formatPlanChecklist, stepContextForTool } from "../ui/plan-view";
17
18
  import { formatCost } from "../modules/pricing/prices";
19
+ import { formatCacheLine } from "./cache-line";
18
20
  import { probeProviders, targetsFromProviders } from "../modules/providers/health";
19
- import { writeWarning } from "../ui/output";
21
+ import { formatWarning } from "../ui/md-formatter";
22
+ import { defaultOutputBus, getDefaultChannel, SessionOutputSink, writeNonTtyLine, } from "../output";
23
+ import { createPromptIO } from "../tools/prompt-io";
20
24
  function formatContextBar(used, limit, compactions, quality) {
21
25
  const pct = Math.min(100, Math.round((used / limit) * 100));
22
26
  const barLen = 20;
@@ -53,6 +57,7 @@ export function createHostBridge(opts) {
53
57
  if (opts.isBusy())
54
58
  opts.interruptRun();
55
59
  },
60
+ subscribeOutput: (fn) => defaultOutputBus.subscribe(fn),
56
61
  };
57
62
  }
58
63
  export class Repl {
@@ -73,6 +78,17 @@ export class Repl {
73
78
  exitOnClose = false;
74
79
  /** Owner of plan state (bootstrap). Null in tests — plan UI degrades. */
75
80
  execModule;
81
+ /**
82
+ * True only once the input prompt frame is actually on screen. The channel's
83
+ * prompt sink stays inactive until then, so the startup banner prints plainly
84
+ * instead of erasing/redrawing a frame that was never drawn.
85
+ */
86
+ promptPresented = false;
87
+ /**
88
+ * Set by `runAgent` so the slash-command path knows the run already restored
89
+ * the prompt and must not present it a second time (e.g. `/run`).
90
+ */
91
+ runHandledPrompt = false;
76
92
  rl;
77
93
  agent;
78
94
  config;
@@ -81,8 +97,25 @@ export class Repl {
81
97
  pluginManager;
82
98
  logger;
83
99
  slog;
100
+ /**
101
+ * Persists `data.persist === true` output events to the active session.
102
+ * Inert until a SessionManager exists; re-bound by `/reload`.
103
+ */
104
+ sessionOutput;
84
105
  envReport;
85
- constructor(agent, config, sessionManager, skillsModule, pluginManager, configDir, baseDir, noAgentsMd, logger, exitOnClose, envReport, historyPath, execModule) {
106
+ /** Background probe of the actually loaded model context (bootstrap). */
107
+ contextProbe;
108
+ /**
109
+ * Single terminal writer for this REPL. Public (not private) because
110
+ * `ReplContext` exposes it to slash commands as `ctx.output`; a private
111
+ * field cannot satisfy a public interface property.
112
+ */
113
+ output;
114
+ /** Bridge that lets the channel draw above the live input frame. */
115
+ promptSink;
116
+ /** Terminal-aware PromptIO installed on the agent's tool executor. */
117
+ promptIO;
118
+ constructor(agent, config, sessionManager, skillsModule, pluginManager, configDir, baseDir, noAgentsMd, logger, exitOnClose, envReport, historyPath, execModule, contextProbe, output) {
86
119
  this.agent = agent;
87
120
  this.config = config;
88
121
  this.exitOnClose = exitOnClose === true;
@@ -91,59 +124,137 @@ export class Repl {
91
124
  this.pluginManager = pluginManager;
92
125
  this.logger = logger;
93
126
  this.envReport = envReport;
127
+ this.contextProbe = contextProbe;
94
128
  this.execModule = execModule;
95
129
  this.slog = new SessionLogger(sessionManager, logger);
130
+ this.bindSessionOutput();
96
131
  this.configDir = configDir || join(homedir(), ".mma");
97
132
  this.baseDir = baseDir || process.cwd();
98
133
  this.noAgentsMd = noAgentsMd === true;
99
134
  this.historyPath = historyPath ?? join(homedir(), ".mma", "repl-history");
100
135
  this.loadHistory();
101
- this.rl = process.stdin.isTTY
102
- ? new LineEditor({
136
+ const editorCompleter = (line) => {
137
+ const [matches, partial] = this.completer.complete(line);
138
+ if (matches.length > 0)
139
+ return [matches, partial];
140
+ return [[], line];
141
+ };
142
+ if (process.stdin.isTTY) {
143
+ this.rl = new LineEditor({
103
144
  input: process.stdin,
104
145
  output: process.stdout,
105
- prompt: pc.cyan(t("repl.you")),
146
+ prompt: this.inputPrompt(),
106
147
  history: this.history,
107
148
  historySize: this.maxHistory,
108
- completer: (line) => {
109
- const [matches, partial] = this.completer.complete(line);
110
- if (matches.length > 0)
111
- return [matches, partial];
112
- return [[], line];
113
- },
114
- })
115
- : readline.createInterface({
149
+ completer: editorCompleter,
150
+ });
151
+ }
152
+ else {
153
+ this.rl = readline.createInterface({
116
154
  input: process.stdin,
117
155
  output: process.stdout,
118
- prompt: pc.cyan(t("repl.you")),
156
+ prompt: this.inputPrompt(),
119
157
  history: this.history,
120
158
  historySize: this.maxHistory,
121
159
  tabSize: 2,
122
- completer: (line) => {
123
- const [matches, partial] = this.completer.complete(line);
124
- if (matches.length > 0)
125
- return [matches, partial];
126
- return [[], line];
127
- },
160
+ completer: editorCompleter,
128
161
  });
162
+ }
163
+ this.output = output ?? getDefaultChannel();
164
+ this.promptSink = {
165
+ isActive: () => this.rl instanceof LineEditor &&
166
+ this.promptPresented &&
167
+ !this.agentRunning &&
168
+ !this.inputLocked,
169
+ printAbove: (text) => {
170
+ if (this.rl instanceof LineEditor) {
171
+ this.rl.printAbove(text);
172
+ return;
173
+ }
174
+ // Non-TTY fallback (readline / piped stdin): no input frame to redraw
175
+ // above, so write directly through the sanctioned machine-output helper
176
+ // — there is no frame for `printAbove` here.
177
+ writeNonTtyLine(text);
178
+ },
179
+ clearScreen: () => {
180
+ if (this.rl instanceof LineEditor)
181
+ this.rl.clearScreen();
182
+ },
183
+ };
184
+ this.output.attachPrompt(this.promptSink);
185
+ // Terminal-aware prompt surface for interactive tools (question/approve).
186
+ // The REPL owns stdin and is in `streaming` mode during a run, so tools
187
+ // must borrow the editor for a question instead of opening a competing
188
+ // readline that would fight the LineEditor's raw mode.
189
+ this.promptIO = createPromptIO({
190
+ ask: (prompt) => this.askViaEditor(prompt),
191
+ print: (line) => this.output.writeLine(line),
192
+ });
193
+ this.agent.setPromptIO?.(this.promptIO);
129
194
  registerAllCommands(this);
130
195
  this.setupCompleter();
131
196
  this.setupListeners();
132
197
  // Expose the agent to plugins (web UI etc.) through a narrow bridge.
133
198
  if (pluginManager) {
134
- pluginManager.setHostBridge(createHostBridge({
135
- isBusy: () => this.agentRunning,
136
- addPendingImage: (url) => {
137
- this.agent.contextManager?.addPendingImage?.({
138
- type: "image_url",
139
- image_url: { url },
140
- });
141
- },
142
- runAgent: (text) => this.runAgent(text),
143
- interruptRun: () => this.agent.shutdown(),
144
- }));
199
+ this.attachHostBridge(pluginManager);
145
200
  }
146
201
  }
202
+ /** (Re)attach the plugin HostBridge. Called at construction and after
203
+ * /reload replaces the PluginManager — without this, a reloaded web-ui
204
+ * plugin silently loses the ability to submit messages. */
205
+ attachHostBridge(pm) {
206
+ const manager = pm ?? this.pluginManager;
207
+ if (!manager)
208
+ return;
209
+ manager.setHostBridge(createHostBridge({
210
+ isBusy: () => this.agentRunning,
211
+ addPendingImage: (url) => {
212
+ this.agent.contextManager?.addPendingImage?.({
213
+ type: "image_url",
214
+ image_url: { url },
215
+ });
216
+ },
217
+ runAgent: (text) => this.runAgent(text),
218
+ interruptRun: () => this.agent.shutdown(),
219
+ }));
220
+ }
221
+ /** (Re)bind the session output sink after construction or `/reload`. */
222
+ bindSessionOutput() {
223
+ this.sessionOutput?.dispose();
224
+ this.sessionOutput = this.sessionManager
225
+ ? new SessionOutputSink(defaultOutputBus, this.sessionManager)
226
+ : undefined;
227
+ }
228
+ /**
229
+ * Перезагрузка агента и модулей (команда /reload). Живёт здесь, а не в
230
+ * repl-commands, потому что меняет mutable-поля класса — команда через
231
+ * интерфейс только вызывает `ctx.reload()`, не ломая readonly-контракт.
232
+ */
233
+ async reload() {
234
+ const { loadConfig } = await import("../config/config");
235
+ const { bootstrap } = await import("../core/bootstrap");
236
+ this.agent.shutdown();
237
+ const projectConfigPath = join(this.baseDir, ".mmrc");
238
+ const { config: freshConfig } = loadConfig({
239
+ configDir: this.configDir,
240
+ projectConfigPath,
241
+ });
242
+ Object.assign(this.config, freshConfig);
243
+ const result = await bootstrap(this.configDir, this.baseDir, false, false);
244
+ this.agent = result.agent;
245
+ // Re-install the prompt surface on the freshly bootstrapped agent — the
246
+ // REPL's `askViaEditor` closure still belongs to this instance.
247
+ this.agent.setPromptIO?.(this.promptIO);
248
+ this.sessionManager = result.sessionManager;
249
+ this.skillsModule = result.skillsModule;
250
+ this.pluginManager = result.pluginManager;
251
+ this.execModule = result.execModule;
252
+ this.bindSessionOutput();
253
+ // Fresh PluginManager has no bridge — re-register it, otherwise plugins
254
+ // like web-ui silently lose control of the agent.
255
+ this.attachHostBridge(result.pluginManager);
256
+ this.setupCompleter();
257
+ }
147
258
  loadHistory() {
148
259
  if (existsSync(this.historyPath)) {
149
260
  try {
@@ -156,10 +267,21 @@ export class Repl {
156
267
  }
157
268
  }
158
269
  saveHistory() {
159
- const allHistory = this.history.slice(-this.maxHistory);
160
- writeFileSync(this.historyPath, allHistory.join("\n"), "utf-8");
270
+ try {
271
+ // The history dir may be gone (e.g. a temp dir removed during test or
272
+ // session teardown). Recreate it and never let a save throw.
273
+ mkdirSync(dirname(this.historyPath), { recursive: true });
274
+ const allHistory = this.history.slice(-this.maxHistory);
275
+ writeFileSync(this.historyPath, allHistory.join("\n"), "utf-8");
276
+ }
277
+ catch (e) {
278
+ this.logger?.debug(`Failed to save REPL history: ${e.message}`);
279
+ }
161
280
  }
162
281
  setupCompleter() {
282
+ // Rebuild from scratch: /reload calls this again after bootstrap, and
283
+ // re-registering without a reset would duplicate every provider.
284
+ this.completer.reset();
163
285
  const slashCommands = Array.from(this.commands.keys());
164
286
  this.completer.registerProvider(new SlashCommandProvider(slashCommands));
165
287
  if (this.sessionManager) {
@@ -170,7 +292,7 @@ export class Repl {
170
292
  this.completer.registerProvider(new SkillNameProvider(this.skillsModule));
171
293
  }
172
294
  // Subcommand + argument completion for provider/model management.
173
- this.completer.registerProvider(new SubcommandProvider("provider", ["list", "use"]));
295
+ this.completer.registerProvider(new SubcommandProvider("provider", ["list", "use", "add"]));
174
296
  this.completer.registerProvider(new SubcommandProvider("model", ["list", "use"]));
175
297
  const entryLabels = this.config.provider?.entries?.map((e) => e.label || e.type) ?? [];
176
298
  if (entryLabels.length > 0) {
@@ -232,18 +354,15 @@ export class Repl {
232
354
  if (fullInput) {
233
355
  if (fullInput.startsWith("/")) {
234
356
  await this.executeCommand(fullInput);
357
+ this.afterCommand();
235
358
  }
236
359
  else {
237
360
  await this.runAgent(fullInput);
238
361
  }
239
362
  }
240
- if (this.running) {
241
- this.rl.setPrompt(pc.cyan(t("repl.you")));
242
- this.rl.prompt();
243
- }
244
363
  }
245
364
  else {
246
- this.rl.setPrompt(pc.cyan(t("repl.you") + "… "));
365
+ this.rl.setPrompt(this.inputPrompt(true));
247
366
  this.rl.prompt();
248
367
  }
249
368
  return;
@@ -251,25 +370,22 @@ export class Repl {
251
370
  if (this.isMultiLineInput(trimmed)) {
252
371
  inMultiLine = true;
253
372
  multiLineBuffer = trimmed;
254
- this.rl.setPrompt(pc.cyan(t("repl.you") + "… "));
373
+ this.rl.setPrompt(this.inputPrompt(true));
255
374
  this.rl.prompt();
256
375
  return;
257
376
  }
258
377
  if (!trimmed) {
259
- this.rl.setPrompt(pc.cyan(t("repl.you")));
378
+ this.rl.setPrompt(this.inputPrompt());
260
379
  this.rl.prompt();
261
380
  return;
262
381
  }
263
382
  if (trimmed.startsWith("/")) {
264
383
  await this.executeCommand(trimmed);
384
+ this.afterCommand();
265
385
  }
266
386
  else {
267
387
  await this.runAgent(trimmed);
268
388
  }
269
- if (this.running) {
270
- this.rl.setPrompt(pc.cyan(t("repl.you")));
271
- this.rl.prompt();
272
- }
273
389
  });
274
390
  this.rl.on("close", () => {
275
391
  this.running = false;
@@ -303,7 +419,7 @@ export class Repl {
303
419
  }
304
420
  Repl.sigintHandler = () => {
305
421
  if (this.agentRunning) {
306
- console.log(pc.yellow(t("repl.ctrl_c_interrupt")));
422
+ this.output.writeLine(pc.yellow(t("repl.ctrl_c_interrupt")));
307
423
  this.agent.shutdown();
308
424
  this.agentRunning = false;
309
425
  if (forceExitTimer)
@@ -316,6 +432,56 @@ export class Repl {
316
432
  };
317
433
  process.on("SIGINT", Repl.sigintHandler);
318
434
  }
435
+ /**
436
+ * Re-present the input prompt after a slash command. Commands do not own a
437
+ * frame lifecycle, so they need this explicit redraw. The agent path is
438
+ * deliberately excluded: `runAgent`'s finally calls `LineEditor.resumeFrame()`
439
+ * as its single prompt presentation, so prompting here again would double-draw.
440
+ */
441
+ presentPrompt() {
442
+ if (!this.running)
443
+ return;
444
+ this.rl.setPrompt(this.inputPrompt());
445
+ this.rl.prompt();
446
+ this.promptPresented = true;
447
+ }
448
+ /**
449
+ * Reasoning label shown in the input prompt. In `auto` mode the effective
450
+ * level is normally decided per iteration by the policy, so we show only the
451
+ * fact that reasoning is automatic. If the user set an explicit override
452
+ * (`/reasoning <level>`, persistent) or the agent set a still-active
453
+ * `set_thinking` override (transient), we show `auto→<level>`. In a fixed
454
+ * mode we show the effective level.
455
+ */
456
+ reasoningLabel() {
457
+ const mode = this.config.reasoning?.mode ?? "default";
458
+ if (mode !== "auto")
459
+ return this.agent.reasoningLevel ?? mode;
460
+ const rs = this.agent.reasoningState;
461
+ const cooldown = this.config.reasoning?.overrideCooldown ?? 0;
462
+ const transientActive = rs.overrideIteration >= 0 && this.agent.currentIteration - rs.overrideIteration <= cooldown;
463
+ if (rs.manual || transientActive)
464
+ return `auto→${this.agent.reasoningLevel}`;
465
+ return "auto";
466
+ }
467
+ /** Localized input prompt embedding the active reasoning mode/level. */
468
+ inputPrompt(continuation = false) {
469
+ const key = continuation ? "repl.you_reasoning_cont" : "repl.you_reasoning";
470
+ return pc.cyan(t(key, { level: this.reasoningLabel() }));
471
+ }
472
+ /**
473
+ * Prompt bookkeeping after a slash command. A command that drove a run
474
+ * (e.g. `/run`) already had its prompt restored by `runAgent`; presenting
475
+ * again would double-render, so skip it exactly once. All other commands
476
+ * present the prompt here.
477
+ */
478
+ afterCommand() {
479
+ if (this.runHandledPrompt) {
480
+ this.runHandledPrompt = false;
481
+ return;
482
+ }
483
+ this.presentPrompt();
484
+ }
319
485
  handleSpecialKey(str, key) {
320
486
  if (key.name === "escape") {
321
487
  // Bun's keypress decoder collapses a fast double-Esc into a single
@@ -329,7 +495,7 @@ export class Repl {
329
495
  if (escBytes >= 2 || withinWindow) {
330
496
  this.lastEscTime = 0;
331
497
  if (this.agentRunning) {
332
- process.stdout.write(pc.yellow(`\n${t("repl.interrupt")}\n`));
498
+ this.output.writeLine(pc.yellow(`\n${t("repl.interrupt")}`));
333
499
  this.agent.shutdown();
334
500
  }
335
501
  }
@@ -349,13 +515,13 @@ export class Repl {
349
515
  const { dataUrl } = await bufferToDataUrl(clipBuf);
350
516
  this.pendingClipboardImage = dataUrl;
351
517
  const sizeKb = Math.round((dataUrl.length * 3) / 4 / 1024);
352
- console.log(pc.green(`\n${t("image.attached", { source: "clipboard", size: `${sizeKb} KB` })}`));
518
+ this.output.writeLine(pc.green(`\n${t("image.attached", { source: "clipboard", size: `${sizeKb} KB` })}`));
353
519
  if (this.rl instanceof LineEditor)
354
520
  this.rl.reset();
355
521
  this.rl.prompt();
356
522
  }
357
523
  else {
358
- console.log(pc.yellow(`\n${t("image.clipboard_empty")}`));
524
+ this.output.writeLine(pc.yellow(`\n${t("image.clipboard_empty")}`));
359
525
  if (this.rl instanceof LineEditor)
360
526
  this.rl.reset();
361
527
  this.rl.prompt();
@@ -390,23 +556,35 @@ export class Repl {
390
556
  this.pendingClipboardImage = null;
391
557
  }
392
558
  this.logger?.logREPL("user", input);
393
- if (this.rl instanceof LineEditor)
559
+ // The prompt frame is about to be taken down; deactivate the sink before
560
+ // any streamed output so nothing tries to draw above it.
561
+ this.promptPresented = false;
562
+ // Hand the terminal to the channel for the duration of the run: the
563
+ // editor stops drawing its frame and ignores typed keys, so streamed
564
+ // output cannot corrupt the input line. suspendFrame() runs before
565
+ // reset(), which would otherwise draw a frame only to erase it.
566
+ if (this.rl instanceof LineEditor) {
567
+ this.rl.suspendFrame();
568
+ this.rl.setInputEnabled(false);
394
569
  this.rl.reset();
395
- // Overwrite the empty prompt that reset() just drew with a divider,
396
- // then label the agent's reply — separates the user's message from
397
- // the agent's answer.
398
- process.stdout.write("\r" + divider() + "\n" + pc.green(t("repl.agent")));
570
+ }
571
+ this.output.beginStreaming();
572
+ // Divider + agent label, written raw (no trailing newline): the next
573
+ // Renderer.text() chunk adds its own leading "\n" ("- ..."), so a
574
+ // writeLine here would insert a blank line ("Agent:\n\n- ...").
575
+ this.output.writeRaw(`\r${divider()}\n${pc.green(t("repl.agent"))}`);
399
576
  const renderer = new Renderer({
400
577
  spinner: this.config.ui?.spinner ?? true,
401
- toolStyle: this.config.ui?.toolStyle ?? "inline",
402
578
  baseDir: this.baseDir,
579
+ channel: this.output,
403
580
  });
581
+ renderer.showLoader();
404
582
  const result = await this.agent.run(input, (c) => renderer.text(c), (m) => renderer.meta(m), (ev) => {
405
583
  if (ev.type === "start") {
406
584
  renderer.toolStart(ev.tool, ev.args, stepContextForTool(this.execModule?.getActivePlan() ?? null, ev.tool, ev.args), ev.icon);
407
585
  }
408
586
  else {
409
- renderer.toolEnd(ev.tool, ev.duration ?? 0, ev.error, ev.ctxDelta, ev.costUsd, ev.provider, ev.model);
587
+ renderer.toolEnd(ev.tool, ev.duration ?? 0, ev.error, ev.ctxDelta, ev.costUsd);
410
588
  if (ev.tool === "plan" || ev.tool === "todo") {
411
589
  this.renderPlan(renderer);
412
590
  }
@@ -420,18 +598,39 @@ export class Repl {
420
598
  }
421
599
  });
422
600
  renderer.flush();
423
- process.stdout.write("\n");
601
+ if (result.provider && result.model) {
602
+ renderer.footer(result.model, result.provider, result.llmDurationMs ?? result.durationMs ?? 0);
603
+ }
604
+ this.output.writeLine("");
424
605
  this.logger?.logREPL(result.success ? "assistant" : "system", result.text?.slice(0, 400) || result.error || "");
425
606
  if (!result.success) {
426
- console.error(pc.red(`${t("error.prefix")}${result.error}`));
607
+ this.output.writeLine(pc.red(`${t("error.prefix")}${result.error}`), "stderr");
427
608
  }
428
609
  this.showContextBar(result);
429
610
  // Close the agent's turn: a divider before the next prompt keeps
430
611
  // consecutive user/agent messages visually separated.
431
- process.stdout.write("\n" + divider() + "\n");
612
+ this.output.writeLine(`\n${divider()}`);
432
613
  }
433
614
  finally {
434
615
  this.agentRunning = false;
616
+ this.output.endStreaming();
617
+ if (this.rl instanceof LineEditor) {
618
+ this.rl.setInputEnabled(true);
619
+ // Single prompt presentation for the agent path: resumeFrame()
620
+ // redraws the frame, so the `line` listener must NOT prompt again
621
+ // after runAgent() (it still does after slash commands).
622
+ // Re-set the prompt first so a reasoning level changed by
623
+ // set_thinking during the run is reflected on the resumed frame.
624
+ this.rl.setPrompt(this.inputPrompt());
625
+ this.rl.resumeFrame();
626
+ this.promptPresented = true;
627
+ }
628
+ else {
629
+ // Piped/non-TTY fallback has no frame to resume; present the readline
630
+ // prompt here to preserve the old post-run behavior.
631
+ this.presentPrompt();
632
+ }
633
+ this.runHandledPrompt = true;
435
634
  }
436
635
  }
437
636
  /** Print the checklist of the plan currently tracked by ExecutionModule.
@@ -453,6 +652,11 @@ export class Repl {
453
652
  }
454
653
  }
455
654
  async withExclusiveInput(fn) {
655
+ // The wizard owns the terminal through its own `rl.question()` calls. Between
656
+ // questions no frame is active, so its menus/scan results must be written
657
+ // directly — bracketing with beginModal/endModal would QUEUE the whole
658
+ // transcript and the user would only see bare `: ` prompts until completion.
659
+ // `inputLocked` makes `promptSink.isActive()` false, routing writes direct.
456
660
  this.inputLocked = true;
457
661
  try {
458
662
  await fn();
@@ -461,22 +665,60 @@ export class Repl {
461
665
  this.inputLocked = false;
462
666
  }
463
667
  }
668
+ /**
669
+ * Borrow the terminal for one interactive-tool question and hand it back.
670
+ * During a run the LineEditor is suspended with input disabled; enabling
671
+ * input + resuming the frame lets `question()` read keys while the agent
672
+ * blocks, and the `finally` restores exactly the streaming state so the
673
+ * agent's own renderer keeps working afterwards.
674
+ *
675
+ * Order matters: `beginModal()` first flushes any output still buffered by
676
+ * the streaming channel so it prints plainly before the frame returns;
677
+ * `endModal()` runs only after the frame is suspended again, so events
678
+ * queued during the question flush plainly instead of corrupting the frame.
679
+ */
680
+ async askViaEditor(prompt) {
681
+ const editor = this.rl instanceof LineEditor ? this.rl : null;
682
+ const wasSuspended = editor ? editor.isFrameSuspended() : true;
683
+ const wasEnabled = editor ? editor.isInputEnabled() : false;
684
+ this.output.beginModal();
685
+ if (editor) {
686
+ editor.setInputEnabled(true);
687
+ editor.resumeFrame();
688
+ }
689
+ try {
690
+ return await new Promise((resolve) => this.rl.question(prompt, resolve));
691
+ }
692
+ finally {
693
+ if (editor) {
694
+ editor.setInputEnabled(wasEnabled);
695
+ if (wasSuspended)
696
+ editor.suspendFrame();
697
+ else
698
+ editor.resumeFrame();
699
+ }
700
+ this.output.endModal();
701
+ }
702
+ }
464
703
  async executeCommand(input) {
465
704
  const parts = input.split(/\s+/);
466
705
  const name = parts[0].slice(1);
467
706
  const args = parts.slice(1);
707
+ // Clear any stale run flag from a previous direct agent run so this
708
+ // command's own presentPrompt() decision is based on its own action.
709
+ this.runHandledPrompt = false;
468
710
  const cmd = this.commands.get(name);
469
711
  if (!cmd) {
470
- console.log(pc.red(t("cli.unknown_cmd", { name })), t("cli.help_hint"));
712
+ this.output.writeLine(`${pc.red(t("cli.unknown_cmd", { name }))} ${t("cli.help_hint")}`);
471
713
  return;
472
714
  }
473
715
  try {
474
716
  await cmd.action(args);
475
717
  }
476
718
  catch (err) {
477
- console.error(pc.red(t("error.command_error", {
478
- message: err instanceof Error ? err.message : String(err),
479
- })));
719
+ this.output.writeLine(pc.red(t("error.command_error", {
720
+ message: errMsg(err),
721
+ })), "stderr");
480
722
  }
481
723
  }
482
724
  showHelp() {
@@ -498,15 +740,15 @@ export class Repl {
498
740
  }
499
741
  if (cmds.length === 0)
500
742
  continue;
501
- console.log(pc.bold(t(`repl.group.${groupKey}`)));
743
+ this.output.writeLine(pc.bold(t(`repl.group.${groupKey}`)));
502
744
  for (const cmd of cmds) {
503
745
  const aliases = cmd.aliases?.length ? ` (${pc.dim(cmd.aliases.join(", "))})` : "";
504
- console.log(` ${pc.cyan("/" + cmd.name)}${aliases} ${pc.dim(cmd.description)}`);
746
+ this.output.writeLine(` ${pc.cyan("/" + cmd.name)}${aliases} ${pc.dim(cmd.description)}`);
505
747
  if (cmd.usage) {
506
- console.log(` ${pc.dim(cmd.usage)}`);
748
+ this.output.writeLine(` ${pc.dim(cmd.usage)}`);
507
749
  }
508
750
  }
509
- console.log();
751
+ this.output.writeLine("");
510
752
  }
511
753
  }
512
754
  lastCompactionShown = 0;
@@ -518,21 +760,25 @@ export class Repl {
518
760
  }
519
761
  const ui = this.config.ui;
520
762
  if (ui?.showContextStats) {
521
- console.log();
763
+ this.output.writeLine("");
522
764
  const ctxLine = formatContextBar(result.contextUsed, result.contextLimit, result.compactionCount, result.contextQuality);
523
- console.log(ctxLine);
765
+ this.output.writeLine(ctxLine);
524
766
  if (result.totalTokens !== undefined && result.totalTokens > 0) {
525
767
  const apiLine = pc.dim(` API: ${result.promptTokens} prompt + ${result.completionTokens} completion = ${result.totalTokens} total`);
526
- console.log(apiLine);
768
+ this.output.writeLine(apiLine);
527
769
  }
528
770
  if (result.totalCost !== undefined && result.totalCost > 0) {
529
- console.log(pc.yellow(` ${t("repl.cost", { cost: formatCost(result.totalCost) })}`));
771
+ this.output.writeLine(pc.yellow(` ${t("repl.cost", { cost: formatCost(result.totalCost) })}`));
772
+ }
773
+ const cacheLine = formatCacheLine(result.cache);
774
+ if (cacheLine) {
775
+ this.output.writeLine(pc.dim(` ${cacheLine}`));
530
776
  }
531
777
  }
532
778
  else if (ui?.showCompaction && result.compactionCount !== undefined) {
533
779
  if (result.compactionCount > this.lastCompactionShown) {
534
780
  this.lastCompactionShown = result.compactionCount;
535
- console.log(pc.dim(`\n ⟳ Context compacted (${result.compactionCount})`));
781
+ this.output.writeLine(pc.dim(`\n ⟳ Context compacted (${result.compactionCount})`));
536
782
  }
537
783
  }
538
784
  }
@@ -574,8 +820,9 @@ export class Repl {
574
820
  const names = enabledServers.map(([name]) => name).join(", ");
575
821
  row(t("repl.mcp_label"), `${pc.white(String(enabledServers.length))} ${pc.dim(`(${names})`)}`);
576
822
  }
577
- const cwd = process.cwd();
578
- row(t("repl.work_dir"), pc.dim(cwd));
823
+ // Show the agent's workspace (the `-d` dir), not the process cwd: `bun run`
824
+ // sets cwd to the package root, which is NOT where the agent operates.
825
+ row(t("repl.work_dir"), pc.dim(this.baseDir));
579
826
  if (this.noAgentsMd) {
580
827
  row(t("repl.agents_label"), pc.red(t("repl.disabled")));
581
828
  }
@@ -605,9 +852,9 @@ export class Repl {
605
852
  const lspEnabled = isTty && (this.config.lsp ?? DEFAULT_LSP_CONFIG).enabled !== false;
606
853
  // Plain banner without a surrounding box — line-based, wraps to width.
607
854
  for (const line of info) {
608
- console.log(pc.dim(line).trimEnd());
855
+ this.output.writeLine(pc.dim(line).trimEnd());
609
856
  }
610
- console.log();
857
+ this.output.writeLine("");
611
858
  // Legacy config hint
612
859
  try {
613
860
  const { existsSync: exists } = await import("fs");
@@ -615,19 +862,22 @@ export class Repl {
615
862
  const { hasDomainFiles } = await import("../config/domains");
616
863
  const legacyPath = pathJoin(this.configDir, "config.json");
617
864
  if (exists(legacyPath) && !hasDomainFiles(this.configDir)) {
618
- console.log(pc.yellow(` ${t("config.legacy_hint")}`));
619
- console.log();
865
+ this.output.writeLine(pc.yellow(` ${t("config.legacy_hint")}`));
866
+ this.output.writeLine("");
620
867
  }
621
868
  }
622
869
  catch { }
870
+ // Single prompt presentation for startup: the banner above was written
871
+ // plainly (promptPresented was false), so no frame needs erasing here.
623
872
  this.rl.prompt();
873
+ this.promptPresented = true;
624
874
  if (lspEnabled) {
625
875
  this.probeLspBanner()
626
876
  .then((lspSummary) => {
627
877
  if (lspSummary) {
628
878
  const line = `${pc.green(t("repl.agent"))}${pc.yellow(t("repl.lsp_label"))} ${lspSummary}`;
629
- process.stdout.write(`\x1b[2K\r${divider()}\n${line}\n`);
630
- this.rl.prompt();
879
+ // Channel owns the prompt row now: no manual erase + prompt() hack.
880
+ this.output.writeLine(`${divider()}\n${line}`);
631
881
  }
632
882
  })
633
883
  .catch(() => { });
@@ -645,12 +895,37 @@ export class Repl {
645
895
  .then((results) => {
646
896
  for (const r of results) {
647
897
  if (!r.ok && r.name !== this.config.provider.active) {
648
- writeWarning(t("repl.provider_down", { name: r.name, error: r.error ?? "" }));
898
+ this.output.writeLine(formatWarning(t("repl.provider_down", { name: r.name, error: r.error ?? "" })));
649
899
  }
650
900
  }
651
901
  })
652
902
  .catch(() => { });
653
903
  }
904
+ // Context probe (background): compare the context length the model is
905
+ // actually loaded with against the configured window. Same non-blocking
906
+ // banner pattern as the LSP probe above.
907
+ if (this.contextProbe) {
908
+ void this.contextProbe
909
+ .then((probe) => {
910
+ if (!probe)
911
+ return;
912
+ this.sessionManager?.appendLog({
913
+ ts: new Date().toISOString(),
914
+ type: "context_probe",
915
+ content: `model=${probe.model} actual=${probe.actual} configured=${this.config.contextWindow}`,
916
+ });
917
+ const line = probe.actual < this.config.contextWindow
918
+ ? `${pc.yellow("⚠")} ${t("repl.context_probe_small", { model: probe.model, actual: probe.actual, configured: this.config.contextWindow })}`
919
+ : probe.actual > this.config.contextWindow
920
+ ? `${pc.dim(t("repl.context_probe_big", { model: probe.model, actual: probe.actual }))}`
921
+ : null;
922
+ if (line) {
923
+ // Channel owns the prompt row now: no manual erase + prompt() hack.
924
+ this.output.writeLine(`${divider()}\n${line}`);
925
+ }
926
+ })
927
+ .catch(() => { });
928
+ }
654
929
  }
655
930
  /**
656
931
  * Probe the LSP servers applicable to the current project and format a
@@ -695,6 +970,8 @@ export class Repl {
695
970
  this.running = false;
696
971
  this.saveHistory();
697
972
  this.agent.shutdown();
973
+ this.sessionOutput?.dispose();
974
+ this.sessionOutput = undefined;
698
975
  // Close session log when REPL stops
699
976
  this.logger?.closeSessionLog();
700
977
  this.rl.close();