fixo-cli 1.0.4 → 2.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.

Potentially problematic release.


This version of fixo-cli might be problematic. Click here for more details.

Files changed (199) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +18 -14
  3. package/dist/agent/agent-client.d.ts +28 -6
  4. package/dist/agent/agent-client.d.ts.map +1 -1
  5. package/dist/agent/agent-client.js +118 -39
  6. package/dist/agent/agent-client.js.map +1 -1
  7. package/dist/agent/agent-pool.d.ts +55 -6
  8. package/dist/agent/agent-pool.d.ts.map +1 -1
  9. package/dist/agent/agent-pool.js +120 -20
  10. package/dist/agent/agent-pool.js.map +1 -1
  11. package/dist/agent/auto-verifier.d.ts +55 -0
  12. package/dist/agent/auto-verifier.d.ts.map +1 -0
  13. package/dist/agent/auto-verifier.js +50 -0
  14. package/dist/agent/auto-verifier.js.map +1 -0
  15. package/dist/agent/command-parser.d.ts.map +1 -1
  16. package/dist/agent/command-parser.js +176 -0
  17. package/dist/agent/command-parser.js.map +1 -1
  18. package/dist/agent/context-builder.d.ts +24 -0
  19. package/dist/agent/context-builder.d.ts.map +1 -0
  20. package/dist/agent/context-builder.js +197 -0
  21. package/dist/agent/context-builder.js.map +1 -0
  22. package/dist/agent/conversation.d.ts +14 -1
  23. package/dist/agent/conversation.d.ts.map +1 -1
  24. package/dist/agent/conversation.js +53 -7
  25. package/dist/agent/conversation.js.map +1 -1
  26. package/dist/agent/mcp-bridge.js +1 -1
  27. package/dist/agent/mcp-bridge.js.map +1 -1
  28. package/dist/agent/orchestrator.d.ts +45 -0
  29. package/dist/agent/orchestrator.d.ts.map +1 -1
  30. package/dist/agent/orchestrator.js +140 -3
  31. package/dist/agent/orchestrator.js.map +1 -1
  32. package/dist/agent/parser-adapter.d.ts +17 -0
  33. package/dist/agent/parser-adapter.d.ts.map +1 -1
  34. package/dist/agent/parser-adapter.js +254 -2
  35. package/dist/agent/parser-adapter.js.map +1 -1
  36. package/dist/agent/predictive-gate.d.ts.map +1 -1
  37. package/dist/agent/predictive-gate.js +4 -1
  38. package/dist/agent/predictive-gate.js.map +1 -1
  39. package/dist/agent/providers-manager.d.ts +5 -0
  40. package/dist/agent/providers-manager.d.ts.map +1 -1
  41. package/dist/agent/providers-manager.js +119 -8
  42. package/dist/agent/providers-manager.js.map +1 -1
  43. package/dist/agent/repo-map.d.ts +18 -1
  44. package/dist/agent/repo-map.d.ts.map +1 -1
  45. package/dist/agent/repo-map.js +144 -54
  46. package/dist/agent/repo-map.js.map +1 -1
  47. package/dist/agent/retry.js +1 -2
  48. package/dist/agent/retry.js.map +1 -1
  49. package/dist/agent/single-agent.d.ts.map +1 -1
  50. package/dist/agent/single-agent.js +129 -22
  51. package/dist/agent/single-agent.js.map +1 -1
  52. package/dist/agent/skills.d.ts.map +1 -1
  53. package/dist/agent/skills.js +2 -1
  54. package/dist/agent/skills.js.map +1 -1
  55. package/dist/agent/subagent.js +2 -2
  56. package/dist/agent/subagent.js.map +1 -1
  57. package/dist/agent/task-router.d.ts +46 -0
  58. package/dist/agent/task-router.d.ts.map +1 -0
  59. package/dist/agent/task-router.js +352 -0
  60. package/dist/agent/task-router.js.map +1 -0
  61. package/dist/agent/telemetry.d.ts +29 -1
  62. package/dist/agent/telemetry.d.ts.map +1 -1
  63. package/dist/agent/telemetry.js +25 -10
  64. package/dist/agent/telemetry.js.map +1 -1
  65. package/dist/agent/tool-definitions.d.ts +3 -0
  66. package/dist/agent/tool-definitions.d.ts.map +1 -0
  67. package/dist/agent/tool-definitions.js +519 -0
  68. package/dist/agent/tool-definitions.js.map +1 -0
  69. package/dist/agent/tool-executor.d.ts +6 -1
  70. package/dist/agent/tool-executor.d.ts.map +1 -1
  71. package/dist/agent/tool-executor.js +99 -553
  72. package/dist/agent/tool-executor.js.map +1 -1
  73. package/dist/agent/tools/command-tools.d.ts +6 -0
  74. package/dist/agent/tools/command-tools.d.ts.map +1 -0
  75. package/dist/agent/tools/command-tools.js +104 -0
  76. package/dist/agent/tools/command-tools.js.map +1 -0
  77. package/dist/agent/tools/file-tools.d.ts +15 -0
  78. package/dist/agent/tools/file-tools.d.ts.map +1 -0
  79. package/dist/agent/tools/file-tools.js +551 -0
  80. package/dist/agent/tools/file-tools.js.map +1 -0
  81. package/dist/agent/tools/todo-tools.d.ts +3 -0
  82. package/dist/agent/tools/todo-tools.d.ts.map +1 -0
  83. package/dist/agent/tools/todo-tools.js +70 -0
  84. package/dist/agent/tools/todo-tools.js.map +1 -0
  85. package/dist/agent/web-impl.d.ts.map +1 -1
  86. package/dist/agent/web-impl.js +45 -0
  87. package/dist/agent/web-impl.js.map +1 -1
  88. package/dist/agent/worker-agent.d.ts +3 -1
  89. package/dist/agent/worker-agent.d.ts.map +1 -1
  90. package/dist/agent/worker-agent.js +51 -14
  91. package/dist/agent/worker-agent.js.map +1 -1
  92. package/dist/config.d.ts +242 -0
  93. package/dist/config.d.ts.map +1 -1
  94. package/dist/config.js +79 -0
  95. package/dist/config.js.map +1 -1
  96. package/dist/git/git-manager.d.ts +33 -2
  97. package/dist/git/git-manager.d.ts.map +1 -1
  98. package/dist/git/git-manager.js +111 -15
  99. package/dist/git/git-manager.js.map +1 -1
  100. package/dist/git/git-ops.d.ts.map +1 -1
  101. package/dist/git/git-ops.js +2 -1
  102. package/dist/git/git-ops.js.map +1 -1
  103. package/dist/index.js +85 -8
  104. package/dist/index.js.map +1 -1
  105. package/dist/lsp/lsp-manager.js +1 -1
  106. package/dist/lsp/lsp-manager.js.map +1 -1
  107. package/dist/model-outcomes.d.ts.map +1 -1
  108. package/dist/model-outcomes.js +2 -1
  109. package/dist/model-outcomes.js.map +1 -1
  110. package/dist/planner.d.ts +0 -9
  111. package/dist/planner.d.ts.map +1 -1
  112. package/dist/planner.js +0 -9
  113. package/dist/planner.js.map +1 -1
  114. package/dist/project-memory.d.ts +12 -1
  115. package/dist/project-memory.d.ts.map +1 -1
  116. package/dist/project-memory.js +8 -6
  117. package/dist/project-memory.js.map +1 -1
  118. package/dist/runtime/loop-mitigation.d.ts +78 -7
  119. package/dist/runtime/loop-mitigation.d.ts.map +1 -1
  120. package/dist/runtime/loop-mitigation.js +122 -9
  121. package/dist/runtime/loop-mitigation.js.map +1 -1
  122. package/dist/runtime/os-sandbox.d.ts +100 -0
  123. package/dist/runtime/os-sandbox.d.ts.map +1 -0
  124. package/dist/runtime/os-sandbox.js +246 -0
  125. package/dist/runtime/os-sandbox.js.map +1 -0
  126. package/dist/runtime/run-inventory.d.ts +17 -0
  127. package/dist/runtime/run-inventory.d.ts.map +1 -0
  128. package/dist/runtime/run-inventory.js +49 -0
  129. package/dist/runtime/run-inventory.js.map +1 -0
  130. package/dist/runtime/staging.d.ts.map +1 -1
  131. package/dist/runtime/staging.js +4 -1
  132. package/dist/runtime/staging.js.map +1 -1
  133. package/dist/runtime/task-session.d.ts +14 -0
  134. package/dist/runtime/task-session.d.ts.map +1 -1
  135. package/dist/runtime/task-session.js +26 -0
  136. package/dist/runtime/task-session.js.map +1 -1
  137. package/dist/setup-wizard.d.ts +11 -3
  138. package/dist/setup-wizard.d.ts.map +1 -1
  139. package/dist/setup-wizard.js +113 -15
  140. package/dist/setup-wizard.js.map +1 -1
  141. package/dist/types.d.ts +8 -0
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/ui/commands/context-commands.d.ts +7 -0
  144. package/dist/ui/commands/context-commands.d.ts.map +1 -0
  145. package/dist/ui/commands/context-commands.js +241 -0
  146. package/dist/ui/commands/context-commands.js.map +1 -0
  147. package/dist/ui/commands/index.d.ts +3 -0
  148. package/dist/ui/commands/index.d.ts.map +1 -0
  149. package/dist/ui/commands/index.js +46 -0
  150. package/dist/ui/commands/index.js.map +1 -0
  151. package/dist/ui/commands/info-commands.d.ts +15 -0
  152. package/dist/ui/commands/info-commands.d.ts.map +1 -0
  153. package/dist/ui/commands/info-commands.js +122 -0
  154. package/dist/ui/commands/info-commands.js.map +1 -0
  155. package/dist/ui/commands/model-commands.d.ts +5 -0
  156. package/dist/ui/commands/model-commands.d.ts.map +1 -0
  157. package/dist/ui/commands/model-commands.js +417 -0
  158. package/dist/ui/commands/model-commands.js.map +1 -0
  159. package/dist/ui/commands/session-commands.d.ts +5 -0
  160. package/dist/ui/commands/session-commands.d.ts.map +1 -0
  161. package/dist/ui/commands/session-commands.js +154 -0
  162. package/dist/ui/commands/session-commands.js.map +1 -0
  163. package/dist/ui/commands/task-commands.d.ts +8 -0
  164. package/dist/ui/commands/task-commands.d.ts.map +1 -0
  165. package/dist/ui/commands/task-commands.js +152 -0
  166. package/dist/ui/commands/task-commands.js.map +1 -0
  167. package/dist/ui/commands/types.d.ts +46 -0
  168. package/dist/ui/commands/types.d.ts.map +1 -0
  169. package/dist/ui/commands/types.js +2 -0
  170. package/dist/ui/commands/types.js.map +1 -0
  171. package/dist/ui/commands/workspace-commands.d.ts +8 -0
  172. package/dist/ui/commands/workspace-commands.d.ts.map +1 -0
  173. package/dist/ui/commands/workspace-commands.js +131 -0
  174. package/dist/ui/commands/workspace-commands.js.map +1 -0
  175. package/dist/ui/loading-animation.d.ts +24 -0
  176. package/dist/ui/loading-animation.d.ts.map +1 -0
  177. package/dist/ui/loading-animation.js +123 -0
  178. package/dist/ui/loading-animation.js.map +1 -0
  179. package/dist/ui/markdown-stream.js +2 -2
  180. package/dist/ui/markdown-stream.js.map +1 -1
  181. package/dist/ui/prompt.d.ts +7 -0
  182. package/dist/ui/prompt.d.ts.map +1 -1
  183. package/dist/ui/prompt.js +435 -1214
  184. package/dist/ui/prompt.js.map +1 -1
  185. package/dist/ui/render-primitives.d.ts +6 -0
  186. package/dist/ui/render-primitives.d.ts.map +1 -1
  187. package/dist/ui/render-primitives.js +30 -13
  188. package/dist/ui/render-primitives.js.map +1 -1
  189. package/dist/ui/render.d.ts.map +1 -1
  190. package/dist/ui/render.js +2 -0
  191. package/dist/ui/render.js.map +1 -1
  192. package/package.json +17 -3
  193. package/scripts/check-vendor-wasm.js +11 -0
  194. package/vendor/tree-sitter-go.wasm +0 -0
  195. package/vendor/tree-sitter-javascript.wasm +0 -0
  196. package/vendor/tree-sitter-python.wasm +0 -0
  197. package/vendor/tree-sitter-rust.wasm +0 -0
  198. package/vendor/tree-sitter-tsx.wasm +0 -0
  199. package/vendor/tree-sitter-typescript.wasm +0 -0
package/dist/ui/prompt.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commandRegistry } from './commands/index.js';
1
2
  /**
2
3
  * Interactive REPL shell for FixO CLI.
3
4
  * Provides command handling, file pinning, model selection,
@@ -11,26 +12,17 @@ import * as p from '@clack/prompts';
11
12
  import { SingleAgent } from '../agent/single-agent.js';
12
13
  import { ConversationManager } from '../agent/conversation.js';
13
14
  import { GitManager } from '../git/git-manager.js';
14
- import { loadImageAsBlock } from './image-attach.js';
15
- import { saveConfig } from '../config.js';
16
15
  import { WorkspaceGuard } from '../workspace-guard.js';
17
- import { listRuns, showRun, undoRun } from '../runtime/task-session.js';
16
+ import { listRuns, showRun } from '../runtime/task-session.js';
18
17
  import { checkPermission } from '../agent/permissions.js';
19
18
  import { redactedEnv, redactSecrets } from '../runtime/redaction.js';
20
- import { appendMemory, doctor, forgetMemory, readMemory } from '../project-memory.js';
21
19
  import { buildIndex, explainIndexedTarget, findInIndex } from '../indexer.js';
22
- import { reviewWorkspace } from '../review.js';
23
- import { runProjectTests } from '../test-runner.js';
24
- import { loadPlan, renderPlan, savePlan, classifyComplexityHeuristic } from '../planner.js';
25
20
  import { mcpManager, mcpBridgeManager } from '../agent/tool-executor.js';
26
- import { ProvidersManager, PROVIDER_REGISTRY } from '../agent/providers-manager.js';
21
+ import { ProvidersManager } from '../agent/providers-manager.js';
27
22
  import { C, colors } from './colors.js';
28
23
  import { COMMANDS_WITH_DESC, printHelp, formatInputPaths } from './render.js';
29
- import { addItem, loadTodoList, removeItem, renderTodoList, saveTodoList, setItemStatus, summariseTodoList, } from '../context/todo.js';
30
24
  import { renderStatusBar } from './render-primitives.js';
31
- const c = {
32
- ...colors,
33
- };
25
+ const c = colors;
34
26
  export async function startREPL(options) {
35
27
  const { config, projectConfig, cwd, verbose, resume } = options;
36
28
  // ──── Initialize components ────
@@ -107,13 +99,21 @@ export async function startREPL(options) {
107
99
  }
108
100
  let lastPromptRow = 0;
109
101
  let mouseReportingEnabled = false;
110
- const stats = {
102
+ let stats = {
111
103
  totalPromptTokens: 0,
112
104
  totalCompletionTokens: 0,
113
105
  totalToolCalls: 0,
114
106
  totalTasks: 0,
115
107
  totalDurationMs: 0,
116
108
  };
109
+ let pendingPastes = [];
110
+ let pasteIdCounter = 1;
111
+ let isPasting = false;
112
+ let pasteBuffer = '';
113
+ /** Builds the inline token string that goes INTO the rl line buffer. */
114
+ function pasteToken(id, lineCount) {
115
+ return `[Paste #${id} +${lineCount} lines]`;
116
+ }
117
117
  // The welcome screen (lava logo + command grid) is printed by
118
118
  // `src/index.ts` before the REPL starts; the startREPL entry
119
119
  // point jumps straight into the prompt loop.
@@ -129,7 +129,8 @@ export async function startREPL(options) {
129
129
  }
130
130
  catch (error) {
131
131
  if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
132
- console.warn(`[Debug Warning] Failed to read command history from ${historyFile}: ${error.message || error}`);
132
+ const msg = error instanceof Error ? error.message : String(error);
133
+ console.warn(`[Debug Warning] Failed to read command history from ${historyFile}: ${msg}`);
133
134
  }
134
135
  }
135
136
  // ──── Create readline interface ────
@@ -160,9 +161,7 @@ export async function startREPL(options) {
160
161
  // existing /mode command semantics intact while still letting
161
162
  // the new bar visualise the live mode.
162
163
  const buildLavaStatusState = () => {
163
- const modeForState = currentMode === 'PLAN' ? 'PLAN' :
164
- currentMode === 'BUILD' ? 'BUILD' :
165
- 'BUILD';
164
+ const modeForState = currentMode === 'PLAN' ? 'PLAN' : 'BUILD';
166
165
  let contextPercent = 0;
167
166
  try {
168
167
  const used = conversation.getTotalTokens();
@@ -192,7 +191,7 @@ export async function startREPL(options) {
192
191
  branch: currentBranch || '(detached HEAD)',
193
192
  contextPercent,
194
193
  providersCount,
195
- transport: 'freellmapi',
194
+ transport: config.provider_mode === 'direct' ? 'direct' : 'freellmapi',
196
195
  };
197
196
  };
198
197
  const drawLavaStatusBar = () => {
@@ -203,6 +202,10 @@ export async function startREPL(options) {
203
202
  // returns.
204
203
  renderStatusBar(buildLavaStatusState());
205
204
  process.stdout.write('\n');
205
+ if (pendingPastes.length > 0) {
206
+ const tokens = pendingPastes.map(p => pasteToken(p.id, p.lines)).join(' ');
207
+ process.stdout.write(`${c.dim} ${tokens}${c.reset}\n`);
208
+ }
206
209
  };
207
210
  // Surface the result of a live model fetch as a one-line status.
208
211
  // Invoked from /providers add and /providers test so the user
@@ -254,6 +257,16 @@ export async function startREPL(options) {
254
257
  }
255
258
  // Register synchronous exit cleanups
256
259
  const exitCleanup = () => {
260
+ try {
261
+ if (process.stdout.isTTY) {
262
+ fs.writeSync(1, '\x1b[?2004l');
263
+ }
264
+ }
265
+ catch (e) {
266
+ if (process.env.DEBUG || process.env.VERBOSE) {
267
+ console.warn('[exit] writeSync failed:', e);
268
+ }
269
+ }
257
270
  try {
258
271
  const hist = rl.history;
259
272
  if (Array.isArray(hist)) {
@@ -262,7 +275,8 @@ export async function startREPL(options) {
262
275
  }
263
276
  catch (error) {
264
277
  if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
265
- console.warn(`[Debug Warning] Failed to write history file on exit: ${error.message || error}`);
278
+ const msg = error instanceof Error ? error.message : String(error);
279
+ console.warn(`[Debug Warning] Failed to write history file on exit: ${msg}`);
266
280
  }
267
281
  }
268
282
  disableMouseReportingSync();
@@ -286,39 +300,50 @@ export async function startREPL(options) {
286
300
  let sigintCount = 0;
287
301
  let lastSigintTime = 0;
288
302
  let sigintResetTimer = null;
303
+ // Dedup guard: prevents double-firing when both `rl` and `process` SIGINT listeners fire.
304
+ let sigintHandling = false;
289
305
  const sigintHandler = () => {
290
- if (isTaskRunning && currentRunningAgent) {
291
- // A task is running — cancel it instead of exiting
292
- currentRunningAgent.abort();
306
+ if (sigintHandling)
293
307
  return;
294
- }
295
- const now = Date.now();
296
- if (now - lastSigintTime > SIGINT_RESET_MS) {
297
- // First press (or after reset window)
298
- sigintCount = 1;
299
- lastSigintTime = now;
300
- // Write hint and redraw the prompt
301
- const promptStr = `${C.LAVA}›${C.RESET} `;
302
- process.stdout.write(`\n${c.yellow}⚠ Press Ctrl+C again to exit${c.reset}\n`);
303
- drawLavaStatusBar();
304
- process.stdout.write(promptStr);
305
- // Auto-reset after the window expires
308
+ sigintHandling = true;
309
+ try {
310
+ if (isTaskRunning && currentRunningAgent) {
311
+ // A task is running cancel it instead of exiting
312
+ currentRunningAgent.abort();
313
+ return;
314
+ }
315
+ const now = Date.now();
316
+ if (now - lastSigintTime > SIGINT_RESET_MS) {
317
+ // First press (or after reset window)
318
+ sigintCount = 1;
319
+ lastSigintTime = now;
320
+ // Write hint and redraw the prompt
321
+ const promptStr = `> `;
322
+ process.stdout.write(`\n${c.yellow}⚠ Press Ctrl+C again to exit${c.reset}\n`);
323
+ drawLavaStatusBar();
324
+ process.stdout.write(`${c.dim}─────────────────────────────────────────────────────────────────${c.reset}\n`);
325
+ process.stdout.write(promptStr);
326
+ // Auto-reset after the window expires
327
+ if (sigintResetTimer)
328
+ clearTimeout(sigintResetTimer);
329
+ sigintResetTimer = setTimeout(() => {
330
+ sigintCount = 0;
331
+ sigintResetTimer = null;
332
+ }, SIGINT_RESET_MS);
333
+ return;
334
+ }
335
+ // Second press within the window — exit
306
336
  if (sigintResetTimer)
307
337
  clearTimeout(sigintResetTimer);
308
- sigintResetTimer = setTimeout(() => {
309
- sigintCount = 0;
310
- sigintResetTimer = null;
311
- }, SIGINT_RESET_MS);
312
- return;
338
+ sigintResetTimer = null;
339
+ sigintCount = 0;
340
+ exitCleanup();
341
+ console.log('\n\n👋 FixO CLI session ended safely. Core engine offline.');
342
+ process.exit(0);
343
+ }
344
+ finally {
345
+ sigintHandling = false;
313
346
  }
314
- // Second press within the window — exit
315
- if (sigintResetTimer)
316
- clearTimeout(sigintResetTimer);
317
- sigintResetTimer = null;
318
- sigintCount = 0;
319
- exitCleanup();
320
- console.log('\n\n👋 FixO CLI session ended safely. Core engine offline.');
321
- process.exit(0);
322
347
  };
323
348
  // Listen on both the readline interface (catches Ctrl+C during rl.question())
324
349
  // and the process (fallback for non-readline scenarios).
@@ -355,7 +380,6 @@ export async function startREPL(options) {
355
380
  enableMouseReporting();
356
381
  const currentCursor = rl.cursor;
357
382
  let output = '\n';
358
- const width = 60;
359
383
  const borderTop = `${c.snow}┌────────────────────────────────────────────────────────┐${c.reset}\n`;
360
384
  const borderBottom = `${c.snow}└────────────────────────────────────────────────────────┘${c.reset}`;
361
385
  output += borderTop;
@@ -468,7 +492,8 @@ export async function startREPL(options) {
468
492
  }
469
493
  catch (error) {
470
494
  if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
471
- console.warn(`[Debug Warning] Failed to load skills list: ${error.message || error}`);
495
+ const msg = error instanceof Error ? error.message : String(error);
496
+ console.warn(`[Debug Warning] Failed to load skills list: ${msg}`);
472
497
  }
473
498
  }
474
499
  const matchingFiles = workspaceFiles.filter(f => f.toLowerCase().includes(q) || path.basename(f).toLowerCase().startsWith(q));
@@ -498,6 +523,7 @@ export async function startREPL(options) {
498
523
  readline.emitKeypressEvents(process.stdin);
499
524
  if (process.stdin.isTTY) {
500
525
  process.stdin.setRawMode(true);
526
+ process.stdout.write('\x1b[?2004h');
501
527
  }
502
528
  const keypressHandler = (_char, key) => {
503
529
  if (!isPrompting)
@@ -535,6 +561,30 @@ export async function startREPL(options) {
535
561
  };
536
562
  process.stdin.on('keypress', keypressHandler);
537
563
  let mouseBuffer = '';
564
+ function getPasteTokenAtCursorForBackspace(line, cursor) {
565
+ const regex = /\[Paste #(\d+) \+\d+ lines\]/g;
566
+ let match;
567
+ while ((match = regex.exec(line)) !== null) {
568
+ const start = match.index;
569
+ const end = regex.lastIndex;
570
+ if (cursor > start && cursor <= end) {
571
+ return { id: parseInt(match[1], 10), start, end };
572
+ }
573
+ }
574
+ return null;
575
+ }
576
+ function getPasteTokenAtCursorForDelete(line, cursor) {
577
+ const regex = /\[Paste #(\d+) \+\d+ lines\]/g;
578
+ let match;
579
+ while ((match = regex.exec(line)) !== null) {
580
+ const start = match.index;
581
+ const end = regex.lastIndex;
582
+ if (cursor >= start && cursor < end) {
583
+ return { id: parseInt(match[1], 10), start, end };
584
+ }
585
+ }
586
+ return null;
587
+ }
538
588
  // Monkey-patch process.stdin.emit to intercept keypress and mouse events
539
589
  const originalEmit = process.stdin.emit;
540
590
  process.stdin.emit = function (event, ...args) {
@@ -543,6 +593,78 @@ export async function startREPL(options) {
543
593
  if (rawData) {
544
594
  let str = mouseBuffer + rawData.toString();
545
595
  mouseBuffer = '';
596
+ // ── Bracketed Paste Interception ──────────────────────────────
597
+ // This fires when the terminal supports bracketed paste mode
598
+ // (\x1b[?2004h is enabled in promptForInput on every render).
599
+ if (str.includes('\x1b[200~')) {
600
+ const parts = str.split('\x1b[200~');
601
+ // Any characters before the paste-start marker are real keystrokes
602
+ if (parts[0]) {
603
+ originalEmit.apply(this, ['data', Buffer.from(parts[0])]);
604
+ }
605
+ isPasting = true;
606
+ pasteBuffer = '';
607
+ str = parts.slice(1).join('\x1b[200~');
608
+ }
609
+ if (isPasting) {
610
+ if (str.includes('\x1b[201~')) {
611
+ const parts = str.split('\x1b[201~');
612
+ pasteBuffer += parts[0];
613
+ isPasting = false;
614
+ const rawLines = pasteBuffer.split(/\r\n|\r|\n/);
615
+ // Trim a single trailing empty line that terminals often append
616
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') {
617
+ rawLines.pop();
618
+ }
619
+ if (rawLines.length > 1) {
620
+ // Multi-line paste → attachment
621
+ const id = pasteIdCounter++;
622
+ pendingPastes.push({ id, content: pasteBuffer.replace(/\r\n/g, '\n'), lines: rawLines.length });
623
+ injectTokenIntoPrompt(pasteToken(id, rawLines.length));
624
+ }
625
+ else {
626
+ // Single line → let it flow into rl normally
627
+ rl.write(pasteBuffer);
628
+ }
629
+ pasteBuffer = '';
630
+ str = parts.slice(1).join('\x1b[201~');
631
+ if (str.length === 0)
632
+ return true;
633
+ }
634
+ else {
635
+ // Still accumulating paste data
636
+ pasteBuffer += str;
637
+ return true;
638
+ }
639
+ }
640
+ // ── Heuristic Paste Fallback ──────────────────────────────────
641
+ // For terminals that strip bracketed paste codes, multi-line
642
+ // pastes arrive as a single large data chunk containing \n chars.
643
+ // Humans cannot produce this pattern; only paste events do.
644
+ //
645
+ // Guards:
646
+ // 1. Not already in isPasting mode (handled above).
647
+ // 2. The chunk is NOT a bare Enter keypress.
648
+ // 3. At least 3 non-empty lines AND total length > 80 chars.
649
+ // (Prevents firing on "2\n" or any short accidental newline.)
650
+ if (!isPasting && str.includes('\n')) {
651
+ const isJustEnter = (str === '\r' || str === '\n' || str === '\r\n');
652
+ if (!isJustEnter && str.length > 80) {
653
+ const rawLines = str.split(/\r\n|\r|\n/);
654
+ // Remove a single trailing empty line
655
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') {
656
+ rawLines.pop();
657
+ }
658
+ const nonEmptyLines = rawLines.filter(l => l.trim().length > 0);
659
+ if (nonEmptyLines.length >= 3) {
660
+ const id = pasteIdCounter++;
661
+ pendingPastes.push({ id, content: str.replace(/\r\n/g, '\n'), lines: rawLines.length });
662
+ injectTokenIntoPrompt(pasteToken(id, rawLines.length));
663
+ // Swallow the chunk so readline never sees the \n characters
664
+ return true;
665
+ }
666
+ }
667
+ }
546
668
  // Intercept cursor position response
547
669
  if (str.startsWith('\x1b[') && str.endsWith('R')) {
548
670
  const match = str.match(/\x1b\[(\d+);(\d+)R/);
@@ -634,6 +756,38 @@ export async function startREPL(options) {
634
756
  }
635
757
  if (event === 'keypress') {
636
758
  const [char, key] = args;
759
+ if (isPrompting && key) {
760
+ if (key.name === 'backspace') {
761
+ const line = rl.line;
762
+ const cursor = rl.cursor;
763
+ const tokenMatch = getPasteTokenAtCursorForBackspace(line, cursor);
764
+ if (tokenMatch) {
765
+ const { id, start, end } = tokenMatch;
766
+ pendingPastes = pendingPastes.filter(p => p.id !== id);
767
+ const newLine = line.slice(0, start) + line.slice(end);
768
+ const newCursor = start;
769
+ rl.line = newLine;
770
+ rl.cursor = newCursor;
771
+ rl._refreshLine();
772
+ return true;
773
+ }
774
+ }
775
+ else if (key.name === 'delete') {
776
+ const line = rl.line;
777
+ const cursor = rl.cursor;
778
+ const tokenMatch = getPasteTokenAtCursorForDelete(line, cursor);
779
+ if (tokenMatch) {
780
+ const { id, start, end } = tokenMatch;
781
+ pendingPastes = pendingPastes.filter(p => p.id !== id);
782
+ const newLine = line.slice(0, start) + line.slice(end);
783
+ const newCursor = start;
784
+ rl.line = newLine;
785
+ rl.cursor = newCursor;
786
+ rl._refreshLine();
787
+ return true;
788
+ }
789
+ }
790
+ }
637
791
  // Intercept Escape or Ctrl+C to cancel a running task (when not prompting)
638
792
  if (key && key.name === 'escape' && isTaskRunning && currentRunningAgent) {
639
793
  currentRunningAgent.abort();
@@ -657,17 +811,42 @@ export async function startREPL(options) {
657
811
  // legacy dirLabel/branchLabel/modelLabel/modeLabel row
658
812
  // is gone — the new bar carries all of that information.
659
813
  drawLavaStatusBar();
660
- process.stdout.write(`${C.LAVA}›${C.RESET} `);
814
+ process.stdout.write(`${c.dim}─────────────────────────────────────────────────────────────────${c.reset}\n> `);
661
815
  return true; // swallow keypress
662
816
  }
663
817
  }
664
818
  return originalEmit.apply(this, [event, ...args]);
665
819
  };
820
+ /**
821
+ * Injects `token` into the readline line buffer and redraws the prompt line.
822
+ * Any text already in rl.line is preserved and appended after the token
823
+ * with a space separator.
824
+ *
825
+ * This produces the Claude Code / Antigravity pattern:
826
+ * > [Paste #1 +45 lines] <any pre-paste text the user was typing>
827
+ */
828
+ function injectTokenIntoPrompt(token) {
829
+ // 1. Capture whatever the user had typed before pasting
830
+ const preTyped = (rl.line ?? '').trimEnd();
831
+ // 2. Clear the entire current line visually
832
+ readline.clearLine(process.stdout, 0);
833
+ readline.cursorTo(process.stdout, 0);
834
+ // 3. Wipe rl's internal buffer with Ctrl-U so readline tracks zero length
835
+ rl.write(null, { ctrl: true, name: 'u' });
836
+ // 4. Write the token (+ pre-typed text if any) back into rl
837
+ const newLine = preTyped.length > 0 ? `${token} ${preTyped}` : token;
838
+ rl.write(newLine);
839
+ // rl.write() both updates rl.line and echoes the characters to stdout,
840
+ // so the user sees: > [Paste #1 +45 lines] Refactor th
841
+ // with the cursor positioned after the last character.
842
+ }
666
843
  // ──── REPL loop ────
667
844
  const promptForInput = () => {
668
845
  // Restore raw mode and resume streams to recover from any clack/spinner interactions
669
846
  if (process.stdin.isTTY) {
670
847
  process.stdin.setRawMode(true);
848
+ // Explicitly re-enable Bracketed Paste Mode just in case a spinner disabled it
849
+ process.stdout.write('\x1b[?2004h');
671
850
  }
672
851
  process.stdin.resume();
673
852
  rl.resume();
@@ -677,7 +856,8 @@ export async function startREPL(options) {
677
856
  // visible in the bar; the prompt itself is the lava `›` glyph.
678
857
  drawLavaStatusBar();
679
858
  isPrompting = true;
680
- rl.question(`${C.LAVA}›${C.RESET} `, async (input) => {
859
+ const promptPrefix = `\n${C.SNOW4}╭─${C.RESET} 👤 ${C.LAVA}${C.BOLD}User${C.RESET}\n${C.SNOW4}╰─❯${C.RESET} `;
860
+ rl.question(promptPrefix, async (input) => {
681
861
  isPrompting = false;
682
862
  disableMouseReporting();
683
863
  clearSuggestions();
@@ -702,12 +882,45 @@ export async function startREPL(options) {
702
882
  else if (msg.includes('429')) {
703
883
  console.log(`${c.dim} → Rate limited. Wait a moment or add more API keys.${c.reset}`);
704
884
  }
885
+ else if (msg.includes('404') || msg.toLowerCase().includes('model not found')) {
886
+ rl.pause();
887
+ const fallback = await p.confirm({
888
+ message: `Model '${currentModel}' not found or unavailable. Switch to default 'auto' model and retry?`,
889
+ initialValue: true,
890
+ });
891
+ rl.resume();
892
+ if (fallback && !p.isCancel(fallback)) {
893
+ console.log(`\n${c.dim}Switching to 'auto' and retrying...${c.reset}`);
894
+ currentModel = 'auto';
895
+ try {
896
+ await handleInput(trimmed);
897
+ }
898
+ catch (retryError) {
899
+ console.log(`\n${c.red}✗ Retry failed: ${retryError instanceof Error ? retryError.message : String(retryError)}${c.reset}`);
900
+ }
901
+ }
902
+ }
705
903
  }
706
904
  promptForInput();
707
905
  });
708
906
  };
709
907
  // ──── Input handler ────
710
- async function handleInput(input) {
908
+ async function handleInput(rawInput) {
909
+ // ── Payload assembly ─────────────────────────────────────────────
910
+ // Strip paste tokens from the user's typed text so the LLM only
911
+ // sees the clean question, not "[Paste #1 +45 lines]" literally.
912
+ const tokenPattern = /\[Paste #\d+ \+\d+ lines\]\s*/g;
913
+ const cleanRawInput = rawInput.replace(tokenPattern, '').trim();
914
+ // Build final LLM payload: question first, context blocks after.
915
+ let input = cleanRawInput;
916
+ if (pendingPastes.length > 0) {
917
+ const contextBlocks = pendingPastes
918
+ .map(p => `<pasted_context id="${p.id}">\n${p.content}\n</pasted_context>`)
919
+ .join('\n\n');
920
+ input = cleanRawInput.length > 0
921
+ ? `${cleanRawInput}\n\n${contextBlocks}`
922
+ : contextBlocks;
923
+ }
711
924
  // ─── Slash commands ───
712
925
  if (input.startsWith('/')) {
713
926
  const parts = input.split(/\s+/).filter(Boolean);
@@ -729,1099 +942,150 @@ export async function startREPL(options) {
729
942
  case '/help':
730
943
  printHelp();
731
944
  return;
732
- case '/model': {
733
- if (args[0] === 'list') {
734
- // Print full model table grouped by provider
735
- // Uses live-fetched cached models when available, otherwise falls
736
- // back to the static registry list (tagged [unverified]).
737
- console.log(`\n${c.bold}${c.cyan}Available Models by Provider${c.reset}`);
738
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
739
- for (const def of PROVIDER_REGISTRY) {
740
- const hasKey = ProvidersManager.has(def.name);
741
- const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.dim}[no key]${c.reset}`;
742
- const cached = ProvidersManager.getCachedModels(def.name);
743
- const modelList = cached?.models?.length ? cached.models : def.models;
744
- const sourceTag = cached?.source === 'live'
745
- ? ''
746
- : ` ${c.dim}[unverified]${c.reset}`;
747
- console.log(`\n ${c.snow}${c.bold}${def.displayName}${c.reset} ${keyStatus}${sourceTag}`);
748
- for (const model of modelList) {
749
- console.log(` ${c.cyan}•${c.reset} ${model}`);
750
- }
751
- }
752
- console.log(`\n${c.dim} Use /providers add <name> to connect a provider with your API key.${c.reset}`);
753
- console.log(`${c.dim} Or set model directly: /model <model-id>${c.reset}\n`);
945
+ case '/view': {
946
+ const id = parseInt(args[0] ?? '', 10);
947
+ if (isNaN(id)) {
948
+ console.log(`\n${c.yellow}⚠ Usage: /view <paste-id>${c.reset}`);
949
+ promptForInput();
754
950
  return;
755
951
  }
756
- if (args.length === 0) {
757
- // Redesigned interactive model picker grouped by provider
758
- rl.pause();
759
- const pickedProvider = await p.select({
760
- message: `Current model: ${c.cyan}${currentModel}${c.reset} — Select AI Provider:`,
761
- options: [
762
- { value: 'all', label: 'Show all models (flat list)', hint: 'classic view' },
763
- ...PROVIDER_REGISTRY.map(def => ({
764
- value: def.name,
765
- label: def.displayName,
766
- hint: ProvidersManager.has(def.name) ? ' [key ✓]' : ' [no key]'
767
- })),
768
- { value: '__manual__', label: 'Enter model ID manually…', hint: '' },
769
- ],
770
- initialValue: PROVIDER_REGISTRY.find(def => def.models.includes(currentModel))?.name || 'all',
771
- });
772
- rl.resume();
773
- if (p.isCancel(pickedProvider)) {
774
- console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
775
- return;
776
- }
777
- if (pickedProvider === '__manual__') {
778
- rl.pause();
779
- const manual = await p.text({
780
- message: 'Enter model ID:',
781
- placeholder: 'e.g. gpt-4o, claude-opus-4-5, gemini-2.5-pro',
782
- validate: v => !v.trim() ? 'Model ID is required' : undefined,
783
- });
784
- rl.resume();
785
- if (!p.isCancel(manual) && manual) {
786
- currentModel = manual.trim();
787
- conversation.setContextLimit(currentModel);
788
- console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
789
- }
790
- return;
791
- }
792
- if (pickedProvider === 'all') {
793
- rl.pause();
794
- const allOptions = PROVIDER_REGISTRY.flatMap(def => def.models.map(m => ({
795
- value: m,
796
- label: `${m}`,
797
- hint: def.displayName + (ProvidersManager.has(def.name) ? ' [key ✓]' : ''),
798
- })));
799
- const picked = await p.select({
800
- message: 'Select a model from the flat list:',
801
- options: [
802
- { value: currentModel, label: `Keep current: ${currentModel}`, hint: 'no change' },
803
- ...allOptions,
804
- ],
805
- initialValue: currentModel,
806
- });
807
- rl.resume();
808
- if (p.isCancel(picked)) {
809
- console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
810
- return;
811
- }
812
- currentModel = picked;
813
- // Store hint — find which provider this model belongs to
814
- const owningDef = PROVIDER_REGISTRY.find(d => d.models.includes(currentModel)
815
- || ProvidersManager.getCachedModels(d.name)?.models?.includes(currentModel));
816
- if (owningDef)
817
- ProvidersManager.setModelProviderHint(currentModel, owningDef.name);
818
- conversation.setContextLimit(currentModel);
819
- console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
820
- return;
821
- }
822
- const def = PROVIDER_REGISTRY.find(p => p.name === pickedProvider);
823
- const hasKey = ProvidersManager.has(def.name);
824
- const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.red}[no key]${c.reset}`;
825
- // Prefer the cached live model list; fall back to the
826
- // registry list (tagged `[unverified]`) when no fresh
827
- // cache exists. Drops the synthetic "(free)" suffix
828
- // since we no longer know that without provider
829
- // metadata.
830
- const cached = ProvidersManager.getCachedModels(def.name);
831
- const modelList = cached?.models?.length ? cached.models : def.models;
832
- const sourceSuffix = cached?.source === 'live'
833
- ? ''
834
- : ` ${c.dim}[unverified]${c.reset}`;
835
- rl.pause();
836
- const picked = await p.select({
837
- message: `Select a model from ${c.bold}${def.displayName}${c.reset} ${keyStatus}${sourceSuffix}:`,
838
- options: modelList.map(m => {
839
- return {
840
- value: m,
841
- label: m,
842
- hint: m === currentModel ? 'currently selected' : ''
843
- };
844
- }),
845
- initialValue: modelList.includes(currentModel) ? currentModel : undefined,
846
- });
847
- rl.resume();
848
- if (p.isCancel(picked)) {
849
- console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
850
- return;
851
- }
852
- currentModel = picked;
853
- // Store explicit model-provider association so
854
- // resolveDirectConfig can route this model directly
855
- // to this provider (critical for live-fetched models
856
- // that don't appear in the static registry).
857
- ProvidersManager.setModelProviderHint(currentModel, def.name);
858
- conversation.setContextLimit(currentModel);
859
- console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
952
+ const paste = pendingPastes.find(p => p.id === id);
953
+ if (!paste) {
954
+ console.log(`\n${c.yellow}⚠ Paste #${id} not found. Active pastes: ${pendingPastes.length > 0
955
+ ? pendingPastes.map(p => `#${p.id}`).join(', ')
956
+ : 'none'}${c.reset}`);
957
+ promptForInput();
860
958
  return;
861
959
  }
862
- currentModel = args.join(' ');
863
- conversation.setContextLimit(currentModel);
864
- console.log(`\n${c.green} Model set to: ${c.bold}${currentModel}${c.reset}`);
960
+ const border = `${c.dim}${''.repeat(60)}${c.reset}`;
961
+ console.log(`\n${border}`);
962
+ console.log(`${c.cyan}Paste #${paste.id} ${paste.lines} lines${c.reset}`);
963
+ console.log(border);
964
+ console.log(paste.content);
965
+ console.log(border);
966
+ promptForInput();
865
967
  return;
866
968
  }
867
- case '/select': {
868
- if (args.length === 0) {
869
- if (selectedFiles.length === 0) {
870
- console.log(`\n${c.dim}No files selected. Usage: /select <file-path>${c.reset}`);
871
- }
872
- else {
873
- console.log(`\n${c.dim}Selected files:${c.reset}`);
874
- for (const f of selectedFiles) {
875
- console.log(` ${c.cyan}${path.basename(f)}${c.reset} ${c.dim}(${f})${c.reset}`);
876
- }
877
- }
969
+ case '/edit': {
970
+ const id = parseInt(args[0] ?? '', 10);
971
+ if (isNaN(id)) {
972
+ console.log(`\n${c.yellow} Usage: /edit <paste-id>${c.reset}`);
973
+ promptForInput();
878
974
  return;
879
975
  }
880
- let rawPath = args.join(' ');
881
- if ((rawPath.startsWith("'") && rawPath.endsWith("'")) ||
882
- (rawPath.startsWith('"') && rawPath.endsWith('"'))) {
883
- rawPath = rawPath.slice(1, -1);
884
- }
885
- let filePath;
886
- try {
887
- filePath = guard.ensureFile(rawPath);
888
- }
889
- catch (error) {
890
- console.log(`\n${c.red}✗ ${error instanceof Error ? error.message : String(error)}${c.reset}`);
976
+ const paste = pendingPastes.find(p => p.id === id);
977
+ if (!paste) {
978
+ console.log(`\n${c.yellow}⚠ Paste #${id} not found.${c.reset}`);
979
+ promptForInput();
891
980
  return;
892
981
  }
893
- if (!fs.existsSync(filePath)) {
894
- console.log(`\n${c.red}✗ File not found: ${rawPath}${c.reset}`);
895
- return;
896
- }
897
- if (!selectedFiles.includes(filePath)) {
898
- selectedFiles.push(filePath);
899
- }
900
- console.log(`\n${c.green}✓ Pinned: ${c.bold}${path.basename(filePath)}${c.reset}`);
901
- return;
902
- }
903
- case '/unselect':
904
- selectedFiles = [];
905
- console.log(`\n${c.green}✓ All pinned files cleared${c.reset}`);
906
- return;
907
- case '/diff':
908
- console.log(`\n${git.getDiff()}`);
909
- return;
910
- case '/undo': {
911
- if (args[0]) {
912
- console.log(`\n${undoRun(cwd, args[0])}`);
913
- return;
914
- }
915
- rl.pause();
916
- const confirmed = await p.confirm({
917
- message: 'Are you sure you want to completely discard the last automated agent commit and restore all files?',
918
- initialValue: false,
919
- });
920
- rl.resume();
921
- if (p.isCancel(confirmed) || !confirmed) {
922
- console.log(`\n${c.yellow} ⚠ Undo cancelled.${c.reset}`);
923
- return;
924
- }
925
- git.undoLastCommit();
926
- return;
927
- }
928
- case '/clear':
929
- conversation.clear();
930
- pendingAttachments = [];
931
- console.log(`\n${c.green}✓ Conversation cleared${c.reset}`);
932
- return;
933
- case '/image': {
934
- // `/image <path>` — queue a local image for the next turn.
935
- // `/image clear` — drop the queue.
936
- // `/image list` — show what's queued.
937
- const sub = args[0];
938
- if (sub === 'clear') {
939
- const n = pendingAttachments.length;
940
- pendingAttachments = [];
941
- console.log(`\n${c.green}✓ Cleared ${n} pending image(s)${c.reset}`);
942
- return;
943
- }
944
- if (sub === 'list') {
945
- if (pendingAttachments.length === 0) {
946
- console.log(`\n${c.dim}No pending images.${c.reset}`);
947
- return;
948
- }
949
- console.log(`\n${c.bold}Pending images (sent on next prompt):${c.reset}`);
950
- for (const [i, block] of pendingAttachments.entries()) {
951
- if (block.type === 'image' && block.source.kind === 'base64') {
952
- const approxBytes = Math.floor((block.source.data.length * 3) / 4);
953
- console.log(` ${i + 1}. ${block.source.mediaType} (~${approxBytes} bytes)`);
954
- }
955
- }
956
- return;
957
- }
958
- if (!sub) {
959
- console.log(`\n${c.yellow}Usage: /image <path> | /image list | /image clear${c.reset}`);
960
- return;
961
- }
962
- const result = loadImageAsBlock(sub, cwd);
963
- if (!result.ok) {
964
- console.log(`\n${c.red}✗ /image: ${result.error}${c.reset}`);
965
- return;
966
- }
967
- pendingAttachments.push(result.block);
968
- console.log(`\n${c.green}✓ Attached${c.reset} ${c.dim}${result.mediaType}, ${result.bytes} bytes — will be sent with your next prompt${c.reset}`);
969
- return;
970
- }
971
- case '/mcp': {
972
- const sub = args[0]?.toLowerCase();
973
- if (!sub || sub === 'list') {
974
- const { listAllMcpSources, mergedMcpServers } = await import('../agent/mcp-registry.js');
975
- const view = listAllMcpSources(cwd);
976
- console.log(`\n${c.bold}${c.cyan}MCP Servers${c.reset} ${c.dim}(project-wins precedence: local > project > global)${c.reset}`);
977
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
978
- const renderSource = (label, s) => {
979
- const names = Object.keys(s.servers);
980
- if (names.length === 0) {
981
- console.log(` ${c.dim}${label}: (empty)${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
982
- return;
983
- }
984
- console.log(` ${c.bold}${label}${c.reset}${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
985
- for (const n of names) {
986
- console.log(` ${c.cyan}•${c.reset} ${n}`);
987
- }
988
- };
989
- renderSource('global', view.global);
990
- renderSource('project', view.project);
991
- renderSource('local', view.local);
992
- const merged = mergedMcpServers(cwd);
993
- const mergedCount = Object.keys(merged).length;
994
- console.log(`\n${c.dim}merged total: ${mergedCount} server(s)${c.reset}`);
995
- return;
996
- }
997
- if (sub === 'add') {
998
- const name = args[1];
999
- if (!name || args.length < 3) {
1000
- console.log(`\n${c.yellow}Usage: /mcp add <name> <command> [args...]${c.reset}`);
1001
- return;
1002
- }
1003
- const cmd = args[2];
1004
- const cmdArgs = args.slice(3);
1005
- const { addLocalMcpServer } = await import('../agent/mcp-registry.js');
1006
- addLocalMcpServer(cwd, name, { command: cmd, args: cmdArgs, type: 'stdio' });
1007
- console.log(`\n${c.green}✓ Added local MCP server:${c.reset} ${name} ${c.dim}(command=${cmd} args=${JSON.stringify(cmdArgs)})${c.reset}`);
1008
- return;
1009
- }
1010
- if (sub === 'remove' || sub === 'rm') {
1011
- const name = args[1];
1012
- if (!name) {
1013
- console.log(`\n${c.yellow}Usage: /mcp remove <name>${c.reset}`);
1014
- return;
1015
- }
1016
- const { removeLocalMcpServer } = await import('../agent/mcp-registry.js');
1017
- const removed = removeLocalMcpServer(cwd, name);
1018
- if (removed) {
1019
- console.log(`\n${c.green}✓ Removed local MCP server:${c.reset} ${name}`);
1020
- }
1021
- else {
1022
- console.log(`\n${c.yellow}No local MCP server named ${name}${c.reset}`);
1023
- }
1024
- return;
1025
- }
1026
- if (sub === 'test') {
1027
- const name = args[1];
1028
- if (!name) {
1029
- console.log(`\n${c.yellow}Usage: /mcp test <name>${c.reset}`);
1030
- return;
1031
- }
1032
- const { mergedMcpServers } = await import('../agent/mcp-registry.js');
1033
- const all = mergedMcpServers(cwd);
1034
- const cfg = all[name];
1035
- if (!cfg) {
1036
- console.log(`\n${c.yellow}No MCP server named ${name} (in any source)${c.reset}`);
1037
- return;
1038
- }
1039
- const hasCommand = typeof cfg.command === 'string';
1040
- const hasUrl = typeof cfg.url === 'string';
1041
- if (hasCommand || hasUrl) {
1042
- console.log(`\n${c.green}✓ ${name}${c.reset} — config looks valid (${hasCommand ? 'stdio' : 'sse'})`);
982
+ const tmpFile = path.join(os.tmpdir(), `fixo-paste-${id}-${Date.now()}.txt`);
983
+ try {
984
+ fs.writeFileSync(tmpFile, paste.content, 'utf-8');
985
+ // Release the terminal before handing it to the external editor
986
+ if (process.stdin.isTTY)
987
+ process.stdin.setRawMode(false);
988
+ process.stdout.write('\x1b[?2004l'); // disable bracketed paste while editor is open
989
+ const editor = process.env.VISUAL ?? process.env.EDITOR ?? (os.platform() === 'win32' ? 'notepad' : 'nano');
990
+ const { spawnSync } = await import('child_process');
991
+ spawnSync(editor, [tmpFile], { stdio: 'inherit' });
992
+ const edited = fs.readFileSync(tmpFile, 'utf-8');
993
+ fs.unlinkSync(tmpFile);
994
+ if (edited.trim().length === 0) {
995
+ console.log(`\n${c.yellow}⚠ Editor returned empty content — paste #${id} unchanged.${c.reset}`);
1043
996
  }
1044
997
  else {
1045
- console.log(`\n${c.red}✗ ${name}${c.reset} — missing 'command' or 'url'`);
1046
- }
1047
- return;
1048
- }
1049
- console.log(`\n${c.yellow}Unknown /mcp subcommand: ${sub}. Use: list | add | remove | test${c.reset}`);
1050
- return;
1051
- }
1052
- case '/todo': {
1053
- const sub = args[0]?.toLowerCase();
1054
- if (!sub || sub === 'list' || sub === 'ls') {
1055
- const list = loadTodoList(cwd);
1056
- const summary = summariseTodoList(list);
1057
- console.log('');
1058
- console.log(renderTodoList(list));
1059
- if (summary.length > 0) {
1060
- console.log(`\n${c.dim}(${summary})${c.reset}`);
998
+ paste.content = edited;
999
+ paste.lines = edited.split(/\r?\n/).filter(l => l.length > 0).length;
1000
+ console.log(`\n${c.green}✓ Updated Paste #${id} (${paste.lines} lines)${c.reset}`);
1061
1001
  }
1062
- return;
1063
1002
  }
1064
- if (sub === 'add') {
1065
- const text = args.slice(1).join(' ').trim();
1066
- if (text.length === 0) {
1067
- console.log(`\n${c.yellow}Usage: /todo add <text>${c.reset}`);
1068
- return;
1069
- }
1070
- const list = addItem(loadTodoList(cwd), { content: text });
1071
- const result = saveTodoList(cwd, list);
1072
- if (!result.ok) {
1073
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1074
- return;
1075
- }
1076
- console.log(`\n${c.green}✓ Added todo:${c.reset} ${text}`);
1077
- return;
1078
- }
1079
- if (sub === 'done' || sub === 'complete' || sub === 'cancel') {
1080
- const id = args[1];
1081
- if (!id) {
1082
- console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
1083
- return;
1084
- }
1085
- const status = sub === 'cancel' ? 'cancelled' : 'done';
1086
- let list = loadTodoList(cwd);
1087
- const exists = list.items.some((it) => it.id === id);
1088
- if (!exists) {
1089
- console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1090
- return;
1091
- }
1092
- list = setItemStatus(list, { id, status });
1093
- const result = saveTodoList(cwd, list);
1094
- if (!result.ok) {
1095
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1096
- return;
1097
- }
1098
- console.log(`\n${c.green}✓ Marked ${status}${c.reset}`);
1099
- return;
1100
- }
1101
- if (sub === 'start' || sub === 'progress') {
1102
- const id = args[1];
1103
- if (!id) {
1104
- console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
1105
- return;
1106
- }
1107
- let list = loadTodoList(cwd);
1108
- const exists = list.items.some((it) => it.id === id);
1109
- if (!exists) {
1110
- console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1111
- return;
1112
- }
1113
- list = setItemStatus(list, { id, status: 'in_progress' });
1114
- const result = saveTodoList(cwd, list);
1115
- if (!result.ok) {
1116
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1117
- return;
1118
- }
1119
- console.log(`\n${c.green}✓ Marked in_progress${c.reset}`);
1120
- return;
1121
- }
1122
- if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
1123
- const id = args[1];
1124
- if (!id) {
1125
- console.log(`\n${c.yellow}Usage: /todo remove <id>${c.reset}`);
1126
- return;
1127
- }
1128
- let list = loadTodoList(cwd);
1129
- const exists = list.items.some((it) => it.id === id);
1130
- if (!exists) {
1131
- console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1132
- return;
1133
- }
1134
- list = removeItem(list, { id });
1135
- const result = saveTodoList(cwd, list);
1136
- if (!result.ok) {
1137
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1138
- return;
1139
- }
1140
- console.log(`\n${c.green}✓ Removed todo${c.reset}`);
1141
- return;
1142
- }
1143
- if (sub === 'clear') {
1144
- const list = loadTodoList(cwd);
1145
- const kept = list.items.filter((it) => it.status !== 'done' && it.status !== 'cancelled');
1146
- const result = saveTodoList(cwd, { ...list, items: kept, updatedAt: Date.now() });
1147
- if (!result.ok) {
1148
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1149
- return;
1003
+ catch (err) {
1004
+ console.log(`\n${c.red}✗ /edit failed: ${err.message}${c.reset}`);
1005
+ try {
1006
+ fs.unlinkSync(tmpFile);
1150
1007
  }
1151
- const cleared = list.items.length - kept.length;
1152
- console.log(`\n${c.green}✓ Cleared ${cleared} completed todo(s)${c.reset}`);
1153
- return;
1008
+ catch { /* already gone */ }
1154
1009
  }
1155
- if (sub === 'help' || sub === '-h' || sub === '--help') {
1156
- console.log(`\n${c.bold}Usage: /todo <subcommand>${c.reset}`);
1157
- console.log(` list List all todo items`);
1158
- console.log(` add <text> Add a new todo`);
1159
- console.log(` start <id> Mark a todo as in-progress`);
1160
- console.log(` done <id> Mark a todo as done`);
1161
- console.log(` cancel <id> Cancel a todo`);
1162
- console.log(` remove <id> Remove a todo entirely`);
1163
- console.log(` clear Remove all done/cancelled todos`);
1164
- return;
1010
+ finally {
1011
+ // Reclaim raw mode and bracketed paste before returning to REPL
1012
+ if (process.stdin.isTTY)
1013
+ process.stdin.setRawMode(true);
1014
+ process.stdout.write('\x1b[?2004h');
1165
1015
  }
1166
- console.log(`\n${c.yellow}Unknown /todo subcommand "${sub}". Try /todo help.${c.reset}`);
1016
+ promptForInput();
1167
1017
  return;
1168
1018
  }
1169
- case '/log':
1170
- console.log(`\n${git.getRecentCommits(10)}`);
1171
- return;
1172
- case '/stats':
1173
- printStats(stats);
1174
- {
1175
- const ctxTokens = conversation.getTotalTokens();
1176
- const ctxLimit = conversation.getContextLimit();
1177
- const ctxPct = Math.round((ctxTokens / ctxLimit) * 100);
1178
- const hasSummary = conversation.getSummary() ? ' (compacted)' : '';
1179
- console.log(`${c.cyan}${c.bold}📊 Context Window${c.reset}`);
1180
- console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
1181
- console.log(` History messages: ${c.bold}${conversation.getMessageCount()}${c.reset}${hasSummary}`);
1182
- console.log(` Context usage: ${c.bold}${(ctxTokens / 1000).toFixed(0)}k / ${(ctxLimit / 1000).toFixed(0)}k${c.reset} (${ctxPct}%)`);
1183
- console.log(` Turns: ${c.bold}${conversation.getTurnCount()}${c.reset}`);
1184
- console.log('');
1019
+ case '/pastes': {
1020
+ if (pendingPastes.length === 0) {
1021
+ console.log(`\n${c.dim}No active paste attachments.${c.reset}`);
1185
1022
  }
1186
- return;
1187
- case '/runs': {
1188
- const runs = listRuns(cwd, 12);
1189
- console.log(runs.length
1190
- ? `\n${runs.map(run => `${run.id} ${run.status} ${run.task.slice(0, 80)}`).join('\n')}`
1191
- : '\n(no FixO runs recorded)');
1192
- return;
1193
- }
1194
- case '/show-run':
1195
- console.log(`\n${showRun(cwd, args[0] ?? '')}`);
1196
- return;
1197
- case '/memory':
1198
- console.log(`\n${readMemory(cwd)}`);
1199
- return;
1200
- case '/remember': {
1201
- const text = args.join(' ').trim();
1202
- if (!text) {
1203
- console.log(`\n${c.yellow}Usage: /remember <project fact>${c.reset}`);
1204
- return;
1205
- }
1206
- rl.pause();
1207
- const confirmed = await p.confirm({ message: `Add to project memory: ${text}?`, initialValue: false });
1208
- rl.resume();
1209
- if (!p.isCancel(confirmed) && confirmed) {
1210
- appendMemory(cwd, text);
1211
- console.log(`\n${c.green}✓ Memory updated${c.reset}`);
1212
- }
1213
- return;
1214
- }
1215
- case '/forget':
1216
- rl.pause();
1217
- {
1218
- const confirmed = await p.confirm({ message: 'Clear FixO project memory?', initialValue: false });
1219
- rl.resume();
1220
- if (!p.isCancel(confirmed) && confirmed) {
1221
- forgetMemory(cwd);
1222
- console.log(`\n${c.green}✓ Memory cleared${c.reset}`);
1023
+ else {
1024
+ console.log(`\n${c.cyan}Active paste attachments:${c.reset}`);
1025
+ for (const p of pendingPastes) {
1026
+ console.log(` ${c.bold}#${p.id}${c.reset} ${p.lines} lines /view ${p.id} · /edit ${p.id}`);
1223
1027
  }
1224
1028
  }
1225
- return;
1226
- case '/doctor':
1227
- console.log(`\n${doctor(cwd)}`);
1228
- return;
1229
- case '/index': {
1230
- const index = await buildIndex(cwd);
1231
- workspaceFiles = index.files.map(f => f.path);
1232
- console.log(`\n${c.green}✓ Indexed ${index.files.length} files${c.reset}`);
1029
+ promptForInput();
1233
1030
  return;
1234
1031
  }
1235
- case '/find':
1236
- console.log(`\n${await findInIndex(cwd, args.join(' '))}`);
1237
- return;
1238
- case '/explain':
1239
- console.log(`\n${await explainIndexedTarget(cwd, args.join(' '))}`);
1240
- return;
1241
- case '/review':
1242
- console.log(`\n${reviewWorkspace(cwd)}`);
1243
- return;
1244
- case '/test':
1245
- console.log(`\n${runProjectTests(cwd)}`);
1246
- return;
1247
- case '/fix-tests': {
1248
- let testResult = runProjectTests(cwd);
1249
- if (testResult.includes('Status: 0')) {
1250
- console.log(`\n${c.green}✓ All tests are passing!${c.reset}`);
1251
- return;
1252
- }
1253
- let attempt = 1;
1254
- const maxAttempts = 3;
1255
- const modifiedFiles = [];
1256
- while (attempt <= maxAttempts) {
1257
- console.log(`\n${c.cyan}🔨 [Auto-Fix] Test failure detected (Attempt ${attempt}/${maxAttempts}). Invoking SingleAgent to repair...${c.reset}`);
1258
- console.log(`${c.dim}${testResult}${c.reset}\n`);
1259
- const repairTask = `The project tests are failing. Here is the test runner output:\n\n${testResult}\n\nPlease identify the files causing the failure, modify them to fix the issues, verify using the test commands, and ensure they pass.`;
1260
- const context = {
1261
- task: repairTask,
1262
- model: currentModel,
1032
+ default: {
1033
+ const handler = commandRegistry[cmd];
1034
+ if (handler) {
1035
+ const ctx = {
1036
+ state: {
1037
+ currentModel,
1038
+ currentMode,
1039
+ currentSessionId,
1040
+ currentSessionLabel,
1041
+ sessionModifiedFiles,
1042
+ pendingAttachments,
1043
+ selectedFiles,
1044
+ stats,
1045
+ isTaskRunning,
1046
+ currentRunningAgent,
1047
+ },
1048
+ args,
1049
+ config,
1050
+ projectConfig,
1263
1051
  cwd,
1264
1052
  verbose,
1265
- selectedFiles: [...selectedFiles],
1266
- systemPromptOverride: projectConfig?.systemPrompt,
1267
- checkCommand: projectConfig?.checkCommand,
1268
- policy: projectConfig?.policy ?? config.preferences.policy,
1269
- mode: 'BUILD',
1270
- yes: true,
1053
+ conversation,
1054
+ agent,
1055
+ git,
1056
+ guard,
1057
+ rl,
1058
+ handleInput,
1059
+ clearSuggestions,
1060
+ refreshModelsForProvider,
1061
+ printStats,
1062
+ listRuns,
1063
+ showRun,
1064
+ buildIndex,
1065
+ workspaceFiles,
1066
+ findInIndex,
1067
+ explainIndexedTarget,
1271
1068
  };
1272
- try {
1273
- isTaskRunning = true;
1274
- currentRunningAgent = agent;
1275
- const result = await agent.runStreaming(context, conversation, rl);
1276
- for (const file of result.modifiedFiles) {
1277
- if (!modifiedFiles.includes(file)) {
1278
- modifiedFiles.push(file);
1279
- }
1280
- }
1281
- }
1282
- catch (err) {
1283
- console.log(`\n${c.red}✗ Repair agent failed on attempt ${attempt}: ${err.message || err}${c.reset}`);
1284
- }
1285
- finally {
1286
- isTaskRunning = false;
1287
- currentRunningAgent = null;
1288
- agent.reset();
1289
- }
1290
- testResult = runProjectTests(cwd);
1291
- if (testResult.includes('Status: 0')) {
1292
- console.log(`\n${c.green}✓ All tests passed after repair attempt ${attempt}!${c.reset}`);
1293
- break;
1294
- }
1295
- else {
1296
- attempt++;
1297
- }
1298
- }
1299
- if (!testResult.includes('Status: 0')) {
1300
- console.log(`\n${c.red}✗ Auto-fix failed after ${maxAttempts} attempts. Remaining failures:${c.reset}`);
1301
- console.log(`${c.dim}${testResult}${c.reset}`);
1302
- }
1303
- else {
1304
- // Auto-commit if enabled and changes were made
1305
- if (config.preferences.autoCommit &&
1306
- (projectConfig?.autoCommit !== false) &&
1307
- modifiedFiles.length > 0) {
1308
- console.log(`\n${c.green}✓ Auto-committing repaired test files...${c.reset}`);
1309
- git.autoCommit('fix-tests: repair test failures', modifiedFiles);
1310
- }
1311
- }
1312
- return;
1313
- }
1314
- case '/fix-ci':
1315
- console.log(`\n${c.yellow}/fix-ci local mode: paste CI logs into a task or save them to a workspace file, then ask FixO to inspect that file.${c.reset}`);
1316
- return;
1317
- case '/plan':
1318
- {
1319
- const task = args.join(' ').trim();
1320
- if (!task) {
1321
- console.log(`\n${c.yellow}Usage: /plan <task>${c.reset}`);
1322
- return;
1323
- }
1324
- const plan = savePlan(cwd, task);
1325
- console.log(`\n${renderPlan(plan)}`);
1326
- }
1327
- return;
1328
- case '/run-plan': {
1329
- const dagFile = path.join(cwd, '.fixo', 'last-dag.json');
1330
- if (fs.existsSync(dagFile)) {
1331
- try {
1332
- const { task, dag } = JSON.parse(fs.readFileSync(dagFile, 'utf-8'));
1333
- console.log(`\n${c.cyan}[Saved Plan] Executing saved subtasks DAG for task: ${c.bold}${task}${c.reset}`);
1334
- const { AgentPool } = await import('../agent/agent-pool.js');
1335
- const pool = new AgentPool(3, projectConfig?.maxAttempts ?? 12);
1336
- const context = {
1337
- task,
1338
- model: currentModel,
1339
- cwd,
1340
- verbose,
1341
- selectedFiles: [...selectedFiles],
1342
- systemPromptOverride: projectConfig?.systemPrompt,
1343
- checkCommand: projectConfig?.checkCommand,
1344
- policy: projectConfig?.policy ?? config.preferences.policy,
1345
- mode: currentMode,
1346
- };
1347
- const success = await pool.execute(context, dag);
1348
- if (success) {
1349
- console.log(`\n${c.green}✓ Successfully completed complex task via parallel agents.${c.reset}`);
1350
- }
1351
- else {
1352
- console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
1353
- if (git.isGitRepo()) {
1354
- console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
1355
- git.discardUncommittedChanges();
1356
- }
1357
- }
1358
- return;
1359
- }
1360
- catch (err) {
1361
- console.log(`\n${c.red}✗ Failed to run saved DAG: ${err.message}${c.reset}`);
1362
- }
1363
- }
1364
- const plan = loadPlan(cwd);
1365
- if (!plan) {
1366
- console.log(`\n${c.yellow}No saved plan or DAG. Generate one with /plan <task> or run a complex task in PLAN mode.${c.reset}`);
1367
- return;
1368
- }
1369
- console.log(`\n${c.dim}Executing saved plan task: ${plan.task}${c.reset}`);
1370
- await handleInput(plan.task);
1371
- return;
1372
- }
1373
- case '/mode': {
1374
- rl.pause();
1375
- const selected = await p.select({
1376
- message: 'Select execution mode:',
1377
- options: [
1378
- { value: 'PLAN', label: 'PLAN Mode (Read-only, dry-run simulation)' },
1379
- { value: 'BUILD', label: 'BUILD Mode (Writing & modifying allowed)' },
1380
- { value: 'EXPLORE', label: 'EXPLORE Mode (Code exploration & LSP, no modifying)' },
1381
- { value: 'SCOUT', label: 'SCOUT Mode (Web search & fetch only)' },
1382
- ],
1383
- initialValue: currentMode,
1384
- });
1385
- rl.resume();
1386
- if (!p.isCancel(selected) && selected) {
1387
- currentMode = selected;
1388
- console.log(`\n${c.green}✓ Execution mode set to: ${c.bold}${currentMode}${c.reset}`);
1389
- }
1390
- else {
1391
- console.log(`\n${c.dim}Execution mode remains: ${c.cyan}${currentMode}${c.reset}`);
1392
- }
1393
- return;
1394
- }
1395
- case '/rename': {
1396
- // Renames the *active* session. Accepts the rest of the
1397
- // input as a free-form label (so spaces don't need quoting).
1398
- const rawLabel = args.join(' ').trim();
1399
- const { isValidSessionLabel, MAX_LABEL_LENGTH } = await import('../runtime/session-snapshots.js');
1400
- const { SessionManager } = await import('../agent/conversation.js');
1401
- if (!rawLabel) {
1402
- console.log(`\n${c.yellow}Usage: /rename <label>${c.reset}\n` +
1403
- `${c.dim} Labels are 1..${MAX_LABEL_LENGTH} chars: letters, digits, space, dash, underscore, dot.${c.reset}`);
1404
- return;
1405
- }
1406
- if (!isValidSessionLabel(rawLabel)) {
1407
- console.log(`\n${c.red}✗ Invalid label.${c.reset} ${c.dim}Allowed: letters, digits, space, dash, underscore, dot — max ${MAX_LABEL_LENGTH} chars.${c.reset}`);
1408
- return;
1409
- }
1410
- // Persist if the session has already been saved at least
1411
- // once; otherwise just remember the label in memory until
1412
- // the next save fires.
1413
- try {
1414
- SessionManager.renameSession(currentSessionId, rawLabel);
1415
- }
1416
- catch {
1417
- /* tolerate first-rename-before-save */
1418
- }
1419
- currentSessionLabel = rawLabel;
1420
- console.log(`\n${c.green}✓ Session renamed:${c.reset} ${c.cyan}${rawLabel}${c.reset} ${c.dim}(id: ${currentSessionId})${c.reset}`);
1421
- return;
1422
- }
1423
- case '/session': {
1424
- const sub = args[0];
1425
- const { SessionManager } = await import('../agent/conversation.js');
1426
- if (sub === 'rename') {
1427
- const id = args[1];
1428
- const rawLabel = args.slice(2).join(' ').trim();
1429
- const { isValidSessionLabel, MAX_LABEL_LENGTH } = await import('../runtime/session-snapshots.js');
1430
- if (!id || !rawLabel) {
1431
- console.log(`\n${c.yellow}Usage: /session rename <id> <label>${c.reset}`);
1432
- return;
1433
- }
1434
- if (!isValidSessionLabel(rawLabel)) {
1435
- console.log(`\n${c.red}✗ Invalid label.${c.reset} ${c.dim}Max ${MAX_LABEL_LENGTH} chars; letters, digits, space, dash, underscore, dot only.${c.reset}`);
1436
- return;
1437
- }
1438
- const ok = SessionManager.renameSession(id, rawLabel);
1439
- if (!ok) {
1440
- console.log(`\n${c.red}✗ Session not found: ${id}${c.reset}`);
1441
- return;
1442
- }
1443
- if (id === currentSessionId)
1444
- currentSessionLabel = rawLabel;
1445
- console.log(`\n${c.green}✓ Renamed${c.reset} ${c.dim}${id}${c.reset} → ${c.cyan}${rawLabel}${c.reset}`);
1446
- return;
1447
- }
1448
- if (sub === 'list') {
1449
- const list = SessionManager.listSessions();
1450
- if (list.length === 0) {
1451
- console.log(`\n${c.dim}No saved sessions found.${c.reset}`);
1452
- }
1453
- else {
1454
- console.log(`\n${c.cyan}${c.bold}Saved Sessions:${c.reset}`);
1455
- for (const s of list) {
1456
- const date = new Date(s.timestamp).toLocaleString();
1457
- const labelDisplay = s.label
1458
- ? `${c.cyan}${s.label}${c.reset} ${c.dim}(${s.sessionId.slice(0, 8)})${c.reset}`
1459
- : `${c.cyan}${s.sessionId}${c.reset}`;
1460
- console.log(` ${labelDisplay} - ${c.bold}${s.model}${c.reset} (${s.messageCount} msgs)`);
1461
- console.log(` ${c.dim}Created: ${date} | Tokens: ${s.totalTokens.toLocaleString()}${c.reset}`);
1462
- if (s.summary) {
1463
- console.log(` ${c.dim}Summary: ${s.summary.slice(0, 80)}...${c.reset}`);
1464
- }
1465
- }
1466
- }
1467
- }
1468
- else if (sub === 'load') {
1469
- const uuid = args[1];
1470
- if (!uuid) {
1471
- console.log(`\n${c.yellow}Usage: /session load <uuid>${c.reset}`);
1472
- return;
1473
- }
1474
- try {
1475
- const data = SessionManager.loadSession(uuid);
1476
- conversation.clear();
1477
- conversation.importHistory(data.history);
1478
- conversation.setSummary(data.summary || '');
1479
- currentModel = data.model;
1480
- conversation.setContextLimit(currentModel);
1481
- sessionModifiedFiles = data.modifiedFiles || [];
1482
- currentSessionId = data.sessionId;
1483
- currentSessionLabel = data.label;
1484
- stats.totalPromptTokens = data.tokenUsage?.prompt_tokens || 0;
1485
- stats.totalCompletionTokens = data.tokenUsage?.completion_tokens || 0;
1486
- console.log(`\n${c.green}✓ Session restored successfully: ${c.bold}${uuid}${c.reset}`);
1487
- console.log(`${c.dim} Model set to: ${c.cyan}${currentModel}${c.reset}`);
1488
- }
1489
- catch (err) {
1490
- console.log(`\n${c.red}✗ Failed to load session: ${err.message}${c.reset}`);
1491
- }
1492
- }
1493
- else if (sub === 'new') {
1494
- conversation.clear();
1495
- sessionModifiedFiles = [];
1496
- stats.totalPromptTokens = 0;
1497
- stats.totalCompletionTokens = 0;
1498
- stats.totalToolCalls = 0;
1499
- stats.totalTasks = 0;
1500
- stats.totalDurationMs = 0;
1501
- const { randomUUID } = await import('node:crypto');
1502
- currentSessionId = randomUUID();
1503
- currentSessionLabel = undefined;
1504
- SessionManager.saveSession(conversation, currentModel, sessionModifiedFiles, {
1505
- prompt_tokens: stats.totalPromptTokens,
1506
- completion_tokens: stats.totalCompletionTokens,
1507
- total_tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
1508
- }, currentSessionId, currentSessionLabel);
1509
- try {
1510
- const { saveSnapshot } = await import('../runtime/session-snapshots.js');
1511
- saveSnapshot({
1512
- cwd,
1513
- conversation: [],
1514
- tokens: 0,
1515
- model: currentModel,
1516
- mode: currentMode,
1517
- selectedFiles: [],
1518
- summary: '',
1519
- label: undefined,
1520
- id: currentSessionId,
1521
- fixedInstructions: projectConfig?.systemPrompt,
1522
- });
1523
- }
1524
- catch {
1525
- // Ignore snapshot save errors on new session
1526
- }
1527
- console.log(`\n${c.green}✓ Active conversation memory purged. New session initialized: ${c.bold}${currentSessionId}${c.reset}`);
1528
- }
1529
- else {
1530
- console.log(`\n${c.yellow}Usage: /session [list | load <uuid> | new | rename <id> <label>]${c.reset}`);
1531
- }
1532
- return;
1533
- }
1534
- case '/providers': {
1535
- const sub = args[0];
1536
- // ── Interactive flow (bare `/providers`): mirrors the
1537
- // /model picker shape. The user picks a provider, then
1538
- // an action, then enters a masked API key via p.password
1539
- // when the action is add/update. The legacy text routes
1540
- // below remain unchanged for muscle-memory + scripting.
1541
- if (!sub) {
1542
- rl.pause();
1543
- const pickedProvider = await p.select({
1544
- message: 'Select an AI provider:',
1545
- options: PROVIDER_REGISTRY.map(def => ({
1546
- value: def.name,
1547
- label: def.displayName,
1548
- hint: ProvidersManager.has(def.name) ? '[key ✓]' : '[no key]',
1549
- })),
1550
- });
1551
- rl.resume();
1552
- if (p.isCancel(pickedProvider)) {
1553
- console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1554
- return;
1555
- }
1556
- const def = ProvidersManager.getDefinition(pickedProvider);
1557
- if (!def) {
1558
- console.log(`\n${c.red}✗ Unknown provider: ${pickedProvider}${c.reset}`);
1559
- return;
1560
- }
1561
- const hasKey = ProvidersManager.has(def.name);
1562
- rl.pause();
1563
- const action = await p.select({
1564
- message: `${def.displayName} — choose an action:`,
1565
- options: [
1566
- { value: 'add', label: hasKey ? 'Update API key' : 'Add API key' },
1567
- { value: 'test', label: 'Test connection', hint: hasKey ? '' : 'requires a key' },
1568
- { value: 'remove', label: 'Remove API key', hint: hasKey ? '' : 'no key configured' },
1569
- { value: 'cancel', label: 'Cancel' },
1570
- ],
1571
- });
1572
- rl.resume();
1573
- if (p.isCancel(action) || action === 'cancel') {
1574
- console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1575
- return;
1576
- }
1577
- if (action === 'add') {
1578
- console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
1579
- rl.pause();
1580
- const key = await p.password({
1581
- message: `Enter your ${def.displayName} API key:`,
1582
- validate: v => !v?.trim() ? 'API key is required' : undefined,
1583
- });
1584
- rl.resume();
1585
- if (p.isCancel(key)) {
1586
- console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1587
- return;
1588
- }
1589
- ProvidersManager.add(def.name, key);
1590
- console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
1591
- await refreshModelsForProvider(def.name);
1592
- return;
1593
- }
1594
- if (action === 'remove') {
1595
- if (!hasKey) {
1596
- console.log(`\n${c.yellow}No key configured for ${def.displayName}.${c.reset}`);
1597
- return;
1598
- }
1599
- rl.pause();
1600
- const confirmed = await p.confirm({
1601
- message: `Remove API key for ${def.displayName}?`,
1602
- initialValue: false,
1603
- });
1604
- rl.resume();
1605
- if (!p.isCancel(confirmed) && confirmed) {
1606
- const removed = ProvidersManager.remove(def.name);
1607
- console.log(removed
1608
- ? `\n${c.green}✓ Removed API key for ${def.displayName}.${c.reset}`
1609
- : `\n${c.yellow}No key found for provider: ${def.name}${c.reset}`);
1610
- }
1611
- return;
1069
+ await handler(ctx);
1070
+ // Sync state back
1071
+ currentModel = ctx.state.currentModel;
1072
+ currentMode = ctx.state.currentMode;
1073
+ currentSessionId = ctx.state.currentSessionId;
1074
+ currentSessionLabel = ctx.state.currentSessionLabel;
1075
+ sessionModifiedFiles = ctx.state.sessionModifiedFiles;
1076
+ pendingAttachments = ctx.state.pendingAttachments;
1077
+ selectedFiles = ctx.state.selectedFiles;
1078
+ stats = ctx.state.stats;
1079
+ isTaskRunning = ctx.state.isTaskRunning;
1080
+ currentRunningAgent = ctx.state.currentRunningAgent;
1081
+ if (ctx.workspaceFiles) {
1082
+ workspaceFiles = ctx.workspaceFiles;
1612
1083
  }
1613
- if (action === 'test') {
1614
- if (!hasKey) {
1615
- console.log(`\n${c.yellow}No key configured for ${def.displayName}. Add one first.${c.reset}`);
1616
- return;
1617
- }
1618
- console.log(`\n${c.dim}Testing connection to ${def.displayName} via live /models fetch…${c.reset}`);
1619
- await refreshModelsForProvider(def.name);
1620
- return;
1621
- }
1622
- return;
1623
- }
1624
- if (sub === 'list') {
1625
- const list = ProvidersManager.list();
1626
- if (list.length === 0) {
1627
- console.log(`\n${c.yellow}No providers configured.${c.reset}`);
1628
- console.log(`${c.dim} Use /providers add <name> to connect a provider (e.g. /providers add groq)${c.reset}`);
1629
- console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1630
- }
1631
- else {
1632
- console.log(`\n${c.bold}${c.cyan}Connected Providers${c.reset}`);
1633
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1634
- for (const entry of list) {
1635
- const addedDate = new Date(entry.addedAt).toLocaleDateString();
1636
- console.log(` ${c.cyan}${entry.name.padEnd(14)}${c.reset}${c.bold}${entry.displayName.padEnd(22)}${c.reset}${c.dim}${entry.maskedKey} (added ${addedDate})${c.reset}`);
1637
- }
1638
- console.log(`\n${c.dim} Use /providers remove <name> to remove a key.${c.reset}`);
1639
- console.log(`${c.dim} Use /providers test <name> to verify a connection.${c.reset}`);
1640
- }
1641
- return;
1642
- }
1643
- if (sub === 'add') {
1644
- const name = args[1]?.toLowerCase();
1645
- if (!name) {
1646
- console.log(`\n${c.yellow}Usage: /providers add <provider-name>${c.reset}`);
1647
- console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1648
- return;
1649
- }
1650
- const def = ProvidersManager.getDefinition(name);
1651
- if (!def) {
1652
- console.log(`\n${c.red}✗ Unknown provider: ${name}${c.reset}`);
1653
- console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1654
- return;
1655
- }
1656
- console.log(`\n${c.cyan}${c.bold}Connecting to ${def.displayName}${c.reset}`);
1657
- console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
1658
- rl.pause();
1659
- const apiKeyInput = await p.text({
1660
- message: `Enter your ${def.displayName} API key:`,
1661
- placeholder: 'sk-... or gsk_...',
1662
- validate: v => !v.trim() ? 'API key is required' : undefined,
1663
- });
1664
- rl.resume();
1665
- if (p.isCancel(apiKeyInput)) {
1666
- console.log(`\n${c.dim}Provider add cancelled.${c.reset}`);
1667
- return;
1668
- }
1669
- ProvidersManager.add(name, apiKeyInput);
1670
- console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
1671
- console.log(`${c.dim} FixO will now route ${def.displayName} requests directly (bypassing the SaaS proxy).${c.reset}`);
1672
- await refreshModelsForProvider(name);
1673
1084
  return;
1674
1085
  }
1675
- if (sub === 'remove') {
1676
- const name = args[1]?.toLowerCase();
1677
- if (!name) {
1678
- console.log(`\n${c.yellow}Usage: /providers remove <name>${c.reset}`);
1679
- return;
1680
- }
1681
- rl.pause();
1682
- const confirmed = await p.confirm({ message: `Remove API key for ${name}?`, initialValue: false });
1683
- rl.resume();
1684
- if (!p.isCancel(confirmed) && confirmed) {
1685
- const removed = ProvidersManager.remove(name);
1686
- console.log(removed
1687
- ? `\n${c.green}✓ Removed API key for ${name}.${c.reset}`
1688
- : `\n${c.yellow}No key found for provider: ${name}${c.reset}`);
1689
- }
1690
- return;
1691
- }
1692
- if (sub === 'test') {
1693
- const name = args[1]?.toLowerCase();
1694
- if (!name) {
1695
- console.log(`\n${c.yellow}Usage: /providers test <name>${c.reset}`);
1696
- return;
1697
- }
1698
- const directConf = ProvidersManager.getDirectConfig(name);
1699
- if (!directConf) {
1700
- console.log(`\n${c.yellow}No key configured for ${name}. Use /providers add ${name} first.${c.reset}`);
1701
- return;
1702
- }
1703
- console.log(`\n${c.dim}Testing connection to ${directConf.displayName} (${directConf.baseUrl})...${c.reset}`);
1704
- try {
1705
- const testHeaders = {
1706
- 'Authorization': `Bearer ${directConf.apiKey}`,
1707
- };
1708
- if (name === 'zen' || name === 'openrouter') {
1709
- testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
1710
- testHeaders['X-Title'] = 'opencode';
1711
- }
1712
- else if (name === 'nvidia') {
1713
- testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
1714
- testHeaders['X-Title'] = 'opencode';
1715
- testHeaders['X-BILLING-INVOKE-ORIGIN'] = 'OpenCode';
1716
- }
1717
- else if (name === 'cerebras') {
1718
- testHeaders['X-Cerebras-3rd-Party-Integration'] = 'opencode';
1719
- }
1720
- const resp = await fetch(`${directConf.baseUrl}/models`, {
1721
- headers: testHeaders,
1722
- signal: AbortSignal.timeout(8000),
1723
- });
1724
- if (resp.ok) {
1725
- console.log(`${c.green}✓ Connection to ${directConf.displayName} successful! (HTTP ${resp.status})${c.reset}`);
1726
- // Warm the cache so /model picker shows live IDs.
1727
- await refreshModelsForProvider(name);
1728
- }
1729
- else {
1730
- const text = await resp.text().catch(() => '');
1731
- console.log(`${c.red}✗ ${directConf.displayName} returned HTTP ${resp.status}${text ? ': ' + text.slice(0, 100) : ''}${c.reset}`);
1732
- }
1733
- }
1734
- catch (err) {
1735
- console.log(`${c.red}✗ Connection failed: ${err.message}${c.reset}`);
1736
- }
1737
- return;
1738
- }
1739
- console.log(`\n${c.yellow}Usage: /providers [list | add <name> | remove <name> | test <name>]${c.reset}`);
1740
- console.log(`${c.dim} Available providers: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1741
- return;
1742
- }
1743
- case '/compact': {
1744
- const msgCount = conversation.getMessageCount();
1745
- if (msgCount === 0) {
1746
- console.log(`\n${c.dim}Nothing to compact — conversation is empty.${c.reset}`);
1747
- return;
1748
- }
1749
- const tokensBefore = conversation.getTotalTokens();
1750
- const contextLimit = conversation.getContextLimit();
1751
- console.log(`\n${c.cyan}[Compact] Summarising ${msgCount} messages to free context tokens...${c.reset}`);
1752
- console.log(`${c.dim} Current context: ${(tokensBefore / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k tokens${c.reset}`);
1753
- try {
1754
- const compacted = await conversation.compact(agent.getClient(), currentModel);
1755
- if (compacted) {
1756
- const info = conversation.getLastCompactionInfo();
1757
- const tokensAfter = conversation.getTotalTokens();
1758
- console.log(`${c.green}✓ Compacted: ${info?.messagesBefore ?? msgCount} messages → summary + ${conversation.getMessageCount()} recent messages.${c.reset}`);
1759
- console.log(`${c.dim} Context: ${(tokensBefore / 1000).toFixed(0)}k → ${(tokensAfter / 1000).toFixed(0)}k tokens (~${((info?.tokensFreed ?? 0) / 1000).toFixed(0)}k freed).${c.reset}`);
1760
- }
1761
- else {
1762
- console.log(`${c.dim}Not enough messages to compact (need more than 4 messages).${c.reset}`);
1763
- }
1764
- }
1765
- catch (err) {
1766
- console.log(`${c.red}✗ Compact failed: ${err.message}${c.reset}`);
1767
- }
1768
- return;
1769
- }
1770
- case '/snapshot': {
1771
- const label = args.join(' ').trim() || `snapshot-${Date.now()}`;
1772
- if (!git.isGitRepo()) {
1773
- console.log(`\n${c.yellow}⚠ Not a git repository — cannot create snapshot.${c.reset}`);
1774
- return;
1775
- }
1776
- const hash = git.createSnapshot(label);
1777
- if (hash) {
1778
- console.log(`\n${c.green}✓ Workspace snapshot created: ${c.bold}${hash}${c.reset}${c.dim} (label: ${label})${c.reset}`);
1779
- console.log(`${c.dim} Use /undo or git revert to roll back to this point.${c.reset}`);
1780
- }
1781
- return;
1782
- }
1783
- case '/skills': {
1784
- const { skillsManager } = await import('../agent/skills.js');
1785
- const list = skillsManager.getSkills();
1786
- if (list.length === 0) {
1787
- console.log(`\n${c.dim}No skills registered. Register skill profiles by adding SKILL.md under ~/.fixocli/skills/<name>/ or .fixocli/skills/<name>/${c.reset}`);
1788
- }
1789
- else {
1790
- console.log(`\n${c.cyan}${c.bold}Registered Skills:${c.reset}`);
1791
- for (const skill of list) {
1792
- console.log(` - ${c.bold}${skill.name}${c.reset}${skill.description ? `: ${skill.description}` : ''} ${c.dim}(${skill.location})${c.reset}`);
1793
- }
1794
- }
1795
- return;
1796
- }
1797
- case '/theme':
1798
- case '/variant': {
1799
- const { themeMode, setThemeMode } = await import('./colors.js');
1800
- const newMode = themeMode === 'dark' ? 'inverted' : 'dark';
1801
- setThemeMode(newMode);
1802
- console.log(`\n${c.cyan}✓ Theme set to: ${newMode === 'dark' ? 'Dark Void Minimalist' : 'High-Contrast Inverted'}${c.reset}`);
1803
- return;
1804
- }
1805
- case '/telemetry': {
1806
- const sub = args[0]?.toLowerCase();
1807
- if (sub === 'on' || sub === 'enable') {
1808
- config.preferences.telemetry = true;
1809
- saveConfig(config);
1810
- console.log(`\n${c.green}✓ Telemetry enabled${c.reset}`);
1811
- }
1812
- else if (sub === 'off' || sub === 'disable') {
1813
- config.preferences.telemetry = false;
1814
- saveConfig(config);
1815
- console.log(`\n${c.green}✓ Telemetry disabled${c.reset}`);
1816
- }
1817
- else {
1818
- console.log(`\n${c.dim}Telemetry is currently ${config.preferences.telemetry ? `${c.green}ON${c.reset}${c.dim}` : `${c.red}OFF${c.reset}${c.dim}`}. Usage: /telemetry on|off${c.reset}`);
1819
- }
1820
- return;
1821
- }
1822
- default:
1823
1086
  console.log(`\n${c.yellow}Unknown command: ${cmd}. Type /help for available commands.${c.reset}`);
1824
1087
  return;
1088
+ }
1825
1089
  }
1826
1090
  }
1827
1091
  // ─── Shell commands (! prefix) ───
@@ -1862,20 +1126,48 @@ export async function startREPL(options) {
1862
1126
  console.log(output);
1863
1127
  }
1864
1128
  catch (error) {
1865
- if (error.stdout)
1866
- console.log(error.stdout);
1867
- if (error.stderr)
1868
- console.error(`${c.red}${error.stderr}${c.reset}`);
1129
+ const err = error;
1130
+ if (err.stdout)
1131
+ console.log(err.stdout);
1132
+ if (err.stderr)
1133
+ console.error(`${c.red}${err.stderr}${c.reset}`);
1869
1134
  }
1870
1135
  return;
1871
1136
  }
1137
+ // ─── Conversation echo (paste expansion) ──────────────────────────
1138
+ // When the submitted input contains paste attachments, overwrite the
1139
+ // readline-echoed `> [Paste #N +M lines]` line with a proper
1140
+ // conversation block so the user can see what they sent.
1141
+ // Mirrors the Claude Code / Antigravity transcript pattern.
1142
+ if (pendingPastes.length > 0) {
1143
+ // Step 1: reconstruct the original input as it would have looked without folding
1144
+ let unfoldedInput = rawInput;
1145
+ for (const paste of pendingPastes) {
1146
+ const token = `[Paste #${paste.id} +${paste.lines} lines]`;
1147
+ unfoldedInput = unfoldedInput.replace(token, paste.content);
1148
+ }
1149
+ // Step 2: move up ONE line and erase it — this erases the readline-
1150
+ // echoed token line ("> [Paste #2 +4 lines]") that is already on screen.
1151
+ process.stdout.write('\x1b[1A\x1b[2K');
1152
+ // Step 3: print the prompt and the unfolded input
1153
+ const lines = unfoldedInput.split(/\r\n|\r|\n/);
1154
+ if (lines.length > 0) {
1155
+ console.log(`> ${lines[0]}`);
1156
+ for (let i = 1; i < lines.length; i++) {
1157
+ console.log(lines[i]);
1158
+ }
1159
+ }
1160
+ // Blank line before the agent spinner starts
1161
+ console.log('');
1162
+ }
1163
+ // ─────────────────────────────────────────────────────────────────
1872
1164
  // ─── Agent task ───
1873
1165
  // Format any paths in the input for display
1874
1166
  const displayInput = formatInputPaths(input, cwd);
1875
1167
  if (displayInput !== input) {
1876
1168
  // Re-display with highlighted paths
1877
1169
  process.stdout.write(`\x1b[1A\x1b[2K`); // Move up and clear line
1878
- console.log(`${C.LAVA}›${C.RESET} ${displayInput}`);
1170
+ console.log(`> ${displayInput}`);
1879
1171
  }
1880
1172
  // Extract any file paths from input for automatic pinning
1881
1173
  const pathsInInput = extractFilePaths(input, cwd);
@@ -1895,105 +1187,34 @@ export async function startREPL(options) {
1895
1187
  // Drain the queue — attachments are one-shot. The agent has its
1896
1188
  // own copy via context above.
1897
1189
  pendingAttachments = [];
1898
- const classification = classifyComplexityHeuristic(input);
1899
- let result;
1900
- const startTime = Date.now();
1901
- if (classification.complexity === 'complex') {
1902
- console.log(`\n${c.cyan}[Routing Engine] Complex task detected (${classification.reason}). Routing to Orchestrator...${c.reset}`);
1903
- try {
1904
- const { Orchestrator } = await import('../agent/orchestrator.js');
1905
- const { AgentPool } = await import('../agent/agent-pool.js');
1906
- console.log(`\n${c.cyan}[Orchestrator] Generating plan for complex task...${c.reset}`);
1907
- const orchestrator = new Orchestrator(verbose);
1908
- const dag = await orchestrator.plan(context);
1909
- // Render planned phases in high contrast box
1910
- const width = 60;
1911
- const borderTop = `┌${'─'.repeat(width)}┐`;
1912
- const borderBottom = `└${'─'.repeat(width)}┘`;
1913
- console.log(`\n${c.cyan}${borderTop}${c.reset}`);
1914
- console.log(`${c.cyan}│${c.reset} ${c.bold}Planned Subtask Phases (Complex Task decomposition):${c.reset}${' '.repeat(width - 52)}${c.cyan}│${c.reset}`);
1915
- console.log(`${c.cyan}├${'─'.repeat(width)}┤${c.reset}`);
1916
- for (const sub of dag.subtasks) {
1917
- const deps = sub.dependencies.length > 0 ? ` (deps: ${sub.dependencies.join(', ')})` : '';
1918
- const lineStr = ` - [${sub.persona.toUpperCase()}] ${sub.title}${deps}`;
1919
- const pad = Math.max(0, width - lineStr.length - 4);
1920
- console.log(`${c.cyan}│${c.reset} ${c.bold}${lineStr}${c.reset}${' '.repeat(pad)} ${c.cyan}│${c.reset}`);
1921
- }
1922
- console.log(`${c.cyan}${borderBottom}${c.reset}\n`);
1923
- // Save the DAG to .fixo/last-dag.json
1924
- const fixoDir = path.join(cwd, '.fixo');
1925
- fs.mkdirSync(fixoDir, { recursive: true });
1926
- fs.writeFileSync(path.join(fixoDir, 'last-dag.json'), JSON.stringify({ task: input, dag }, null, 2), 'utf-8');
1927
- if (currentMode === 'PLAN') {
1928
- console.log(`${c.green}✓ Plan generated and saved successfully.${c.reset}`);
1929
- console.log(`${c.dim} To execute this plan, switch to BUILD mode (type /mode build or hit [TAB]) and run: /run-plan${c.reset}\n`);
1930
- return;
1931
- }
1932
- const budgetLimit = projectConfig?.maxAttempts ?? 12;
1933
- const pool = new AgentPool(3, budgetLimit);
1934
- console.log(`\n${c.cyan}[Agent Pool] Executing DAG of subtasks (concurrency limit: 3, budget: ${budgetLimit} tool calls)...${c.reset}`);
1935
- const success = await pool.execute(context, dag);
1936
- const durationMs = Date.now() - startTime;
1937
- const totalPromptTokens = orchestrator.tokensUsed.prompt_tokens + pool.tokensUsed.prompt_tokens;
1938
- const totalCompletionTokens = orchestrator.tokensUsed.completion_tokens + pool.tokensUsed.completion_tokens;
1939
- // Find modified files to report
1940
- const { getModifiedFiles, getBranchPoint } = await import('../agent/worker-agent.js');
1941
- const relativeModified = getModifiedFiles(cwd, getBranchPoint(cwd));
1942
- const modifiedFiles = relativeModified.map(f => path.resolve(cwd, f));
1943
- if (!success) {
1944
- console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
1945
- if (git.isGitRepo()) {
1946
- console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
1947
- git.discardUncommittedChanges();
1948
- }
1949
- }
1950
- result = {
1951
- success,
1952
- response: success
1953
- ? 'Successfully completed complex task via parallel agents.'
1954
- : 'Failed to complete all complex subtasks.',
1955
- modifiedFiles,
1956
- tokensUsed: {
1957
- prompt_tokens: totalPromptTokens,
1958
- completion_tokens: totalCompletionTokens,
1959
- total_tokens: totalPromptTokens + totalCompletionTokens
1960
- },
1961
- toolCallCount: pool.toolCallCount,
1962
- durationMs,
1963
- model: context.model,
1964
- };
1965
- }
1966
- catch (err) {
1967
- console.error(`\n${c.red}✗ Orchestrated execution failed: ${err.message || err}${c.reset}`);
1968
- if (git.isGitRepo()) {
1969
- console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to error...${c.reset}`);
1970
- git.discardUncommittedChanges();
1971
- }
1972
- const durationMs = Date.now() - startTime;
1973
- result = {
1974
- success: false,
1975
- response: `Orchestrated run failed: ${err.message || err}`,
1976
- modifiedFiles: [],
1977
- tokensUsed: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
1978
- toolCallCount: 0,
1979
- durationMs,
1980
- model: context.model,
1981
- };
1982
- }
1983
- }
1984
- else {
1985
- console.log(`\n${c.cyan}[Routing Engine] Simple task detected (${classification.reason}). Routing to SingleAgent...${c.reset}`);
1986
- isTaskRunning = true;
1987
- currentRunningAgent = agent;
1988
- try {
1989
- result = await agent.runStreaming(context, conversation, rl);
1990
- }
1991
- finally {
1190
+ // Phase 2.1 — routing decision + execution lives in
1191
+ // task-router.ts so it can be unit-tested independently of the
1192
+ // REPL and reused by future non-TUI entry points (--headless,
1193
+ // web backend, IDE extension). Console output is byte-identical
1194
+ // to the pre-extraction inline path. The rollback inside the
1195
+ // complex path uses git.discardChangesIn() (Phase 0.0 — scoped).
1196
+ const { routeAndExecute } = await import('../agent/task-router.js');
1197
+ const routed = await routeAndExecute(input, context, {
1198
+ agent,
1199
+ conversation,
1200
+ rl,
1201
+ projectConfig,
1202
+ verbose,
1203
+ onSimplePathStart: (a) => {
1204
+ isTaskRunning = true;
1205
+ currentRunningAgent = a;
1206
+ },
1207
+ onSimplePathEnd: () => {
1992
1208
  isTaskRunning = false;
1993
1209
  currentRunningAgent = null;
1994
- agent.reset();
1995
- }
1210
+ },
1211
+ });
1212
+ if (routed.route === 'plan-mode-deferred') {
1213
+ pendingPastes = [];
1214
+ return;
1996
1215
  }
1216
+ const result = routed.result;
1217
+ pendingPastes = [];
1997
1218
  // Print result summary
1998
1219
  console.log('');
1999
1220
  const modelPart = result.model ? `${result.model} · ` : '';