fixo-cli 1.0.3 → 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 (222) 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 +37 -0
  16. package/dist/agent/command-parser.d.ts.map +1 -1
  17. package/dist/agent/command-parser.js +473 -1
  18. package/dist/agent/command-parser.js.map +1 -1
  19. package/dist/agent/context-builder.d.ts +24 -0
  20. package/dist/agent/context-builder.d.ts.map +1 -0
  21. package/dist/agent/context-builder.js +197 -0
  22. package/dist/agent/context-builder.js.map +1 -0
  23. package/dist/agent/conversation.d.ts +32 -2
  24. package/dist/agent/conversation.d.ts.map +1 -1
  25. package/dist/agent/conversation.js +84 -9
  26. package/dist/agent/conversation.js.map +1 -1
  27. package/dist/agent/duration.d.ts +24 -0
  28. package/dist/agent/duration.d.ts.map +1 -0
  29. package/dist/agent/duration.js +42 -0
  30. package/dist/agent/duration.js.map +1 -0
  31. package/dist/agent/file-writing-rules.d.ts +19 -0
  32. package/dist/agent/file-writing-rules.d.ts.map +1 -0
  33. package/dist/agent/file-writing-rules.js +31 -0
  34. package/dist/agent/file-writing-rules.js.map +1 -0
  35. package/dist/agent/mcp-bridge.js +1 -1
  36. package/dist/agent/mcp-bridge.js.map +1 -1
  37. package/dist/agent/orchestrator.d.ts +45 -0
  38. package/dist/agent/orchestrator.d.ts.map +1 -1
  39. package/dist/agent/orchestrator.js +140 -3
  40. package/dist/agent/orchestrator.js.map +1 -1
  41. package/dist/agent/parser-adapter.d.ts +17 -0
  42. package/dist/agent/parser-adapter.d.ts.map +1 -1
  43. package/dist/agent/parser-adapter.js +311 -7
  44. package/dist/agent/parser-adapter.js.map +1 -1
  45. package/dist/agent/predictive-gate.d.ts.map +1 -1
  46. package/dist/agent/predictive-gate.js +4 -1
  47. package/dist/agent/predictive-gate.js.map +1 -1
  48. package/dist/agent/provider-cooldown.d.ts.map +1 -1
  49. package/dist/agent/provider-cooldown.js +3 -2
  50. package/dist/agent/provider-cooldown.js.map +1 -1
  51. package/dist/agent/providers-manager.d.ts +5 -0
  52. package/dist/agent/providers-manager.d.ts.map +1 -1
  53. package/dist/agent/providers-manager.js +119 -8
  54. package/dist/agent/providers-manager.js.map +1 -1
  55. package/dist/agent/repo-map.d.ts +18 -1
  56. package/dist/agent/repo-map.d.ts.map +1 -1
  57. package/dist/agent/repo-map.js +144 -54
  58. package/dist/agent/repo-map.js.map +1 -1
  59. package/dist/agent/retry.js +1 -2
  60. package/dist/agent/retry.js.map +1 -1
  61. package/dist/agent/single-agent.d.ts +13 -0
  62. package/dist/agent/single-agent.d.ts.map +1 -1
  63. package/dist/agent/single-agent.js +225 -37
  64. package/dist/agent/single-agent.js.map +1 -1
  65. package/dist/agent/skills.d.ts.map +1 -1
  66. package/dist/agent/skills.js +2 -1
  67. package/dist/agent/skills.js.map +1 -1
  68. package/dist/agent/subagent.js +2 -2
  69. package/dist/agent/subagent.js.map +1 -1
  70. package/dist/agent/task-router.d.ts +46 -0
  71. package/dist/agent/task-router.d.ts.map +1 -0
  72. package/dist/agent/task-router.js +352 -0
  73. package/dist/agent/task-router.js.map +1 -0
  74. package/dist/agent/telemetry.d.ts +29 -1
  75. package/dist/agent/telemetry.d.ts.map +1 -1
  76. package/dist/agent/telemetry.js +29 -11
  77. package/dist/agent/telemetry.js.map +1 -1
  78. package/dist/agent/tool-definitions.d.ts +3 -0
  79. package/dist/agent/tool-definitions.d.ts.map +1 -0
  80. package/dist/agent/tool-definitions.js +519 -0
  81. package/dist/agent/tool-definitions.js.map +1 -0
  82. package/dist/agent/tool-executor.d.ts +6 -1
  83. package/dist/agent/tool-executor.d.ts.map +1 -1
  84. package/dist/agent/tool-executor.js +99 -553
  85. package/dist/agent/tool-executor.js.map +1 -1
  86. package/dist/agent/tools/command-tools.d.ts +6 -0
  87. package/dist/agent/tools/command-tools.d.ts.map +1 -0
  88. package/dist/agent/tools/command-tools.js +104 -0
  89. package/dist/agent/tools/command-tools.js.map +1 -0
  90. package/dist/agent/tools/file-tools.d.ts +15 -0
  91. package/dist/agent/tools/file-tools.d.ts.map +1 -0
  92. package/dist/agent/tools/file-tools.js +551 -0
  93. package/dist/agent/tools/file-tools.js.map +1 -0
  94. package/dist/agent/tools/todo-tools.d.ts +3 -0
  95. package/dist/agent/tools/todo-tools.d.ts.map +1 -0
  96. package/dist/agent/tools/todo-tools.js +70 -0
  97. package/dist/agent/tools/todo-tools.js.map +1 -0
  98. package/dist/agent/web-impl.d.ts.map +1 -1
  99. package/dist/agent/web-impl.js +45 -0
  100. package/dist/agent/web-impl.js.map +1 -1
  101. package/dist/agent/worker-agent.d.ts +3 -1
  102. package/dist/agent/worker-agent.d.ts.map +1 -1
  103. package/dist/agent/worker-agent.js +56 -16
  104. package/dist/agent/worker-agent.js.map +1 -1
  105. package/dist/config.d.ts +253 -1
  106. package/dist/config.d.ts.map +1 -1
  107. package/dist/config.js +81 -1
  108. package/dist/config.js.map +1 -1
  109. package/dist/git/git-manager.d.ts +33 -2
  110. package/dist/git/git-manager.d.ts.map +1 -1
  111. package/dist/git/git-manager.js +111 -15
  112. package/dist/git/git-manager.js.map +1 -1
  113. package/dist/git/git-ops.d.ts.map +1 -1
  114. package/dist/git/git-ops.js +2 -1
  115. package/dist/git/git-ops.js.map +1 -1
  116. package/dist/index.js +89 -8
  117. package/dist/index.js.map +1 -1
  118. package/dist/lsp/lsp-manager.js +1 -1
  119. package/dist/lsp/lsp-manager.js.map +1 -1
  120. package/dist/model-outcomes.d.ts.map +1 -1
  121. package/dist/model-outcomes.js +2 -1
  122. package/dist/model-outcomes.js.map +1 -1
  123. package/dist/planner.d.ts +0 -9
  124. package/dist/planner.d.ts.map +1 -1
  125. package/dist/planner.js +0 -9
  126. package/dist/planner.js.map +1 -1
  127. package/dist/project-memory.d.ts +12 -1
  128. package/dist/project-memory.d.ts.map +1 -1
  129. package/dist/project-memory.js +8 -6
  130. package/dist/project-memory.js.map +1 -1
  131. package/dist/runtime/loop-mitigation.d.ts +119 -0
  132. package/dist/runtime/loop-mitigation.d.ts.map +1 -0
  133. package/dist/runtime/loop-mitigation.js +192 -0
  134. package/dist/runtime/loop-mitigation.js.map +1 -0
  135. package/dist/runtime/os-sandbox.d.ts +100 -0
  136. package/dist/runtime/os-sandbox.d.ts.map +1 -0
  137. package/dist/runtime/os-sandbox.js +246 -0
  138. package/dist/runtime/os-sandbox.js.map +1 -0
  139. package/dist/runtime/run-inventory.d.ts +17 -0
  140. package/dist/runtime/run-inventory.d.ts.map +1 -0
  141. package/dist/runtime/run-inventory.js +49 -0
  142. package/dist/runtime/run-inventory.js.map +1 -0
  143. package/dist/runtime/session-snapshots.d.ts +52 -2
  144. package/dist/runtime/session-snapshots.d.ts.map +1 -1
  145. package/dist/runtime/session-snapshots.js +76 -1
  146. package/dist/runtime/session-snapshots.js.map +1 -1
  147. package/dist/runtime/staging.d.ts.map +1 -1
  148. package/dist/runtime/staging.js +4 -1
  149. package/dist/runtime/staging.js.map +1 -1
  150. package/dist/runtime/task-session.d.ts +14 -0
  151. package/dist/runtime/task-session.d.ts.map +1 -1
  152. package/dist/runtime/task-session.js +26 -0
  153. package/dist/runtime/task-session.js.map +1 -1
  154. package/dist/setup-wizard.d.ts +11 -3
  155. package/dist/setup-wizard.d.ts.map +1 -1
  156. package/dist/setup-wizard.js +113 -15
  157. package/dist/setup-wizard.js.map +1 -1
  158. package/dist/types.d.ts +8 -0
  159. package/dist/types.d.ts.map +1 -1
  160. package/dist/ui/commands/context-commands.d.ts +7 -0
  161. package/dist/ui/commands/context-commands.d.ts.map +1 -0
  162. package/dist/ui/commands/context-commands.js +241 -0
  163. package/dist/ui/commands/context-commands.js.map +1 -0
  164. package/dist/ui/commands/index.d.ts +3 -0
  165. package/dist/ui/commands/index.d.ts.map +1 -0
  166. package/dist/ui/commands/index.js +46 -0
  167. package/dist/ui/commands/index.js.map +1 -0
  168. package/dist/ui/commands/info-commands.d.ts +15 -0
  169. package/dist/ui/commands/info-commands.d.ts.map +1 -0
  170. package/dist/ui/commands/info-commands.js +122 -0
  171. package/dist/ui/commands/info-commands.js.map +1 -0
  172. package/dist/ui/commands/model-commands.d.ts +5 -0
  173. package/dist/ui/commands/model-commands.d.ts.map +1 -0
  174. package/dist/ui/commands/model-commands.js +417 -0
  175. package/dist/ui/commands/model-commands.js.map +1 -0
  176. package/dist/ui/commands/session-commands.d.ts +5 -0
  177. package/dist/ui/commands/session-commands.d.ts.map +1 -0
  178. package/dist/ui/commands/session-commands.js +154 -0
  179. package/dist/ui/commands/session-commands.js.map +1 -0
  180. package/dist/ui/commands/task-commands.d.ts +8 -0
  181. package/dist/ui/commands/task-commands.d.ts.map +1 -0
  182. package/dist/ui/commands/task-commands.js +152 -0
  183. package/dist/ui/commands/task-commands.js.map +1 -0
  184. package/dist/ui/commands/types.d.ts +46 -0
  185. package/dist/ui/commands/types.d.ts.map +1 -0
  186. package/dist/ui/commands/types.js +2 -0
  187. package/dist/ui/commands/types.js.map +1 -0
  188. package/dist/ui/commands/workspace-commands.d.ts +8 -0
  189. package/dist/ui/commands/workspace-commands.d.ts.map +1 -0
  190. package/dist/ui/commands/workspace-commands.js +131 -0
  191. package/dist/ui/commands/workspace-commands.js.map +1 -0
  192. package/dist/ui/loading-animation.d.ts +24 -0
  193. package/dist/ui/loading-animation.d.ts.map +1 -0
  194. package/dist/ui/loading-animation.js +123 -0
  195. package/dist/ui/loading-animation.js.map +1 -0
  196. package/dist/ui/markdown-stream.js +2 -2
  197. package/dist/ui/markdown-stream.js.map +1 -1
  198. package/dist/ui/prompt.d.ts +7 -0
  199. package/dist/ui/prompt.d.ts.map +1 -1
  200. package/dist/ui/prompt.js +461 -1143
  201. package/dist/ui/prompt.js.map +1 -1
  202. package/dist/ui/render-primitives.d.ts +6 -0
  203. package/dist/ui/render-primitives.d.ts.map +1 -1
  204. package/dist/ui/render-primitives.js +30 -13
  205. package/dist/ui/render-primitives.js.map +1 -1
  206. package/dist/ui/render.d.ts.map +1 -1
  207. package/dist/ui/render.js +2 -0
  208. package/dist/ui/render.js.map +1 -1
  209. package/dist/ui/session-header.d.ts +13 -0
  210. package/dist/ui/session-header.d.ts.map +1 -1
  211. package/dist/ui/session-header.js +6 -0
  212. package/dist/ui/session-header.js.map +1 -1
  213. package/package.json +22 -4
  214. package/scripts/check-vendor-wasm.js +55 -0
  215. package/vendor/tree-sitter-bash.wasm +0 -0
  216. package/vendor/tree-sitter-go.wasm +0 -0
  217. package/vendor/tree-sitter-javascript.wasm +0 -0
  218. package/vendor/tree-sitter-python.wasm +0 -0
  219. package/vendor/tree-sitter-rust.wasm +0 -0
  220. package/vendor/tree-sitter-tsx.wasm +0 -0
  221. package/vendor/tree-sitter-typescript.wasm +0 -0
  222. package/vendor/tree-sitter.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 ────
@@ -45,6 +37,7 @@ export async function startREPL(options) {
45
37
  await mcpBridgeManager.initialize(cwd);
46
38
  const { randomUUID } = await import('node:crypto');
47
39
  let currentSessionId = randomUUID();
40
+ let currentSessionLabel;
48
41
  let sessionModifiedFiles = [];
49
42
  let currentMode = 'BUILD';
50
43
  let currentModel = projectConfig?.model ?? config.defaultModel ?? 'auto';
@@ -76,6 +69,8 @@ export async function startREPL(options) {
76
69
  conversation.setContextLimit(currentModel);
77
70
  currentMode = snap.mode;
78
71
  selectedFiles = [...snap.selectedFiles];
72
+ currentSessionId = snap.id;
73
+ currentSessionLabel = snap.label;
79
74
  console.log(`\n${c.green}✓ Resumed session${c.reset} ${c.dim}${snap.id}${c.reset}`);
80
75
  console.log(` ${c.dim}messages=${snap.conversation.length} tokens=${snap.tokens} model=${snap.model} mode=${snap.mode}${c.reset}`);
81
76
  if (snap.summary) {
@@ -104,13 +99,21 @@ export async function startREPL(options) {
104
99
  }
105
100
  let lastPromptRow = 0;
106
101
  let mouseReportingEnabled = false;
107
- const stats = {
102
+ let stats = {
108
103
  totalPromptTokens: 0,
109
104
  totalCompletionTokens: 0,
110
105
  totalToolCalls: 0,
111
106
  totalTasks: 0,
112
107
  totalDurationMs: 0,
113
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
+ }
114
117
  // The welcome screen (lava logo + command grid) is printed by
115
118
  // `src/index.ts` before the REPL starts; the startREPL entry
116
119
  // point jumps straight into the prompt loop.
@@ -126,7 +129,8 @@ export async function startREPL(options) {
126
129
  }
127
130
  catch (error) {
128
131
  if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
129
- 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}`);
130
134
  }
131
135
  }
132
136
  // ──── Create readline interface ────
@@ -157,9 +161,7 @@ export async function startREPL(options) {
157
161
  // existing /mode command semantics intact while still letting
158
162
  // the new bar visualise the live mode.
159
163
  const buildLavaStatusState = () => {
160
- const modeForState = currentMode === 'PLAN' ? 'PLAN' :
161
- currentMode === 'BUILD' ? 'BUILD' :
162
- 'BUILD';
164
+ const modeForState = currentMode === 'PLAN' ? 'PLAN' : 'BUILD';
163
165
  let contextPercent = 0;
164
166
  try {
165
167
  const used = conversation.getTotalTokens();
@@ -183,10 +185,13 @@ export async function startREPL(options) {
183
185
  mode: modeForState,
184
186
  routing: 'auto',
185
187
  model: currentModel,
186
- branch: currentBranch || 'detached',
188
+ // Show '(detached HEAD)' instead of bare 'detached' so the
189
+ // status bar is unambiguous — the previous label read as "the
190
+ // CLI is detached from the API server" to several users.
191
+ branch: currentBranch || '(detached HEAD)',
187
192
  contextPercent,
188
193
  providersCount,
189
- transport: 'freellmapi',
194
+ transport: config.provider_mode === 'direct' ? 'direct' : 'freellmapi',
190
195
  };
191
196
  };
192
197
  const drawLavaStatusBar = () => {
@@ -197,6 +202,10 @@ export async function startREPL(options) {
197
202
  // returns.
198
203
  renderStatusBar(buildLavaStatusState());
199
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
+ }
200
209
  };
201
210
  // Surface the result of a live model fetch as a one-line status.
202
211
  // Invoked from /providers add and /providers test so the user
@@ -248,6 +257,16 @@ export async function startREPL(options) {
248
257
  }
249
258
  // Register synchronous exit cleanups
250
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
+ }
251
270
  try {
252
271
  const hist = rl.history;
253
272
  if (Array.isArray(hist)) {
@@ -256,7 +275,8 @@ export async function startREPL(options) {
256
275
  }
257
276
  catch (error) {
258
277
  if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
259
- 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}`);
260
280
  }
261
281
  }
262
282
  disableMouseReportingSync();
@@ -280,39 +300,50 @@ export async function startREPL(options) {
280
300
  let sigintCount = 0;
281
301
  let lastSigintTime = 0;
282
302
  let sigintResetTimer = null;
303
+ // Dedup guard: prevents double-firing when both `rl` and `process` SIGINT listeners fire.
304
+ let sigintHandling = false;
283
305
  const sigintHandler = () => {
284
- if (isTaskRunning && currentRunningAgent) {
285
- // A task is running — cancel it instead of exiting
286
- currentRunningAgent.abort();
306
+ if (sigintHandling)
287
307
  return;
288
- }
289
- const now = Date.now();
290
- if (now - lastSigintTime > SIGINT_RESET_MS) {
291
- // First press (or after reset window)
292
- sigintCount = 1;
293
- lastSigintTime = now;
294
- // Write hint and redraw the prompt
295
- const promptStr = `${C.LAVA}›${C.RESET} `;
296
- process.stdout.write(`\n${c.yellow}⚠ Press Ctrl+C again to exit${c.reset}\n`);
297
- drawLavaStatusBar();
298
- process.stdout.write(promptStr);
299
- // 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
300
336
  if (sigintResetTimer)
301
337
  clearTimeout(sigintResetTimer);
302
- sigintResetTimer = setTimeout(() => {
303
- sigintCount = 0;
304
- sigintResetTimer = null;
305
- }, SIGINT_RESET_MS);
306
- 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;
307
346
  }
308
- // Second press within the window — exit
309
- if (sigintResetTimer)
310
- clearTimeout(sigintResetTimer);
311
- sigintResetTimer = null;
312
- sigintCount = 0;
313
- exitCleanup();
314
- console.log('\n\n👋 FixO CLI session ended safely. Core engine offline.');
315
- process.exit(0);
316
347
  };
317
348
  // Listen on both the readline interface (catches Ctrl+C during rl.question())
318
349
  // and the process (fallback for non-readline scenarios).
@@ -349,7 +380,6 @@ export async function startREPL(options) {
349
380
  enableMouseReporting();
350
381
  const currentCursor = rl.cursor;
351
382
  let output = '\n';
352
- const width = 60;
353
383
  const borderTop = `${c.snow}┌────────────────────────────────────────────────────────┐${c.reset}\n`;
354
384
  const borderBottom = `${c.snow}└────────────────────────────────────────────────────────┘${c.reset}`;
355
385
  output += borderTop;
@@ -462,7 +492,8 @@ export async function startREPL(options) {
462
492
  }
463
493
  catch (error) {
464
494
  if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
465
- 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}`);
466
497
  }
467
498
  }
468
499
  const matchingFiles = workspaceFiles.filter(f => f.toLowerCase().includes(q) || path.basename(f).toLowerCase().startsWith(q));
@@ -492,6 +523,7 @@ export async function startREPL(options) {
492
523
  readline.emitKeypressEvents(process.stdin);
493
524
  if (process.stdin.isTTY) {
494
525
  process.stdin.setRawMode(true);
526
+ process.stdout.write('\x1b[?2004h');
495
527
  }
496
528
  const keypressHandler = (_char, key) => {
497
529
  if (!isPrompting)
@@ -529,6 +561,30 @@ export async function startREPL(options) {
529
561
  };
530
562
  process.stdin.on('keypress', keypressHandler);
531
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
+ }
532
588
  // Monkey-patch process.stdin.emit to intercept keypress and mouse events
533
589
  const originalEmit = process.stdin.emit;
534
590
  process.stdin.emit = function (event, ...args) {
@@ -537,6 +593,78 @@ export async function startREPL(options) {
537
593
  if (rawData) {
538
594
  let str = mouseBuffer + rawData.toString();
539
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
+ }
540
668
  // Intercept cursor position response
541
669
  if (str.startsWith('\x1b[') && str.endsWith('R')) {
542
670
  const match = str.match(/\x1b\[(\d+);(\d+)R/);
@@ -628,6 +756,38 @@ export async function startREPL(options) {
628
756
  }
629
757
  if (event === 'keypress') {
630
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
+ }
631
791
  // Intercept Escape or Ctrl+C to cancel a running task (when not prompting)
632
792
  if (key && key.name === 'escape' && isTaskRunning && currentRunningAgent) {
633
793
  currentRunningAgent.abort();
@@ -651,17 +811,42 @@ export async function startREPL(options) {
651
811
  // legacy dirLabel/branchLabel/modelLabel/modeLabel row
652
812
  // is gone — the new bar carries all of that information.
653
813
  drawLavaStatusBar();
654
- process.stdout.write(`${C.LAVA}›${C.RESET} `);
814
+ process.stdout.write(`${c.dim}─────────────────────────────────────────────────────────────────${c.reset}\n> `);
655
815
  return true; // swallow keypress
656
816
  }
657
817
  }
658
818
  return originalEmit.apply(this, [event, ...args]);
659
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
+ }
660
843
  // ──── REPL loop ────
661
844
  const promptForInput = () => {
662
845
  // Restore raw mode and resume streams to recover from any clack/spinner interactions
663
846
  if (process.stdin.isTTY) {
664
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');
665
850
  }
666
851
  process.stdin.resume();
667
852
  rl.resume();
@@ -671,7 +856,8 @@ export async function startREPL(options) {
671
856
  // visible in the bar; the prompt itself is the lava `›` glyph.
672
857
  drawLavaStatusBar();
673
858
  isPrompting = true;
674
- 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) => {
675
861
  isPrompting = false;
676
862
  disableMouseReporting();
677
863
  clearSuggestions();
@@ -696,12 +882,45 @@ export async function startREPL(options) {
696
882
  else if (msg.includes('429')) {
697
883
  console.log(`${c.dim} → Rate limited. Wait a moment or add more API keys.${c.reset}`);
698
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
+ }
699
903
  }
700
904
  promptForInput();
701
905
  });
702
906
  };
703
907
  // ──── Input handler ────
704
- 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
+ }
705
924
  // ─── Slash commands ───
706
925
  if (input.startsWith('/')) {
707
926
  const parts = input.split(/\s+/).filter(Boolean);
@@ -723,1026 +942,150 @@ export async function startREPL(options) {
723
942
  case '/help':
724
943
  printHelp();
725
944
  return;
726
- case '/model': {
727
- if (args[0] === 'list') {
728
- // Print full model table grouped by provider
729
- // Uses live-fetched cached models when available, otherwise falls
730
- // back to the static registry list (tagged [unverified]).
731
- console.log(`\n${c.bold}${c.cyan}Available Models by Provider${c.reset}`);
732
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
733
- for (const def of PROVIDER_REGISTRY) {
734
- const hasKey = ProvidersManager.has(def.name);
735
- const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.dim}[no key]${c.reset}`;
736
- const cached = ProvidersManager.getCachedModels(def.name);
737
- const modelList = cached?.models?.length ? cached.models : def.models;
738
- const sourceTag = cached?.source === 'live'
739
- ? ''
740
- : ` ${c.dim}[unverified]${c.reset}`;
741
- console.log(`\n ${c.snow}${c.bold}${def.displayName}${c.reset} ${keyStatus}${sourceTag}`);
742
- for (const model of modelList) {
743
- console.log(` ${c.cyan}•${c.reset} ${model}`);
744
- }
745
- }
746
- console.log(`\n${c.dim} Use /providers add <name> to connect a provider with your API key.${c.reset}`);
747
- 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();
748
950
  return;
749
951
  }
750
- if (args.length === 0) {
751
- // Redesigned interactive model picker grouped by provider
752
- rl.pause();
753
- const pickedProvider = await p.select({
754
- message: `Current model: ${c.cyan}${currentModel}${c.reset} — Select AI Provider:`,
755
- options: [
756
- { value: 'all', label: 'Show all models (flat list)', hint: 'classic view' },
757
- ...PROVIDER_REGISTRY.map(def => ({
758
- value: def.name,
759
- label: def.displayName,
760
- hint: ProvidersManager.has(def.name) ? ' [key ✓]' : ' [no key]'
761
- })),
762
- { value: '__manual__', label: 'Enter model ID manually…', hint: '' },
763
- ],
764
- initialValue: PROVIDER_REGISTRY.find(def => def.models.includes(currentModel))?.name || 'all',
765
- });
766
- rl.resume();
767
- if (p.isCancel(pickedProvider)) {
768
- console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
769
- return;
770
- }
771
- if (pickedProvider === '__manual__') {
772
- rl.pause();
773
- const manual = await p.text({
774
- message: 'Enter model ID:',
775
- placeholder: 'e.g. gpt-4o, claude-opus-4-5, gemini-2.5-pro',
776
- validate: v => !v.trim() ? 'Model ID is required' : undefined,
777
- });
778
- rl.resume();
779
- if (!p.isCancel(manual) && manual) {
780
- currentModel = manual.trim();
781
- conversation.setContextLimit(currentModel);
782
- console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
783
- }
784
- return;
785
- }
786
- if (pickedProvider === 'all') {
787
- rl.pause();
788
- const allOptions = PROVIDER_REGISTRY.flatMap(def => def.models.map(m => ({
789
- value: m,
790
- label: `${m}`,
791
- hint: def.displayName + (ProvidersManager.has(def.name) ? ' [key ✓]' : ''),
792
- })));
793
- const picked = await p.select({
794
- message: 'Select a model from the flat list:',
795
- options: [
796
- { value: currentModel, label: `Keep current: ${currentModel}`, hint: 'no change' },
797
- ...allOptions,
798
- ],
799
- initialValue: currentModel,
800
- });
801
- rl.resume();
802
- if (p.isCancel(picked)) {
803
- console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
804
- return;
805
- }
806
- currentModel = picked;
807
- // Store hint — find which provider this model belongs to
808
- const owningDef = PROVIDER_REGISTRY.find(d => d.models.includes(currentModel)
809
- || ProvidersManager.getCachedModels(d.name)?.models?.includes(currentModel));
810
- if (owningDef)
811
- ProvidersManager.setModelProviderHint(currentModel, owningDef.name);
812
- conversation.setContextLimit(currentModel);
813
- console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
814
- return;
815
- }
816
- const def = PROVIDER_REGISTRY.find(p => p.name === pickedProvider);
817
- const hasKey = ProvidersManager.has(def.name);
818
- const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.red}[no key]${c.reset}`;
819
- // Prefer the cached live model list; fall back to the
820
- // registry list (tagged `[unverified]`) when no fresh
821
- // cache exists. Drops the synthetic "(free)" suffix
822
- // since we no longer know that without provider
823
- // metadata.
824
- const cached = ProvidersManager.getCachedModels(def.name);
825
- const modelList = cached?.models?.length ? cached.models : def.models;
826
- const sourceSuffix = cached?.source === 'live'
827
- ? ''
828
- : ` ${c.dim}[unverified]${c.reset}`;
829
- rl.pause();
830
- const picked = await p.select({
831
- message: `Select a model from ${c.bold}${def.displayName}${c.reset} ${keyStatus}${sourceSuffix}:`,
832
- options: modelList.map(m => {
833
- return {
834
- value: m,
835
- label: m,
836
- hint: m === currentModel ? 'currently selected' : ''
837
- };
838
- }),
839
- initialValue: modelList.includes(currentModel) ? currentModel : undefined,
840
- });
841
- rl.resume();
842
- if (p.isCancel(picked)) {
843
- console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
844
- return;
845
- }
846
- currentModel = picked;
847
- // Store explicit model-provider association so
848
- // resolveDirectConfig can route this model directly
849
- // to this provider (critical for live-fetched models
850
- // that don't appear in the static registry).
851
- ProvidersManager.setModelProviderHint(currentModel, def.name);
852
- conversation.setContextLimit(currentModel);
853
- 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();
854
958
  return;
855
959
  }
856
- currentModel = args.join(' ');
857
- conversation.setContextLimit(currentModel);
858
- 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();
859
967
  return;
860
968
  }
861
- case '/select': {
862
- if (args.length === 0) {
863
- if (selectedFiles.length === 0) {
864
- console.log(`\n${c.dim}No files selected. Usage: /select <file-path>${c.reset}`);
865
- }
866
- else {
867
- console.log(`\n${c.dim}Selected files:${c.reset}`);
868
- for (const f of selectedFiles) {
869
- console.log(` ${c.cyan}${path.basename(f)}${c.reset} ${c.dim}(${f})${c.reset}`);
870
- }
871
- }
872
- return;
873
- }
874
- let rawPath = args.join(' ');
875
- if ((rawPath.startsWith("'") && rawPath.endsWith("'")) ||
876
- (rawPath.startsWith('"') && rawPath.endsWith('"'))) {
877
- rawPath = rawPath.slice(1, -1);
878
- }
879
- let filePath;
880
- try {
881
- filePath = guard.ensureFile(rawPath);
882
- }
883
- catch (error) {
884
- console.log(`\n${c.red}✗ ${error instanceof Error ? error.message : String(error)}${c.reset}`);
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();
885
974
  return;
886
975
  }
887
- if (!fs.existsSync(filePath)) {
888
- console.log(`\n${c.red}✗ File not found: ${rawPath}${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();
889
980
  return;
890
981
  }
891
- if (!selectedFiles.includes(filePath)) {
892
- selectedFiles.push(filePath);
893
- }
894
- console.log(`\n${c.green}✓ Pinned: ${c.bold}${path.basename(filePath)}${c.reset}`);
895
- return;
896
- }
897
- case '/unselect':
898
- selectedFiles = [];
899
- console.log(`\n${c.green} All pinned files cleared${c.reset}`);
900
- return;
901
- case '/diff':
902
- console.log(`\n${git.getDiff()}`);
903
- return;
904
- case '/undo': {
905
- if (args[0]) {
906
- console.log(`\n${undoRun(cwd, args[0])}`);
907
- return;
908
- }
909
- rl.pause();
910
- const confirmed = await p.confirm({
911
- message: 'Are you sure you want to completely discard the last automated agent commit and restore all files?',
912
- initialValue: false,
913
- });
914
- rl.resume();
915
- if (p.isCancel(confirmed) || !confirmed) {
916
- console.log(`\n${c.yellow} ⚠ Undo cancelled.${c.reset}`);
917
- return;
918
- }
919
- git.undoLastCommit();
920
- return;
921
- }
922
- case '/clear':
923
- conversation.clear();
924
- pendingAttachments = [];
925
- console.log(`\n${c.green}✓ Conversation cleared${c.reset}`);
926
- return;
927
- case '/image': {
928
- // `/image <path>` — queue a local image for the next turn.
929
- // `/image clear` — drop the queue.
930
- // `/image list` — show what's queued.
931
- const sub = args[0];
932
- if (sub === 'clear') {
933
- const n = pendingAttachments.length;
934
- pendingAttachments = [];
935
- console.log(`\n${c.green}✓ Cleared ${n} pending image(s)${c.reset}`);
936
- return;
937
- }
938
- if (sub === 'list') {
939
- if (pendingAttachments.length === 0) {
940
- console.log(`\n${c.dim}No pending images.${c.reset}`);
941
- return;
942
- }
943
- console.log(`\n${c.bold}Pending images (sent on next prompt):${c.reset}`);
944
- for (const [i, block] of pendingAttachments.entries()) {
945
- if (block.type === 'image' && block.source.kind === 'base64') {
946
- const approxBytes = Math.floor((block.source.data.length * 3) / 4);
947
- console.log(` ${i + 1}. ${block.source.mediaType} (~${approxBytes} bytes)`);
948
- }
949
- }
950
- return;
951
- }
952
- if (!sub) {
953
- console.log(`\n${c.yellow}Usage: /image <path> | /image list | /image clear${c.reset}`);
954
- return;
955
- }
956
- const result = loadImageAsBlock(sub, cwd);
957
- if (!result.ok) {
958
- console.log(`\n${c.red}✗ /image: ${result.error}${c.reset}`);
959
- return;
960
- }
961
- pendingAttachments.push(result.block);
962
- console.log(`\n${c.green}✓ Attached${c.reset} ${c.dim}${result.mediaType}, ${result.bytes} bytes — will be sent with your next prompt${c.reset}`);
963
- return;
964
- }
965
- case '/mcp': {
966
- const sub = args[0]?.toLowerCase();
967
- if (!sub || sub === 'list') {
968
- const { listAllMcpSources, mergedMcpServers } = await import('../agent/mcp-registry.js');
969
- const view = listAllMcpSources(cwd);
970
- console.log(`\n${c.bold}${c.cyan}MCP Servers${c.reset} ${c.dim}(project-wins precedence: local > project > global)${c.reset}`);
971
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
972
- const renderSource = (label, s) => {
973
- const names = Object.keys(s.servers);
974
- if (names.length === 0) {
975
- console.log(` ${c.dim}${label}: (empty)${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
976
- return;
977
- }
978
- console.log(` ${c.bold}${label}${c.reset}${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
979
- for (const n of names) {
980
- console.log(` ${c.cyan}•${c.reset} ${n}`);
981
- }
982
- };
983
- renderSource('global', view.global);
984
- renderSource('project', view.project);
985
- renderSource('local', view.local);
986
- const merged = mergedMcpServers(cwd);
987
- const mergedCount = Object.keys(merged).length;
988
- console.log(`\n${c.dim}merged total: ${mergedCount} server(s)${c.reset}`);
989
- return;
990
- }
991
- if (sub === 'add') {
992
- const name = args[1];
993
- if (!name || args.length < 3) {
994
- console.log(`\n${c.yellow}Usage: /mcp add <name> <command> [args...]${c.reset}`);
995
- return;
996
- }
997
- const cmd = args[2];
998
- const cmdArgs = args.slice(3);
999
- const { addLocalMcpServer } = await import('../agent/mcp-registry.js');
1000
- addLocalMcpServer(cwd, name, { command: cmd, args: cmdArgs, type: 'stdio' });
1001
- console.log(`\n${c.green}✓ Added local MCP server:${c.reset} ${name} ${c.dim}(command=${cmd} args=${JSON.stringify(cmdArgs)})${c.reset}`);
1002
- return;
1003
- }
1004
- if (sub === 'remove' || sub === 'rm') {
1005
- const name = args[1];
1006
- if (!name) {
1007
- console.log(`\n${c.yellow}Usage: /mcp remove <name>${c.reset}`);
1008
- return;
1009
- }
1010
- const { removeLocalMcpServer } = await import('../agent/mcp-registry.js');
1011
- const removed = removeLocalMcpServer(cwd, name);
1012
- if (removed) {
1013
- console.log(`\n${c.green}✓ Removed local MCP server:${c.reset} ${name}`);
1014
- }
1015
- else {
1016
- console.log(`\n${c.yellow}No local MCP server named ${name}${c.reset}`);
1017
- }
1018
- return;
1019
- }
1020
- if (sub === 'test') {
1021
- const name = args[1];
1022
- if (!name) {
1023
- console.log(`\n${c.yellow}Usage: /mcp test <name>${c.reset}`);
1024
- return;
1025
- }
1026
- const { mergedMcpServers } = await import('../agent/mcp-registry.js');
1027
- const all = mergedMcpServers(cwd);
1028
- const cfg = all[name];
1029
- if (!cfg) {
1030
- console.log(`\n${c.yellow}No MCP server named ${name} (in any source)${c.reset}`);
1031
- return;
1032
- }
1033
- const hasCommand = typeof cfg.command === 'string';
1034
- const hasUrl = typeof cfg.url === 'string';
1035
- if (hasCommand || hasUrl) {
1036
- 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}`);
1037
996
  }
1038
997
  else {
1039
- console.log(`\n${c.red}✗ ${name}${c.reset} — missing 'command' or 'url'`);
1040
- }
1041
- return;
1042
- }
1043
- console.log(`\n${c.yellow}Unknown /mcp subcommand: ${sub}. Use: list | add | remove | test${c.reset}`);
1044
- return;
1045
- }
1046
- case '/todo': {
1047
- const sub = args[0]?.toLowerCase();
1048
- if (!sub || sub === 'list' || sub === 'ls') {
1049
- const list = loadTodoList(cwd);
1050
- const summary = summariseTodoList(list);
1051
- console.log('');
1052
- console.log(renderTodoList(list));
1053
- if (summary.length > 0) {
1054
- console.log(`\n${c.dim}(${summary})${c.reset}`);
1055
- }
1056
- return;
1057
- }
1058
- if (sub === 'add') {
1059
- const text = args.slice(1).join(' ').trim();
1060
- if (text.length === 0) {
1061
- console.log(`\n${c.yellow}Usage: /todo add <text>${c.reset}`);
1062
- return;
1063
- }
1064
- const list = addItem(loadTodoList(cwd), { content: text });
1065
- const result = saveTodoList(cwd, list);
1066
- if (!result.ok) {
1067
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1068
- return;
1069
- }
1070
- console.log(`\n${c.green}✓ Added todo:${c.reset} ${text}`);
1071
- return;
1072
- }
1073
- if (sub === 'done' || sub === 'complete' || sub === 'cancel') {
1074
- const id = args[1];
1075
- if (!id) {
1076
- console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
1077
- return;
1078
- }
1079
- const status = sub === 'cancel' ? 'cancelled' : 'done';
1080
- let list = loadTodoList(cwd);
1081
- const exists = list.items.some((it) => it.id === id);
1082
- if (!exists) {
1083
- console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1084
- return;
1085
- }
1086
- list = setItemStatus(list, { id, status });
1087
- const result = saveTodoList(cwd, list);
1088
- if (!result.ok) {
1089
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1090
- return;
1091
- }
1092
- console.log(`\n${c.green}✓ Marked ${status}${c.reset}`);
1093
- return;
1094
- }
1095
- if (sub === 'start' || sub === 'progress') {
1096
- const id = args[1];
1097
- if (!id) {
1098
- console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
1099
- return;
1100
- }
1101
- let list = loadTodoList(cwd);
1102
- const exists = list.items.some((it) => it.id === id);
1103
- if (!exists) {
1104
- console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1105
- return;
1106
- }
1107
- list = setItemStatus(list, { id, status: 'in_progress' });
1108
- const result = saveTodoList(cwd, list);
1109
- if (!result.ok) {
1110
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1111
- return;
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}`);
1112
1001
  }
1113
- console.log(`\n${c.green}✓ Marked in_progress${c.reset}`);
1114
- return;
1115
- }
1116
- if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
1117
- const id = args[1];
1118
- if (!id) {
1119
- console.log(`\n${c.yellow}Usage: /todo remove <id>${c.reset}`);
1120
- return;
1121
- }
1122
- let list = loadTodoList(cwd);
1123
- const exists = list.items.some((it) => it.id === id);
1124
- if (!exists) {
1125
- console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1126
- return;
1127
- }
1128
- list = removeItem(list, { id });
1129
- const result = saveTodoList(cwd, list);
1130
- if (!result.ok) {
1131
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1132
- return;
1133
- }
1134
- console.log(`\n${c.green}✓ Removed todo${c.reset}`);
1135
- return;
1136
1002
  }
1137
- if (sub === 'clear') {
1138
- const list = loadTodoList(cwd);
1139
- const kept = list.items.filter((it) => it.status !== 'done' && it.status !== 'cancelled');
1140
- const result = saveTodoList(cwd, { ...list, items: kept, updatedAt: Date.now() });
1141
- if (!result.ok) {
1142
- console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1143
- return;
1003
+ catch (err) {
1004
+ console.log(`\n${c.red}✗ /edit failed: ${err.message}${c.reset}`);
1005
+ try {
1006
+ fs.unlinkSync(tmpFile);
1144
1007
  }
1145
- const cleared = list.items.length - kept.length;
1146
- console.log(`\n${c.green}✓ Cleared ${cleared} completed todo(s)${c.reset}`);
1147
- return;
1148
- }
1149
- if (sub === 'help' || sub === '-h' || sub === '--help') {
1150
- console.log(`\n${c.bold}Usage: /todo <subcommand>${c.reset}`);
1151
- console.log(` list List all todo items`);
1152
- console.log(` add <text> Add a new todo`);
1153
- console.log(` start <id> Mark a todo as in-progress`);
1154
- console.log(` done <id> Mark a todo as done`);
1155
- console.log(` cancel <id> Cancel a todo`);
1156
- console.log(` remove <id> Remove a todo entirely`);
1157
- console.log(` clear Remove all done/cancelled todos`);
1158
- return;
1008
+ catch { /* already gone */ }
1159
1009
  }
1160
- console.log(`\n${c.yellow}Unknown /todo subcommand "${sub}". Try /todo help.${c.reset}`);
1161
- return;
1162
- }
1163
- case '/log':
1164
- console.log(`\n${git.getRecentCommits(10)}`);
1165
- return;
1166
- case '/stats':
1167
- printStats(stats);
1168
- {
1169
- const ctxTokens = conversation.getTotalTokens();
1170
- const ctxLimit = conversation.getContextLimit();
1171
- const ctxPct = Math.round((ctxTokens / ctxLimit) * 100);
1172
- const hasSummary = conversation.getSummary() ? ' (compacted)' : '';
1173
- console.log(`${c.cyan}${c.bold}📊 Context Window${c.reset}`);
1174
- console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
1175
- console.log(` History messages: ${c.bold}${conversation.getMessageCount()}${c.reset}${hasSummary}`);
1176
- console.log(` Context usage: ${c.bold}${(ctxTokens / 1000).toFixed(0)}k / ${(ctxLimit / 1000).toFixed(0)}k${c.reset} (${ctxPct}%)`);
1177
- console.log(` Turns: ${c.bold}${conversation.getTurnCount()}${c.reset}`);
1178
- console.log('');
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');
1179
1015
  }
1180
- return;
1181
- case '/runs': {
1182
- const runs = listRuns(cwd, 12);
1183
- console.log(runs.length
1184
- ? `\n${runs.map(run => `${run.id} ${run.status} ${run.task.slice(0, 80)}`).join('\n')}`
1185
- : '\n(no FixO runs recorded)');
1016
+ promptForInput();
1186
1017
  return;
1187
1018
  }
1188
- case '/show-run':
1189
- console.log(`\n${showRun(cwd, args[0] ?? '')}`);
1190
- return;
1191
- case '/memory':
1192
- console.log(`\n${readMemory(cwd)}`);
1193
- return;
1194
- case '/remember': {
1195
- const text = args.join(' ').trim();
1196
- if (!text) {
1197
- console.log(`\n${c.yellow}Usage: /remember <project fact>${c.reset}`);
1198
- return;
1199
- }
1200
- rl.pause();
1201
- const confirmed = await p.confirm({ message: `Add to project memory: ${text}?`, initialValue: false });
1202
- rl.resume();
1203
- if (!p.isCancel(confirmed) && confirmed) {
1204
- appendMemory(cwd, text);
1205
- console.log(`\n${c.green}✓ Memory updated${c.reset}`);
1019
+ case '/pastes': {
1020
+ if (pendingPastes.length === 0) {
1021
+ console.log(`\n${c.dim}No active paste attachments.${c.reset}`);
1206
1022
  }
1207
- return;
1208
- }
1209
- case '/forget':
1210
- rl.pause();
1211
- {
1212
- const confirmed = await p.confirm({ message: 'Clear FixO project memory?', initialValue: false });
1213
- rl.resume();
1214
- if (!p.isCancel(confirmed) && confirmed) {
1215
- forgetMemory(cwd);
1216
- 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}`);
1217
1027
  }
1218
1028
  }
1219
- return;
1220
- case '/doctor':
1221
- console.log(`\n${doctor(cwd)}`);
1222
- return;
1223
- case '/index': {
1224
- const index = await buildIndex(cwd);
1225
- workspaceFiles = index.files.map(f => f.path);
1226
- console.log(`\n${c.green}✓ Indexed ${index.files.length} files${c.reset}`);
1029
+ promptForInput();
1227
1030
  return;
1228
1031
  }
1229
- case '/find':
1230
- console.log(`\n${await findInIndex(cwd, args.join(' '))}`);
1231
- return;
1232
- case '/explain':
1233
- console.log(`\n${await explainIndexedTarget(cwd, args.join(' '))}`);
1234
- return;
1235
- case '/review':
1236
- console.log(`\n${reviewWorkspace(cwd)}`);
1237
- return;
1238
- case '/test':
1239
- console.log(`\n${runProjectTests(cwd)}`);
1240
- return;
1241
- case '/fix-tests': {
1242
- let testResult = runProjectTests(cwd);
1243
- if (testResult.includes('Status: 0')) {
1244
- console.log(`\n${c.green}✓ All tests are passing!${c.reset}`);
1245
- return;
1246
- }
1247
- let attempt = 1;
1248
- const maxAttempts = 3;
1249
- const modifiedFiles = [];
1250
- while (attempt <= maxAttempts) {
1251
- console.log(`\n${c.cyan}🔨 [Auto-Fix] Test failure detected (Attempt ${attempt}/${maxAttempts}). Invoking SingleAgent to repair...${c.reset}`);
1252
- console.log(`${c.dim}${testResult}${c.reset}\n`);
1253
- 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.`;
1254
- const context = {
1255
- task: repairTask,
1256
- 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,
1257
1051
  cwd,
1258
1052
  verbose,
1259
- selectedFiles: [...selectedFiles],
1260
- systemPromptOverride: projectConfig?.systemPrompt,
1261
- checkCommand: projectConfig?.checkCommand,
1262
- policy: projectConfig?.policy ?? config.preferences.policy,
1263
- mode: 'BUILD',
1264
- 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,
1265
1068
  };
1266
- try {
1267
- isTaskRunning = true;
1268
- currentRunningAgent = agent;
1269
- const result = await agent.runStreaming(context, conversation, rl);
1270
- for (const file of result.modifiedFiles) {
1271
- if (!modifiedFiles.includes(file)) {
1272
- modifiedFiles.push(file);
1273
- }
1274
- }
1275
- }
1276
- catch (err) {
1277
- console.log(`\n${c.red}✗ Repair agent failed on attempt ${attempt}: ${err.message || err}${c.reset}`);
1278
- }
1279
- finally {
1280
- isTaskRunning = false;
1281
- currentRunningAgent = null;
1282
- agent.reset();
1283
- }
1284
- testResult = runProjectTests(cwd);
1285
- if (testResult.includes('Status: 0')) {
1286
- console.log(`\n${c.green}✓ All tests passed after repair attempt ${attempt}!${c.reset}`);
1287
- break;
1288
- }
1289
- else {
1290
- attempt++;
1291
- }
1292
- }
1293
- if (!testResult.includes('Status: 0')) {
1294
- console.log(`\n${c.red}✗ Auto-fix failed after ${maxAttempts} attempts. Remaining failures:${c.reset}`);
1295
- console.log(`${c.dim}${testResult}${c.reset}`);
1296
- }
1297
- else {
1298
- // Auto-commit if enabled and changes were made
1299
- if (config.preferences.autoCommit &&
1300
- (projectConfig?.autoCommit !== false) &&
1301
- modifiedFiles.length > 0) {
1302
- console.log(`\n${c.green}✓ Auto-committing repaired test files...${c.reset}`);
1303
- git.autoCommit('fix-tests: repair test failures', modifiedFiles);
1304
- }
1305
- }
1306
- return;
1307
- }
1308
- case '/fix-ci':
1309
- 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}`);
1310
- return;
1311
- case '/plan':
1312
- {
1313
- const task = args.join(' ').trim();
1314
- if (!task) {
1315
- console.log(`\n${c.yellow}Usage: /plan <task>${c.reset}`);
1316
- return;
1317
- }
1318
- const plan = savePlan(cwd, task);
1319
- console.log(`\n${renderPlan(plan)}`);
1320
- }
1321
- return;
1322
- case '/run-plan': {
1323
- const dagFile = path.join(cwd, '.fixo', 'last-dag.json');
1324
- if (fs.existsSync(dagFile)) {
1325
- try {
1326
- const { task, dag } = JSON.parse(fs.readFileSync(dagFile, 'utf-8'));
1327
- console.log(`\n${c.cyan}[Saved Plan] Executing saved subtasks DAG for task: ${c.bold}${task}${c.reset}`);
1328
- const { AgentPool } = await import('../agent/agent-pool.js');
1329
- const pool = new AgentPool(3, projectConfig?.maxAttempts ?? 12);
1330
- const context = {
1331
- task,
1332
- model: currentModel,
1333
- cwd,
1334
- verbose,
1335
- selectedFiles: [...selectedFiles],
1336
- systemPromptOverride: projectConfig?.systemPrompt,
1337
- checkCommand: projectConfig?.checkCommand,
1338
- policy: projectConfig?.policy ?? config.preferences.policy,
1339
- mode: currentMode,
1340
- };
1341
- const success = await pool.execute(context, dag);
1342
- if (success) {
1343
- console.log(`\n${c.green}✓ Successfully completed complex task via parallel agents.${c.reset}`);
1344
- }
1345
- else {
1346
- console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
1347
- if (git.isGitRepo()) {
1348
- console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
1349
- git.discardUncommittedChanges();
1350
- }
1351
- }
1352
- return;
1353
- }
1354
- catch (err) {
1355
- console.log(`\n${c.red}✗ Failed to run saved DAG: ${err.message}${c.reset}`);
1356
- }
1357
- }
1358
- const plan = loadPlan(cwd);
1359
- if (!plan) {
1360
- 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}`);
1361
- return;
1362
- }
1363
- console.log(`\n${c.dim}Executing saved plan task: ${plan.task}${c.reset}`);
1364
- await handleInput(plan.task);
1365
- return;
1366
- }
1367
- case '/mode': {
1368
- rl.pause();
1369
- const selected = await p.select({
1370
- message: 'Select execution mode:',
1371
- options: [
1372
- { value: 'PLAN', label: 'PLAN Mode (Read-only, dry-run simulation)' },
1373
- { value: 'BUILD', label: 'BUILD Mode (Writing & modifying allowed)' },
1374
- { value: 'EXPLORE', label: 'EXPLORE Mode (Code exploration & LSP, no modifying)' },
1375
- { value: 'SCOUT', label: 'SCOUT Mode (Web search & fetch only)' },
1376
- ],
1377
- initialValue: currentMode,
1378
- });
1379
- rl.resume();
1380
- if (!p.isCancel(selected) && selected) {
1381
- currentMode = selected;
1382
- console.log(`\n${c.green}✓ Execution mode set to: ${c.bold}${currentMode}${c.reset}`);
1383
- }
1384
- else {
1385
- console.log(`\n${c.dim}Execution mode remains: ${c.cyan}${currentMode}${c.reset}`);
1386
- }
1387
- return;
1388
- }
1389
- case '/session': {
1390
- const sub = args[0];
1391
- const { SessionManager } = await import('../agent/conversation.js');
1392
- if (sub === 'list') {
1393
- const list = SessionManager.listSessions();
1394
- if (list.length === 0) {
1395
- console.log(`\n${c.dim}No saved sessions found.${c.reset}`);
1396
- }
1397
- else {
1398
- console.log(`\n${c.cyan}${c.bold}Saved Sessions:${c.reset}`);
1399
- for (const s of list) {
1400
- const date = new Date(s.timestamp).toLocaleString();
1401
- console.log(` ${c.cyan}${s.sessionId}${c.reset} - ${c.bold}${s.model}${c.reset} (${s.messageCount} msgs)`);
1402
- console.log(` ${c.dim}Created: ${date} | Tokens: ${s.totalTokens.toLocaleString()}${c.reset}`);
1403
- if (s.summary) {
1404
- console.log(` ${c.dim}Summary: ${s.summary.slice(0, 80)}...${c.reset}`);
1405
- }
1406
- }
1407
- }
1408
- }
1409
- else if (sub === 'load') {
1410
- const uuid = args[1];
1411
- if (!uuid) {
1412
- console.log(`\n${c.yellow}Usage: /session load <uuid>${c.reset}`);
1413
- return;
1414
- }
1415
- try {
1416
- const data = SessionManager.loadSession(uuid);
1417
- conversation.clear();
1418
- conversation.importHistory(data.history);
1419
- conversation.setSummary(data.summary || '');
1420
- currentModel = data.model;
1421
- conversation.setContextLimit(currentModel);
1422
- sessionModifiedFiles = data.modifiedFiles || [];
1423
- currentSessionId = data.sessionId;
1424
- stats.totalPromptTokens = data.tokenUsage?.prompt_tokens || 0;
1425
- stats.totalCompletionTokens = data.tokenUsage?.completion_tokens || 0;
1426
- console.log(`\n${c.green}✓ Session restored successfully: ${c.bold}${uuid}${c.reset}`);
1427
- console.log(`${c.dim} Model set to: ${c.cyan}${currentModel}${c.reset}`);
1428
- }
1429
- catch (err) {
1430
- console.log(`\n${c.red}✗ Failed to load session: ${err.message}${c.reset}`);
1431
- }
1432
- }
1433
- else if (sub === 'new') {
1434
- conversation.clear();
1435
- sessionModifiedFiles = [];
1436
- stats.totalPromptTokens = 0;
1437
- stats.totalCompletionTokens = 0;
1438
- stats.totalToolCalls = 0;
1439
- stats.totalTasks = 0;
1440
- stats.totalDurationMs = 0;
1441
- const { randomUUID } = await import('node:crypto');
1442
- currentSessionId = randomUUID();
1443
- SessionManager.saveSession(conversation, currentModel, sessionModifiedFiles, {
1444
- prompt_tokens: stats.totalPromptTokens,
1445
- completion_tokens: stats.totalCompletionTokens,
1446
- total_tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
1447
- }, currentSessionId);
1448
- console.log(`\n${c.green}✓ Active conversation memory purged. New session initialized: ${c.bold}${currentSessionId}${c.reset}`);
1449
- }
1450
- else {
1451
- console.log(`\n${c.yellow}Usage: /session [list | load <uuid> | new]${c.reset}`);
1452
- }
1453
- return;
1454
- }
1455
- case '/providers': {
1456
- const sub = args[0];
1457
- // ── Interactive flow (bare `/providers`): mirrors the
1458
- // /model picker shape. The user picks a provider, then
1459
- // an action, then enters a masked API key via p.password
1460
- // when the action is add/update. The legacy text routes
1461
- // below remain unchanged for muscle-memory + scripting.
1462
- if (!sub) {
1463
- rl.pause();
1464
- const pickedProvider = await p.select({
1465
- message: 'Select an AI provider:',
1466
- options: PROVIDER_REGISTRY.map(def => ({
1467
- value: def.name,
1468
- label: def.displayName,
1469
- hint: ProvidersManager.has(def.name) ? '[key ✓]' : '[no key]',
1470
- })),
1471
- });
1472
- rl.resume();
1473
- if (p.isCancel(pickedProvider)) {
1474
- console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1475
- return;
1476
- }
1477
- const def = ProvidersManager.getDefinition(pickedProvider);
1478
- if (!def) {
1479
- console.log(`\n${c.red}✗ Unknown provider: ${pickedProvider}${c.reset}`);
1480
- return;
1481
- }
1482
- const hasKey = ProvidersManager.has(def.name);
1483
- rl.pause();
1484
- const action = await p.select({
1485
- message: `${def.displayName} — choose an action:`,
1486
- options: [
1487
- { value: 'add', label: hasKey ? 'Update API key' : 'Add API key' },
1488
- { value: 'test', label: 'Test connection', hint: hasKey ? '' : 'requires a key' },
1489
- { value: 'remove', label: 'Remove API key', hint: hasKey ? '' : 'no key configured' },
1490
- { value: 'cancel', label: 'Cancel' },
1491
- ],
1492
- });
1493
- rl.resume();
1494
- if (p.isCancel(action) || action === 'cancel') {
1495
- console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1496
- return;
1497
- }
1498
- if (action === 'add') {
1499
- console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
1500
- rl.pause();
1501
- const key = await p.password({
1502
- message: `Enter your ${def.displayName} API key:`,
1503
- validate: v => !v?.trim() ? 'API key is required' : undefined,
1504
- });
1505
- rl.resume();
1506
- if (p.isCancel(key)) {
1507
- console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1508
- return;
1509
- }
1510
- ProvidersManager.add(def.name, key);
1511
- console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
1512
- await refreshModelsForProvider(def.name);
1513
- return;
1514
- }
1515
- if (action === 'remove') {
1516
- if (!hasKey) {
1517
- console.log(`\n${c.yellow}No key configured for ${def.displayName}.${c.reset}`);
1518
- return;
1519
- }
1520
- rl.pause();
1521
- const confirmed = await p.confirm({
1522
- message: `Remove API key for ${def.displayName}?`,
1523
- initialValue: false,
1524
- });
1525
- rl.resume();
1526
- if (!p.isCancel(confirmed) && confirmed) {
1527
- const removed = ProvidersManager.remove(def.name);
1528
- console.log(removed
1529
- ? `\n${c.green}✓ Removed API key for ${def.displayName}.${c.reset}`
1530
- : `\n${c.yellow}No key found for provider: ${def.name}${c.reset}`);
1531
- }
1532
- return;
1533
- }
1534
- if (action === 'test') {
1535
- if (!hasKey) {
1536
- console.log(`\n${c.yellow}No key configured for ${def.displayName}. Add one first.${c.reset}`);
1537
- return;
1538
- }
1539
- console.log(`\n${c.dim}Testing connection to ${def.displayName} via live /models fetch…${c.reset}`);
1540
- await refreshModelsForProvider(def.name);
1541
- 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;
1542
1083
  }
1543
1084
  return;
1544
1085
  }
1545
- if (sub === 'list') {
1546
- const list = ProvidersManager.list();
1547
- if (list.length === 0) {
1548
- console.log(`\n${c.yellow}No providers configured.${c.reset}`);
1549
- console.log(`${c.dim} Use /providers add <name> to connect a provider (e.g. /providers add groq)${c.reset}`);
1550
- console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1551
- }
1552
- else {
1553
- console.log(`\n${c.bold}${c.cyan}Connected Providers${c.reset}`);
1554
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1555
- for (const entry of list) {
1556
- const addedDate = new Date(entry.addedAt).toLocaleDateString();
1557
- 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}`);
1558
- }
1559
- console.log(`\n${c.dim} Use /providers remove <name> to remove a key.${c.reset}`);
1560
- console.log(`${c.dim} Use /providers test <name> to verify a connection.${c.reset}`);
1561
- }
1562
- return;
1563
- }
1564
- if (sub === 'add') {
1565
- const name = args[1]?.toLowerCase();
1566
- if (!name) {
1567
- console.log(`\n${c.yellow}Usage: /providers add <provider-name>${c.reset}`);
1568
- console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1569
- return;
1570
- }
1571
- const def = ProvidersManager.getDefinition(name);
1572
- if (!def) {
1573
- console.log(`\n${c.red}✗ Unknown provider: ${name}${c.reset}`);
1574
- console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1575
- return;
1576
- }
1577
- console.log(`\n${c.cyan}${c.bold}Connecting to ${def.displayName}${c.reset}`);
1578
- console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
1579
- rl.pause();
1580
- const apiKeyInput = await p.text({
1581
- message: `Enter your ${def.displayName} API key:`,
1582
- placeholder: 'sk-... or gsk_...',
1583
- validate: v => !v.trim() ? 'API key is required' : undefined,
1584
- });
1585
- rl.resume();
1586
- if (p.isCancel(apiKeyInput)) {
1587
- console.log(`\n${c.dim}Provider add cancelled.${c.reset}`);
1588
- return;
1589
- }
1590
- ProvidersManager.add(name, apiKeyInput);
1591
- console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
1592
- console.log(`${c.dim} FixO will now route ${def.displayName} requests directly (bypassing the SaaS proxy).${c.reset}`);
1593
- await refreshModelsForProvider(name);
1594
- return;
1595
- }
1596
- if (sub === 'remove') {
1597
- const name = args[1]?.toLowerCase();
1598
- if (!name) {
1599
- console.log(`\n${c.yellow}Usage: /providers remove <name>${c.reset}`);
1600
- return;
1601
- }
1602
- rl.pause();
1603
- const confirmed = await p.confirm({ message: `Remove API key for ${name}?`, initialValue: false });
1604
- rl.resume();
1605
- if (!p.isCancel(confirmed) && confirmed) {
1606
- const removed = ProvidersManager.remove(name);
1607
- console.log(removed
1608
- ? `\n${c.green}✓ Removed API key for ${name}.${c.reset}`
1609
- : `\n${c.yellow}No key found for provider: ${name}${c.reset}`);
1610
- }
1611
- return;
1612
- }
1613
- if (sub === 'test') {
1614
- const name = args[1]?.toLowerCase();
1615
- if (!name) {
1616
- console.log(`\n${c.yellow}Usage: /providers test <name>${c.reset}`);
1617
- return;
1618
- }
1619
- const directConf = ProvidersManager.getDirectConfig(name);
1620
- if (!directConf) {
1621
- console.log(`\n${c.yellow}No key configured for ${name}. Use /providers add ${name} first.${c.reset}`);
1622
- return;
1623
- }
1624
- console.log(`\n${c.dim}Testing connection to ${directConf.displayName} (${directConf.baseUrl})...${c.reset}`);
1625
- try {
1626
- const testHeaders = {
1627
- 'Authorization': `Bearer ${directConf.apiKey}`,
1628
- };
1629
- if (name === 'zen' || name === 'openrouter') {
1630
- testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
1631
- testHeaders['X-Title'] = 'opencode';
1632
- }
1633
- else if (name === 'nvidia') {
1634
- testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
1635
- testHeaders['X-Title'] = 'opencode';
1636
- testHeaders['X-BILLING-INVOKE-ORIGIN'] = 'OpenCode';
1637
- }
1638
- else if (name === 'cerebras') {
1639
- testHeaders['X-Cerebras-3rd-Party-Integration'] = 'opencode';
1640
- }
1641
- const resp = await fetch(`${directConf.baseUrl}/models`, {
1642
- headers: testHeaders,
1643
- signal: AbortSignal.timeout(8000),
1644
- });
1645
- if (resp.ok) {
1646
- console.log(`${c.green}✓ Connection to ${directConf.displayName} successful! (HTTP ${resp.status})${c.reset}`);
1647
- // Warm the cache so /model picker shows live IDs.
1648
- await refreshModelsForProvider(name);
1649
- }
1650
- else {
1651
- const text = await resp.text().catch(() => '');
1652
- console.log(`${c.red}✗ ${directConf.displayName} returned HTTP ${resp.status}${text ? ': ' + text.slice(0, 100) : ''}${c.reset}`);
1653
- }
1654
- }
1655
- catch (err) {
1656
- console.log(`${c.red}✗ Connection failed: ${err.message}${c.reset}`);
1657
- }
1658
- return;
1659
- }
1660
- console.log(`\n${c.yellow}Usage: /providers [list | add <name> | remove <name> | test <name>]${c.reset}`);
1661
- console.log(`${c.dim} Available providers: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1662
- return;
1663
- }
1664
- case '/compact': {
1665
- const msgCount = conversation.getMessageCount();
1666
- if (msgCount === 0) {
1667
- console.log(`\n${c.dim}Nothing to compact — conversation is empty.${c.reset}`);
1668
- return;
1669
- }
1670
- const tokensBefore = conversation.getTotalTokens();
1671
- const contextLimit = conversation.getContextLimit();
1672
- console.log(`\n${c.cyan}[Compact] Summarising ${msgCount} messages to free context tokens...${c.reset}`);
1673
- console.log(`${c.dim} Current context: ${(tokensBefore / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k tokens${c.reset}`);
1674
- try {
1675
- const compacted = await conversation.compact(agent.getClient(), currentModel);
1676
- if (compacted) {
1677
- const info = conversation.getLastCompactionInfo();
1678
- const tokensAfter = conversation.getTotalTokens();
1679
- console.log(`${c.green}✓ Compacted: ${info?.messagesBefore ?? msgCount} messages → summary + ${conversation.getMessageCount()} recent messages.${c.reset}`);
1680
- 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}`);
1681
- }
1682
- else {
1683
- console.log(`${c.dim}Not enough messages to compact (need more than 4 messages).${c.reset}`);
1684
- }
1685
- }
1686
- catch (err) {
1687
- console.log(`${c.red}✗ Compact failed: ${err.message}${c.reset}`);
1688
- }
1689
- return;
1690
- }
1691
- case '/snapshot': {
1692
- const label = args.join(' ').trim() || `snapshot-${Date.now()}`;
1693
- if (!git.isGitRepo()) {
1694
- console.log(`\n${c.yellow}⚠ Not a git repository — cannot create snapshot.${c.reset}`);
1695
- return;
1696
- }
1697
- const hash = git.createSnapshot(label);
1698
- if (hash) {
1699
- console.log(`\n${c.green}✓ Workspace snapshot created: ${c.bold}${hash}${c.reset}${c.dim} (label: ${label})${c.reset}`);
1700
- console.log(`${c.dim} Use /undo or git revert to roll back to this point.${c.reset}`);
1701
- }
1702
- return;
1703
- }
1704
- case '/skills': {
1705
- const { skillsManager } = await import('../agent/skills.js');
1706
- const list = skillsManager.getSkills();
1707
- if (list.length === 0) {
1708
- 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}`);
1709
- }
1710
- else {
1711
- console.log(`\n${c.cyan}${c.bold}Registered Skills:${c.reset}`);
1712
- for (const skill of list) {
1713
- console.log(` - ${c.bold}${skill.name}${c.reset}${skill.description ? `: ${skill.description}` : ''} ${c.dim}(${skill.location})${c.reset}`);
1714
- }
1715
- }
1716
- return;
1717
- }
1718
- case '/theme':
1719
- case '/variant': {
1720
- const { themeMode, setThemeMode } = await import('./colors.js');
1721
- const newMode = themeMode === 'dark' ? 'inverted' : 'dark';
1722
- setThemeMode(newMode);
1723
- console.log(`\n${c.cyan}✓ Theme set to: ${newMode === 'dark' ? 'Dark Void Minimalist' : 'High-Contrast Inverted'}${c.reset}`);
1724
- return;
1725
- }
1726
- case '/telemetry': {
1727
- const sub = args[0]?.toLowerCase();
1728
- if (sub === 'on' || sub === 'enable') {
1729
- config.preferences.telemetry = true;
1730
- saveConfig(config);
1731
- console.log(`\n${c.green}✓ Telemetry enabled${c.reset}`);
1732
- }
1733
- else if (sub === 'off' || sub === 'disable') {
1734
- config.preferences.telemetry = false;
1735
- saveConfig(config);
1736
- console.log(`\n${c.green}✓ Telemetry disabled${c.reset}`);
1737
- }
1738
- else {
1739
- 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}`);
1740
- }
1741
- return;
1742
- }
1743
- default:
1744
1086
  console.log(`\n${c.yellow}Unknown command: ${cmd}. Type /help for available commands.${c.reset}`);
1745
1087
  return;
1088
+ }
1746
1089
  }
1747
1090
  }
1748
1091
  // ─── Shell commands (! prefix) ───
@@ -1783,20 +1126,48 @@ export async function startREPL(options) {
1783
1126
  console.log(output);
1784
1127
  }
1785
1128
  catch (error) {
1786
- if (error.stdout)
1787
- console.log(error.stdout);
1788
- if (error.stderr)
1789
- 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}`);
1790
1134
  }
1791
1135
  return;
1792
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
+ // ─────────────────────────────────────────────────────────────────
1793
1164
  // ─── Agent task ───
1794
1165
  // Format any paths in the input for display
1795
1166
  const displayInput = formatInputPaths(input, cwd);
1796
1167
  if (displayInput !== input) {
1797
1168
  // Re-display with highlighted paths
1798
1169
  process.stdout.write(`\x1b[1A\x1b[2K`); // Move up and clear line
1799
- console.log(`${C.LAVA}›${C.RESET} ${displayInput}`);
1170
+ console.log(`> ${displayInput}`);
1800
1171
  }
1801
1172
  // Extract any file paths from input for automatic pinning
1802
1173
  const pathsInInput = extractFilePaths(input, cwd);
@@ -1816,105 +1187,34 @@ export async function startREPL(options) {
1816
1187
  // Drain the queue — attachments are one-shot. The agent has its
1817
1188
  // own copy via context above.
1818
1189
  pendingAttachments = [];
1819
- const classification = classifyComplexityHeuristic(input);
1820
- let result;
1821
- const startTime = Date.now();
1822
- if (classification.complexity === 'complex') {
1823
- console.log(`\n${c.cyan}[Routing Engine] Complex task detected (${classification.reason}). Routing to Orchestrator...${c.reset}`);
1824
- try {
1825
- const { Orchestrator } = await import('../agent/orchestrator.js');
1826
- const { AgentPool } = await import('../agent/agent-pool.js');
1827
- console.log(`\n${c.cyan}[Orchestrator] Generating plan for complex task...${c.reset}`);
1828
- const orchestrator = new Orchestrator(verbose);
1829
- const dag = await orchestrator.plan(context);
1830
- // Render planned phases in high contrast box
1831
- const width = 60;
1832
- const borderTop = `┌${'─'.repeat(width)}┐`;
1833
- const borderBottom = `└${'─'.repeat(width)}┘`;
1834
- console.log(`\n${c.cyan}${borderTop}${c.reset}`);
1835
- console.log(`${c.cyan}│${c.reset} ${c.bold}Planned Subtask Phases (Complex Task decomposition):${c.reset}${' '.repeat(width - 52)}${c.cyan}│${c.reset}`);
1836
- console.log(`${c.cyan}├${'─'.repeat(width)}┤${c.reset}`);
1837
- for (const sub of dag.subtasks) {
1838
- const deps = sub.dependencies.length > 0 ? ` (deps: ${sub.dependencies.join(', ')})` : '';
1839
- const lineStr = ` - [${sub.persona.toUpperCase()}] ${sub.title}${deps}`;
1840
- const pad = Math.max(0, width - lineStr.length - 4);
1841
- console.log(`${c.cyan}│${c.reset} ${c.bold}${lineStr}${c.reset}${' '.repeat(pad)} ${c.cyan}│${c.reset}`);
1842
- }
1843
- console.log(`${c.cyan}${borderBottom}${c.reset}\n`);
1844
- // Save the DAG to .fixo/last-dag.json
1845
- const fixoDir = path.join(cwd, '.fixo');
1846
- fs.mkdirSync(fixoDir, { recursive: true });
1847
- fs.writeFileSync(path.join(fixoDir, 'last-dag.json'), JSON.stringify({ task: input, dag }, null, 2), 'utf-8');
1848
- if (currentMode === 'PLAN') {
1849
- console.log(`${c.green}✓ Plan generated and saved successfully.${c.reset}`);
1850
- 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`);
1851
- return;
1852
- }
1853
- const budgetLimit = projectConfig?.maxAttempts ?? 12;
1854
- const pool = new AgentPool(3, budgetLimit);
1855
- console.log(`\n${c.cyan}[Agent Pool] Executing DAG of subtasks (concurrency limit: 3, budget: ${budgetLimit} tool calls)...${c.reset}`);
1856
- const success = await pool.execute(context, dag);
1857
- const durationMs = Date.now() - startTime;
1858
- const totalPromptTokens = orchestrator.tokensUsed.prompt_tokens + pool.tokensUsed.prompt_tokens;
1859
- const totalCompletionTokens = orchestrator.tokensUsed.completion_tokens + pool.tokensUsed.completion_tokens;
1860
- // Find modified files to report
1861
- const { getModifiedFiles, getBranchPoint } = await import('../agent/worker-agent.js');
1862
- const relativeModified = getModifiedFiles(cwd, getBranchPoint(cwd));
1863
- const modifiedFiles = relativeModified.map(f => path.resolve(cwd, f));
1864
- if (!success) {
1865
- console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
1866
- if (git.isGitRepo()) {
1867
- console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
1868
- git.discardUncommittedChanges();
1869
- }
1870
- }
1871
- result = {
1872
- success,
1873
- response: success
1874
- ? 'Successfully completed complex task via parallel agents.'
1875
- : 'Failed to complete all complex subtasks.',
1876
- modifiedFiles,
1877
- tokensUsed: {
1878
- prompt_tokens: totalPromptTokens,
1879
- completion_tokens: totalCompletionTokens,
1880
- total_tokens: totalPromptTokens + totalCompletionTokens
1881
- },
1882
- toolCallCount: pool.toolCallCount,
1883
- durationMs,
1884
- model: context.model,
1885
- };
1886
- }
1887
- catch (err) {
1888
- console.error(`\n${c.red}✗ Orchestrated execution failed: ${err.message || err}${c.reset}`);
1889
- if (git.isGitRepo()) {
1890
- console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to error...${c.reset}`);
1891
- git.discardUncommittedChanges();
1892
- }
1893
- const durationMs = Date.now() - startTime;
1894
- result = {
1895
- success: false,
1896
- response: `Orchestrated run failed: ${err.message || err}`,
1897
- modifiedFiles: [],
1898
- tokensUsed: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
1899
- toolCallCount: 0,
1900
- durationMs,
1901
- model: context.model,
1902
- };
1903
- }
1904
- }
1905
- else {
1906
- console.log(`\n${c.cyan}[Routing Engine] Simple task detected (${classification.reason}). Routing to SingleAgent...${c.reset}`);
1907
- isTaskRunning = true;
1908
- currentRunningAgent = agent;
1909
- try {
1910
- result = await agent.runStreaming(context, conversation, rl);
1911
- }
1912
- 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: () => {
1913
1208
  isTaskRunning = false;
1914
1209
  currentRunningAgent = null;
1915
- agent.reset();
1916
- }
1210
+ },
1211
+ });
1212
+ if (routed.route === 'plan-mode-deferred') {
1213
+ pendingPastes = [];
1214
+ return;
1917
1215
  }
1216
+ const result = routed.result;
1217
+ pendingPastes = [];
1918
1218
  // Print result summary
1919
1219
  console.log('');
1920
1220
  const modelPart = result.model ? `${result.model} · ` : '';
@@ -1985,7 +1285,25 @@ export async function startREPL(options) {
1985
1285
  prompt_tokens: stats.totalPromptTokens,
1986
1286
  completion_tokens: stats.totalCompletionTokens,
1987
1287
  total_tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
1988
- }, currentSessionId);
1288
+ }, currentSessionId, currentSessionLabel);
1289
+ const { saveSnapshot } = await import('../runtime/session-snapshots.js');
1290
+ saveSnapshot({
1291
+ cwd,
1292
+ conversation: conversation.exportHistory().map((m, idx) => ({
1293
+ role: m.role,
1294
+ content: m.content || '',
1295
+ name: m.name,
1296
+ index: idx,
1297
+ })),
1298
+ tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
1299
+ model: currentModel,
1300
+ mode: currentMode,
1301
+ selectedFiles: [...selectedFiles],
1302
+ summary: conversation.getSummary(),
1303
+ label: currentSessionLabel,
1304
+ id: currentSessionId,
1305
+ fixedInstructions: projectConfig?.systemPrompt,
1306
+ });
1989
1307
  }
1990
1308
  catch (err) {
1991
1309
  // Ignore session save errors