fixo-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +530 -0
  3. package/dist/agent/agent-client.d.ts +108 -0
  4. package/dist/agent/agent-client.d.ts.map +1 -0
  5. package/dist/agent/agent-client.js +1247 -0
  6. package/dist/agent/agent-client.js.map +1 -0
  7. package/dist/agent/agent-pool.d.ts +20 -0
  8. package/dist/agent/agent-pool.d.ts.map +1 -0
  9. package/dist/agent/agent-pool.js +217 -0
  10. package/dist/agent/agent-pool.js.map +1 -0
  11. package/dist/agent/background-awareness.d.ts +55 -0
  12. package/dist/agent/background-awareness.d.ts.map +1 -0
  13. package/dist/agent/background-awareness.js +104 -0
  14. package/dist/agent/background-awareness.js.map +1 -0
  15. package/dist/agent/command-parser.d.ts +33 -0
  16. package/dist/agent/command-parser.d.ts.map +1 -0
  17. package/dist/agent/command-parser.js +120 -0
  18. package/dist/agent/command-parser.js.map +1 -0
  19. package/dist/agent/context-budget.d.ts +91 -0
  20. package/dist/agent/context-budget.d.ts.map +1 -0
  21. package/dist/agent/context-budget.js +219 -0
  22. package/dist/agent/context-budget.js.map +1 -0
  23. package/dist/agent/conversation.d.ts +190 -0
  24. package/dist/agent/conversation.d.ts.map +1 -0
  25. package/dist/agent/conversation.js +547 -0
  26. package/dist/agent/conversation.js.map +1 -0
  27. package/dist/agent/hooks.d.ts +72 -0
  28. package/dist/agent/hooks.d.ts.map +1 -0
  29. package/dist/agent/hooks.js +214 -0
  30. package/dist/agent/hooks.js.map +1 -0
  31. package/dist/agent/mcp-bridge.d.ts +13 -0
  32. package/dist/agent/mcp-bridge.d.ts.map +1 -0
  33. package/dist/agent/mcp-bridge.js +86 -0
  34. package/dist/agent/mcp-bridge.js.map +1 -0
  35. package/dist/agent/mcp-client.d.ts +24 -0
  36. package/dist/agent/mcp-client.d.ts.map +1 -0
  37. package/dist/agent/mcp-client.js +146 -0
  38. package/dist/agent/mcp-client.js.map +1 -0
  39. package/dist/agent/mcp-manager.d.ts +13 -0
  40. package/dist/agent/mcp-manager.d.ts.map +1 -0
  41. package/dist/agent/mcp-manager.js +84 -0
  42. package/dist/agent/mcp-manager.js.map +1 -0
  43. package/dist/agent/mcp-registry.d.ts +45 -0
  44. package/dist/agent/mcp-registry.d.ts.map +1 -0
  45. package/dist/agent/mcp-registry.js +98 -0
  46. package/dist/agent/mcp-registry.js.map +1 -0
  47. package/dist/agent/orchestrator.d.ts +14 -0
  48. package/dist/agent/orchestrator.d.ts.map +1 -0
  49. package/dist/agent/orchestrator.js +118 -0
  50. package/dist/agent/orchestrator.js.map +1 -0
  51. package/dist/agent/parser-adapter.d.ts +120 -0
  52. package/dist/agent/parser-adapter.d.ts.map +1 -0
  53. package/dist/agent/parser-adapter.js +265 -0
  54. package/dist/agent/parser-adapter.js.map +1 -0
  55. package/dist/agent/parsers/imports.d.ts +11 -0
  56. package/dist/agent/parsers/imports.d.ts.map +1 -0
  57. package/dist/agent/parsers/imports.js +94 -0
  58. package/dist/agent/parsers/imports.js.map +1 -0
  59. package/dist/agent/parsers/shell.d.ts +23 -0
  60. package/dist/agent/parsers/shell.d.ts.map +1 -0
  61. package/dist/agent/parsers/shell.js +200 -0
  62. package/dist/agent/parsers/shell.js.map +1 -0
  63. package/dist/agent/parsers/symbols.d.ts +17 -0
  64. package/dist/agent/parsers/symbols.d.ts.map +1 -0
  65. package/dist/agent/parsers/symbols.js +103 -0
  66. package/dist/agent/parsers/symbols.js.map +1 -0
  67. package/dist/agent/permissions.d.ts +65 -0
  68. package/dist/agent/permissions.d.ts.map +1 -0
  69. package/dist/agent/permissions.js +219 -0
  70. package/dist/agent/permissions.js.map +1 -0
  71. package/dist/agent/predictive-gate.d.ts +69 -0
  72. package/dist/agent/predictive-gate.d.ts.map +1 -0
  73. package/dist/agent/predictive-gate.js +128 -0
  74. package/dist/agent/predictive-gate.js.map +1 -0
  75. package/dist/agent/provider-cooldown.d.ts +144 -0
  76. package/dist/agent/provider-cooldown.d.ts.map +1 -0
  77. package/dist/agent/provider-cooldown.js +300 -0
  78. package/dist/agent/provider-cooldown.js.map +1 -0
  79. package/dist/agent/providers-manager.d.ts +109 -0
  80. package/dist/agent/providers-manager.d.ts.map +1 -0
  81. package/dist/agent/providers-manager.js +464 -0
  82. package/dist/agent/providers-manager.js.map +1 -0
  83. package/dist/agent/repo-map.d.ts +6 -0
  84. package/dist/agent/repo-map.d.ts.map +1 -0
  85. package/dist/agent/repo-map.js +221 -0
  86. package/dist/agent/repo-map.js.map +1 -0
  87. package/dist/agent/retry.d.ts +103 -0
  88. package/dist/agent/retry.d.ts.map +1 -0
  89. package/dist/agent/retry.js +276 -0
  90. package/dist/agent/retry.js.map +1 -0
  91. package/dist/agent/search/index.d.ts +61 -0
  92. package/dist/agent/search/index.d.ts.map +1 -0
  93. package/dist/agent/search/index.js +314 -0
  94. package/dist/agent/search/index.js.map +1 -0
  95. package/dist/agent/single-agent.d.ts +76 -0
  96. package/dist/agent/single-agent.d.ts.map +1 -0
  97. package/dist/agent/single-agent.js +697 -0
  98. package/dist/agent/single-agent.js.map +1 -0
  99. package/dist/agent/skills.d.ts +22 -0
  100. package/dist/agent/skills.d.ts.map +1 -0
  101. package/dist/agent/skills.js +139 -0
  102. package/dist/agent/skills.js.map +1 -0
  103. package/dist/agent/stream-glue.d.ts +85 -0
  104. package/dist/agent/stream-glue.d.ts.map +1 -0
  105. package/dist/agent/stream-glue.js +120 -0
  106. package/dist/agent/stream-glue.js.map +1 -0
  107. package/dist/agent/subagent.d.ts +72 -0
  108. package/dist/agent/subagent.d.ts.map +1 -0
  109. package/dist/agent/subagent.js +193 -0
  110. package/dist/agent/subagent.js.map +1 -0
  111. package/dist/agent/telemetry.d.ts +192 -0
  112. package/dist/agent/telemetry.d.ts.map +1 -0
  113. package/dist/agent/telemetry.js +400 -0
  114. package/dist/agent/telemetry.js.map +1 -0
  115. package/dist/agent/tokenizer.d.ts +42 -0
  116. package/dist/agent/tokenizer.d.ts.map +1 -0
  117. package/dist/agent/tokenizer.js +107 -0
  118. package/dist/agent/tokenizer.js.map +1 -0
  119. package/dist/agent/tool-executor.d.ts +289 -0
  120. package/dist/agent/tool-executor.d.ts.map +1 -0
  121. package/dist/agent/tool-executor.js +2519 -0
  122. package/dist/agent/tool-executor.js.map +1 -0
  123. package/dist/agent/web-impl.d.ts +2 -0
  124. package/dist/agent/web-impl.d.ts.map +1 -0
  125. package/dist/agent/web-impl.js +34 -0
  126. package/dist/agent/web-impl.js.map +1 -0
  127. package/dist/agent/web.d.ts +8 -0
  128. package/dist/agent/web.d.ts.map +1 -0
  129. package/dist/agent/web.js +8 -0
  130. package/dist/agent/web.js.map +1 -0
  131. package/dist/agent/worker-agent.d.ts +27 -0
  132. package/dist/agent/worker-agent.d.ts.map +1 -0
  133. package/dist/agent/worker-agent.js +503 -0
  134. package/dist/agent/worker-agent.js.map +1 -0
  135. package/dist/config.d.ts +162 -0
  136. package/dist/config.d.ts.map +1 -0
  137. package/dist/config.js +138 -0
  138. package/dist/config.js.map +1 -0
  139. package/dist/context/fixo-md-watcher.d.ts +42 -0
  140. package/dist/context/fixo-md-watcher.d.ts.map +1 -0
  141. package/dist/context/fixo-md-watcher.js +126 -0
  142. package/dist/context/fixo-md-watcher.js.map +1 -0
  143. package/dist/context/fixo-md.d.ts +50 -0
  144. package/dist/context/fixo-md.d.ts.map +1 -0
  145. package/dist/context/fixo-md.js +118 -0
  146. package/dist/context/fixo-md.js.map +1 -0
  147. package/dist/context/todo.d.ts +65 -0
  148. package/dist/context/todo.d.ts.map +1 -0
  149. package/dist/context/todo.js +194 -0
  150. package/dist/context/todo.js.map +1 -0
  151. package/dist/git/git-manager.d.ts +33 -0
  152. package/dist/git/git-manager.d.ts.map +1 -0
  153. package/dist/git/git-manager.js +293 -0
  154. package/dist/git/git-manager.js.map +1 -0
  155. package/dist/git/git-ops.d.ts +10 -0
  156. package/dist/git/git-ops.d.ts.map +1 -0
  157. package/dist/git/git-ops.js +131 -0
  158. package/dist/git/git-ops.js.map +1 -0
  159. package/dist/index.d.ts +3 -0
  160. package/dist/index.d.ts.map +1 -0
  161. package/dist/index.js +352 -0
  162. package/dist/index.js.map +1 -0
  163. package/dist/indexer.d.ts +30 -0
  164. package/dist/indexer.d.ts.map +1 -0
  165. package/dist/indexer.js +273 -0
  166. package/dist/indexer.js.map +1 -0
  167. package/dist/lsp/lsp-client.d.ts +24 -0
  168. package/dist/lsp/lsp-client.d.ts.map +1 -0
  169. package/dist/lsp/lsp-client.js +205 -0
  170. package/dist/lsp/lsp-client.js.map +1 -0
  171. package/dist/lsp/lsp-manager.d.ts +17 -0
  172. package/dist/lsp/lsp-manager.d.ts.map +1 -0
  173. package/dist/lsp/lsp-manager.js +154 -0
  174. package/dist/lsp/lsp-manager.js.map +1 -0
  175. package/dist/lsp/lsp-pre-save.d.ts +137 -0
  176. package/dist/lsp/lsp-pre-save.d.ts.map +1 -0
  177. package/dist/lsp/lsp-pre-save.js +245 -0
  178. package/dist/lsp/lsp-pre-save.js.map +1 -0
  179. package/dist/lsp/syntax-fallback.d.ts +83 -0
  180. package/dist/lsp/syntax-fallback.d.ts.map +1 -0
  181. package/dist/lsp/syntax-fallback.js +275 -0
  182. package/dist/lsp/syntax-fallback.js.map +1 -0
  183. package/dist/model-outcomes.d.ts +12 -0
  184. package/dist/model-outcomes.d.ts.map +1 -0
  185. package/dist/model-outcomes.js +46 -0
  186. package/dist/model-outcomes.js.map +1 -0
  187. package/dist/planner.d.ts +32 -0
  188. package/dist/planner.d.ts.map +1 -0
  189. package/dist/planner.js +163 -0
  190. package/dist/planner.js.map +1 -0
  191. package/dist/project-memory.d.ts +29 -0
  192. package/dist/project-memory.d.ts.map +1 -0
  193. package/dist/project-memory.js +349 -0
  194. package/dist/project-memory.js.map +1 -0
  195. package/dist/review.d.ts +2 -0
  196. package/dist/review.d.ts.map +1 -0
  197. package/dist/review.js +61 -0
  198. package/dist/review.js.map +1 -0
  199. package/dist/runtime/background-jobs.d.ts +97 -0
  200. package/dist/runtime/background-jobs.d.ts.map +1 -0
  201. package/dist/runtime/background-jobs.js +331 -0
  202. package/dist/runtime/background-jobs.js.map +1 -0
  203. package/dist/runtime/credential-vault.d.ts +124 -0
  204. package/dist/runtime/credential-vault.d.ts.map +1 -0
  205. package/dist/runtime/credential-vault.js +184 -0
  206. package/dist/runtime/credential-vault.js.map +1 -0
  207. package/dist/runtime/loop-trap.d.ts +197 -0
  208. package/dist/runtime/loop-trap.d.ts.map +1 -0
  209. package/dist/runtime/loop-trap.js +420 -0
  210. package/dist/runtime/loop-trap.js.map +1 -0
  211. package/dist/runtime/policy.d.ts +15 -0
  212. package/dist/runtime/policy.d.ts.map +1 -0
  213. package/dist/runtime/policy.js +60 -0
  214. package/dist/runtime/policy.js.map +1 -0
  215. package/dist/runtime/redaction.d.ts +66 -0
  216. package/dist/runtime/redaction.d.ts.map +1 -0
  217. package/dist/runtime/redaction.js +155 -0
  218. package/dist/runtime/redaction.js.map +1 -0
  219. package/dist/runtime/session-snapshots.d.ts +76 -0
  220. package/dist/runtime/session-snapshots.d.ts.map +1 -0
  221. package/dist/runtime/session-snapshots.js +166 -0
  222. package/dist/runtime/session-snapshots.js.map +1 -0
  223. package/dist/runtime/staging.d.ts +205 -0
  224. package/dist/runtime/staging.d.ts.map +1 -0
  225. package/dist/runtime/staging.js +526 -0
  226. package/dist/runtime/staging.js.map +1 -0
  227. package/dist/runtime/task-session.d.ts +95 -0
  228. package/dist/runtime/task-session.d.ts.map +1 -0
  229. package/dist/runtime/task-session.js +263 -0
  230. package/dist/runtime/task-session.js.map +1 -0
  231. package/dist/runtime/worktree.d.ts +55 -0
  232. package/dist/runtime/worktree.d.ts.map +1 -0
  233. package/dist/runtime/worktree.js +175 -0
  234. package/dist/runtime/worktree.js.map +1 -0
  235. package/dist/setup-wizard.d.ts +8 -0
  236. package/dist/setup-wizard.d.ts.map +1 -0
  237. package/dist/setup-wizard.js +73 -0
  238. package/dist/setup-wizard.js.map +1 -0
  239. package/dist/shared/content.d.ts +43 -0
  240. package/dist/shared/content.d.ts.map +1 -0
  241. package/dist/shared/content.js +61 -0
  242. package/dist/shared/content.js.map +1 -0
  243. package/dist/shared/types.d.ts +217 -0
  244. package/dist/shared/types.d.ts.map +1 -0
  245. package/dist/shared/types.js +3 -0
  246. package/dist/shared/types.js.map +1 -0
  247. package/dist/test-runner.d.ts +5 -0
  248. package/dist/test-runner.d.ts.map +1 -0
  249. package/dist/test-runner.js +42 -0
  250. package/dist/test-runner.js.map +1 -0
  251. package/dist/types.d.ts +85 -0
  252. package/dist/types.d.ts.map +1 -0
  253. package/dist/types.js +2 -0
  254. package/dist/types.js.map +1 -0
  255. package/dist/ui/ascii.d.ts +23 -0
  256. package/dist/ui/ascii.d.ts.map +1 -0
  257. package/dist/ui/ascii.js +45 -0
  258. package/dist/ui/ascii.js.map +1 -0
  259. package/dist/ui/colors.d.ts +111 -0
  260. package/dist/ui/colors.d.ts.map +1 -0
  261. package/dist/ui/colors.js +166 -0
  262. package/dist/ui/colors.js.map +1 -0
  263. package/dist/ui/image-attach.d.ts +27 -0
  264. package/dist/ui/image-attach.d.ts.map +1 -0
  265. package/dist/ui/image-attach.js +100 -0
  266. package/dist/ui/image-attach.js.map +1 -0
  267. package/dist/ui/index.d.ts +18 -0
  268. package/dist/ui/index.d.ts.map +1 -0
  269. package/dist/ui/index.js +18 -0
  270. package/dist/ui/index.js.map +1 -0
  271. package/dist/ui/markdown-stream.d.ts +91 -0
  272. package/dist/ui/markdown-stream.d.ts.map +1 -0
  273. package/dist/ui/markdown-stream.js +524 -0
  274. package/dist/ui/markdown-stream.js.map +1 -0
  275. package/dist/ui/plan-renderer.d.ts +36 -0
  276. package/dist/ui/plan-renderer.d.ts.map +1 -0
  277. package/dist/ui/plan-renderer.js +79 -0
  278. package/dist/ui/plan-renderer.js.map +1 -0
  279. package/dist/ui/prompt.d.ts +11 -0
  280. package/dist/ui/prompt.d.ts.map +1 -0
  281. package/dist/ui/prompt.js +1960 -0
  282. package/dist/ui/prompt.js.map +1 -0
  283. package/dist/ui/render-primitives.d.ts +117 -0
  284. package/dist/ui/render-primitives.d.ts.map +1 -0
  285. package/dist/ui/render-primitives.js +322 -0
  286. package/dist/ui/render-primitives.js.map +1 -0
  287. package/dist/ui/render.d.ts +133 -0
  288. package/dist/ui/render.d.ts.map +1 -0
  289. package/dist/ui/render.js +547 -0
  290. package/dist/ui/render.js.map +1 -0
  291. package/dist/ui/session-header.d.ts +30 -0
  292. package/dist/ui/session-header.d.ts.map +1 -0
  293. package/dist/ui/session-header.js +74 -0
  294. package/dist/ui/session-header.js.map +1 -0
  295. package/dist/workspace-guard.d.ts +68 -0
  296. package/dist/workspace-guard.d.ts.map +1 -0
  297. package/dist/workspace-guard.js +168 -0
  298. package/dist/workspace-guard.js.map +1 -0
  299. package/dist/workspace-lock.d.ts +27 -0
  300. package/dist/workspace-lock.d.ts.map +1 -0
  301. package/dist/workspace-lock.js +95 -0
  302. package/dist/workspace-lock.js.map +1 -0
  303. package/package.json +63 -0
@@ -0,0 +1,1960 @@
1
+ /**
2
+ * Interactive REPL shell for FixO CLI.
3
+ * Provides command handling, file pinning, model selection,
4
+ * and routes user input to the SingleAgent.
5
+ */
6
+ import readline from 'readline';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import * as p from '@clack/prompts';
11
+ import { SingleAgent } from '../agent/single-agent.js';
12
+ import { ConversationManager } from '../agent/conversation.js';
13
+ import { GitManager } from '../git/git-manager.js';
14
+ import { loadImageAsBlock } from './image-attach.js';
15
+ import { saveConfig } from '../config.js';
16
+ import { WorkspaceGuard } from '../workspace-guard.js';
17
+ import { listRuns, showRun, undoRun } from '../runtime/task-session.js';
18
+ import { checkPermission } from '../agent/permissions.js';
19
+ import { redactedEnv, redactSecrets } from '../runtime/redaction.js';
20
+ import { appendMemory, doctor, forgetMemory, readMemory } from '../project-memory.js';
21
+ 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
+ import { mcpManager, mcpBridgeManager } from '../agent/tool-executor.js';
26
+ import { ProvidersManager, PROVIDER_REGISTRY } from '../agent/providers-manager.js';
27
+ import { C, colors } from './colors.js';
28
+ import { COMMANDS_WITH_DESC, printHelp, formatInputPaths } from './render.js';
29
+ import { addItem, loadTodoList, removeItem, renderTodoList, saveTodoList, setItemStatus, summariseTodoList, } from '../context/todo.js';
30
+ import { renderStatusBar } from './render-primitives.js';
31
+ const c = {
32
+ ...colors,
33
+ };
34
+ export async function startREPL(options) {
35
+ const { config, projectConfig, cwd, verbose, resume } = options;
36
+ // ──── Initialize components ────
37
+ const agent = new SingleAgent(verbose);
38
+ const conversation = new ConversationManager();
39
+ const git = new GitManager(cwd);
40
+ const guard = new WorkspaceGuard(cwd);
41
+ const branch = git.isGitRepo() ? git.getCurrentBranch() : '';
42
+ // Initialize local skills and local MCP bridge
43
+ const { skillsManager } = await import('../agent/skills.js');
44
+ skillsManager.initialize(cwd);
45
+ await mcpBridgeManager.initialize(cwd);
46
+ const { randomUUID } = await import('node:crypto');
47
+ let currentSessionId = randomUUID();
48
+ let sessionModifiedFiles = [];
49
+ let currentMode = 'BUILD';
50
+ let currentModel = projectConfig?.model ?? config.defaultModel ?? 'auto';
51
+ conversation.setContextLimit(currentModel);
52
+ let selectedFiles = [];
53
+ // Image (or future non-text) blocks the user has queued with
54
+ // `/image`. Drained into AgentContext.pendingAttachments on the
55
+ // next non-slash input, then cleared.
56
+ let pendingAttachments = [];
57
+ // ──── --resume <id> ────
58
+ if (resume) {
59
+ try {
60
+ const { loadSnapshot, listSnapshots } = await import('../runtime/session-snapshots.js');
61
+ const result = loadSnapshot(cwd, resume);
62
+ if (!result.ok || !result.snapshot) {
63
+ console.log(`\n${c.red}✗ Resume failed: ${result.error ?? 'unknown error'}${c.reset}`);
64
+ const available = listSnapshots(cwd);
65
+ if (available.length > 0) {
66
+ console.log(`\n${c.dim}Available snapshots for this workspace:${c.reset}`);
67
+ for (const s of available.slice(0, 5)) {
68
+ console.log(` ${c.cyan}${s.id}${c.reset} ${c.dim}(${s.items} items, ${s.tokens} tokens)${c.reset}`);
69
+ }
70
+ }
71
+ process.exit(1);
72
+ }
73
+ const snap = result.snapshot;
74
+ conversation.restoreFromSnapshot(snap.conversation.map((m) => ({ role: m.role, content: m.content, name: m.name })), snap.summary ?? '', snap.tokens);
75
+ currentModel = snap.model;
76
+ conversation.setContextLimit(currentModel);
77
+ currentMode = snap.mode;
78
+ selectedFiles = [...snap.selectedFiles];
79
+ console.log(`\n${c.green}✓ Resumed session${c.reset} ${c.dim}${snap.id}${c.reset}`);
80
+ console.log(` ${c.dim}messages=${snap.conversation.length} tokens=${snap.tokens} model=${snap.model} mode=${snap.mode}${c.reset}`);
81
+ if (snap.summary) {
82
+ console.log(` ${c.dim}summary: ${snap.summary}${c.reset}`);
83
+ }
84
+ }
85
+ catch (err) {
86
+ console.log(`\n${c.red}✗ Resume failed: ${err.message}${c.reset}`);
87
+ process.exit(1);
88
+ }
89
+ }
90
+ let isPrompting = false;
91
+ let activeSuggestionsCount = 0;
92
+ let currentMatches = [];
93
+ let highlightedIndex = 0;
94
+ let workspaceFiles = [];
95
+ try {
96
+ const { loadIndex } = await import('../indexer.js');
97
+ const index = await loadIndex(cwd);
98
+ workspaceFiles = index.files.map(f => f.path);
99
+ }
100
+ catch (err) {
101
+ // Ignore
102
+ }
103
+ let lastPromptRow = 0;
104
+ let mouseReportingEnabled = false;
105
+ const stats = {
106
+ totalPromptTokens: 0,
107
+ totalCompletionTokens: 0,
108
+ totalToolCalls: 0,
109
+ totalTasks: 0,
110
+ totalDurationMs: 0,
111
+ };
112
+ // The welcome screen (lava logo + command grid) is printed by
113
+ // `src/index.ts` before the REPL starts; the startREPL entry
114
+ // point jumps straight into the prompt loop.
115
+ if (projectConfig?.systemPrompt) {
116
+ console.log(`${c.dim}📋 Project config loaded (.freellmapi.yml)${c.reset}`);
117
+ }
118
+ const historyFile = path.join(os.homedir(), '.fixo_history');
119
+ let commandHistory = [];
120
+ try {
121
+ if (fs.existsSync(historyFile)) {
122
+ commandHistory = fs.readFileSync(historyFile, 'utf-8').split('\n').filter(Boolean);
123
+ }
124
+ }
125
+ catch (error) {
126
+ if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
127
+ console.warn(`[Debug Warning] Failed to read command history from ${historyFile}: ${error.message || error}`);
128
+ }
129
+ }
130
+ // ──── Create readline interface ────
131
+ const rl = readline.createInterface({
132
+ input: process.stdin,
133
+ output: process.stdout,
134
+ terminal: true,
135
+ history: commandHistory,
136
+ historySize: 1000,
137
+ completer: (line) => {
138
+ const list = COMMANDS_WITH_DESC.map((c) => c.cmd);
139
+ if (line.startsWith('/')) {
140
+ const matches = list.filter((cmd) => cmd.startsWith(line));
141
+ return [matches, line];
142
+ }
143
+ return [[], line];
144
+ },
145
+ });
146
+ // ──── Lava status bar ────
147
+ // The new lava-redesign status bar lives directly above the REPL
148
+ // prompt. It re-renders on every mode change and every model
149
+ // change, plus whenever the user starts a new turn (via
150
+ // `promptForInput` below).
151
+ //
152
+ // We map our internal 4-mode enum onto the 3-mode `CLIState`
153
+ // contract that the renderer expects: EXPLORE/SCOUT collapse to
154
+ // BUILD (the default lava-coloured pill). This keeps the
155
+ // existing /mode command semantics intact while still letting
156
+ // the new bar visualise the live mode.
157
+ const buildLavaStatusState = () => {
158
+ const modeForState = currentMode === 'PLAN' ? 'PLAN' :
159
+ currentMode === 'BUILD' ? 'BUILD' :
160
+ 'BUILD';
161
+ let contextPercent = 0;
162
+ try {
163
+ const used = conversation.getTotalTokens();
164
+ const limit = conversation.getContextLimit();
165
+ if (limit > 0) {
166
+ contextPercent = Math.min(100, Math.round((used / limit) * 100));
167
+ }
168
+ }
169
+ catch {
170
+ // Conversation not yet hydrated — show 0% rather than NaN.
171
+ }
172
+ let providersCount = 0;
173
+ try {
174
+ providersCount = ProvidersManager.list().length;
175
+ }
176
+ catch {
177
+ // Vault not yet available — show 0.
178
+ }
179
+ const currentBranch = git.isGitRepo() ? git.getCurrentBranch() : '';
180
+ return {
181
+ mode: modeForState,
182
+ routing: 'auto',
183
+ model: currentModel,
184
+ branch: currentBranch || 'detached',
185
+ contextPercent,
186
+ providersCount,
187
+ transport: 'freellmapi',
188
+ };
189
+ };
190
+ const drawLavaStatusBar = () => {
191
+ // renderStatusBar writes a single `\r` line (no newline) so the
192
+ // REPL prompt can sit on the same row as a redo. For the
193
+ // normal "above the prompt" layout we want a full line of its
194
+ // own, so we manually append a newline after the renderer
195
+ // returns.
196
+ renderStatusBar(buildLavaStatusState());
197
+ process.stdout.write('\n');
198
+ };
199
+ // Surface the result of a live model fetch as a one-line status.
200
+ // Invoked from /providers add and /providers test so the user
201
+ // immediately sees whether the live API was reachable or whether
202
+ // the picker will fall back to the cached / registry list.
203
+ const refreshModelsForProvider = async (name) => {
204
+ try {
205
+ const result = await ProvidersManager.fetchRemoteModels(name);
206
+ if (result.source === 'live') {
207
+ console.log(`${c.green}✓ Fetched ${result.models.length} models from live API.${c.reset}`);
208
+ }
209
+ else if (result.source === 'cache') {
210
+ const ageHours = Math.max(0, Math.round((Date.now() - Date.parse(result.fetchedAt)) / (60 * 60 * 1000)));
211
+ console.log(`${c.yellow}⚠ Live fetch unavailable — using cached list (~${ageHours}h old).${c.reset}`);
212
+ }
213
+ else {
214
+ console.log(`${c.yellow}⚠ Live fetch failed — using built-in registry list (marked [unverified] in /model).${c.reset}`);
215
+ }
216
+ }
217
+ catch (err) {
218
+ console.log(`${c.dim} (model list refresh skipped: ${err?.message ?? err})${c.reset}`);
219
+ }
220
+ };
221
+ // ──── Mouse Reporting Helpers ────
222
+ function enableMouseReporting() {
223
+ if (process.stdout.isTTY && !mouseReportingEnabled) {
224
+ process.stdout.write('\x1b[?1003h\x1b[?1006h');
225
+ mouseReportingEnabled = true;
226
+ }
227
+ }
228
+ function disableMouseReporting() {
229
+ if (process.stdout.isTTY && mouseReportingEnabled) {
230
+ process.stdout.write('\x1b[?1003l\x1b[?1006l');
231
+ mouseReportingEnabled = false;
232
+ }
233
+ }
234
+ function disableMouseReportingSync() {
235
+ try {
236
+ if (process.stdout.isTTY && mouseReportingEnabled) {
237
+ fs.writeSync(1, '\x1b[?1003l\x1b[?1006l');
238
+ mouseReportingEnabled = false;
239
+ }
240
+ }
241
+ catch (e) {
242
+ if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
243
+ console.warn(`[Debug Warning] Failed to disable mouse reporting: ${e.message || e}`);
244
+ }
245
+ }
246
+ }
247
+ // Register synchronous exit cleanups
248
+ const exitCleanup = () => {
249
+ try {
250
+ const hist = rl.history;
251
+ if (Array.isArray(hist)) {
252
+ fs.writeFileSync(historyFile, hist.join('\n'), 'utf-8');
253
+ }
254
+ }
255
+ catch (error) {
256
+ if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
257
+ console.warn(`[Debug Warning] Failed to write history file on exit: ${error.message || error}`);
258
+ }
259
+ }
260
+ disableMouseReportingSync();
261
+ mcpManager.shutdown();
262
+ mcpBridgeManager.shutdown();
263
+ // Restore the original `process.stdin.emit` so a Ctrl-C or
264
+ // uncaught-exit doesn't leave the monkey-patch installed.
265
+ // Previously this was only done on `/exit`, so SIGINT and
266
+ // SIGTERM corrupted subsequent stdin listeners.
267
+ try {
268
+ process.stdin.emit = originalEmit;
269
+ process.stdin.off('keypress', keypressHandler);
270
+ }
271
+ catch {
272
+ // ignore — process may already be tearing down
273
+ }
274
+ };
275
+ process.on('exit', exitCleanup);
276
+ // ──── Graceful exit handlers ────
277
+ const sigintHandler = () => {
278
+ exitCleanup();
279
+ console.log('\n\n👋 FixO CLI session ended safely. Core engine offline.');
280
+ process.exit(0);
281
+ };
282
+ process.on('SIGINT', sigintHandler);
283
+ const sigtermHandler = () => {
284
+ exitCleanup();
285
+ process.exit(0);
286
+ };
287
+ process.on('SIGTERM', sigtermHandler);
288
+ const uncaughtExceptionHandler = (err) => {
289
+ exitCleanup();
290
+ console.error('\n🔥 Uncaught Exception:', err);
291
+ process.exit(1);
292
+ };
293
+ process.on('uncaughtException', uncaughtExceptionHandler);
294
+ // ──── Suggestion Box Helpers ────
295
+ function clearSuggestions() {
296
+ if (activeSuggestionsCount > 0) {
297
+ disableMouseReporting();
298
+ const currentCursor = rl.cursor;
299
+ readline.moveCursor(process.stdout, 0, 1);
300
+ readline.cursorTo(process.stdout, 0);
301
+ process.stdout.write('\x1b[J');
302
+ readline.moveCursor(process.stdout, 0, -1);
303
+ readline.cursorTo(process.stdout, 2 + currentCursor);
304
+ activeSuggestionsCount = 0;
305
+ }
306
+ }
307
+ function drawSuggestions(matches) {
308
+ clearSuggestions();
309
+ if (matches.length === 0)
310
+ return;
311
+ enableMouseReporting();
312
+ const currentCursor = rl.cursor;
313
+ let output = '\n';
314
+ const width = 60;
315
+ const borderTop = `${c.snow}┌────────────────────────────────────────────────────────┐${c.reset}\n`;
316
+ const borderBottom = `${c.snow}└────────────────────────────────────────────────────────┘${c.reset}`;
317
+ output += borderTop;
318
+ let startIndex = 0;
319
+ if (highlightedIndex >= 8) {
320
+ startIndex = highlightedIndex - 7;
321
+ }
322
+ const visibleMatches = matches.slice(startIndex, startIndex + 8);
323
+ visibleMatches.forEach((item, index) => {
324
+ const actualIndex = startIndex + index;
325
+ const isHighlighted = actualIndex === highlightedIndex;
326
+ const prefix = isHighlighted ? '❯ ' : ' ';
327
+ const displayStr = item.display;
328
+ const descStr = item.desc || '';
329
+ const displayLimit = 25;
330
+ const descLimit = 28;
331
+ let dispText = displayStr;
332
+ if (dispText.length > displayLimit) {
333
+ dispText = dispText.slice(0, displayLimit - 3) + '...';
334
+ }
335
+ dispText = dispText.padEnd(displayLimit);
336
+ let descText = descStr;
337
+ if (descText.length > descLimit) {
338
+ descText = descText.slice(0, descLimit - 3) + '...';
339
+ }
340
+ descText = descText.padEnd(descLimit);
341
+ if (isHighlighted) {
342
+ output += `${c.snow}│${c.reset} \x1b[48;5;236m\x1b[38;5;208m${prefix}${dispText} ${c.dim}${descText}\x1b[0m ${c.snow}│${c.reset}\n`;
343
+ }
344
+ else {
345
+ output += `${c.snow}│${c.reset} ${prefix}${dispText} ${c.dim}${descText}${c.reset} ${c.snow}│${c.reset}\n`;
346
+ }
347
+ });
348
+ if (matches.length > 8) {
349
+ const remaining = matches.length - 8;
350
+ const moreStr = `... and ${remaining} more matches`.padEnd(54);
351
+ output += `${c.snow}│${c.reset} ${c.dim}${moreStr}${c.reset} ${c.snow}│${c.reset}\n`;
352
+ }
353
+ output += borderBottom;
354
+ activeSuggestionsCount = visibleMatches.length + (matches.length > 8 ? 1 : 0) + 2;
355
+ process.stdout.write(output);
356
+ readline.moveCursor(process.stdout, 0, -activeSuggestionsCount);
357
+ readline.cursorTo(process.stdout, 2 + currentCursor);
358
+ // Request cursor position asynchronously
359
+ process.stdout.write('\x1b[6n');
360
+ }
361
+ function getActiveToken(lineStr, cursorOffset) {
362
+ const beforeCursor = lineStr.slice(0, cursorOffset);
363
+ const lastSlash = beforeCursor.lastIndexOf('/');
364
+ const lastAt = beforeCursor.lastIndexOf('@');
365
+ const lastTriggerIdx = Math.max(lastSlash, lastAt);
366
+ if (lastTriggerIdx === -1) {
367
+ return { trigger: null, query: '', index: -1 };
368
+ }
369
+ if (lastTriggerIdx > 0 && !/\s/.test(beforeCursor[lastTriggerIdx - 1])) {
370
+ return { trigger: null, query: '', index: -1 };
371
+ }
372
+ const trigger = lastTriggerIdx === lastSlash ? '/' : '@';
373
+ const query = beforeCursor.slice(lastTriggerIdx + 1);
374
+ if (/\s/.test(query)) {
375
+ return { trigger: null, query: '', index: -1 };
376
+ }
377
+ return { trigger, query, index: lastTriggerIdx };
378
+ }
379
+ function getSuggestions(lineStr, cursorOffset) {
380
+ const active = getActiveToken(lineStr, cursorOffset);
381
+ if (!active.trigger) {
382
+ return { options: [], trigger: null, query: '', triggerIndex: -1 };
383
+ }
384
+ const q = active.query.toLowerCase();
385
+ if (active.trigger === '/') {
386
+ const matches = COMMANDS_WITH_DESC.filter(c => c.cmd.toLowerCase().startsWith(active.query.toLowerCase() ? '/' + active.query.toLowerCase() : '/'));
387
+ const options = matches.map(m => ({
388
+ display: m.cmd,
389
+ value: m.cmd + ' ',
390
+ desc: m.desc,
391
+ }));
392
+ return { options, trigger: '/', query: active.query, triggerIndex: active.index };
393
+ }
394
+ else {
395
+ const options = [];
396
+ const subagents = [
397
+ { name: 'code', desc: 'Code Agent: read and modify workspace files' },
398
+ { name: 'test', desc: 'Test Agent: write, run, or fix tests' },
399
+ { name: 'doc', desc: 'Documentation Agent: edit markdown and docstrings' },
400
+ { name: 'reviewer', desc: 'Reviewer Agent: audit diffs and code modifications' },
401
+ ];
402
+ for (const sa of subagents) {
403
+ const key = '@' + sa.name;
404
+ if (!active.query || sa.name.toLowerCase().startsWith(q)) {
405
+ options.push({
406
+ display: key,
407
+ value: key + ' ',
408
+ desc: sa.desc,
409
+ });
410
+ }
411
+ }
412
+ try {
413
+ const list = skillsManager.getSkills();
414
+ for (const s of list) {
415
+ const key = '@' + s.name;
416
+ if (!active.query || s.name.toLowerCase().startsWith(q)) {
417
+ options.push({
418
+ display: key,
419
+ value: key + ' ',
420
+ desc: s.description || 'Skill profile',
421
+ });
422
+ }
423
+ }
424
+ }
425
+ catch (error) {
426
+ if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
427
+ console.warn(`[Debug Warning] Failed to load skills list: ${error.message || error}`);
428
+ }
429
+ }
430
+ const matchingFiles = workspaceFiles.filter(f => f.toLowerCase().includes(q) || path.basename(f).toLowerCase().startsWith(q));
431
+ matchingFiles.sort((a, b) => {
432
+ const baseA = path.basename(a).toLowerCase();
433
+ const baseB = path.basename(b).toLowerCase();
434
+ const aStarts = baseA.startsWith(q);
435
+ const bStarts = baseB.startsWith(q);
436
+ if (aStarts && !bStarts)
437
+ return -1;
438
+ if (!aStarts && bStarts)
439
+ return 1;
440
+ return a.localeCompare(b);
441
+ });
442
+ for (const file of matchingFiles.slice(0, 12)) {
443
+ const key = '@' + file;
444
+ options.push({
445
+ display: '@' + path.basename(file),
446
+ value: key + ' ',
447
+ desc: file,
448
+ });
449
+ }
450
+ return { options, trigger: '@', query: active.query, triggerIndex: active.index };
451
+ }
452
+ }
453
+ // ──── Keypress registration ────
454
+ readline.emitKeypressEvents(process.stdin);
455
+ if (process.stdin.isTTY) {
456
+ process.stdin.setRawMode(true);
457
+ }
458
+ const keypressHandler = (_char, key) => {
459
+ if (!isPrompting)
460
+ return;
461
+ if (key && (key.name === 'up' || key.name === 'down' || key.name === 'escape' || key.name === 'tab' || key.name === 'enter' || key.name === 'return')) {
462
+ return;
463
+ }
464
+ process.nextTick(() => {
465
+ if (!isPrompting)
466
+ return;
467
+ const line = rl.line;
468
+ const cursor = rl.cursor;
469
+ const suggs = getSuggestions(line, cursor);
470
+ if (suggs.trigger) {
471
+ const oldMatchesCount = currentMatches.length;
472
+ currentMatches = suggs.options;
473
+ if (currentMatches.length !== oldMatchesCount) {
474
+ highlightedIndex = 0;
475
+ }
476
+ drawSuggestions(currentMatches);
477
+ }
478
+ else {
479
+ clearSuggestions();
480
+ currentMatches = [];
481
+ }
482
+ });
483
+ };
484
+ process.stdin.on('keypress', keypressHandler);
485
+ let mouseBuffer = '';
486
+ // Monkey-patch process.stdin.emit to intercept keypress and mouse events
487
+ const originalEmit = process.stdin.emit;
488
+ process.stdin.emit = function (event, ...args) {
489
+ if (event === 'data') {
490
+ const rawData = args[0];
491
+ if (rawData) {
492
+ let str = mouseBuffer + rawData.toString();
493
+ mouseBuffer = '';
494
+ // Intercept cursor position response
495
+ if (str.startsWith('\x1b[') && str.endsWith('R')) {
496
+ const match = str.match(/\x1b\[(\d+);(\d+)R/);
497
+ if (match) {
498
+ lastPromptRow = parseInt(match[1], 10);
499
+ return true;
500
+ }
501
+ }
502
+ // Remove fully-formed SGR mouse events
503
+ str = str.replace(/\x1b\[<[0-9;]+[Mm]/g, '');
504
+ // Buffer any trailing partial SGR mouse event
505
+ const partialIdx = str.lastIndexOf('\x1b[<');
506
+ if (partialIdx !== -1) {
507
+ const remaining = str.slice(partialIdx);
508
+ if (!/[Mm]/.test(remaining)) {
509
+ mouseBuffer = remaining;
510
+ str = str.slice(0, partialIdx);
511
+ }
512
+ }
513
+ // Process mouse events for suggestions list if present in raw data
514
+ const mouseMatches = rawData.toString().match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g);
515
+ if (mouseMatches) {
516
+ for (const rawMatch of mouseMatches) {
517
+ const m = rawMatch.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
518
+ if (m) {
519
+ const [_, buttonStr, colStr, rowStr, action] = m;
520
+ const button = parseInt(buttonStr, 10);
521
+ const clickRow = parseInt(rowStr, 10);
522
+ const isPressed = action === 'M';
523
+ if (activeSuggestionsCount > 0 && lastPromptRow > 0) {
524
+ // Mouse Scroll UP
525
+ if (button === 64) {
526
+ highlightedIndex = (highlightedIndex - 1 + currentMatches.length) % currentMatches.length;
527
+ drawSuggestions(currentMatches);
528
+ }
529
+ // Mouse Scroll DOWN
530
+ else if (button === 65) {
531
+ highlightedIndex = (highlightedIndex + 1) % currentMatches.length;
532
+ drawSuggestions(currentMatches);
533
+ }
534
+ else {
535
+ const boxStartRow = lastPromptRow + 1;
536
+ let startIndex = 0;
537
+ if (highlightedIndex >= 8) {
538
+ startIndex = highlightedIndex - 7;
539
+ }
540
+ const clickedItemIndex = clickRow - boxStartRow - 1;
541
+ const actualHoveredIndex = startIndex + clickedItemIndex;
542
+ if (actualHoveredIndex >= 0 && actualHoveredIndex < currentMatches.length && clickedItemIndex < Math.min(currentMatches.length, 8)) {
543
+ // Mouse hover/motion
544
+ if (button === 35 || button === 32) {
545
+ if (highlightedIndex !== actualHoveredIndex) {
546
+ highlightedIndex = actualHoveredIndex;
547
+ drawSuggestions(currentMatches);
548
+ }
549
+ }
550
+ // Left click press
551
+ else if (button === 0 && isPressed) {
552
+ highlightedIndex = actualHoveredIndex;
553
+ const selected = currentMatches[highlightedIndex];
554
+ if (selected) {
555
+ const line = rl.line;
556
+ const cursor = rl.cursor;
557
+ const active = getActiveToken(line, cursor);
558
+ if (active.index !== -1) {
559
+ const beforeTrigger = line.slice(0, active.index);
560
+ const afterCursor = line.slice(cursor);
561
+ const newLine = beforeTrigger + selected.value + afterCursor;
562
+ rl.write(null, { ctrl: true, name: 'u' });
563
+ rl.write(newLine);
564
+ const moveCount = newLine.length - (beforeTrigger.length + selected.value.length);
565
+ for (let i = 0; i < moveCount; i++) {
566
+ rl.write(null, { name: 'left' });
567
+ }
568
+ }
569
+ clearSuggestions();
570
+ }
571
+ return true;
572
+ }
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+ }
579
+ // If the remaining string is empty, forward an empty buffer rather than swallowing
580
+ args[0] = str.length > 0 ? Buffer.from(str) : Buffer.alloc(0);
581
+ }
582
+ }
583
+ if (event === 'keypress') {
584
+ const [char, key] = args;
585
+ // Tab on empty line → cycle mode (BEFORE suggestion handling, so it always works)
586
+ if (isPrompting && key && key.name === 'tab' && rl.line.trim() === '') {
587
+ const modes = ['BUILD', 'EXPLORE', 'SCOUT', 'PLAN'];
588
+ const nextIndex = (modes.indexOf(currentMode) + 1) % modes.length;
589
+ currentMode = modes[nextIndex];
590
+ // Clear readline state
591
+ rl.line = '';
592
+ rl.cursor = 0;
593
+ // Clear current prompt line:
594
+ process.stdout.write('\r\x1b[K');
595
+ // Re-draw the lava status bar with the new mode. The
596
+ // legacy dirLabel/branchLabel/modelLabel/modeLabel row
597
+ // is gone — the new bar carries all of that information.
598
+ drawLavaStatusBar();
599
+ process.stdout.write(`${C.LAVA}›${C.RESET} `);
600
+ return true; // swallow keypress
601
+ }
602
+ }
603
+ return originalEmit.apply(this, [event, ...args]);
604
+ };
605
+ // ──── REPL loop ────
606
+ const promptForInput = () => {
607
+ // Restore raw mode and resume streams to recover from any clack/spinner interactions
608
+ if (process.stdin.isTTY) {
609
+ process.stdin.setRawMode(true);
610
+ }
611
+ process.stdin.resume();
612
+ rl.resume();
613
+ // The new lava status bar is the ONLY status surface — it
614
+ // replaces the legacy dirLabel/branchLabel/modelLabel/modeLabel
615
+ // row entirely. Mode + model + branch + context usage are all
616
+ // visible in the bar; the prompt itself is the lava `›` glyph.
617
+ drawLavaStatusBar();
618
+ isPrompting = true;
619
+ rl.question(`${C.LAVA}›${C.RESET} `, async (input) => {
620
+ isPrompting = false;
621
+ disableMouseReporting();
622
+ clearSuggestions();
623
+ const trimmed = input.trim();
624
+ if (!trimmed) {
625
+ promptForInput();
626
+ return;
627
+ }
628
+ try {
629
+ await handleInput(trimmed);
630
+ }
631
+ catch (error) {
632
+ const msg = error instanceof Error ? error.message : String(error);
633
+ console.log(`\n${c.red}✗ Error: ${msg}${c.reset}`);
634
+ // Actionable error suggestions
635
+ if (msg.includes('ECONNREFUSED')) {
636
+ console.log(`${c.dim} → Proxy server is down. Restart with: npm run dev${c.reset}`);
637
+ }
638
+ else if (msg.includes('413')) {
639
+ console.log(`${c.dim} → Reduce context: /unselect to clear pinned files${c.reset}`);
640
+ }
641
+ else if (msg.includes('429')) {
642
+ console.log(`${c.dim} → Rate limited. Wait a moment or add more API keys.${c.reset}`);
643
+ }
644
+ }
645
+ promptForInput();
646
+ });
647
+ };
648
+ // ──── Input handler ────
649
+ async function handleInput(input) {
650
+ // ─── Slash commands ───
651
+ if (input.startsWith('/')) {
652
+ const parts = input.split(/\s+/).filter(Boolean);
653
+ const cmd = parts[0];
654
+ const args = parts.slice(1);
655
+ switch (cmd) {
656
+ case '/exit':
657
+ case '/quit':
658
+ disableMouseReporting();
659
+ console.log(`\n${c.dim}👋 Goodbye!${c.reset}`);
660
+ process.stdin.off('keypress', keypressHandler);
661
+ process.stdin.emit = originalEmit;
662
+ process.off('exit', exitCleanup);
663
+ process.off('SIGINT', sigintHandler);
664
+ process.off('SIGTERM', sigtermHandler);
665
+ process.off('uncaughtException', uncaughtExceptionHandler);
666
+ rl.close();
667
+ process.exit(0);
668
+ case '/help':
669
+ printHelp();
670
+ return;
671
+ case '/model': {
672
+ if (args[0] === 'list') {
673
+ // Print full model table grouped by provider
674
+ console.log(`\n${c.bold}${c.cyan}Available Models by Provider${c.reset}`);
675
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
676
+ for (const def of PROVIDER_REGISTRY) {
677
+ const hasKey = ProvidersManager.has(def.name);
678
+ const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.dim}[no key]${c.reset}`;
679
+ console.log(`\n ${c.snow}${c.bold}${def.displayName}${c.reset} ${keyStatus}`);
680
+ for (const model of def.models) {
681
+ console.log(` ${c.cyan}•${c.reset} ${model}`);
682
+ }
683
+ }
684
+ console.log(`\n${c.dim} Use /providers add <name> to connect a provider with your API key.${c.reset}`);
685
+ console.log(`${c.dim} Or set model directly: /model <model-id>${c.reset}\n`);
686
+ return;
687
+ }
688
+ if (args.length === 0) {
689
+ // Redesigned interactive model picker grouped by provider
690
+ rl.pause();
691
+ const pickedProvider = await p.select({
692
+ message: `Current model: ${c.cyan}${currentModel}${c.reset} — Select AI Provider:`,
693
+ options: [
694
+ { value: 'all', label: 'Show all models (flat list)', hint: 'classic view' },
695
+ ...PROVIDER_REGISTRY.map(def => ({
696
+ value: def.name,
697
+ label: def.displayName,
698
+ hint: ProvidersManager.has(def.name) ? ' [key ✓]' : ' [no key]'
699
+ })),
700
+ { value: '__manual__', label: 'Enter model ID manually…', hint: '' },
701
+ ],
702
+ initialValue: PROVIDER_REGISTRY.find(def => def.models.includes(currentModel))?.name || 'all',
703
+ });
704
+ rl.resume();
705
+ if (p.isCancel(pickedProvider)) {
706
+ console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
707
+ return;
708
+ }
709
+ if (pickedProvider === '__manual__') {
710
+ rl.pause();
711
+ const manual = await p.text({
712
+ message: 'Enter model ID:',
713
+ placeholder: 'e.g. gpt-4o, claude-opus-4-5, gemini-2.5-pro',
714
+ validate: v => !v.trim() ? 'Model ID is required' : undefined,
715
+ });
716
+ rl.resume();
717
+ if (!p.isCancel(manual) && manual) {
718
+ currentModel = manual.trim();
719
+ conversation.setContextLimit(currentModel);
720
+ console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
721
+ }
722
+ return;
723
+ }
724
+ if (pickedProvider === 'all') {
725
+ rl.pause();
726
+ const allOptions = PROVIDER_REGISTRY.flatMap(def => def.models.map(m => ({
727
+ value: m,
728
+ label: `${m}`,
729
+ hint: def.displayName + (ProvidersManager.has(def.name) ? ' [key ✓]' : ''),
730
+ })));
731
+ const picked = await p.select({
732
+ message: 'Select a model from the flat list:',
733
+ options: [
734
+ { value: currentModel, label: `Keep current: ${currentModel}`, hint: 'no change' },
735
+ ...allOptions,
736
+ ],
737
+ initialValue: currentModel,
738
+ });
739
+ rl.resume();
740
+ if (p.isCancel(picked)) {
741
+ console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
742
+ return;
743
+ }
744
+ currentModel = picked;
745
+ conversation.setContextLimit(currentModel);
746
+ console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
747
+ return;
748
+ }
749
+ const def = PROVIDER_REGISTRY.find(p => p.name === pickedProvider);
750
+ const hasKey = ProvidersManager.has(def.name);
751
+ const keyStatus = hasKey ? `${c.green}[key ✓]${c.reset}` : `${c.red}[no key]${c.reset}`;
752
+ // Prefer the cached live model list; fall back to the
753
+ // registry list (tagged `[unverified]`) when no fresh
754
+ // cache exists. Drops the synthetic "(free)" suffix
755
+ // since we no longer know that without provider
756
+ // metadata.
757
+ const cached = ProvidersManager.getCachedModels(def.name);
758
+ const modelList = cached?.models?.length ? cached.models : def.models;
759
+ const sourceSuffix = cached?.source === 'live'
760
+ ? ''
761
+ : ` ${c.dim}[unverified]${c.reset}`;
762
+ rl.pause();
763
+ const picked = await p.select({
764
+ message: `Select a model from ${c.bold}${def.displayName}${c.reset} ${keyStatus}${sourceSuffix}:`,
765
+ options: modelList.map(m => {
766
+ return {
767
+ value: m,
768
+ label: m,
769
+ hint: m === currentModel ? 'currently selected' : ''
770
+ };
771
+ }),
772
+ initialValue: modelList.includes(currentModel) ? currentModel : undefined,
773
+ });
774
+ rl.resume();
775
+ if (p.isCancel(picked)) {
776
+ console.log(`\n${c.dim}Model unchanged: ${c.cyan}${currentModel}${c.reset}`);
777
+ return;
778
+ }
779
+ currentModel = picked;
780
+ conversation.setContextLimit(currentModel);
781
+ console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
782
+ return;
783
+ }
784
+ currentModel = args.join(' ');
785
+ conversation.setContextLimit(currentModel);
786
+ console.log(`\n${c.green}✓ Model set to: ${c.bold}${currentModel}${c.reset}`);
787
+ return;
788
+ }
789
+ case '/select': {
790
+ if (args.length === 0) {
791
+ if (selectedFiles.length === 0) {
792
+ console.log(`\n${c.dim}No files selected. Usage: /select <file-path>${c.reset}`);
793
+ }
794
+ else {
795
+ console.log(`\n${c.dim}Selected files:${c.reset}`);
796
+ for (const f of selectedFiles) {
797
+ console.log(` ${c.cyan}${path.basename(f)}${c.reset} ${c.dim}(${f})${c.reset}`);
798
+ }
799
+ }
800
+ return;
801
+ }
802
+ let rawPath = args.join(' ');
803
+ if ((rawPath.startsWith("'") && rawPath.endsWith("'")) ||
804
+ (rawPath.startsWith('"') && rawPath.endsWith('"'))) {
805
+ rawPath = rawPath.slice(1, -1);
806
+ }
807
+ let filePath;
808
+ try {
809
+ filePath = guard.ensureFile(rawPath);
810
+ }
811
+ catch (error) {
812
+ console.log(`\n${c.red}✗ ${error instanceof Error ? error.message : String(error)}${c.reset}`);
813
+ return;
814
+ }
815
+ if (!fs.existsSync(filePath)) {
816
+ console.log(`\n${c.red}✗ File not found: ${rawPath}${c.reset}`);
817
+ return;
818
+ }
819
+ if (!selectedFiles.includes(filePath)) {
820
+ selectedFiles.push(filePath);
821
+ }
822
+ console.log(`\n${c.green}✓ Pinned: ${c.bold}${path.basename(filePath)}${c.reset}`);
823
+ return;
824
+ }
825
+ case '/unselect':
826
+ selectedFiles = [];
827
+ console.log(`\n${c.green}✓ All pinned files cleared${c.reset}`);
828
+ return;
829
+ case '/diff':
830
+ console.log(`\n${git.getDiff()}`);
831
+ return;
832
+ case '/undo': {
833
+ if (args[0]) {
834
+ console.log(`\n${undoRun(cwd, args[0])}`);
835
+ return;
836
+ }
837
+ rl.pause();
838
+ const confirmed = await p.confirm({
839
+ message: 'Are you sure you want to completely discard the last automated agent commit and restore all files?',
840
+ initialValue: false,
841
+ });
842
+ rl.resume();
843
+ if (p.isCancel(confirmed) || !confirmed) {
844
+ console.log(`\n${c.yellow} ⚠ Undo cancelled.${c.reset}`);
845
+ return;
846
+ }
847
+ git.undoLastCommit();
848
+ return;
849
+ }
850
+ case '/clear':
851
+ conversation.clear();
852
+ pendingAttachments = [];
853
+ console.log(`\n${c.green}✓ Conversation cleared${c.reset}`);
854
+ return;
855
+ case '/image': {
856
+ // `/image <path>` — queue a local image for the next turn.
857
+ // `/image clear` — drop the queue.
858
+ // `/image list` — show what's queued.
859
+ const sub = args[0];
860
+ if (sub === 'clear') {
861
+ const n = pendingAttachments.length;
862
+ pendingAttachments = [];
863
+ console.log(`\n${c.green}✓ Cleared ${n} pending image(s)${c.reset}`);
864
+ return;
865
+ }
866
+ if (sub === 'list') {
867
+ if (pendingAttachments.length === 0) {
868
+ console.log(`\n${c.dim}No pending images.${c.reset}`);
869
+ return;
870
+ }
871
+ console.log(`\n${c.bold}Pending images (sent on next prompt):${c.reset}`);
872
+ for (const [i, block] of pendingAttachments.entries()) {
873
+ if (block.type === 'image' && block.source.kind === 'base64') {
874
+ const approxBytes = Math.floor((block.source.data.length * 3) / 4);
875
+ console.log(` ${i + 1}. ${block.source.mediaType} (~${approxBytes} bytes)`);
876
+ }
877
+ }
878
+ return;
879
+ }
880
+ if (!sub) {
881
+ console.log(`\n${c.yellow}Usage: /image <path> | /image list | /image clear${c.reset}`);
882
+ return;
883
+ }
884
+ const result = loadImageAsBlock(sub, cwd);
885
+ if (!result.ok) {
886
+ console.log(`\n${c.red}✗ /image: ${result.error}${c.reset}`);
887
+ return;
888
+ }
889
+ pendingAttachments.push(result.block);
890
+ console.log(`\n${c.green}✓ Attached${c.reset} ${c.dim}${result.mediaType}, ${result.bytes} bytes — will be sent with your next prompt${c.reset}`);
891
+ return;
892
+ }
893
+ case '/mcp': {
894
+ const sub = args[0]?.toLowerCase();
895
+ if (!sub || sub === 'list') {
896
+ const { listAllMcpSources, mergedMcpServers } = await import('../agent/mcp-registry.js');
897
+ const view = listAllMcpSources(cwd);
898
+ console.log(`\n${c.bold}${c.cyan}MCP Servers${c.reset} ${c.dim}(project-wins precedence: local > project > global)${c.reset}`);
899
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
900
+ const renderSource = (label, s) => {
901
+ const names = Object.keys(s.servers);
902
+ if (names.length === 0) {
903
+ console.log(` ${c.dim}${label}: (empty)${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
904
+ return;
905
+ }
906
+ console.log(` ${c.bold}${label}${c.reset}${s.configPath ? ` ${c.dim}${s.configPath}${c.reset}` : ''}`);
907
+ for (const n of names) {
908
+ console.log(` ${c.cyan}•${c.reset} ${n}`);
909
+ }
910
+ };
911
+ renderSource('global', view.global);
912
+ renderSource('project', view.project);
913
+ renderSource('local', view.local);
914
+ const merged = mergedMcpServers(cwd);
915
+ const mergedCount = Object.keys(merged).length;
916
+ console.log(`\n${c.dim}merged total: ${mergedCount} server(s)${c.reset}`);
917
+ return;
918
+ }
919
+ if (sub === 'add') {
920
+ const name = args[1];
921
+ if (!name || args.length < 3) {
922
+ console.log(`\n${c.yellow}Usage: /mcp add <name> <command> [args...]${c.reset}`);
923
+ return;
924
+ }
925
+ const cmd = args[2];
926
+ const cmdArgs = args.slice(3);
927
+ const { addLocalMcpServer } = await import('../agent/mcp-registry.js');
928
+ addLocalMcpServer(cwd, name, { command: cmd, args: cmdArgs, type: 'stdio' });
929
+ console.log(`\n${c.green}✓ Added local MCP server:${c.reset} ${name} ${c.dim}(command=${cmd} args=${JSON.stringify(cmdArgs)})${c.reset}`);
930
+ return;
931
+ }
932
+ if (sub === 'remove' || sub === 'rm') {
933
+ const name = args[1];
934
+ if (!name) {
935
+ console.log(`\n${c.yellow}Usage: /mcp remove <name>${c.reset}`);
936
+ return;
937
+ }
938
+ const { removeLocalMcpServer } = await import('../agent/mcp-registry.js');
939
+ const removed = removeLocalMcpServer(cwd, name);
940
+ if (removed) {
941
+ console.log(`\n${c.green}✓ Removed local MCP server:${c.reset} ${name}`);
942
+ }
943
+ else {
944
+ console.log(`\n${c.yellow}No local MCP server named ${name}${c.reset}`);
945
+ }
946
+ return;
947
+ }
948
+ if (sub === 'test') {
949
+ const name = args[1];
950
+ if (!name) {
951
+ console.log(`\n${c.yellow}Usage: /mcp test <name>${c.reset}`);
952
+ return;
953
+ }
954
+ const { mergedMcpServers } = await import('../agent/mcp-registry.js');
955
+ const all = mergedMcpServers(cwd);
956
+ const cfg = all[name];
957
+ if (!cfg) {
958
+ console.log(`\n${c.yellow}No MCP server named ${name} (in any source)${c.reset}`);
959
+ return;
960
+ }
961
+ const hasCommand = typeof cfg.command === 'string';
962
+ const hasUrl = typeof cfg.url === 'string';
963
+ if (hasCommand || hasUrl) {
964
+ console.log(`\n${c.green}✓ ${name}${c.reset} — config looks valid (${hasCommand ? 'stdio' : 'sse'})`);
965
+ }
966
+ else {
967
+ console.log(`\n${c.red}✗ ${name}${c.reset} — missing 'command' or 'url'`);
968
+ }
969
+ return;
970
+ }
971
+ console.log(`\n${c.yellow}Unknown /mcp subcommand: ${sub}. Use: list | add | remove | test${c.reset}`);
972
+ return;
973
+ }
974
+ case '/todo': {
975
+ const sub = args[0]?.toLowerCase();
976
+ if (!sub || sub === 'list' || sub === 'ls') {
977
+ const list = loadTodoList(cwd);
978
+ const summary = summariseTodoList(list);
979
+ console.log('');
980
+ console.log(renderTodoList(list));
981
+ if (summary.length > 0) {
982
+ console.log(`\n${c.dim}(${summary})${c.reset}`);
983
+ }
984
+ return;
985
+ }
986
+ if (sub === 'add') {
987
+ const text = args.slice(1).join(' ').trim();
988
+ if (text.length === 0) {
989
+ console.log(`\n${c.yellow}Usage: /todo add <text>${c.reset}`);
990
+ return;
991
+ }
992
+ const list = addItem(loadTodoList(cwd), { content: text });
993
+ const result = saveTodoList(cwd, list);
994
+ if (!result.ok) {
995
+ console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
996
+ return;
997
+ }
998
+ console.log(`\n${c.green}✓ Added todo:${c.reset} ${text}`);
999
+ return;
1000
+ }
1001
+ if (sub === 'done' || sub === 'complete' || sub === 'cancel') {
1002
+ const id = args[1];
1003
+ if (!id) {
1004
+ console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
1005
+ return;
1006
+ }
1007
+ const status = sub === 'cancel' ? 'cancelled' : 'done';
1008
+ let list = loadTodoList(cwd);
1009
+ const exists = list.items.some((it) => it.id === id);
1010
+ if (!exists) {
1011
+ console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1012
+ return;
1013
+ }
1014
+ list = setItemStatus(list, { id, status });
1015
+ const result = saveTodoList(cwd, list);
1016
+ if (!result.ok) {
1017
+ console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1018
+ return;
1019
+ }
1020
+ console.log(`\n${c.green}✓ Marked ${status}${c.reset}`);
1021
+ return;
1022
+ }
1023
+ if (sub === 'start' || sub === 'progress') {
1024
+ const id = args[1];
1025
+ if (!id) {
1026
+ console.log(`\n${c.yellow}Usage: /todo ${sub} <id>${c.reset}`);
1027
+ return;
1028
+ }
1029
+ let list = loadTodoList(cwd);
1030
+ const exists = list.items.some((it) => it.id === id);
1031
+ if (!exists) {
1032
+ console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1033
+ return;
1034
+ }
1035
+ list = setItemStatus(list, { id, status: 'in_progress' });
1036
+ const result = saveTodoList(cwd, list);
1037
+ if (!result.ok) {
1038
+ console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1039
+ return;
1040
+ }
1041
+ console.log(`\n${c.green}✓ Marked in_progress${c.reset}`);
1042
+ return;
1043
+ }
1044
+ if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
1045
+ const id = args[1];
1046
+ if (!id) {
1047
+ console.log(`\n${c.yellow}Usage: /todo remove <id>${c.reset}`);
1048
+ return;
1049
+ }
1050
+ let list = loadTodoList(cwd);
1051
+ const exists = list.items.some((it) => it.id === id);
1052
+ if (!exists) {
1053
+ console.log(`\n${c.red}✗ No todo with id "${id}"${c.reset}`);
1054
+ return;
1055
+ }
1056
+ list = removeItem(list, { id });
1057
+ const result = saveTodoList(cwd, list);
1058
+ if (!result.ok) {
1059
+ console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1060
+ return;
1061
+ }
1062
+ console.log(`\n${c.green}✓ Removed todo${c.reset}`);
1063
+ return;
1064
+ }
1065
+ if (sub === 'clear') {
1066
+ const list = loadTodoList(cwd);
1067
+ const kept = list.items.filter((it) => it.status !== 'done' && it.status !== 'cancelled');
1068
+ const result = saveTodoList(cwd, { ...list, items: kept, updatedAt: Date.now() });
1069
+ if (!result.ok) {
1070
+ console.log(`\n${c.red}✗ Failed to save todo: ${result.error}${c.reset}`);
1071
+ return;
1072
+ }
1073
+ const cleared = list.items.length - kept.length;
1074
+ console.log(`\n${c.green}✓ Cleared ${cleared} completed todo(s)${c.reset}`);
1075
+ return;
1076
+ }
1077
+ if (sub === 'help' || sub === '-h' || sub === '--help') {
1078
+ console.log(`\n${c.bold}Usage: /todo <subcommand>${c.reset}`);
1079
+ console.log(` list List all todo items`);
1080
+ console.log(` add <text> Add a new todo`);
1081
+ console.log(` start <id> Mark a todo as in-progress`);
1082
+ console.log(` done <id> Mark a todo as done`);
1083
+ console.log(` cancel <id> Cancel a todo`);
1084
+ console.log(` remove <id> Remove a todo entirely`);
1085
+ console.log(` clear Remove all done/cancelled todos`);
1086
+ return;
1087
+ }
1088
+ console.log(`\n${c.yellow}Unknown /todo subcommand "${sub}". Try /todo help.${c.reset}`);
1089
+ return;
1090
+ }
1091
+ case '/log':
1092
+ console.log(`\n${git.getRecentCommits(10)}`);
1093
+ return;
1094
+ case '/stats':
1095
+ printStats(stats);
1096
+ {
1097
+ const ctxTokens = conversation.getTotalTokens();
1098
+ const ctxLimit = conversation.getContextLimit();
1099
+ const ctxPct = Math.round((ctxTokens / ctxLimit) * 100);
1100
+ const hasSummary = conversation.getSummary() ? ' (compacted)' : '';
1101
+ console.log(`${c.cyan}${c.bold}📊 Context Window${c.reset}`);
1102
+ console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
1103
+ console.log(` History messages: ${c.bold}${conversation.getMessageCount()}${c.reset}${hasSummary}`);
1104
+ console.log(` Context usage: ${c.bold}${(ctxTokens / 1000).toFixed(0)}k / ${(ctxLimit / 1000).toFixed(0)}k${c.reset} (${ctxPct}%)`);
1105
+ console.log(` Turns: ${c.bold}${conversation.getTurnCount()}${c.reset}`);
1106
+ console.log('');
1107
+ }
1108
+ return;
1109
+ case '/runs': {
1110
+ const runs = listRuns(cwd, 12);
1111
+ console.log(runs.length
1112
+ ? `\n${runs.map(run => `${run.id} ${run.status} ${run.task.slice(0, 80)}`).join('\n')}`
1113
+ : '\n(no FixO runs recorded)');
1114
+ return;
1115
+ }
1116
+ case '/show-run':
1117
+ console.log(`\n${showRun(cwd, args[0] ?? '')}`);
1118
+ return;
1119
+ case '/memory':
1120
+ console.log(`\n${readMemory(cwd)}`);
1121
+ return;
1122
+ case '/remember': {
1123
+ const text = args.join(' ').trim();
1124
+ if (!text) {
1125
+ console.log(`\n${c.yellow}Usage: /remember <project fact>${c.reset}`);
1126
+ return;
1127
+ }
1128
+ rl.pause();
1129
+ const confirmed = await p.confirm({ message: `Add to project memory: ${text}?`, initialValue: false });
1130
+ rl.resume();
1131
+ if (!p.isCancel(confirmed) && confirmed) {
1132
+ appendMemory(cwd, text);
1133
+ console.log(`\n${c.green}✓ Memory updated${c.reset}`);
1134
+ }
1135
+ return;
1136
+ }
1137
+ case '/forget':
1138
+ rl.pause();
1139
+ {
1140
+ const confirmed = await p.confirm({ message: 'Clear FixO project memory?', initialValue: false });
1141
+ rl.resume();
1142
+ if (!p.isCancel(confirmed) && confirmed) {
1143
+ forgetMemory(cwd);
1144
+ console.log(`\n${c.green}✓ Memory cleared${c.reset}`);
1145
+ }
1146
+ }
1147
+ return;
1148
+ case '/doctor':
1149
+ console.log(`\n${doctor(cwd)}`);
1150
+ return;
1151
+ case '/index': {
1152
+ const index = await buildIndex(cwd);
1153
+ workspaceFiles = index.files.map(f => f.path);
1154
+ console.log(`\n${c.green}✓ Indexed ${index.files.length} files${c.reset}`);
1155
+ return;
1156
+ }
1157
+ case '/find':
1158
+ console.log(`\n${await findInIndex(cwd, args.join(' '))}`);
1159
+ return;
1160
+ case '/explain':
1161
+ console.log(`\n${await explainIndexedTarget(cwd, args.join(' '))}`);
1162
+ return;
1163
+ case '/review':
1164
+ console.log(`\n${reviewWorkspace(cwd)}`);
1165
+ return;
1166
+ case '/test':
1167
+ console.log(`\n${runProjectTests(cwd)}`);
1168
+ return;
1169
+ case '/fix-tests': {
1170
+ let testResult = runProjectTests(cwd);
1171
+ if (testResult.includes('Status: 0')) {
1172
+ console.log(`\n${c.green}✓ All tests are passing!${c.reset}`);
1173
+ return;
1174
+ }
1175
+ let attempt = 1;
1176
+ const maxAttempts = 3;
1177
+ const modifiedFiles = [];
1178
+ while (attempt <= maxAttempts) {
1179
+ console.log(`\n${c.cyan}🔨 [Auto-Fix] Test failure detected (Attempt ${attempt}/${maxAttempts}). Invoking SingleAgent to repair...${c.reset}`);
1180
+ console.log(`${c.dim}${testResult}${c.reset}\n`);
1181
+ 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.`;
1182
+ const context = {
1183
+ task: repairTask,
1184
+ model: currentModel,
1185
+ cwd,
1186
+ verbose,
1187
+ selectedFiles: [...selectedFiles],
1188
+ systemPromptOverride: projectConfig?.systemPrompt,
1189
+ checkCommand: projectConfig?.checkCommand,
1190
+ policy: projectConfig?.policy ?? config.preferences.policy,
1191
+ mode: 'BUILD',
1192
+ yes: true,
1193
+ };
1194
+ try {
1195
+ const result = await agent.runStreaming(context, conversation, rl);
1196
+ for (const file of result.modifiedFiles) {
1197
+ if (!modifiedFiles.includes(file)) {
1198
+ modifiedFiles.push(file);
1199
+ }
1200
+ }
1201
+ }
1202
+ catch (err) {
1203
+ console.log(`\n${c.red}✗ Repair agent failed on attempt ${attempt}: ${err.message || err}${c.reset}`);
1204
+ }
1205
+ testResult = runProjectTests(cwd);
1206
+ if (testResult.includes('Status: 0')) {
1207
+ console.log(`\n${c.green}✓ All tests passed after repair attempt ${attempt}!${c.reset}`);
1208
+ break;
1209
+ }
1210
+ else {
1211
+ attempt++;
1212
+ }
1213
+ }
1214
+ if (!testResult.includes('Status: 0')) {
1215
+ console.log(`\n${c.red}✗ Auto-fix failed after ${maxAttempts} attempts. Remaining failures:${c.reset}`);
1216
+ console.log(`${c.dim}${testResult}${c.reset}`);
1217
+ }
1218
+ else {
1219
+ // Auto-commit if enabled and changes were made
1220
+ if (config.preferences.autoCommit &&
1221
+ (projectConfig?.autoCommit !== false) &&
1222
+ modifiedFiles.length > 0) {
1223
+ console.log(`\n${c.green}✓ Auto-committing repaired test files...${c.reset}`);
1224
+ git.autoCommit('fix-tests: repair test failures', modifiedFiles);
1225
+ }
1226
+ }
1227
+ return;
1228
+ }
1229
+ case '/fix-ci':
1230
+ 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}`);
1231
+ return;
1232
+ case '/plan':
1233
+ {
1234
+ const task = args.join(' ').trim();
1235
+ if (!task) {
1236
+ console.log(`\n${c.yellow}Usage: /plan <task>${c.reset}`);
1237
+ return;
1238
+ }
1239
+ const plan = savePlan(cwd, task);
1240
+ console.log(`\n${renderPlan(plan)}`);
1241
+ }
1242
+ return;
1243
+ case '/run-plan': {
1244
+ const dagFile = path.join(cwd, '.fixo', 'last-dag.json');
1245
+ if (fs.existsSync(dagFile)) {
1246
+ try {
1247
+ const { task, dag } = JSON.parse(fs.readFileSync(dagFile, 'utf-8'));
1248
+ console.log(`\n${c.cyan}[Saved Plan] Executing saved subtasks DAG for task: ${c.bold}${task}${c.reset}`);
1249
+ const { AgentPool } = await import('../agent/agent-pool.js');
1250
+ const pool = new AgentPool(3, projectConfig?.maxAttempts ?? 12);
1251
+ const context = {
1252
+ task,
1253
+ model: currentModel,
1254
+ cwd,
1255
+ verbose,
1256
+ selectedFiles: [...selectedFiles],
1257
+ systemPromptOverride: projectConfig?.systemPrompt,
1258
+ checkCommand: projectConfig?.checkCommand,
1259
+ policy: projectConfig?.policy ?? config.preferences.policy,
1260
+ mode: currentMode,
1261
+ };
1262
+ const success = await pool.execute(context, dag);
1263
+ if (success) {
1264
+ console.log(`\n${c.green}✓ Successfully completed complex task via parallel agents.${c.reset}`);
1265
+ }
1266
+ else {
1267
+ console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
1268
+ if (git.isGitRepo()) {
1269
+ console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
1270
+ git.discardUncommittedChanges();
1271
+ }
1272
+ }
1273
+ return;
1274
+ }
1275
+ catch (err) {
1276
+ console.log(`\n${c.red}✗ Failed to run saved DAG: ${err.message}${c.reset}`);
1277
+ }
1278
+ }
1279
+ const plan = loadPlan(cwd);
1280
+ if (!plan) {
1281
+ 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}`);
1282
+ return;
1283
+ }
1284
+ console.log(`\n${c.dim}Executing saved plan task: ${plan.task}${c.reset}`);
1285
+ await handleInput(plan.task);
1286
+ return;
1287
+ }
1288
+ case '/mode': {
1289
+ rl.pause();
1290
+ const selected = await p.select({
1291
+ message: 'Select execution mode:',
1292
+ options: [
1293
+ { value: 'PLAN', label: 'PLAN Mode (Read-only, dry-run simulation)' },
1294
+ { value: 'BUILD', label: 'BUILD Mode (Writing & modifying allowed)' },
1295
+ { value: 'EXPLORE', label: 'EXPLORE Mode (Code exploration & LSP, no modifying)' },
1296
+ { value: 'SCOUT', label: 'SCOUT Mode (Web search & fetch only)' },
1297
+ ],
1298
+ initialValue: currentMode,
1299
+ });
1300
+ rl.resume();
1301
+ if (!p.isCancel(selected) && selected) {
1302
+ currentMode = selected;
1303
+ console.log(`\n${c.green}✓ Execution mode set to: ${c.bold}${currentMode}${c.reset}`);
1304
+ }
1305
+ else {
1306
+ console.log(`\n${c.dim}Execution mode remains: ${c.cyan}${currentMode}${c.reset}`);
1307
+ }
1308
+ return;
1309
+ }
1310
+ case '/session': {
1311
+ const sub = args[0];
1312
+ const { SessionManager } = await import('../agent/conversation.js');
1313
+ if (sub === 'list') {
1314
+ const list = SessionManager.listSessions();
1315
+ if (list.length === 0) {
1316
+ console.log(`\n${c.dim}No saved sessions found.${c.reset}`);
1317
+ }
1318
+ else {
1319
+ console.log(`\n${c.cyan}${c.bold}Saved Sessions:${c.reset}`);
1320
+ for (const s of list) {
1321
+ const date = new Date(s.timestamp).toLocaleString();
1322
+ console.log(` ${c.cyan}${s.sessionId}${c.reset} - ${c.bold}${s.model}${c.reset} (${s.messageCount} msgs)`);
1323
+ console.log(` ${c.dim}Created: ${date} | Tokens: ${s.totalTokens.toLocaleString()}${c.reset}`);
1324
+ if (s.summary) {
1325
+ console.log(` ${c.dim}Summary: ${s.summary.slice(0, 80)}...${c.reset}`);
1326
+ }
1327
+ }
1328
+ }
1329
+ }
1330
+ else if (sub === 'load') {
1331
+ const uuid = args[1];
1332
+ if (!uuid) {
1333
+ console.log(`\n${c.yellow}Usage: /session load <uuid>${c.reset}`);
1334
+ return;
1335
+ }
1336
+ try {
1337
+ const data = SessionManager.loadSession(uuid);
1338
+ conversation.clear();
1339
+ conversation.importHistory(data.history);
1340
+ conversation.setSummary(data.summary || '');
1341
+ currentModel = data.model;
1342
+ conversation.setContextLimit(currentModel);
1343
+ sessionModifiedFiles = data.modifiedFiles || [];
1344
+ currentSessionId = data.sessionId;
1345
+ stats.totalPromptTokens = data.tokenUsage?.prompt_tokens || 0;
1346
+ stats.totalCompletionTokens = data.tokenUsage?.completion_tokens || 0;
1347
+ console.log(`\n${c.green}✓ Session restored successfully: ${c.bold}${uuid}${c.reset}`);
1348
+ console.log(`${c.dim} Model set to: ${c.cyan}${currentModel}${c.reset}`);
1349
+ }
1350
+ catch (err) {
1351
+ console.log(`\n${c.red}✗ Failed to load session: ${err.message}${c.reset}`);
1352
+ }
1353
+ }
1354
+ else if (sub === 'new') {
1355
+ conversation.clear();
1356
+ sessionModifiedFiles = [];
1357
+ stats.totalPromptTokens = 0;
1358
+ stats.totalCompletionTokens = 0;
1359
+ stats.totalToolCalls = 0;
1360
+ stats.totalTasks = 0;
1361
+ stats.totalDurationMs = 0;
1362
+ const { randomUUID } = await import('node:crypto');
1363
+ currentSessionId = randomUUID();
1364
+ SessionManager.saveSession(conversation, currentModel, sessionModifiedFiles, {
1365
+ prompt_tokens: stats.totalPromptTokens,
1366
+ completion_tokens: stats.totalCompletionTokens,
1367
+ total_tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
1368
+ }, currentSessionId);
1369
+ console.log(`\n${c.green}✓ Active conversation memory purged. New session initialized: ${c.bold}${currentSessionId}${c.reset}`);
1370
+ }
1371
+ else {
1372
+ console.log(`\n${c.yellow}Usage: /session [list | load <uuid> | new]${c.reset}`);
1373
+ }
1374
+ return;
1375
+ }
1376
+ case '/providers': {
1377
+ const sub = args[0];
1378
+ // ── Interactive flow (bare `/providers`): mirrors the
1379
+ // /model picker shape. The user picks a provider, then
1380
+ // an action, then enters a masked API key via p.password
1381
+ // when the action is add/update. The legacy text routes
1382
+ // below remain unchanged for muscle-memory + scripting.
1383
+ if (!sub) {
1384
+ rl.pause();
1385
+ const pickedProvider = await p.select({
1386
+ message: 'Select an AI provider:',
1387
+ options: PROVIDER_REGISTRY.map(def => ({
1388
+ value: def.name,
1389
+ label: def.displayName,
1390
+ hint: ProvidersManager.has(def.name) ? '[key ✓]' : '[no key]',
1391
+ })),
1392
+ });
1393
+ rl.resume();
1394
+ if (p.isCancel(pickedProvider)) {
1395
+ console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1396
+ return;
1397
+ }
1398
+ const def = ProvidersManager.getDefinition(pickedProvider);
1399
+ if (!def) {
1400
+ console.log(`\n${c.red}✗ Unknown provider: ${pickedProvider}${c.reset}`);
1401
+ return;
1402
+ }
1403
+ const hasKey = ProvidersManager.has(def.name);
1404
+ rl.pause();
1405
+ const action = await p.select({
1406
+ message: `${def.displayName} — choose an action:`,
1407
+ options: [
1408
+ { value: 'add', label: hasKey ? 'Update API key' : 'Add API key' },
1409
+ { value: 'test', label: 'Test connection', hint: hasKey ? '' : 'requires a key' },
1410
+ { value: 'remove', label: 'Remove API key', hint: hasKey ? '' : 'no key configured' },
1411
+ { value: 'cancel', label: 'Cancel' },
1412
+ ],
1413
+ });
1414
+ rl.resume();
1415
+ if (p.isCancel(action) || action === 'cancel') {
1416
+ console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1417
+ return;
1418
+ }
1419
+ if (action === 'add') {
1420
+ console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
1421
+ rl.pause();
1422
+ const key = await p.password({
1423
+ message: `Enter your ${def.displayName} API key:`,
1424
+ validate: v => !v?.trim() ? 'API key is required' : undefined,
1425
+ });
1426
+ rl.resume();
1427
+ if (p.isCancel(key)) {
1428
+ console.log(`\n${c.dim}/providers cancelled.${c.reset}`);
1429
+ return;
1430
+ }
1431
+ ProvidersManager.add(def.name, key);
1432
+ console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
1433
+ await refreshModelsForProvider(def.name);
1434
+ return;
1435
+ }
1436
+ if (action === 'remove') {
1437
+ if (!hasKey) {
1438
+ console.log(`\n${c.yellow}No key configured for ${def.displayName}.${c.reset}`);
1439
+ return;
1440
+ }
1441
+ rl.pause();
1442
+ const confirmed = await p.confirm({
1443
+ message: `Remove API key for ${def.displayName}?`,
1444
+ initialValue: false,
1445
+ });
1446
+ rl.resume();
1447
+ if (!p.isCancel(confirmed) && confirmed) {
1448
+ const removed = ProvidersManager.remove(def.name);
1449
+ console.log(removed
1450
+ ? `\n${c.green}✓ Removed API key for ${def.displayName}.${c.reset}`
1451
+ : `\n${c.yellow}No key found for provider: ${def.name}${c.reset}`);
1452
+ }
1453
+ return;
1454
+ }
1455
+ if (action === 'test') {
1456
+ if (!hasKey) {
1457
+ console.log(`\n${c.yellow}No key configured for ${def.displayName}. Add one first.${c.reset}`);
1458
+ return;
1459
+ }
1460
+ console.log(`\n${c.dim}Testing connection to ${def.displayName} via live /models fetch…${c.reset}`);
1461
+ await refreshModelsForProvider(def.name);
1462
+ return;
1463
+ }
1464
+ return;
1465
+ }
1466
+ if (sub === 'list') {
1467
+ const list = ProvidersManager.list();
1468
+ if (list.length === 0) {
1469
+ console.log(`\n${c.yellow}No providers configured.${c.reset}`);
1470
+ console.log(`${c.dim} Use /providers add <name> to connect a provider (e.g. /providers add groq)${c.reset}`);
1471
+ console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1472
+ }
1473
+ else {
1474
+ console.log(`\n${c.bold}${c.cyan}Connected Providers${c.reset}`);
1475
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1476
+ for (const entry of list) {
1477
+ const addedDate = new Date(entry.addedAt).toLocaleDateString();
1478
+ 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}`);
1479
+ }
1480
+ console.log(`\n${c.dim} Use /providers remove <name> to remove a key.${c.reset}`);
1481
+ console.log(`${c.dim} Use /providers test <name> to verify a connection.${c.reset}`);
1482
+ }
1483
+ return;
1484
+ }
1485
+ if (sub === 'add') {
1486
+ const name = args[1]?.toLowerCase();
1487
+ if (!name) {
1488
+ console.log(`\n${c.yellow}Usage: /providers add <provider-name>${c.reset}`);
1489
+ console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1490
+ return;
1491
+ }
1492
+ const def = ProvidersManager.getDefinition(name);
1493
+ if (!def) {
1494
+ console.log(`\n${c.red}✗ Unknown provider: ${name}${c.reset}`);
1495
+ console.log(`${c.dim} Available: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1496
+ return;
1497
+ }
1498
+ console.log(`\n${c.cyan}${c.bold}Connecting to ${def.displayName}${c.reset}`);
1499
+ console.log(`${c.dim} Get your API key at: ${def.docsUrl}${c.reset}`);
1500
+ rl.pause();
1501
+ const apiKeyInput = await p.text({
1502
+ message: `Enter your ${def.displayName} API key:`,
1503
+ placeholder: 'sk-... or gsk_...',
1504
+ validate: v => !v.trim() ? 'API key is required' : undefined,
1505
+ });
1506
+ rl.resume();
1507
+ if (p.isCancel(apiKeyInput)) {
1508
+ console.log(`\n${c.dim}Provider add cancelled.${c.reset}`);
1509
+ return;
1510
+ }
1511
+ ProvidersManager.add(name, apiKeyInput);
1512
+ console.log(`\n${c.green}✓ ${def.displayName} API key saved securely to ~/.fixocli/providers.json${c.reset}`);
1513
+ console.log(`${c.dim} FixO will now route ${def.displayName} requests directly (bypassing the SaaS proxy).${c.reset}`);
1514
+ await refreshModelsForProvider(name);
1515
+ return;
1516
+ }
1517
+ if (sub === 'remove') {
1518
+ const name = args[1]?.toLowerCase();
1519
+ if (!name) {
1520
+ console.log(`\n${c.yellow}Usage: /providers remove <name>${c.reset}`);
1521
+ return;
1522
+ }
1523
+ rl.pause();
1524
+ const confirmed = await p.confirm({ message: `Remove API key for ${name}?`, initialValue: false });
1525
+ rl.resume();
1526
+ if (!p.isCancel(confirmed) && confirmed) {
1527
+ const removed = ProvidersManager.remove(name);
1528
+ console.log(removed
1529
+ ? `\n${c.green}✓ Removed API key for ${name}.${c.reset}`
1530
+ : `\n${c.yellow}No key found for provider: ${name}${c.reset}`);
1531
+ }
1532
+ return;
1533
+ }
1534
+ if (sub === 'test') {
1535
+ const name = args[1]?.toLowerCase();
1536
+ if (!name) {
1537
+ console.log(`\n${c.yellow}Usage: /providers test <name>${c.reset}`);
1538
+ return;
1539
+ }
1540
+ const directConf = ProvidersManager.getDirectConfig(name);
1541
+ if (!directConf) {
1542
+ console.log(`\n${c.yellow}No key configured for ${name}. Use /providers add ${name} first.${c.reset}`);
1543
+ return;
1544
+ }
1545
+ console.log(`\n${c.dim}Testing connection to ${directConf.displayName} (${directConf.baseUrl})...${c.reset}`);
1546
+ try {
1547
+ const testHeaders = {
1548
+ 'Authorization': `Bearer ${directConf.apiKey}`,
1549
+ };
1550
+ if (name === 'zen' || name === 'openrouter') {
1551
+ testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
1552
+ testHeaders['X-Title'] = 'opencode';
1553
+ }
1554
+ else if (name === 'nvidia') {
1555
+ testHeaders['HTTP-Referer'] = 'https://opencode.ai/';
1556
+ testHeaders['X-Title'] = 'opencode';
1557
+ testHeaders['X-BILLING-INVOKE-ORIGIN'] = 'OpenCode';
1558
+ }
1559
+ else if (name === 'cerebras') {
1560
+ testHeaders['X-Cerebras-3rd-Party-Integration'] = 'opencode';
1561
+ }
1562
+ const resp = await fetch(`${directConf.baseUrl}/models`, {
1563
+ headers: testHeaders,
1564
+ signal: AbortSignal.timeout(8000),
1565
+ });
1566
+ if (resp.ok) {
1567
+ console.log(`${c.green}✓ Connection to ${directConf.displayName} successful! (HTTP ${resp.status})${c.reset}`);
1568
+ // Warm the cache so /model picker shows live IDs.
1569
+ await refreshModelsForProvider(name);
1570
+ }
1571
+ else {
1572
+ const text = await resp.text().catch(() => '');
1573
+ console.log(`${c.red}✗ ${directConf.displayName} returned HTTP ${resp.status}${text ? ': ' + text.slice(0, 100) : ''}${c.reset}`);
1574
+ }
1575
+ }
1576
+ catch (err) {
1577
+ console.log(`${c.red}✗ Connection failed: ${err.message}${c.reset}`);
1578
+ }
1579
+ return;
1580
+ }
1581
+ console.log(`\n${c.yellow}Usage: /providers [list | add <name> | remove <name> | test <name>]${c.reset}`);
1582
+ console.log(`${c.dim} Available providers: ${PROVIDER_REGISTRY.map(p => p.name).join(', ')}${c.reset}`);
1583
+ return;
1584
+ }
1585
+ case '/compact': {
1586
+ const msgCount = conversation.getMessageCount();
1587
+ if (msgCount === 0) {
1588
+ console.log(`\n${c.dim}Nothing to compact — conversation is empty.${c.reset}`);
1589
+ return;
1590
+ }
1591
+ const tokensBefore = conversation.getTotalTokens();
1592
+ const contextLimit = conversation.getContextLimit();
1593
+ console.log(`\n${c.cyan}[Compact] Summarising ${msgCount} messages to free context tokens...${c.reset}`);
1594
+ console.log(`${c.dim} Current context: ${(tokensBefore / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k tokens${c.reset}`);
1595
+ try {
1596
+ const compacted = await conversation.compact(agent.getClient(), currentModel);
1597
+ if (compacted) {
1598
+ const info = conversation.getLastCompactionInfo();
1599
+ const tokensAfter = conversation.getTotalTokens();
1600
+ console.log(`${c.green}✓ Compacted: ${info?.messagesBefore ?? msgCount} messages → summary + ${conversation.getMessageCount()} recent messages.${c.reset}`);
1601
+ 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}`);
1602
+ }
1603
+ else {
1604
+ console.log(`${c.dim}Not enough messages to compact (need more than 4 messages).${c.reset}`);
1605
+ }
1606
+ }
1607
+ catch (err) {
1608
+ console.log(`${c.red}✗ Compact failed: ${err.message}${c.reset}`);
1609
+ }
1610
+ return;
1611
+ }
1612
+ case '/snapshot': {
1613
+ const label = args.join(' ').trim() || `snapshot-${Date.now()}`;
1614
+ if (!git.isGitRepo()) {
1615
+ console.log(`\n${c.yellow}⚠ Not a git repository — cannot create snapshot.${c.reset}`);
1616
+ return;
1617
+ }
1618
+ const hash = git.createSnapshot(label);
1619
+ if (hash) {
1620
+ console.log(`\n${c.green}✓ Workspace snapshot created: ${c.bold}${hash}${c.reset}${c.dim} (label: ${label})${c.reset}`);
1621
+ console.log(`${c.dim} Use /undo or git revert to roll back to this point.${c.reset}`);
1622
+ }
1623
+ return;
1624
+ }
1625
+ case '/skills': {
1626
+ const { skillsManager } = await import('../agent/skills.js');
1627
+ const list = skillsManager.getSkills();
1628
+ if (list.length === 0) {
1629
+ 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}`);
1630
+ }
1631
+ else {
1632
+ console.log(`\n${c.cyan}${c.bold}Registered Skills:${c.reset}`);
1633
+ for (const skill of list) {
1634
+ console.log(` - ${c.bold}${skill.name}${c.reset}${skill.description ? `: ${skill.description}` : ''} ${c.dim}(${skill.location})${c.reset}`);
1635
+ }
1636
+ }
1637
+ return;
1638
+ }
1639
+ case '/theme':
1640
+ case '/variant': {
1641
+ const { themeMode, setThemeMode } = await import('./colors.js');
1642
+ const newMode = themeMode === 'dark' ? 'inverted' : 'dark';
1643
+ setThemeMode(newMode);
1644
+ console.log(`\n${c.cyan}✓ Theme set to: ${newMode === 'dark' ? 'Dark Void Minimalist' : 'High-Contrast Inverted'}${c.reset}`);
1645
+ return;
1646
+ }
1647
+ case '/telemetry': {
1648
+ const sub = args[0]?.toLowerCase();
1649
+ if (sub === 'on' || sub === 'enable') {
1650
+ config.preferences.telemetry = true;
1651
+ saveConfig(config);
1652
+ console.log(`\n${c.green}✓ Telemetry enabled${c.reset}`);
1653
+ }
1654
+ else if (sub === 'off' || sub === 'disable') {
1655
+ config.preferences.telemetry = false;
1656
+ saveConfig(config);
1657
+ console.log(`\n${c.green}✓ Telemetry disabled${c.reset}`);
1658
+ }
1659
+ else {
1660
+ 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}`);
1661
+ }
1662
+ return;
1663
+ }
1664
+ default:
1665
+ console.log(`\n${c.yellow}Unknown command: ${cmd}. Type /help for available commands.${c.reset}`);
1666
+ return;
1667
+ }
1668
+ }
1669
+ // ─── Shell commands (! prefix) ───
1670
+ if (input.startsWith('!')) {
1671
+ const cmd = input.slice(1).trim();
1672
+ if (!cmd)
1673
+ return;
1674
+ const check = checkPermission('run_command', { command: cmd }, process.cwd(), config.preferences.policy ?? 'shell-confirm');
1675
+ if (check.decision === 'deny') {
1676
+ console.log(`\n${c.red}✗ ${check.reason}${c.reset}`);
1677
+ return;
1678
+ }
1679
+ if (check.decision === 'ask') {
1680
+ rl.pause();
1681
+ const confirmed = await p.confirm({
1682
+ message: `Allow execution of local shell command: ${c.cyan}${cmd}${c.reset}? (${check.reason})`,
1683
+ initialValue: false,
1684
+ });
1685
+ rl.resume();
1686
+ if (p.isCancel(confirmed) || !confirmed) {
1687
+ console.log(`\n${c.cyan} ⚠ Execution cancelled.${c.reset}`);
1688
+ return;
1689
+ }
1690
+ }
1691
+ console.log(`${c.dim}⚙️ Running: ${cmd}${c.reset}`);
1692
+ try {
1693
+ const { spawnSync } = await import('child_process');
1694
+ const result = spawnSync(cmd, {
1695
+ shell: true,
1696
+ cwd,
1697
+ encoding: 'utf-8',
1698
+ timeout: 30_000,
1699
+ maxBuffer: 1024 * 1024,
1700
+ env: redactedEnv(),
1701
+ });
1702
+ const output = redactSecrets([result.stdout ?? '', result.stderr ?? ''].filter(Boolean).join('\n'));
1703
+ if (output.trim())
1704
+ console.log(output);
1705
+ }
1706
+ catch (error) {
1707
+ if (error.stdout)
1708
+ console.log(error.stdout);
1709
+ if (error.stderr)
1710
+ console.error(`${c.red}${error.stderr}${c.reset}`);
1711
+ }
1712
+ return;
1713
+ }
1714
+ // ─── Agent task ───
1715
+ // Format any paths in the input for display
1716
+ const displayInput = formatInputPaths(input, cwd);
1717
+ if (displayInput !== input) {
1718
+ // Re-display with highlighted paths
1719
+ process.stdout.write(`\x1b[1A\x1b[2K`); // Move up and clear line
1720
+ console.log(`${C.LAVA}›${C.RESET} ${displayInput}`);
1721
+ }
1722
+ // Extract any file paths from input for automatic pinning
1723
+ const pathsInInput = extractFilePaths(input, cwd);
1724
+ const dirtyBefore = git.isGitRepo() ? git.getDirtyFiles() : [];
1725
+ const context = {
1726
+ task: input,
1727
+ model: currentModel,
1728
+ cwd,
1729
+ verbose,
1730
+ selectedFiles: [...selectedFiles, ...pathsInInput],
1731
+ systemPromptOverride: projectConfig?.systemPrompt,
1732
+ checkCommand: projectConfig?.checkCommand,
1733
+ policy: projectConfig?.policy ?? config.preferences.policy,
1734
+ mode: currentMode,
1735
+ pendingAttachments: pendingAttachments.length > 0 ? [...pendingAttachments] : undefined,
1736
+ };
1737
+ // Drain the queue — attachments are one-shot. The agent has its
1738
+ // own copy via context above.
1739
+ pendingAttachments = [];
1740
+ const classification = classifyComplexityHeuristic(input);
1741
+ let result;
1742
+ const startTime = Date.now();
1743
+ if (classification.complexity === 'complex') {
1744
+ console.log(`\n${c.cyan}[Routing Engine] Complex task detected (${classification.reason}). Routing to Orchestrator...${c.reset}`);
1745
+ try {
1746
+ const { Orchestrator } = await import('../agent/orchestrator.js');
1747
+ const { AgentPool } = await import('../agent/agent-pool.js');
1748
+ console.log(`\n${c.cyan}[Orchestrator] Generating plan for complex task...${c.reset}`);
1749
+ const orchestrator = new Orchestrator(verbose);
1750
+ const dag = await orchestrator.plan(context);
1751
+ // Render planned phases in high contrast box
1752
+ const width = 60;
1753
+ const borderTop = `┌${'─'.repeat(width)}┐`;
1754
+ const borderBottom = `└${'─'.repeat(width)}┘`;
1755
+ console.log(`\n${c.cyan}${borderTop}${c.reset}`);
1756
+ console.log(`${c.cyan}│${c.reset} ${c.bold}Planned Subtask Phases (Complex Task decomposition):${c.reset}${' '.repeat(width - 52)}${c.cyan}│${c.reset}`);
1757
+ console.log(`${c.cyan}├${'─'.repeat(width)}┤${c.reset}`);
1758
+ for (const sub of dag.subtasks) {
1759
+ const deps = sub.dependencies.length > 0 ? ` (deps: ${sub.dependencies.join(', ')})` : '';
1760
+ const lineStr = ` - [${sub.persona.toUpperCase()}] ${sub.title}${deps}`;
1761
+ const pad = Math.max(0, width - lineStr.length - 4);
1762
+ console.log(`${c.cyan}│${c.reset} ${c.bold}${lineStr}${c.reset}${' '.repeat(pad)} ${c.cyan}│${c.reset}`);
1763
+ }
1764
+ console.log(`${c.cyan}${borderBottom}${c.reset}\n`);
1765
+ // Save the DAG to .fixo/last-dag.json
1766
+ const fixoDir = path.join(cwd, '.fixo');
1767
+ fs.mkdirSync(fixoDir, { recursive: true });
1768
+ fs.writeFileSync(path.join(fixoDir, 'last-dag.json'), JSON.stringify({ task: input, dag }, null, 2), 'utf-8');
1769
+ if (currentMode === 'PLAN') {
1770
+ console.log(`${c.green}✓ Plan generated and saved successfully.${c.reset}`);
1771
+ 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`);
1772
+ return;
1773
+ }
1774
+ const budgetLimit = projectConfig?.maxAttempts ?? 12;
1775
+ const pool = new AgentPool(3, budgetLimit);
1776
+ console.log(`\n${c.cyan}[Agent Pool] Executing DAG of subtasks (concurrency limit: 3, budget: ${budgetLimit} tool calls)...${c.reset}`);
1777
+ const success = await pool.execute(context, dag);
1778
+ const durationMs = Date.now() - startTime;
1779
+ const totalPromptTokens = orchestrator.tokensUsed.prompt_tokens + pool.tokensUsed.prompt_tokens;
1780
+ const totalCompletionTokens = orchestrator.tokensUsed.completion_tokens + pool.tokensUsed.completion_tokens;
1781
+ // Find modified files to report
1782
+ const { getModifiedFiles, getBranchPoint } = await import('../agent/worker-agent.js');
1783
+ const relativeModified = getModifiedFiles(cwd, getBranchPoint(cwd));
1784
+ const modifiedFiles = relativeModified.map(f => path.resolve(cwd, f));
1785
+ if (!success) {
1786
+ console.log(`\n${c.red}✗ Parallel workers failed to complete all subtasks.${c.reset}`);
1787
+ if (git.isGitRepo()) {
1788
+ console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to run failure...${c.reset}`);
1789
+ git.discardUncommittedChanges();
1790
+ }
1791
+ }
1792
+ result = {
1793
+ success,
1794
+ response: success
1795
+ ? 'Successfully completed complex task via parallel agents.'
1796
+ : 'Failed to complete all complex subtasks.',
1797
+ modifiedFiles,
1798
+ tokensUsed: {
1799
+ prompt_tokens: totalPromptTokens,
1800
+ completion_tokens: totalCompletionTokens,
1801
+ total_tokens: totalPromptTokens + totalCompletionTokens
1802
+ },
1803
+ toolCallCount: pool.toolCallCount,
1804
+ durationMs,
1805
+ model: context.model,
1806
+ };
1807
+ }
1808
+ catch (err) {
1809
+ console.error(`\n${c.red}✗ Orchestrated execution failed: ${err.message || err}${c.reset}`);
1810
+ if (git.isGitRepo()) {
1811
+ console.log(`\n${c.yellow}[Agent Pool] Rolling back all uncommitted changes due to error...${c.reset}`);
1812
+ git.discardUncommittedChanges();
1813
+ }
1814
+ const durationMs = Date.now() - startTime;
1815
+ result = {
1816
+ success: false,
1817
+ response: `Orchestrated run failed: ${err.message || err}`,
1818
+ modifiedFiles: [],
1819
+ tokensUsed: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
1820
+ toolCallCount: 0,
1821
+ durationMs,
1822
+ model: context.model,
1823
+ };
1824
+ }
1825
+ }
1826
+ else {
1827
+ console.log(`\n${c.cyan}[Routing Engine] Simple task detected (${classification.reason}). Routing to SingleAgent...${c.reset}`);
1828
+ result = await agent.runStreaming(context, conversation, rl);
1829
+ }
1830
+ // Print result summary
1831
+ console.log('');
1832
+ const modelPart = result.model ? `${result.model} · ` : '';
1833
+ const tokenInfo = `${c.dim}${modelPart}${result.tokensUsed.total_tokens} tokens · ${result.toolCallCount} tool calls · ${(result.durationMs / 1000).toFixed(1)}s${c.reset}`;
1834
+ console.log(tokenInfo);
1835
+ // Auto-commit if enabled
1836
+ if (config.preferences.autoCommit &&
1837
+ (projectConfig?.autoCommit !== false) &&
1838
+ result.modifiedFiles.length > 0) {
1839
+ const gitModified = result.modifiedFiles.map(f => guard.relative(f));
1840
+ const preExistingEdits = gitModified.filter(f => dirtyBefore.includes(f));
1841
+ let allowed = true;
1842
+ if (preExistingEdits.length > 0) {
1843
+ rl.pause();
1844
+ const confirmed = await p.confirm({
1845
+ message: `The agent modified files with pre-existing uncommitted edits: ${preExistingEdits.join(', ')}. Allow auto-commit?`,
1846
+ initialValue: false,
1847
+ });
1848
+ rl.resume();
1849
+ if (p.isCancel(confirmed) || !confirmed) {
1850
+ allowed = false;
1851
+ console.log(`\n${c.yellow} ⚠ Auto-commit skipped due to pre-existing edits.${c.reset}`);
1852
+ }
1853
+ }
1854
+ if (allowed) {
1855
+ git.autoCommit(input, result.modifiedFiles);
1856
+ }
1857
+ }
1858
+ // Update stats
1859
+ stats.totalPromptTokens += result.tokensUsed.prompt_tokens;
1860
+ stats.totalCompletionTokens += result.tokensUsed.completion_tokens;
1861
+ stats.totalToolCalls += result.toolCallCount;
1862
+ stats.totalTasks++;
1863
+ stats.totalDurationMs += result.durationMs;
1864
+ // Token budget warning → replaced with auto-compact
1865
+ const currentContextTokens = conversation.getTotalTokens();
1866
+ const contextLimit = conversation.getContextLimit();
1867
+ const contextPct = Math.round((currentContextTokens / contextLimit) * 100);
1868
+ // Auto-compact after each turn if context is getting large
1869
+ if (conversation.shouldCompact()) {
1870
+ console.log(`\n${c.yellow}🔄 Context at ${contextPct}% (${(currentContextTokens / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k) — auto-compacting...${c.reset}`);
1871
+ try {
1872
+ const compacted = await conversation.compact(agent.getClient(), currentModel);
1873
+ if (compacted) {
1874
+ const info = conversation.getLastCompactionInfo();
1875
+ const newTokens = conversation.getTotalTokens();
1876
+ console.log(`${c.green}✓ Compacted: ${info?.messagesBefore ?? '?'} messages → summary + ${conversation.getMessageCount()} recent. ${(currentContextTokens / 1000).toFixed(0)}k → ${(newTokens / 1000).toFixed(0)}k tokens.${c.reset}`);
1877
+ }
1878
+ }
1879
+ catch (err) {
1880
+ // Don't let compaction errors crash the REPL
1881
+ console.log(`${c.dim}[Context] Auto-compact failed, continuing with current context.${c.reset}`);
1882
+ }
1883
+ }
1884
+ else if (contextPct > 50) {
1885
+ console.log(`\n${c.dim}📊 Context: ${(currentContextTokens / 1000).toFixed(0)}k / ${(contextLimit / 1000).toFixed(0)}k tokens (${contextPct}%)${c.reset}`);
1886
+ }
1887
+ // Save stateful session persistence
1888
+ try {
1889
+ const { SessionManager } = await import('../agent/conversation.js');
1890
+ // Merge modified files from this run
1891
+ for (const file of result.modifiedFiles) {
1892
+ if (!sessionModifiedFiles.includes(file)) {
1893
+ sessionModifiedFiles.push(file);
1894
+ }
1895
+ }
1896
+ SessionManager.saveSession(conversation, currentModel, sessionModifiedFiles, {
1897
+ prompt_tokens: stats.totalPromptTokens,
1898
+ completion_tokens: stats.totalCompletionTokens,
1899
+ total_tokens: stats.totalPromptTokens + stats.totalCompletionTokens,
1900
+ }, currentSessionId);
1901
+ }
1902
+ catch (err) {
1903
+ // Ignore session save errors
1904
+ }
1905
+ }
1906
+ // Start the loop
1907
+ promptForInput();
1908
+ }
1909
+ /* ──────────────────────── Helpers ──────────────────────── */
1910
+ function extractFilePaths(input, cwd) {
1911
+ const paths = [];
1912
+ const guard = new WorkspaceGuard(cwd);
1913
+ // Only match paths that look like real file references:
1914
+ // - Quoted paths with extensions
1915
+ // - Unquoted paths with a directory separator AND a code/doc extension
1916
+ const extensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.css', '.scss', '.json', '.md', '.yml', '.yaml', '.toml', '.env', '.sh', '.bash', '.txt', '.html', '.vue', '.svelte']);
1917
+ const extensionPattern = Array.from(extensions).join('|').replace(/\./g, '\\.');
1918
+ const patterns = [
1919
+ new RegExp(`'([^']+${extensionPattern})'`, 'g'),
1920
+ new RegExp(`"([^"]+${extensionPattern})"`, 'g'),
1921
+ new RegExp(`\\b([\\w.-]+\\/${extensionPattern})\\b`, 'g'),
1922
+ ];
1923
+ for (const pattern of patterns) {
1924
+ let match;
1925
+ while ((match = pattern.exec(input)) !== null) {
1926
+ let filePath;
1927
+ try {
1928
+ filePath = guard.ensureFile(match[1]);
1929
+ }
1930
+ catch {
1931
+ continue;
1932
+ }
1933
+ if (fs.existsSync(filePath) && !paths.includes(filePath)) {
1934
+ paths.push(filePath);
1935
+ }
1936
+ }
1937
+ }
1938
+ return paths;
1939
+ }
1940
+ function printStats(stats) {
1941
+ const totalTokens = stats.totalPromptTokens + stats.totalCompletionTokens;
1942
+ const avgDuration = stats.totalTasks > 0
1943
+ ? (stats.totalDurationMs / stats.totalTasks / 1000).toFixed(1)
1944
+ : '0';
1945
+ // Rough cost estimation: $3/M input + $15/M output tokens (average across providers)
1946
+ const estimatedCost = (stats.totalPromptTokens / 1_000_000) * 3 +
1947
+ (stats.totalCompletionTokens / 1_000_000) * 15;
1948
+ console.log('');
1949
+ console.log(`${c.cyan}${c.bold}📊 Session Statistics${c.reset}`);
1950
+ console.log(`${c.dim}${'─'.repeat(40)}${c.reset}`);
1951
+ console.log(` Tasks completed: ${c.bold}${stats.totalTasks}${c.reset}`);
1952
+ console.log(` Tool calls: ${c.bold}${stats.totalToolCalls}${c.reset}`);
1953
+ console.log(` Input tokens: ${c.bold}${stats.totalPromptTokens.toLocaleString()}${c.reset}`);
1954
+ console.log(` Output tokens: ${c.bold}${stats.totalCompletionTokens.toLocaleString()}${c.reset}`);
1955
+ console.log(` Total tokens: ${c.bold}${totalTokens.toLocaleString()}${c.reset}`);
1956
+ console.log(` Avg task duration: ${c.bold}${avgDuration}s${c.reset}`);
1957
+ console.log(` Cost savings: ${c.green}${c.bold}~$${estimatedCost.toFixed(2)} saved${c.reset} ${c.dim}(free models!)${c.reset}`);
1958
+ console.log('');
1959
+ }
1960
+ //# sourceMappingURL=prompt.js.map