ashlrcode 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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1120 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * AshlrCode (ac) — Multi-provider AI coding agent CLI.
5
+ *
6
+ * Entry point: sets up providers, tools, sessions, plan mode, context
7
+ * management, and runs the interactive REPL.
8
+ */
9
+
10
+ import { readFile } from "fs/promises";
11
+ import { resolve, join } from "path";
12
+ import { existsSync } from "fs";
13
+ import chalk from "chalk";
14
+ import { createInterface } from "readline";
15
+
16
+ import { ProviderRouter } from "./providers/router.ts";
17
+ import { ToolRegistry } from "./tools/registry.ts";
18
+ import { runAgentLoop } from "./agent/loop.ts";
19
+ import { loadSettings } from "./config/settings.ts";
20
+ import { initRemoteSettings, startPolling, loadCachedSettings, stopPolling } from "./config/remote-settings.ts";
21
+ import { Session, listSessions, resumeSession, getLastSessionForCwd, forkSession } from "./persistence/session.ts";
22
+ import {
23
+ needsCompaction,
24
+ autoCompact,
25
+ snipCompact,
26
+ estimateTokens,
27
+ getProviderContextLimit,
28
+ } from "./agent/context.ts";
29
+ import {
30
+ isPlanMode,
31
+ getPlanModePrompt,
32
+ getPlanState,
33
+ exitPlanMode,
34
+ } from "./planning/plan-mode.ts";
35
+ import type { Message } from "./providers/types.ts";
36
+ import type { ToolContext } from "./tools/types.ts";
37
+
38
+ // Tools
39
+ import { bashTool } from "./tools/bash.ts";
40
+ import { fileReadTool } from "./tools/file-read.ts";
41
+ import { fileWriteTool } from "./tools/file-write.ts";
42
+ import { fileEditTool } from "./tools/file-edit.ts";
43
+ import { globTool } from "./tools/glob.ts";
44
+ import { grepTool } from "./tools/grep.ts";
45
+ import { askUserTool } from "./tools/ask-user.ts";
46
+ import { webFetchTool } from "./tools/web-fetch.ts";
47
+ import { enterPlanTool, exitPlanTool, planWriteTool } from "./planning/plan-tools.ts";
48
+ import { agentTool, initAgentTool } from "./tools/agent.ts";
49
+ import { taskCreateTool, taskUpdateTool, taskListTool, taskGetTool } from "./tools/tasks.ts";
50
+ import { loadMemories, formatMemoriesForPrompt } from "./persistence/memory.ts";
51
+ import {
52
+ loadPermissions,
53
+ checkPermission,
54
+ recordPermission,
55
+ allowForSession,
56
+ setBypassMode,
57
+ setAutoAcceptEdits,
58
+ isBypassMode,
59
+ setRules,
60
+ } from "./config/permissions.ts";
61
+ import { Spinner, getToolPhrase } from "./ui/spinner.ts";
62
+ import { renderMarkdownDelta, flushMarkdown, resetMarkdown } from "./ui/markdown.ts";
63
+ import { printBanner, printTurnSeparator, printInputLine, printStatusLine } from "./ui/banner.ts";
64
+ import { getCurrentMode, setMode, cycleMode, getPromptForMode, type Mode } from "./ui/mode.ts";
65
+ import { renderContextBar } from "./ui/context-bar.ts";
66
+ import { loadBuddy, printBuddy, saveBuddy, startSession, recordToolCallSuccess, recordThinking, recordError, getBuddyReaction, isFirstToolCall, type BuddyData } from "./ui/buddy.ts";
67
+ import { theme, styleCost, styleTokens } from "./ui/theme.ts";
68
+ import { lsTool } from "./tools/ls.ts";
69
+ import { configTool } from "./tools/config.ts";
70
+ import { enterWorktreeTool, exitWorktreeTool } from "./tools/worktree.ts";
71
+ import { webSearchTool } from "./tools/web-search.ts";
72
+ import { toolSearchTool, initToolSearch } from "./tools/tool-search.ts";
73
+ import { powershellTool } from "./tools/powershell.ts";
74
+ import { getGitContext, formatGitPrompt } from "./config/git.ts";
75
+ import { loadHooksFromSettings } from "./config/hooks.ts";
76
+ import { fileHistory } from "./state/file-history.ts";
77
+ import { memorySaveTool, memoryListTool, memoryDeleteTool } from "./tools/memory.ts";
78
+ import { notebookEditTool } from "./tools/notebook-edit.ts";
79
+ import { sendMessageTool } from "./tools/send-message.ts";
80
+ import { sleepTool } from "./tools/sleep.ts";
81
+ import { todoWriteTool } from "./tools/todo-write.ts";
82
+ import { diffTool } from "./tools/diff.ts";
83
+ import { snipTool, initSnipTool } from "./tools/snip.ts";
84
+ import { lspTool, shutdownLSP } from "./tools/lsp.ts";
85
+ import { webBrowserTool, shutdownBrowser } from "./tools/web-browser.ts";
86
+ import { feature } from "./config/features.ts";
87
+ import { teamCreateTool, teamDeleteTool, teamListTool, teamDispatchTool, initTeamTools } from "./tools/team.ts";
88
+ import { workflowTool } from "./tools/workflow.ts";
89
+ import { listPeersTool } from "./tools/peers.ts";
90
+ import { MCPManager } from "./mcp/manager.ts";
91
+ import { createMCPTool } from "./tools/mcp-tool.ts";
92
+ import { listMcpResourcesTool, setMCPManager } from "./tools/mcp-resources.ts";
93
+ import { initTasks } from "./tools/tasks.ts";
94
+ import { loadSkills } from "./skills/loader.ts";
95
+ import { SkillRegistry } from "./skills/registry.ts";
96
+ import { categorizeError } from "./agent/error-handler.ts";
97
+ import { buildSystemPrompt } from "./agent/system-prompt.ts";
98
+ import { runSetupWizard, needsSetup } from "./setup.ts";
99
+ import { loadProjectConfig } from "./config/project-config.ts";
100
+ import { startInkRepl } from "./repl.tsx";
101
+ import { VERSION } from "./version.ts";
102
+ let maxCostUSD = Infinity;
103
+
104
+ interface AppState {
105
+ router: ProviderRouter;
106
+ registry: ToolRegistry;
107
+ toolContext: ToolContext;
108
+ session: Session;
109
+ history: Message[];
110
+ baseSystemPrompt: string;
111
+ skillRegistry: SkillRegistry;
112
+ buddy: BuddyData;
113
+ }
114
+
115
+ async function main() {
116
+ const args = process.argv.slice(2);
117
+
118
+ if (args.includes("--version") || args.includes("-v")) {
119
+ console.log(`AshlrCode v${VERSION}`);
120
+ process.exit(0);
121
+ }
122
+
123
+ if (args.includes("--help") || args.includes("-h")) {
124
+ printHelp();
125
+ process.exit(0);
126
+ }
127
+
128
+ // Load settings and permissions
129
+ let settings = await loadSettings();
130
+ await loadPermissions();
131
+
132
+ // Wire up permission rules from settings
133
+ if (settings.permissionRules) {
134
+ setRules(settings.permissionRules);
135
+ }
136
+
137
+ // Initialize remote managed settings
138
+ const remoteUrl = process.env.AC_REMOTE_SETTINGS_URL ?? settings.remoteSettingsUrl;
139
+ if (remoteUrl) {
140
+ initRemoteSettings(remoteUrl, settings.providers.primary.apiKey);
141
+ await loadCachedSettings();
142
+ startPolling();
143
+ }
144
+
145
+ // Parse mode flags
146
+ const dangerouslySkipPermissions = args.includes("--dangerously-skip-permissions") || args.includes("--yolo");
147
+ const autoAcceptEditsFlag = args.includes("--auto-accept-edits");
148
+ const printMode = args.includes("--print");
149
+ const maxCostArg = getArg(args, "--max-cost");
150
+ maxCostUSD = maxCostArg ? parseFloat(maxCostArg) : Infinity;
151
+
152
+ if (dangerouslySkipPermissions) {
153
+ setBypassMode(true);
154
+ }
155
+ if (autoAcceptEditsFlag) {
156
+ setAutoAcceptEdits(true);
157
+ }
158
+
159
+ if (needsSetup(settings)) {
160
+ const newSettings = await runSetupWizard();
161
+ settings = newSettings;
162
+ }
163
+
164
+ // Initialize provider router
165
+ const router = new ProviderRouter(settings.providers);
166
+
167
+ // Initialize tool registry
168
+ const registry = new ToolRegistry();
169
+ registry.register(bashTool);
170
+ registry.register(fileReadTool);
171
+ registry.register(fileWriteTool);
172
+ registry.register(fileEditTool);
173
+ registry.register(globTool);
174
+ registry.register(grepTool);
175
+ registry.register(askUserTool);
176
+ registry.register(webFetchTool);
177
+ registry.register(enterPlanTool);
178
+ registry.register(exitPlanTool);
179
+ registry.register(planWriteTool);
180
+ registry.register(agentTool);
181
+ registry.register(taskCreateTool);
182
+ registry.register(taskUpdateTool);
183
+ registry.register(taskListTool);
184
+ registry.register(taskGetTool);
185
+ registry.register(lsTool);
186
+ registry.register(configTool);
187
+ registry.register(enterWorktreeTool);
188
+ registry.register(exitWorktreeTool);
189
+ registry.register(webSearchTool);
190
+ registry.register(toolSearchTool);
191
+ registry.register(memorySaveTool);
192
+ registry.register(memoryListTool);
193
+ registry.register(memoryDeleteTool);
194
+ registry.register(notebookEditTool);
195
+ registry.register(sendMessageTool);
196
+ registry.register(sleepTool);
197
+ registry.register(todoWriteTool);
198
+ registry.register(diffTool);
199
+ registry.register(snipTool);
200
+ registry.register(lspTool);
201
+ registry.register(teamCreateTool);
202
+ registry.register(teamDeleteTool);
203
+ registry.register(teamListTool);
204
+ registry.register(teamDispatchTool);
205
+ registry.register(workflowTool);
206
+ registry.register(listPeersTool);
207
+ if (process.platform === "win32") {
208
+ registry.register(powershellTool);
209
+ }
210
+ if (feature("BROWSER_TOOL")) {
211
+ registry.register(webBrowserTool);
212
+ }
213
+ initToolSearch(registry);
214
+
215
+ // Set up hooks from settings — toolHooks (new format) takes priority, falls back to hooks (legacy)
216
+ if (settings.toolHooks) {
217
+ const hooksConfig = loadHooksFromSettings(settings.toolHooks);
218
+ registry.setHooks(hooksConfig);
219
+ } else if (settings.hooks) {
220
+ registry.setHooks(settings.hooks);
221
+ }
222
+
223
+ // Connect MCP servers in background (don't block startup)
224
+ const mcpManager = new MCPManager();
225
+ if (settings.mcpServers && Object.keys(settings.mcpServers).length > 0) {
226
+ setMCPManager(mcpManager);
227
+ registry.register(listMcpResourcesTool);
228
+ mcpManager.connectAll(settings.mcpServers).then(() => {
229
+ for (const { serverName, tool } of mcpManager.getAllTools()) {
230
+ registry.register(createMCPTool(serverName, tool, mcpManager));
231
+ }
232
+ }).catch(() => {});
233
+ }
234
+
235
+ // Load system prompt via builder (knowledge files, memory, git context)
236
+ const rawSystemPrompt = await loadSystemPrompt();
237
+ const cwd = process.cwd();
238
+
239
+ // Build prompt using SystemPromptBuilder — but skip addToolDescriptions()
240
+ // because tools are already sent via the API's `tools` parameter.
241
+ const { SystemPromptBuilder } = await import("./agent/system-prompt.ts");
242
+ const promptBuilder = new SystemPromptBuilder();
243
+ promptBuilder.addCoreInstructions(rawSystemPrompt);
244
+ promptBuilder.addPermissionContext(getCurrentMode());
245
+
246
+ if (isPlanMode()) {
247
+ promptBuilder.addPlanMode();
248
+ }
249
+
250
+ await promptBuilder.addKnowledgeFiles(cwd);
251
+ await promptBuilder.addMemoryFiles();
252
+
253
+ // Append legacy memories (from persistence/memory.ts) for backward compat
254
+ const memories = await loadMemories(cwd);
255
+ if (memories.length > 0) {
256
+ promptBuilder.addSection("legacy-memories", formatMemoriesForPrompt(memories), 45);
257
+ }
258
+
259
+ // Git context via builder method (richer than legacy formatGitPrompt)
260
+ await promptBuilder.addGitContext(cwd);
261
+
262
+ const assembled = promptBuilder.build(8000);
263
+ let baseSystemPrompt = assembled.text;
264
+
265
+ // Initialize agent tool with router/registry references
266
+ initAgentTool(router, registry, baseSystemPrompt);
267
+ initTeamTools(router, registry, baseSystemPrompt);
268
+
269
+ // Tool context
270
+ const toolContext: ToolContext = {
271
+ cwd,
272
+ requestPermission: async (tool, description) => {
273
+ return await askPermission(tool, description);
274
+ },
275
+ };
276
+
277
+ // Session handling
278
+ let session: Session;
279
+ let history: Message[] = [];
280
+
281
+ const resumeId = getArg(args, "--resume");
282
+ const forkId = getArg(args, "--fork-session");
283
+ const continueFlag = args.includes("--continue") || args.includes("-c");
284
+
285
+ if (resumeId) {
286
+ const resumed = await resumeSession(resumeId);
287
+ if (resumed) {
288
+ session = resumed.session;
289
+ history = resumed.messages;
290
+ console.log(chalk.dim(`Resumed session ${resumeId} (${history.length} messages)`));
291
+ } else {
292
+ console.error(chalk.red(`Session ${resumeId} not found`));
293
+ process.exit(1);
294
+ }
295
+ } else if (continueFlag) {
296
+ const lastId = await getLastSessionForCwd(cwd);
297
+ if (lastId) {
298
+ const resumed = await resumeSession(lastId);
299
+ if (resumed) {
300
+ session = resumed.session;
301
+ history = resumed.messages;
302
+ console.log(chalk.dim(`Continued session ${lastId} (${history.length} messages)`));
303
+ } else {
304
+ session = new Session();
305
+ await session.init(router.currentProvider.name, router.currentProvider.config.model);
306
+ }
307
+ } else {
308
+ session = new Session();
309
+ await session.init(router.currentProvider.name, router.currentProvider.config.model);
310
+ }
311
+ } else if (forkId) {
312
+ const forked = await forkSession(forkId, router.currentProvider.name, router.currentProvider.config.model);
313
+ if (forked) {
314
+ session = forked.session;
315
+ history = forked.messages;
316
+ console.log(chalk.dim(`Forked session ${forkId} → ${session.id} (${history.length} messages)`));
317
+ } else {
318
+ console.error(chalk.red(`Session ${forkId} not found`));
319
+ process.exit(1);
320
+ }
321
+ } else {
322
+ session = new Session();
323
+ await session.init(router.currentProvider.name, router.currentProvider.config.model);
324
+ }
325
+
326
+ // Initialize task persistence
327
+ await initTasks(session.id);
328
+
329
+ // Load skills
330
+ const skillRegistry = new SkillRegistry();
331
+ const skills = await loadSkills(cwd);
332
+ skillRegistry.registerAll(skills);
333
+ // Skills loaded silently — use /skills to list them
334
+
335
+ // Load buddy (don't reset mood — let it carry from last session)
336
+ const buddy = await loadBuddy();
337
+ await startSession(buddy);
338
+
339
+ const state: AppState = {
340
+ router,
341
+ registry,
342
+ toolContext,
343
+ session,
344
+ history,
345
+ baseSystemPrompt,
346
+ skillRegistry,
347
+ buddy,
348
+ };
349
+
350
+ // Wire SnipTool with history accessors
351
+ initSnipTool(
352
+ () => state.history,
353
+ (msgs) => { state.history.length = 0; state.history.push(...msgs); },
354
+ );
355
+
356
+ // Set initial mode based on flags
357
+ if (dangerouslySkipPermissions) setMode("yolo");
358
+ else if (autoAcceptEditsFlag) setMode("accept-edits");
359
+
360
+ // Header (suppress in print mode)
361
+ if (!printMode) {
362
+ const startMode = dangerouslySkipPermissions ? "yolo" : autoAcceptEditsFlag ? "accept-edits" : undefined;
363
+ printBanner(VERSION, router.currentProvider.name, router.currentProvider.config.model, startMode);
364
+ printBuddy(buddy);
365
+ console.log(theme.tertiary(` ${cwd}`));
366
+ if (buddy.totalSessions <= 1) {
367
+ // First-time quick-start
368
+ console.log(theme.accent("\n Welcome! Here are some things to try:"));
369
+ console.log(theme.secondary(` "fix the login bug"`) + theme.tertiary(` — describe any task`));
370
+ console.log(theme.secondary(` /explore`) + theme.tertiary(` — analyze this codebase`));
371
+ console.log(theme.secondary(` /commit`) + theme.tertiary(` — commit your changes`));
372
+ console.log(theme.secondary(` /buddy`) + theme.tertiary(` — meet ${buddy.name}!`));
373
+ console.log(theme.tertiary(`\n Shift+Tab switches modes. /help for all commands.\n`));
374
+ } else {
375
+ console.log(theme.tertiary(` Shift+Tab to switch modes. /help for commands. Ctrl+C to exit.\n`));
376
+ }
377
+ }
378
+
379
+ // Graceful Ctrl+C — only for non-interactive paths (--print, single-shot)
380
+ // In Ink mode, repl.tsx handleExit() manages cleanup + process.exit
381
+ // Only register SIGINT for non-Ink paths. Ink handles its own exit.
382
+ const isNonInteractive = printMode || args.some(a => !a.startsWith("-") && !a.startsWith("--"));
383
+ if (isNonInteractive) process.on("SIGINT", async () => {
384
+ try {
385
+ if (state.history.length > 0) {
386
+ await state.session.appendMessages(state.history.slice(-2));
387
+ }
388
+ state.buddy.mood = "sleepy";
389
+ await saveBuddy(state.buddy);
390
+ } catch {}
391
+ if (!printMode) {
392
+ console.log(chalk.dim(`\n${router.getCostSummary()}`));
393
+ }
394
+ mcpManager.disconnectAll().catch(() => {});
395
+ shutdownLSP().catch(() => {});
396
+ shutdownBrowser().catch(() => {});
397
+ stopPolling();
398
+ process.exit(0);
399
+ });
400
+
401
+ // Check for inline command
402
+ const inlineMessage = args
403
+ .filter((a) => !a.startsWith("-") && !a.startsWith("--"))
404
+ .join(" ");
405
+
406
+ if (inlineMessage) {
407
+ await runTurn(inlineMessage, state, printMode);
408
+ if (!printMode) {
409
+ console.log(chalk.dim(`\n${router.getCostSummary()}`));
410
+ }
411
+ process.exit(0);
412
+ }
413
+
414
+ // Interactive REPL — use Ink for proper cursor positioning
415
+ startInkRepl(state, maxCostUSD);
416
+ }
417
+
418
+ function getPrompt(): string {
419
+ return getPromptForMode();
420
+ }
421
+
422
+ async function handleCommand(
423
+ input: string,
424
+ state: AppState,
425
+ rl: ReturnType<typeof createInterface>
426
+ ): Promise<void> {
427
+ const [cmd, ...rest] = input.split(" ");
428
+ const arg = rest.join(" ").trim();
429
+
430
+ switch (cmd) {
431
+ case "/quit":
432
+ case "/exit":
433
+ case "/q":
434
+ console.log(chalk.dim(state.router.getCostSummary()));
435
+ process.exit(0);
436
+
437
+ case "/cost":
438
+ console.log(theme.secondary(state.router.getCostSummary()));
439
+ console.log(
440
+ theme.tertiary(
441
+ `Context: ~${estimateTokens(state.history).toLocaleString()} tokens, ${state.history.length} messages`
442
+ )
443
+ );
444
+ // Cost nudge
445
+ if (state.router.costs.totalCostUSD > 5) {
446
+ console.log(theme.warning(`\n 💡 Expensive session ($${state.router.costs.totalCostUSD.toFixed(2)}). Consider:`));
447
+ console.log(theme.tertiary(` /model grok-3 — 80% cheaper for exploration`));
448
+ console.log(theme.tertiary(` /compact — reduce context size`));
449
+ console.log(theme.tertiary(` /effort fast — shorter responses`));
450
+ } else if (state.router.costs.totalCostUSD > 1) {
451
+ console.log(theme.tertiary(`\n 💡 Tip: /model grok-3 for cheaper exploration tasks`));
452
+ }
453
+ break;
454
+
455
+ case "/clear":
456
+ state.history.length = 0;
457
+ if (isPlanMode()) exitPlanMode();
458
+ console.log(chalk.dim("Conversation cleared."));
459
+ break;
460
+
461
+ case "/plan":
462
+ if (isPlanMode()) {
463
+ const planState = getPlanState();
464
+ console.log(chalk.magenta("Plan mode is active."));
465
+ console.log(chalk.dim(`Plan file: ${planState.planFilePath}`));
466
+ console.log(chalk.dim(`Started: ${planState.startedAt}`));
467
+ } else {
468
+ console.log(
469
+ chalk.dim(
470
+ "Plan mode is not active. The model can enter plan mode by calling EnterPlan."
471
+ )
472
+ );
473
+ console.log(
474
+ chalk.dim(
475
+ 'Tip: Ask the model to "plan first" and it will use plan mode.'
476
+ )
477
+ );
478
+ }
479
+ break;
480
+
481
+ case "/sessions": {
482
+ const sessions = await listSessions();
483
+ if (sessions.length === 0) {
484
+ console.log(chalk.dim("No saved sessions."));
485
+ } else {
486
+ console.log(chalk.bold("Recent sessions:"));
487
+ for (const s of sessions) {
488
+ const age = timeSince(new Date(s.updatedAt));
489
+ const title = s.title ?? s.cwd.split("/").pop() ?? s.id;
490
+ const current = s.id === state.session.id ? chalk.cyan(" (current)") : "";
491
+ console.log(
492
+ ` ${chalk.bold(s.id)}${current} — ${title} (${s.messageCount} msgs, ${age} ago)`
493
+ );
494
+ }
495
+ console.log(chalk.dim("\nResume with: ac --resume <id>"));
496
+ }
497
+ break;
498
+ }
499
+
500
+ case "/model":
501
+ if (arg) {
502
+ // Model switching
503
+ const models: Record<string, string> = {
504
+ "grok-fast": "grok-4-1-fast-reasoning",
505
+ "grok-4": "grok-4-0314",
506
+ "grok-3": "grok-3-fast",
507
+ "sonnet": "claude-sonnet-4-6-20250514",
508
+ "opus": "claude-opus-4-6-20250514",
509
+ "haiku": "claude-haiku-4-5-20251001",
510
+ };
511
+ const resolved = models[arg] ?? arg;
512
+ state.router.currentProvider.config.model = resolved;
513
+ console.log(chalk.dim(`Switched to model: ${resolved}`));
514
+ } else {
515
+ console.log(chalk.bold("Current:"));
516
+ console.log(chalk.dim(` Provider: ${state.router.currentProvider.name}`));
517
+ console.log(chalk.dim(` Model: ${state.router.currentProvider.config.model}`));
518
+ console.log(chalk.bold("\nAliases:"));
519
+ console.log(chalk.dim(" grok-fast → grok-4-1-fast-reasoning"));
520
+ console.log(chalk.dim(" grok-4 → grok-4-0314"));
521
+ console.log(chalk.dim(" grok-3 → grok-3-fast"));
522
+ console.log(chalk.dim(" sonnet → claude-sonnet-4-6-20250514"));
523
+ console.log(chalk.dim(" opus → claude-opus-4-6-20250514"));
524
+ console.log(chalk.dim("\nUsage: /model <alias or model-id>"));
525
+ }
526
+ break;
527
+
528
+ case "/compact": {
529
+ const before = estimateTokens(state.history);
530
+ state.history = snipCompact(state.history);
531
+ state.history = await autoCompact(state.history, state.router);
532
+ const after = estimateTokens(state.history);
533
+ console.log(
534
+ chalk.dim(
535
+ `Compacted: ${before.toLocaleString()} → ${after.toLocaleString()} tokens`
536
+ )
537
+ );
538
+ break;
539
+ }
540
+
541
+ case "/history": {
542
+ if (state.history.length === 0) {
543
+ console.log(chalk.dim("No messages yet."));
544
+ } else {
545
+ let turnNum = 0;
546
+ for (const msg of state.history) {
547
+ if (msg.role === "user" && typeof msg.content === "string") {
548
+ turnNum++;
549
+ const preview = msg.content.length > 80 ? msg.content.slice(0, 77) + "..." : msg.content;
550
+ console.log(chalk.cyan(` ${turnNum}. `) + preview);
551
+ } else if (msg.role === "assistant" && typeof msg.content === "string") {
552
+ const preview = msg.content.length > 80 ? msg.content.slice(0, 77) + "..." : msg.content;
553
+ console.log(chalk.dim(` → ${preview}`));
554
+ }
555
+ }
556
+ }
557
+ break;
558
+ }
559
+
560
+ case "/undo": {
561
+ // Remove last user + assistant turn
562
+ if (state.history.length < 2) {
563
+ console.log(chalk.dim("Nothing to undo."));
564
+ } else {
565
+ // Find and remove the last user message and everything after
566
+ let lastUserIdx = -1;
567
+ for (let i = state.history.length - 1; i >= 0; i--) {
568
+ if (state.history[i]!.role === "user") {
569
+ lastUserIdx = i;
570
+ break;
571
+ }
572
+ }
573
+ if (lastUserIdx >= 0) {
574
+ const removed = state.history.length - lastUserIdx;
575
+ state.history.splice(lastUserIdx);
576
+ console.log(chalk.dim(`Undid last turn (removed ${removed} messages).`));
577
+ }
578
+ }
579
+ break;
580
+ }
581
+
582
+ case "/restore": {
583
+ if (!arg) {
584
+ const snapshots = fileHistory.getSnapshotFiles();
585
+ if (snapshots.length === 0) {
586
+ console.log(chalk.dim("No file snapshots available."));
587
+ } else {
588
+ console.log(chalk.bold("Files with snapshots:"));
589
+ for (const s of snapshots) {
590
+ console.log(chalk.dim(` ${s.path} (${s.count} snapshot(s))`));
591
+ }
592
+ console.log(chalk.dim("\nUsage: /restore <file-path>"));
593
+ }
594
+ } else {
595
+ const restored = await fileHistory.restore(arg);
596
+ if (restored) {
597
+ console.log(chalk.green(`Restored: ${arg}`));
598
+ } else {
599
+ console.log(chalk.red(`No snapshot found for: ${arg}`));
600
+ }
601
+ }
602
+ break;
603
+ }
604
+
605
+ case "/memory": {
606
+ const mems = await loadMemories(process.cwd());
607
+ if (mems.length === 0) {
608
+ console.log(chalk.dim("No memories for this project. The model can save memories using MemorySave."));
609
+ } else {
610
+ console.log(chalk.bold(`${mems.length} project memories:`));
611
+ for (const m of mems) {
612
+ console.log(chalk.dim(` ${chalk.bold(m.name)} (${m.type}): ${m.description || m.content.slice(0, 60)}`));
613
+ }
614
+ }
615
+ break;
616
+ }
617
+
618
+ case "/skills": {
619
+ const allSkills = state.skillRegistry.getAll();
620
+ if (allSkills.length === 0) {
621
+ console.log(chalk.dim("No skills loaded. Add .md files to ~/.ashlrcode/skills/"));
622
+ } else {
623
+ console.log(chalk.bold(`${allSkills.length} skills:`));
624
+ for (const s of allSkills) {
625
+ console.log(chalk.dim(` ${chalk.bold(s.trigger)} — ${s.description}`));
626
+ }
627
+ }
628
+ break;
629
+ }
630
+
631
+ case "/tools": {
632
+ const tools = state.registry.getAll();
633
+ console.log(chalk.bold(`${tools.length} tools registered:`));
634
+ for (const tool of tools) {
635
+ const flags = [
636
+ tool.isReadOnly() ? chalk.green("read-only") : chalk.yellow("write"),
637
+ tool.isConcurrencySafe() ? "parallel" : "serial",
638
+ ].join(", ");
639
+ console.log(chalk.dim(` ${chalk.bold(tool.name)} (${flags})`));
640
+ }
641
+ break;
642
+ }
643
+
644
+ case "/diff": {
645
+ const proc = Bun.spawn(["git", "diff", "--stat"], {
646
+ cwd: process.cwd(),
647
+ stdout: "pipe",
648
+ stderr: "pipe",
649
+ });
650
+ const stdout = await new Response(proc.stdout).text();
651
+ if (stdout.trim()) {
652
+ console.log(stdout);
653
+ } else {
654
+ console.log(chalk.dim("No uncommitted changes."));
655
+ }
656
+ break;
657
+ }
658
+
659
+ case "/git": {
660
+ const gitInfo = await getGitContext(process.cwd());
661
+ if (!gitInfo.isRepo) {
662
+ console.log(chalk.dim("Not a git repository."));
663
+ } else {
664
+ console.log(chalk.bold("Git:"));
665
+ console.log(chalk.dim(` Branch: ${gitInfo.branch}`));
666
+ console.log(chalk.dim(` Remote: ${gitInfo.remoteUrl ?? "none"}`));
667
+ const changes = gitInfo.status?.split("\n").filter(Boolean).length ?? 0;
668
+ console.log(chalk.dim(` Changes: ${changes > 0 ? changes : "clean"}`));
669
+ }
670
+ break;
671
+ }
672
+
673
+ case "/buddy": {
674
+ if (arg === "name" || arg?.startsWith("name ")) {
675
+ const newName = arg.replace("name", "").trim();
676
+ if (newName) {
677
+ state.buddy.name = newName;
678
+ await saveBuddy(state.buddy);
679
+ console.log(theme.success(` Buddy renamed to ${newName}!`));
680
+ } else {
681
+ console.log(theme.tertiary(" Usage: /buddy name <new-name>"));
682
+ }
683
+ } else {
684
+ printBuddy(state.buddy);
685
+ const b = state.buddy;
686
+ const shinyStr = b.shiny ? " ✨ SHINY" : "";
687
+ console.log(theme.primary(` ${b.name} the ${b.species}${shinyStr}`));
688
+ console.log(theme.primary(` Rarity: ${b.rarity.toUpperCase()} · Level ${b.level} · Hat: ${b.hat}`));
689
+ console.log(theme.primary(` Stats: 🐛${b.stats.debugging} 🧘${b.stats.patience} 🌀${b.stats.chaos} 🦉${b.stats.wisdom} 😏${b.stats.snark}`));
690
+ console.log(theme.primary(` Mood: ${b.mood}`));
691
+ console.log(theme.tertiary(` Sessions: ${b.totalSessions} · Tool calls: ${b.toolCalls}`));
692
+ console.log(theme.tertiary(`\n Rename: /buddy name <new-name>`));
693
+ }
694
+ break;
695
+ }
696
+
697
+ case "/effort": {
698
+ const levels = ["fast", "balanced", "thorough"];
699
+ if (!arg) {
700
+ console.log(theme.primary("Effort: " + (state.router.currentProvider.config.maxTokens === 4096 ? "fast" : state.router.currentProvider.config.maxTokens === 16384 ? "thorough" : "balanced")));
701
+ console.log(theme.tertiary(" fast — shorter responses, fewer tokens"));
702
+ console.log(theme.tertiary(" balanced — default behavior"));
703
+ console.log(theme.tertiary(" thorough — deeper analysis, more tokens"));
704
+ console.log(theme.tertiary(" Usage: /effort <level>"));
705
+ } else if (levels.includes(arg)) {
706
+ const tokenMap: Record<string, number> = { fast: 4096, balanced: 8192, thorough: 16384 };
707
+ state.router.currentProvider.config.maxTokens = tokenMap[arg]!;
708
+ console.log(theme.success(` Effort set to: ${arg} (${tokenMap[arg]} max tokens)`));
709
+ } else {
710
+ console.log(theme.error(` Unknown effort level. Choose: ${levels.join(", ")}`));
711
+ }
712
+ break;
713
+ }
714
+
715
+ case "/btw": {
716
+ if (!arg) {
717
+ console.log(theme.tertiary(" Ask a quick side question: /btw <question>"));
718
+ } else {
719
+ await runTurn(`[Side question — answer briefly, don't change the main task] ${arg}`, state);
720
+ }
721
+ break;
722
+ }
723
+
724
+ case "/status": {
725
+ const taskList = await import("./tools/tasks.ts");
726
+ console.log(theme.primary("Session: ") + theme.accent(state.session.id));
727
+ console.log(theme.primary("Provider: ") + theme.accent(state.router.currentProvider.name + ":" + state.router.currentProvider.config.model));
728
+ console.log(theme.primary("Messages: ") + theme.tokens(String(state.history.length)));
729
+ console.log(theme.primary("Cost: ") + styleCost(state.router.costs.totalCostUSD));
730
+ const ctxLimit = getProviderContextLimit(state.router.currentProvider.name);
731
+ const ctxUsed = estimateTokens(state.history);
732
+ console.log(theme.primary("Context: ") + styleTokens(ctxUsed) + theme.tertiary(" / ") + styleTokens(ctxLimit) + theme.tertiary(` (${Math.round((ctxUsed / ctxLimit) * 100)}%)`));
733
+ break;
734
+ }
735
+
736
+ case "/help":
737
+ printCommands();
738
+ break;
739
+
740
+ default:
741
+ console.log(theme.tertiary(`Unknown command: ${cmd}. Type /help for available commands.`));
742
+ }
743
+ }
744
+
745
+ async function runTurn(input: string, state: AppState, printMode = false): Promise<void> {
746
+ const spinner = printMode ? null : new Spinner("Thinking");
747
+ let firstTextReceived = false;
748
+
749
+ try {
750
+ // Ultrathink: if user includes "ultrathink" in message, use max tokens for this turn
751
+ const isUltrathink = input.toLowerCase().includes("ultrathink");
752
+ const savedMaxTokens = state.router.currentProvider.config.maxTokens;
753
+ if (isUltrathink) {
754
+ state.router.currentProvider.config.maxTokens = 32768;
755
+ if (!printMode) console.log(theme.accent(" ⚡ Ultrathink mode — deep reasoning enabled\n"));
756
+ }
757
+
758
+ // Check cost budget
759
+ if (maxCostUSD < Infinity && state.router.costs.totalCostUSD >= maxCostUSD) {
760
+ console.error(chalk.yellow(`\n Cost limit reached ($${state.router.costs.totalCostUSD.toFixed(4)} >= $${maxCostUSD}). Use --max-cost to increase.`));
761
+ return;
762
+ }
763
+
764
+ // Build system prompt (base + plan mode if active)
765
+ const systemPrompt =
766
+ state.baseSystemPrompt + getPlanModePrompt();
767
+
768
+ // Check if context needs compaction before this turn
769
+ const systemTokens = Math.ceil(systemPrompt.length / 4);
770
+ const contextLimit = getProviderContextLimit(state.router.currentProvider.name);
771
+
772
+ // Warn at 50% and 75% of context limit
773
+ const currentTokens = estimateTokens(state.history) + systemTokens;
774
+ if (!printMode && currentTokens > contextLimit * 0.85) {
775
+ console.log(theme.error(` ⚠ Context at ${Math.round((currentTokens / contextLimit) * 100)}% — approaching limit!`));
776
+ console.log(theme.tertiary(` 💡 Run /compact to shrink context, or start fresh with ac --continue`));
777
+ } else if (!printMode && currentTokens > contextLimit * 0.75) {
778
+ console.log(theme.warning(` ⚠ Context at ${Math.round((currentTokens / contextLimit) * 100)}% of ${contextLimit.toLocaleString()} token limit`));
779
+ } else if (!printMode && currentTokens > contextLimit * 0.5) {
780
+ console.log(theme.tertiary(` Context at ${Math.round((currentTokens / contextLimit) * 100)}% of limit`));
781
+ }
782
+
783
+ if (needsCompaction(state.history, systemTokens, { maxContextTokens: contextLimit })) {
784
+ if (!printMode) console.log(chalk.dim(" [compacting context...]"));
785
+ state.history = snipCompact(state.history);
786
+ state.history = await autoCompact(state.history, state.router);
787
+ }
788
+
789
+ // Start spinner (not in print mode)
790
+ spinner?.start();
791
+ resetMarkdown();
792
+
793
+ // Auto-title session from first message
794
+ if (state.history.length === 0) {
795
+ const title = input.length > 60 ? input.slice(0, 57) + "..." : input;
796
+ await state.session.setTitle(title);
797
+ }
798
+
799
+ // Capture message count AFTER compaction (not before)
800
+ const preTurnMessageCount = state.history.length;
801
+
802
+ // Echo user input as a styled message (so it stays visible during output)
803
+ if (!printMode) {
804
+ console.log("\n" + theme.accent(" ❯ ") + theme.primary(input.length > 100 ? input.slice(0, 97) + "..." : input));
805
+ console.log("");
806
+ }
807
+
808
+ const result = await runAgentLoop(input, state.history, {
809
+ systemPrompt,
810
+ router: state.router,
811
+ toolRegistry: state.registry,
812
+ toolContext: state.toolContext,
813
+ readOnly: isPlanMode(),
814
+ onText: (text) => {
815
+ if (!firstTextReceived) {
816
+ spinner?.stop();
817
+ firstTextReceived = true;
818
+ if (!printMode) console.log(""); // breathing room before response
819
+ }
820
+ if (printMode) {
821
+ process.stdout.write(text);
822
+ } else {
823
+ const rendered = renderMarkdownDelta(text);
824
+ process.stdout.write(rendered);
825
+ }
826
+ },
827
+ onToolStart: (name, toolInput) => {
828
+ if (printMode) return;
829
+ spinner?.stop();
830
+ firstTextReceived = false;
831
+ recordThinking(state.buddy);
832
+ const icon = isPlanMode() ? theme.plan("◆") : theme.toolIcon("◆");
833
+ const preview = formatToolPreview(name, toolInput);
834
+ console.log(`\n ${icon} ${theme.toolName(name)}`);
835
+ console.log(theme.tertiary(` ${preview}`));
836
+ if (isFirstToolCall()) {
837
+ console.log(getBuddyReaction(state.buddy, "first_tool"));
838
+ }
839
+ spinner?.start(getToolPhrase(name));
840
+ },
841
+ onToolEnd: (_name, result, isError) => {
842
+ if (printMode) return;
843
+ spinner?.stop();
844
+ if (isError) {
845
+ recordError(state.buddy);
846
+ } else {
847
+ recordToolCallSuccess(state.buddy);
848
+ }
849
+ const status = isError ? theme.error(" ✗") : theme.success(" ✓");
850
+ const lines = result.split("\n");
851
+ const preview = lines[0]?.slice(0, 90) ?? "";
852
+ const extra =
853
+ lines.length > 1
854
+ ? theme.tertiary(` (+${lines.length - 1} lines)`)
855
+ : "";
856
+ console.log(`${status} ${theme.toolResult(preview)}${extra}`);
857
+ if (isError) {
858
+ console.log(getBuddyReaction(state.buddy, "error"));
859
+ }
860
+ console.log("");
861
+ },
862
+ });
863
+
864
+ spinner?.stop();
865
+
866
+ // Flush any remaining markdown buffer
867
+ const remaining = flushMarkdown();
868
+ if (remaining) process.stdout.write(remaining);
869
+
870
+ // Update history
871
+ state.history.length = 0;
872
+ state.history.push(...result.messages);
873
+
874
+ // Persist all new messages from this turn (not just last 2)
875
+ const newMessages = result.messages.slice(preTurnMessageCount);
876
+ if (newMessages.length > 0) {
877
+ await state.session.appendMessages(newMessages);
878
+ }
879
+
880
+ // Restore max tokens if ultrathink was used
881
+ if (isUltrathink && savedMaxTokens !== undefined) {
882
+ state.router.currentProvider.config.maxTokens = savedMaxTokens;
883
+ }
884
+ } catch (err) {
885
+ spinner?.stop();
886
+ resetMarkdown(); // Ensure markdown state is clean after errors
887
+
888
+ const error = err instanceof Error ? err : new Error(String(err));
889
+ const categorized = categorizeError(error);
890
+
891
+ switch (categorized.category) {
892
+ case "rate_limit":
893
+ console.error(chalk.yellow(`\nRate limited. The router will try the next provider automatically.`));
894
+ break;
895
+ case "auth":
896
+ console.error(chalk.red(`\nAuth error: ${categorized.message}`));
897
+ console.error(chalk.dim("Check your API key (XAI_API_KEY or ANTHROPIC_API_KEY)"));
898
+ break;
899
+ case "network":
900
+ console.error(chalk.red(`\nNetwork error: ${categorized.message}`));
901
+ break;
902
+ default:
903
+ console.error(chalk.red(`\nError: ${categorized.message}`));
904
+ }
905
+ }
906
+ }
907
+
908
+ async function loadSystemPrompt(): Promise<string> {
909
+ // Load base system prompt
910
+ const promptPath = resolve(import.meta.dir, "../../prompts/system.md");
911
+ let prompt = "";
912
+ if (existsSync(promptPath)) {
913
+ prompt = await readFile(promptPath, "utf-8");
914
+ }
915
+
916
+ const projectConfig = await loadProjectConfig(process.cwd());
917
+ if (projectConfig.instructions) {
918
+ prompt += `\n\n# Project Instructions\n\n${projectConfig.instructions}`;
919
+ }
920
+
921
+ // Add environment context
922
+ prompt += `\n\n# Environment
923
+ - Working directory: ${process.cwd()}
924
+ - Platform: ${process.platform}
925
+ - Date: ${new Date().toISOString().split("T")[0]}
926
+ `;
927
+
928
+ return prompt;
929
+ }
930
+
931
+ async function askPermission(
932
+ tool: string,
933
+ description: string
934
+ ): Promise<boolean> {
935
+ // In plan mode, block non-read-only tools silently
936
+ if (isPlanMode()) {
937
+ return false;
938
+ }
939
+
940
+ // Check permission system
941
+ const perm = checkPermission(tool);
942
+ if (perm === "allow") return true;
943
+ if (perm === "deny") return false;
944
+
945
+ return new Promise((resolve) => {
946
+ const rl = createInterface({
947
+ input: process.stdin,
948
+ output: process.stdout,
949
+ });
950
+
951
+ rl.question(
952
+ chalk.yellow(` Allow ${chalk.bold(tool)}? `) +
953
+ chalk.dim(description) +
954
+ chalk.yellow("\n [y]es / [a]lways / [n]o / [d]eny always: "),
955
+ async (answer) => {
956
+ rl.close();
957
+ const choice = answer.toLowerCase().trim();
958
+ switch (choice) {
959
+ case "y":
960
+ case "yes":
961
+ resolve(true);
962
+ break;
963
+ case "a":
964
+ case "always":
965
+ await recordPermission(tool, "always_allow");
966
+ console.log(chalk.dim(` ${tool} will be auto-allowed from now on.`));
967
+ resolve(true);
968
+ break;
969
+ case "d":
970
+ case "deny":
971
+ await recordPermission(tool, "always_deny");
972
+ console.log(chalk.dim(` ${tool} will be auto-denied from now on.`));
973
+ resolve(false);
974
+ break;
975
+ default:
976
+ resolve(false);
977
+ break;
978
+ }
979
+ }
980
+ );
981
+ });
982
+ }
983
+
984
+ function formatToolPreview(name: string, input: Record<string, unknown>): string {
985
+ switch (name) {
986
+ case "Bash":
987
+ return `$ ${input.command}`;
988
+ case "Read":
989
+ return `${input.file_path}${input.offset ? `:${input.offset}` : ""}`;
990
+ case "Write":
991
+ return `→ ${input.file_path}`;
992
+ case "Edit":
993
+ return `${input.file_path}`;
994
+ case "Glob":
995
+ return `${input.pattern}${input.path ? ` in ${input.path}` : ""}`;
996
+ case "Grep":
997
+ return `/${input.pattern}/${input.path ? ` in ${input.path}` : ""}`;
998
+ case "WebFetch":
999
+ return `${input.url}`;
1000
+ case "Agent":
1001
+ return `${input.description}`;
1002
+ case "LS":
1003
+ return input.path ? `${input.path}` : ".";
1004
+ default:
1005
+ return JSON.stringify(input).slice(0, 100);
1006
+ }
1007
+ }
1008
+
1009
+ function getArg(args: string[], flag: string): string | undefined {
1010
+ const idx = args.indexOf(flag);
1011
+ if (idx === -1) return undefined;
1012
+ return args[idx + 1];
1013
+ }
1014
+
1015
+ function timeSince(date: Date): string {
1016
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1017
+ if (seconds < 60) return `${seconds}s`;
1018
+ const minutes = Math.floor(seconds / 60);
1019
+ if (minutes < 60) return `${minutes}m`;
1020
+ const hours = Math.floor(minutes / 60);
1021
+ if (hours < 24) return `${hours}h`;
1022
+ const days = Math.floor(hours / 24);
1023
+ return `${days}d`;
1024
+ }
1025
+
1026
+ function printCommands() {
1027
+ console.log(`
1028
+ ${theme.accent("Commands:")}
1029
+ ${theme.toolName("/plan")} ${theme.secondary("Plan mode status")}
1030
+ ${theme.toolName("/cost")} ${theme.secondary("Token usage and costs")}
1031
+ ${theme.toolName("/status")} ${theme.secondary("Full session status")}
1032
+ ${theme.toolName("/effort")} ${theme.tertiary("<lvl>")} ${theme.secondary("Set reasoning effort (fast/balanced/thorough)")}
1033
+ ${theme.toolName("/btw")} ${theme.tertiary("<q>")} ${theme.secondary("Quick side question")}
1034
+ ${theme.toolName("/history")} ${theme.secondary("Conversation turns")}
1035
+ ${theme.toolName("/undo")} ${theme.secondary("Remove last turn")}
1036
+ ${theme.toolName("/restore")} ${theme.tertiary("<f>")} ${theme.secondary("Undo file edit")}
1037
+ ${theme.toolName("/diff")} ${theme.secondary("Git diff --stat")}
1038
+ ${theme.toolName("/git")} ${theme.secondary("Branch, remote, changes")}
1039
+ ${theme.toolName("/compact")} ${theme.secondary("Compress context")}
1040
+ ${theme.toolName("/tools")} ${theme.secondary("List all tools")}
1041
+ ${theme.toolName("/skills")} ${theme.secondary("List all skills")}
1042
+ ${theme.toolName("/memory")} ${theme.secondary("Project memories")}
1043
+ ${theme.toolName("/sessions")} ${theme.secondary("Saved sessions")}
1044
+ ${theme.toolName("/buddy")} ${theme.secondary("Meet your companion")}
1045
+ ${theme.toolName("/model")} ${theme.tertiary("<name>")} ${theme.secondary("Show/switch model")}
1046
+ ${theme.toolName("/clear")} ${theme.secondary("Clear conversation")}
1047
+ ${theme.toolName("/help")} ${theme.secondary("Show this help")}
1048
+ ${theme.toolName("/quit")} ${theme.secondary("Exit")}
1049
+
1050
+ ${theme.accent("Tips:")}
1051
+ ${theme.tertiary("Shift+Tab")} ${theme.secondary("Cycle mode: Normal → Plan → Edits → YOLO")}
1052
+ ${theme.tertiary("line ending \\\\")} ${theme.secondary("Multi-line input")}
1053
+ ${theme.tertiary("[y/a/n/d]")} ${theme.secondary("Permission: yes / always / no / deny-always")}
1054
+ `);
1055
+ }
1056
+
1057
+ function printHelp() {
1058
+ console.log(`
1059
+ ${chalk.bold.cyan("AshlrCode")} ${chalk.dim(`v${VERSION}`)} — Multi-provider AI coding agent
1060
+
1061
+ ${chalk.bold("USAGE")}
1062
+ ac [message] Run with a single message (non-interactive)
1063
+ ac Start interactive REPL
1064
+ ac --resume <id> Resume a previous session
1065
+
1066
+ ${chalk.bold("OPTIONS")}
1067
+ -h, --help Show this help
1068
+ -v, --version Show version
1069
+ -c, --continue Resume last session in this directory
1070
+ --resume <id> Resume a specific session
1071
+ --fork-session <id> Copy session into new session
1072
+ --dangerously-skip-permissions Auto-approve all tool calls (alias: --yolo)
1073
+ --auto-accept-edits Auto-approve Write/Edit (Bash still asks)
1074
+ --print Output only text (for piping)
1075
+ --max-cost <dollars> Stop when cost exceeds limit
1076
+
1077
+ ${chalk.bold("COMMANDS")} (in REPL)
1078
+ /plan Show plan mode status
1079
+ /cost Show token usage and costs
1080
+ /compact Compress conversation context
1081
+ /sessions List saved sessions
1082
+ /model Show current model
1083
+ /clear Clear conversation
1084
+ /help Show available commands
1085
+ /quit Exit
1086
+
1087
+ ${chalk.bold("TOOLS")} (available to the AI)
1088
+ Bash Execute shell commands
1089
+ Read Read files with line numbers
1090
+ Write Create/overwrite files
1091
+ Edit Exact string replacement
1092
+ Glob Find files by pattern
1093
+ Grep Search file contents
1094
+ WebFetch Fetch URLs
1095
+ AskUser Ask questions with structured options
1096
+ Agent Spawn sub-agents for exploration
1097
+ TaskCreate/Update/List Track work progress
1098
+ EnterPlan Enter plan mode (read-only exploration)
1099
+ PlanWrite Write to plan file
1100
+ ExitPlan Exit plan mode
1101
+
1102
+ ${chalk.bold("ENVIRONMENT")}
1103
+ XAI_API_KEY xAI API key (primary provider)
1104
+ ANTHROPIC_API_KEY Anthropic API key (fallback provider)
1105
+ AC_MODEL Override default model
1106
+
1107
+ ${chalk.bold("CONFIG")}
1108
+ ~/.ashlrcode/settings.json Provider configuration
1109
+ ~/.ashlrcode/sessions/ Saved sessions (JSONL)
1110
+ ~/.ashlrcode/plans/ Plan files
1111
+ ./ASHLR.md Project-level instructions
1112
+ ./CLAUDE.md Also supported for compatibility
1113
+ `);
1114
+ }
1115
+
1116
+ // Run
1117
+ main().catch((err) => {
1118
+ console.error(chalk.red(`Fatal: ${err.message}`));
1119
+ process.exit(1);
1120
+ });