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/repl.tsx ADDED
@@ -0,0 +1,1384 @@
1
+ /**
2
+ * Ink-based REPL — replaces readline for interactive mode.
3
+ *
4
+ * Manages display state and bridges between the agent loop callbacks
5
+ * and the Ink React component tree.
6
+ */
7
+
8
+ import React from "react";
9
+ import { render } from "ink";
10
+ import { App } from "./ui/App.tsx";
11
+ import { runAgentLoop } from "./agent/loop.ts";
12
+ import { getCurrentMode, cycleMode } from "./ui/mode.ts";
13
+ import { getEffort, setEffort, cycleEffort, getEffortConfig, getEffortEmoji, type EffortLevel } from "./ui/effort.ts";
14
+ import { estimateTokens, getProviderContextLimit, needsCompaction, autoCompact, snipCompact, contextCollapse } from "./agent/context.ts";
15
+ import { runWithAgentContext, type AgentContext } from "./agent/async-context.ts";
16
+ import { resetMarkdown } from "./ui/markdown.ts";
17
+ import { getBuddyReaction, getBuddyArt, isFirstToolCall, recordThinking, recordToolCallSuccess, recordError, saveBuddy, startBuddyAnimation, stopBuddyAnimation } from "./ui/buddy.ts";
18
+ import { renderBuddyWithBubble } from "./ui/speech-bubble.ts";
19
+ import { isPlanMode, getPlanModePrompt } from "./planning/plan-mode.ts";
20
+ import { categorizeError } from "./agent/error-handler.ts";
21
+ import { theme } from "./ui/theme.ts";
22
+ import { getRemoteSettings, stopPolling as stopRemotePolling } from "./config/remote-settings.ts";
23
+ import { exportSettings, importSettings, getSyncStatus } from "./config/settings-sync.ts";
24
+ import { join } from "path";
25
+ import { formatToolExecution, formatTurnSeparator } from "./ui/message-renderer.ts";
26
+ import chalk from "chalk";
27
+ import type { ProviderRouter } from "./providers/router.ts";
28
+ import type { ToolRegistry } from "./tools/registry.ts";
29
+ import type { ToolContext } from "./tools/types.ts";
30
+ import type { Message } from "./providers/types.ts";
31
+ import type { Session } from "./persistence/session.ts";
32
+ import type { SkillRegistry } from "./skills/registry.ts";
33
+ import type { BuddyData } from "./ui/buddy.ts";
34
+ import { shutdownLSP } from "./tools/lsp.ts";
35
+ import { shutdownBrowser } from "./tools/web-browser.ts";
36
+ import { startIPCServer, stopIPCServer } from "./agent/ipc.ts";
37
+ import { checkPermission, hasPendingPermission, answerPendingPermission, requestPermissionInk } from "./config/permissions.ts";
38
+ import { feature, listFeatures } from "./config/features.ts";
39
+ import { hasPendingQuestion, answerPendingQuestion } from "./tools/ask-user.ts";
40
+ import { generateBuddyComment, shouldUseAI, type BuddyCommentType } from "./ui/buddy-ai.ts";
41
+ import { scanCodebase } from "./autopilot/scanner.ts";
42
+ import { WorkQueue } from "./autopilot/queue.ts";
43
+ import { DEFAULT_CONFIG } from "./autopilot/types.ts";
44
+ import { generateDream, loadRecentDreams, formatDreamsForPrompt, IdleDetector } from "./agent/dream.ts";
45
+ import { FileHistoryStore, setFileHistory, getFileHistory } from "./state/file-history.ts";
46
+ import { loadKeybindings, getBindings, InputHistory } from "./ui/keybindings.ts";
47
+ import { SpeculationCache } from "./agent/speculation.ts";
48
+ import { setSpeculationCache } from "./agent/tool-executor.ts";
49
+ import { KairosLoop, detectTerminalFocus } from "./agent/kairos.ts";
50
+ import { notifyTurnComplete, notifyError } from "./ui/notifications.ts";
51
+ import { initTelemetry, logEvent, readRecentEvents, formatEvents } from "./telemetry/event-log.ts";
52
+ import { createTrigger, listTriggers, deleteTrigger, toggleTrigger, TriggerRunner } from "./agent/cron.ts";
53
+ import { startRecording, stopRecording, isRecording, transcribeRecording, checkVoiceAvailability, type VoiceConfig } from "./voice/voice-mode.ts";
54
+ import { checkForUpgrade } from "./config/upgrade-notice.ts";
55
+ import { VERSION } from "./version.ts";
56
+ import { startBridgeServer, stopBridgeServer, getBridgePort } from "./bridge/bridge-server.ts";
57
+ import { randomBytes } from "crypto";
58
+
59
+ // Buddy quips (imported from banner for status line)
60
+ const QUIPS: Record<string, string[]> = {
61
+ happy: [
62
+ "ship it, yolo",
63
+ "lgtm, didn't read a damn thing",
64
+ "tests are for people with trust issues",
65
+ "it works on my machine, deploy it",
66
+ "that code is mid but whatever",
67
+ "we move fast and break stuff here",
68
+ "clean code is for nerds",
69
+ "have you tried turning it off and never back on",
70
+ "git push --force and pray",
71
+ "code review? I am the code review",
72
+ "technically it compiles",
73
+ "the real bugs were the friends we made",
74
+ "this is either genius or insanity",
75
+ "stack overflow told me to do this",
76
+ "my therapist says I should stop enabling devs",
77
+ ],
78
+ thinking: [
79
+ "hold on, downloading more brain...",
80
+ "consulting my imaginary friend",
81
+ "pretending to understand your code",
82
+ "asking chatgpt for help (jk... unless?)",
83
+ "processing... or napping, hard to tell",
84
+ "my last brain cell is working overtime",
85
+ "calculating the meaning of your spaghetti code",
86
+ "I've seen worse... actually no I haven't",
87
+ "trying not to hallucinate here",
88
+ "one sec, arguing with myself",
89
+ ],
90
+ sleepy: [
91
+ "*yawns in binary*",
92
+ "do we HAVE to code right now?",
93
+ "I was having a great dream about typescript",
94
+ "loading enthusiasm... 404 not found",
95
+ "five more minutes...",
96
+ "my motivation called in sick today",
97
+ "I'm not lazy, I'm energy efficient",
98
+ "can we just deploy yesterday's code again?",
99
+ ],
100
+ };
101
+ let quipIdx = Math.floor(Math.random() * 10);
102
+ function getQuip(mood: string): string {
103
+ const q = QUIPS[mood] ?? QUIPS.sleepy!;
104
+ quipIdx = (quipIdx + 1) % q.length;
105
+ return q[quipIdx]!;
106
+ }
107
+
108
+ interface ReplState {
109
+ router: ProviderRouter;
110
+ registry: ToolRegistry;
111
+ toolContext: ToolContext;
112
+ session: Session;
113
+ history: Message[];
114
+ baseSystemPrompt: string;
115
+ skillRegistry: SkillRegistry;
116
+ buddy: BuddyData;
117
+ }
118
+
119
+ export function startInkRepl(state: ReplState, maxCostUSD: number): void {
120
+ // We need addOutput before overriding requestPermission, but addOutput is
121
+ // defined later. Use a deferred wrapper that gets patched after addOutput exists.
122
+ let _addOutput: (text: string) => void = () => {};
123
+
124
+ // Override requestPermission to use Ink-native prompts instead of readline.
125
+ // This replaces the old setBypassMode(true) which disabled ALL permission checks.
126
+ state.toolContext.requestPermission = async (toolName: string, description: string) => {
127
+ const perm = checkPermission(toolName);
128
+ if (perm === "allow") return true;
129
+ if (perm === "deny") return false;
130
+ // Show permission prompt inline in the output stream
131
+ _addOutput(`\n ⚡ ${theme.warning("Permission:")} ${theme.primary(toolName)}`);
132
+ _addOutput(theme.tertiary(` ${description}`));
133
+ _addOutput(theme.tertiary(` [y] allow [a] always [n] deny [d] always deny\n`));
134
+ return requestPermissionInk(toolName, description);
135
+ };
136
+
137
+ startBuddyAnimation();
138
+
139
+ // Autopilot work queue
140
+ const workQueue = new WorkQueue(state.toolContext.cwd);
141
+ workQueue.load().catch(() => {});
142
+
143
+ // File history for undo support
144
+ const fileHistoryStore = new FileHistoryStore(state.session.id);
145
+ setFileHistory(fileHistoryStore);
146
+ fileHistoryStore.loadFromDisk().catch(() => {});
147
+
148
+ // IPC server — enables peer discovery and inter-process messaging
149
+ startIPCServer(state.session.id, state.toolContext.cwd).catch(() => {});
150
+
151
+ // Speculation cache — pre-fetches likely read-only tool results
152
+ const speculationCache = new SpeculationCache(100, 30_000);
153
+ setSpeculationCache(speculationCache);
154
+
155
+ // KAIROS autonomous mode — lazy-initialized when /kairos is used
156
+ let kairos: KairosLoop | null = null;
157
+
158
+ // Cron trigger runner — background polling for due triggers
159
+ // Uses deferred callback since runTurnInk is defined later
160
+ let _triggerCallback: ((prompt: string) => Promise<void>) | null = null;
161
+ const triggerRunner = new TriggerRunner(async (trigger) => {
162
+ if (isProcessing) return; // Don't fire during active turn
163
+ addOutput(theme.accent(`\n ⏰ Trigger: ${trigger.name} (${trigger.schedule})\n`));
164
+ if (_triggerCallback) await _triggerCallback(trigger.prompt);
165
+ });
166
+ triggerRunner.start(15_000);
167
+
168
+ // Initialize local event telemetry
169
+ initTelemetry(state.session.id);
170
+ logEvent("session_start", { cwd: state.toolContext.cwd }).catch(() => {});
171
+
172
+ // Bridge server — expose API for IDE extensions and remote clients
173
+ const bridgePort = parseInt(process.env.AC_BRIDGE_PORT ?? "", 10);
174
+ if (bridgePort > 0) {
175
+ const bridgeToken = process.env.AC_BRIDGE_TOKEN ?? randomBytes(16).toString("hex");
176
+ startBridgeServer({
177
+ port: bridgePort,
178
+ authToken: bridgeToken,
179
+ onSubmit: async (prompt) => {
180
+ if (_triggerCallback) await _triggerCallback(prompt);
181
+ return "Submitted";
182
+ },
183
+ getStatus: () => ({
184
+ mode: getCurrentMode(),
185
+ contextPercent: Math.round(
186
+ (estimateTokens(state.history) / getProviderContextLimit(state.router.currentProvider.name)) * 100,
187
+ ),
188
+ isProcessing,
189
+ sessionId: state.session.id,
190
+ }),
191
+ getHistory: () =>
192
+ state.history.map((m) => ({
193
+ role: m.role,
194
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content).slice(0, 200),
195
+ })),
196
+ });
197
+ }
198
+
199
+ // Keybindings & input history
200
+ loadKeybindings().catch(() => {});
201
+ const inputHistory = new InputHistory();
202
+
203
+ // Load dreams from previous sessions into system prompt
204
+ loadRecentDreams(3).then(dreams => {
205
+ if (dreams.length > 0) {
206
+ const dreamContext = formatDreamsForPrompt(dreams);
207
+ state.baseSystemPrompt += "\n\n" + dreamContext;
208
+ }
209
+ }).catch(() => {});
210
+
211
+ // Idle detector — generate dream when user is idle for 2 minutes
212
+ const idleDetector = new IdleDetector(async () => {
213
+ if (state.history.length > 4) {
214
+ await generateDream(state.history, state.session.id).catch(() => {});
215
+ }
216
+ }, 120_000);
217
+
218
+ let items: Array<{ id: number; text: string }> = [
219
+ { id: 0, text: theme.accent(" Ready. Permission prompts enabled.") },
220
+ ];
221
+ let nextId = 1;
222
+ let turnCount = 0;
223
+ let currentQuipType: BuddyCommentType = "quip";
224
+ let cachedQuip = getQuip(state.buddy.mood); // Cache quip — don't regenerate on every render
225
+ let lastToolName = "";
226
+ let lastToolResult = "";
227
+ let lastHadError = false;
228
+ let toolStartTime = 0;
229
+ let turnToolCount = 0;
230
+ let currentToolInput: Record<string, unknown> = {};
231
+ let aiCommentGen = 0; // Guards against stale AI callbacks overwriting mid-turn
232
+ let aiCommentInFlight = false;
233
+ let isProcessing = false;
234
+ let spinnerText = "Thinking";
235
+ const formatTk = (n: number) => n >= 1_000_000 ? `${(n/1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n/1_000).toFixed(0)}K` : `${n}`;
236
+
237
+ const MAX_ITEMS = 2000;
238
+
239
+ function addOutput(text: string) {
240
+ items = [...items.slice(-MAX_ITEMS), { id: nextId++, text }];
241
+ update();
242
+ }
243
+ // Patch the deferred wrapper so permission prompts can use addOutput
244
+ _addOutput = addOutput;
245
+
246
+ // Check for upgrades (fire and forget)
247
+ checkForUpgrade(VERSION).then(newVersion => {
248
+ if (newVersion) {
249
+ addOutput(theme.warning(`\n ⬆ AshlrCode ${newVersion} available (current: ${VERSION}). Run: bun update -g ashlrcode\n`));
250
+ }
251
+ }).catch(() => {});
252
+
253
+ function getDisplayProps() {
254
+ const ctxLimit = getProviderContextLimit(state.router.currentProvider.name);
255
+ const ctxUsed = estimateTokens(state.history);
256
+ const ctxPct = Math.round((ctxUsed / ctxLimit) * 100);
257
+ const mode = getCurrentMode();
258
+ const modeColors: Record<string, string> = { normal: "green", plan: "magenta", "accept-edits": "yellow", yolo: "red" };
259
+
260
+ const effort = getEffort();
261
+ const effortDisplay = effort !== "normal" ? ` ${getEffortEmoji()} ${effort}` : "";
262
+
263
+ return {
264
+ mode: mode + effortDisplay,
265
+ modeColor: modeColors[mode] ?? "green",
266
+ contextPercent: ctxPct,
267
+ contextUsed: formatTk(ctxUsed),
268
+ contextLimit: formatTk(ctxLimit),
269
+ buddyName: state.buddy.name,
270
+ buddyQuip: cachedQuip,
271
+ buddyQuipType: currentQuipType,
272
+ buddyArt: getBuddyArt(state.buddy),
273
+ items,
274
+ isProcessing,
275
+ spinnerText,
276
+ commands: [
277
+ "/help", "/cost", "/status", "/effort", "/btw", "/history", "/undo",
278
+ "/restore", "/tools", "/skills", "/buddy", "/memory", "/sessions",
279
+ "/model", "/compact", "/diff", "/git", "/clear", "/quit",
280
+ "/autopilot", "/autopilot scan", "/autopilot queue", "/autopilot auto",
281
+ "/autopilot approve all", "/autopilot run", "/features", "/keybindings",
282
+ "/kairos", "/kairos stop", "/telemetry", "/voice",
283
+ ...state.skillRegistry.getAll().map(s => s.trigger),
284
+ ],
285
+ };
286
+ }
287
+
288
+ async function handleSubmit(input: string) {
289
+ idleDetector.ping();
290
+ inputHistory.push(input);
291
+ logEvent("turn_start", { input: input.slice(0, 100) }).catch(() => {});
292
+
293
+ // If the AskUser tool is waiting for an answer, route the input there
294
+ // instead of starting a new agent turn.
295
+ if (hasPendingQuestion()) {
296
+ answerPendingQuestion(input);
297
+ return;
298
+ }
299
+
300
+ // If a permission prompt is waiting, route the input there
301
+ if (hasPendingPermission()) {
302
+ const handled = answerPendingPermission(input.trim());
303
+ if (handled) {
304
+ addOutput(theme.success(` ✓ ${input.trim()}`));
305
+ return;
306
+ }
307
+ // Unrecognized key — remind user of valid options
308
+ addOutput(theme.warning(" Type y/a/n/d to answer the permission prompt."));
309
+ return;
310
+ }
311
+
312
+ // Prevent concurrent turns
313
+ if (isProcessing) return;
314
+
315
+ // Detect image file paths (drag-and-drop inserts path as text)
316
+ const imageMatch = input.match(/(?:^|\s)(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/i)
317
+ ?? input.match(/(?:^|\s)([^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/i);
318
+
319
+ if (imageMatch) {
320
+ const imagePath = imageMatch[1]!;
321
+ const textPart = input.replace(imagePath, "").trim() || "Describe this image.";
322
+ try {
323
+ const { existsSync } = await import("fs");
324
+ const { readFile } = await import("fs/promises");
325
+ const { resolve } = await import("path");
326
+
327
+ const fullPath = resolve(state.toolContext.cwd, imagePath);
328
+ if (existsSync(fullPath)) {
329
+ const buffer = await readFile(fullPath);
330
+ const base64 = buffer.toString("base64");
331
+ const ext = fullPath.split(".").pop()?.toLowerCase() ?? "png";
332
+ const mime = ext === "jpg" ? "jpeg" : ext;
333
+
334
+ addOutput(theme.accent(`\n 📎 [Image: ${imagePath.split("/").pop()}]\n`));
335
+ addOutput(theme.secondary(` ${textPart}\n`));
336
+
337
+ // Send as multimodal message with image
338
+ await runTurnInkWithImage(textPart, `data:image/${mime};base64,${base64}`);
339
+ return;
340
+ }
341
+ } catch {}
342
+ }
343
+
344
+ // Smart paste: collapse long multi-line text
345
+ const lines = input.split("\n");
346
+ let displayInput = input;
347
+ if (lines.length > 10) {
348
+ displayInput = `[Pasted ${lines.length} lines]`;
349
+ }
350
+
351
+ // Smart paste detection — categorize pasted content for better display
352
+ if (lines.length > 3) {
353
+ // Detect JSON
354
+ try {
355
+ JSON.parse(input);
356
+ displayInput = `[Pasted JSON — ${input.length} chars]`;
357
+ } catch {}
358
+
359
+ // Detect stack trace
360
+ if (input.includes("at ") && (input.includes("Error:") || input.includes("error:"))) {
361
+ displayInput = `[Stack trace — ${lines.length} lines]`;
362
+ }
363
+
364
+ // Detect diff/patch
365
+ if (input.startsWith("diff ") || input.startsWith("---") || lines.some(l => l.startsWith("@@"))) {
366
+ displayInput = `[Pasted diff — ${lines.length} lines]`;
367
+ }
368
+ }
369
+
370
+ // Handle built-in commands
371
+ if (input.startsWith("/")) {
372
+ // Skills
373
+ if (state.skillRegistry.isSkill(input.split(" ")[0]!)) {
374
+ const expanded = state.skillRegistry.expand(input);
375
+ if (expanded) {
376
+ addOutput(theme.accent(`\n ⚡ Running skill: ${input.split(" ")[0]}\n`));
377
+ await runTurnInk(expanded);
378
+ return;
379
+ }
380
+ }
381
+
382
+ // Commands
383
+ const handled = await handleCommand(input);
384
+ if (handled) return;
385
+ }
386
+
387
+ await runTurnInk(input, displayInput);
388
+ }
389
+
390
+ /** Run with image attachment */
391
+ async function runTurnInkWithImage(text: string, imageDataUrl: string) {
392
+ isProcessing = true; spinnerText = "Analyzing image"; update();
393
+ try {
394
+ const { getUndercoverPrompt } = await import("./config/undercover.ts");
395
+ const { getModelPatches: getPatches } = await import("./agent/model-patches.ts");
396
+ const imgModelPatches = getPatches(state.router.currentProvider.config.model).combinedSuffix;
397
+ const systemPrompt = state.baseSystemPrompt + getPlanModePrompt() + imgModelPatches + getUndercoverPrompt();
398
+ const userMsg: import("./providers/types.ts").Message = { role: "user", content: [{ type: "image_url", image_url: { url: imageDataUrl } }, { type: "text", text }] };
399
+ const preTurn = state.history.length;
400
+ state.history.push(userMsg);
401
+ const result = await runAgentLoop("", state.history, { systemPrompt, router: state.router, toolRegistry: state.registry, toolContext: state.toolContext, readOnly: isPlanMode(), onText: (t) => { isProcessing = false; addOutput(t); update(); }, onToolStart: (name) => { isProcessing = true; spinnerText = name; update(); }, onToolEnd: (_n, r, e) => { isProcessing = false; addOutput((e ? theme.error(" ✗ ") : theme.success(" ✓ ")) + r.split("\n")[0]?.slice(0, 90)); update(); } });
402
+ state.history.length = 0; state.history.push(...result.messages);
403
+ const newMsgs = result.messages.slice(preTurn);
404
+ if (newMsgs.length > 0) await state.session.appendMessages(newMsgs);
405
+ } catch (err) { addOutput(theme.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`)); }
406
+ isProcessing = false; update();
407
+ }
408
+
409
+ async function handleCommand(input: string): Promise<boolean> {
410
+ const [cmd, ...rest] = input.split(" ");
411
+ const arg = rest.join(" ").trim();
412
+
413
+ switch (cmd) {
414
+ case "/help":
415
+ addOutput(`\nCommands: /plan /cost /status /effort /btw /history /undo /restore /tools /skills /buddy /memory /sessions /model /compact /diff /git /sync /features /keybindings /undercover /patches /kairos /trigger /telemetry /voice /clear /help /quit\n`);
416
+ return true;
417
+ case "/cost":
418
+ addOutput("\n" + state.router.getCostSummary() + "\n");
419
+ return true;
420
+ case "/clear":
421
+ state.history.length = 0;
422
+ addOutput(theme.secondary("\n Conversation cleared.\n"));
423
+ return true;
424
+ case "/quit":
425
+ case "/exit":
426
+ case "/q":
427
+ addOutput("\n" + state.router.getCostSummary());
428
+ saveBuddy(state.buddy).then(() => process.exit(0));
429
+ return true;
430
+ case "/buddy": {
431
+ const b = state.buddy;
432
+ const shinyStr = b.shiny ? " ✨ SHINY" : "";
433
+ addOutput(`\n ${b.name} the ${b.species}${shinyStr}`);
434
+ addOutput(` Rarity: ${b.rarity.toUpperCase()} · Level ${b.level} · Hat: ${b.hat}`);
435
+ addOutput(` Stats: 🐛${b.stats.debugging} 🧘${b.stats.patience} 🌀${b.stats.chaos} 🦉${b.stats.wisdom} 😏${b.stats.snark}`);
436
+ addOutput(` Sessions: ${b.totalSessions} · Tool calls: ${b.toolCalls}\n`);
437
+ return true;
438
+ }
439
+ case "/tools":
440
+ const tools = state.registry.getAll();
441
+ addOutput(`\n ${tools.length} tools: ${tools.map(t => t.name).join(", ")}\n`);
442
+ return true;
443
+ case "/skills":
444
+ const skills = state.skillRegistry.getAll();
445
+ addOutput(`\n ${skills.length} skills: ${skills.map(s => s.trigger).join(", ")}\n`);
446
+ return true;
447
+ case "/model":
448
+ if (arg) {
449
+ const aliases: Record<string, string> = {
450
+ "grok-fast": "grok-4-1-fast-reasoning", "grok-4": "grok-4-0314",
451
+ "grok-3": "grok-3-fast", "sonnet": "claude-sonnet-4-6-20250514",
452
+ "opus": "claude-opus-4-6-20250514", "llama": "llama3.2", "local": "llama3.2",
453
+ };
454
+ state.router.currentProvider.config.model = aliases[arg] ?? arg;
455
+ addOutput(theme.success(`\n Model: ${state.router.currentProvider.config.model}\n`));
456
+ } else {
457
+ addOutput(`\n ${state.router.currentProvider.name}:${state.router.currentProvider.config.model}\n`);
458
+ }
459
+ return true;
460
+ case "/effort": {
461
+ if (arg && ["low", "normal", "high"].includes(arg)) {
462
+ setEffort(arg as EffortLevel);
463
+ addOutput(theme.success(`\n Effort: ${getEffortEmoji()} ${arg}\n`));
464
+ } else {
465
+ const next = cycleEffort();
466
+ addOutput(theme.success(`\n Effort: ${getEffortEmoji()} ${next}\n`));
467
+ }
468
+ return true;
469
+ }
470
+ case "/autopilot": {
471
+ const subCmd = arg?.split(" ")[0];
472
+
473
+ if (!subCmd || subCmd === "scan") {
474
+ // Run scan
475
+ addOutput(theme.accent("\n 🔍 Scanning codebase for work items...\n"));
476
+ isProcessing = true;
477
+ spinnerText = "Scanning";
478
+ update();
479
+
480
+ try {
481
+ const scanCtx = {
482
+ cwd: state.toolContext.cwd,
483
+ runCommand: async (cmd: string) => {
484
+ const proc = Bun.spawn(["bash", "-c", cmd], {
485
+ cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe",
486
+ });
487
+ return await new Response(proc.stdout).text();
488
+ },
489
+ searchFiles: async (pattern: string, path?: string) => {
490
+ const fg = await import("fast-glob");
491
+ const files = await fg.default(pattern, {
492
+ cwd: path ? `${state.toolContext.cwd}/${path}` : state.toolContext.cwd,
493
+ absolute: false, ignore: ["**/node_modules/**", "**/.git/**"],
494
+ });
495
+ return files.join("\n");
496
+ },
497
+ grepContent: async (pattern: string, glob?: string) => {
498
+ const args = ["bash", "-c", `grep -rn '${pattern}' ${state.toolContext.cwd} ${glob ? `--include='${glob}'` : ""} 2>/dev/null | head -50`];
499
+ const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
500
+ return await new Response(proc.stdout).text();
501
+ },
502
+ };
503
+
504
+ const discovered = await scanCodebase(scanCtx, DEFAULT_CONFIG.scanTypes);
505
+ const added = workQueue.addItems(discovered);
506
+ await workQueue.save();
507
+
508
+ const stats = workQueue.getStats();
509
+ addOutput(theme.success(` ✓ Scan complete: ${discovered.length} issues found, ${added} new\n`));
510
+
511
+ // Show summary by type
512
+ const byType = new Map<string, number>();
513
+ for (const item of discovered) {
514
+ byType.set(item.type, (byType.get(item.type) ?? 0) + 1);
515
+ }
516
+ for (const [type, count] of byType) {
517
+ addOutput(theme.secondary(` ${type}: ${count}`));
518
+ }
519
+
520
+ addOutput(theme.tertiary(`\n Queue: ${stats.discovered ?? 0} pending · ${stats.approved ?? 0} approved · ${stats.completed ?? 0} done`));
521
+ addOutput(theme.tertiary(` Use /autopilot queue to see items, /autopilot approve all to approve\n`));
522
+
523
+ } catch (err) {
524
+ addOutput(theme.error(` Scan failed: ${err instanceof Error ? err.message : String(err)}\n`));
525
+ }
526
+
527
+ isProcessing = false;
528
+ update();
529
+ return true;
530
+ }
531
+
532
+ if (subCmd === "queue" || subCmd === "status") {
533
+ const pending = workQueue.getByStatus("discovered");
534
+ const approved = workQueue.getByStatus("approved");
535
+ const stats = workQueue.getStats();
536
+
537
+ addOutput(theme.accent(`\n 📋 Autopilot Queue\n`));
538
+ addOutput(theme.tertiary(` ${stats.discovered ?? 0} discovered · ${stats.approved ?? 0} approved · ${stats.in_progress ?? 0} in progress · ${stats.completed ?? 0} done\n`));
539
+
540
+ if (pending.length > 0) {
541
+ addOutput(theme.primary(" Pending (needs approval):"));
542
+ for (const item of pending.slice(0, 15)) {
543
+ const pColor = item.priority === "critical" ? theme.error : item.priority === "high" ? theme.warning : theme.secondary;
544
+ addOutput(` ${pColor(`[${item.priority}]`)} ${theme.accent(item.id)} ${item.title}`);
545
+ }
546
+ if (pending.length > 15) addOutput(theme.tertiary(` ... and ${pending.length - 15} more`));
547
+ }
548
+
549
+ if (approved.length > 0) {
550
+ addOutput(theme.primary("\n Approved (ready to execute):"));
551
+ for (const item of approved.slice(0, 10)) {
552
+ addOutput(` ${theme.success("✓")} ${theme.accent(item.id)} ${item.title}`);
553
+ }
554
+ }
555
+
556
+ addOutput(theme.tertiary(`\n /autopilot approve <id> — approve one`));
557
+ addOutput(theme.tertiary(` /autopilot approve all — approve all`));
558
+ addOutput(theme.tertiary(` /autopilot run — execute next approved item\n`));
559
+ return true;
560
+ }
561
+
562
+ if (subCmd === "approve") {
563
+ const target = arg?.split(" ").slice(1).join(" ");
564
+ if (target === "all") {
565
+ const count = workQueue.approveAll();
566
+ await workQueue.save();
567
+ addOutput(theme.success(`\n ✓ Approved ${count} items\n`));
568
+ } else if (target) {
569
+ const ok = workQueue.approve(target);
570
+ await workQueue.save();
571
+ addOutput(ok ? theme.success(`\n ✓ Approved ${target}\n`) : theme.error(`\n Item ${target} not found or already approved\n`));
572
+ } else {
573
+ addOutput(theme.tertiary("\n Usage: /autopilot approve <id> or /autopilot approve all\n"));
574
+ }
575
+ return true;
576
+ }
577
+
578
+ if (subCmd === "run") {
579
+ const next = workQueue.getNextApproved();
580
+ if (!next) {
581
+ addOutput(theme.tertiary("\n No approved items to execute. Run /autopilot scan then /autopilot approve all\n"));
582
+ return true;
583
+ }
584
+
585
+ workQueue.startItem(next.id);
586
+ await workQueue.save();
587
+ addOutput(theme.accent(`\n 🚀 Executing: ${next.title}\n`));
588
+
589
+ // Execute through the agent loop
590
+ const prompt = `Fix this issue:\n\nType: ${next.type}\nFile: ${next.file}${next.line ? `:${next.line}` : ""}\nDescription: ${next.description}\n\nMake the fix, then verify it works.`;
591
+ await runTurnInk(prompt);
592
+
593
+ workQueue.completeItem(next.id);
594
+ await workQueue.save();
595
+ addOutput(theme.success(` ✓ Completed: ${next.title}\n`));
596
+
597
+ // Check for more
598
+ const remaining = workQueue.getByStatus("approved").length;
599
+ if (remaining > 0) {
600
+ addOutput(theme.tertiary(` ${remaining} more approved items. /autopilot run to continue\n`));
601
+ }
602
+ return true;
603
+ }
604
+
605
+ if (subCmd === "auto") {
606
+ // Full autonomous loop: scan → approve → fix → test → commit → PR → merge
607
+ addOutput(theme.accent("\n 🚀 AUTOPILOT AUTO MODE — fully autonomous\n"));
608
+ addOutput(theme.warning(" Scanning → fixing → testing → committing → PR → merge\n"));
609
+ isProcessing = true;
610
+ update();
611
+
612
+ try {
613
+ const cwd = state.toolContext.cwd;
614
+ // Safe shell runner — returns stdout + exit code
615
+ const run = async (cmd: string): Promise<{ out: string; code: number }> => {
616
+ const proc = Bun.spawn(["bash", "-c", cmd], { cwd, stdout: "pipe", stderr: "pipe" });
617
+ const out = await new Response(proc.stdout).text();
618
+ const code = await proc.exited;
619
+ return { out: out.trim(), code };
620
+ };
621
+ // Safe git commands using argument arrays (no shell injection)
622
+ const git = async (...args: string[]): Promise<string> => {
623
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
624
+ const out = await new Response(proc.stdout).text();
625
+ await proc.exited;
626
+ return out.trim();
627
+ };
628
+
629
+ // 1. Create autopilot branch
630
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
631
+ const branch = `autopilot/${timestamp}`;
632
+ await git("checkout", "-b", branch);
633
+ addOutput(theme.secondary(` Branch: ${branch}`));
634
+
635
+ // 2. Scan
636
+ addOutput(theme.accent("\n 🔍 Scanning...\n"));
637
+ const scanCtx = {
638
+ cwd,
639
+ runCommand: async (cmd: string) => (await run(cmd)).out,
640
+ searchFiles: async (pattern: string) => {
641
+ const fg = await import("fast-glob");
642
+ const files = await fg.default(pattern, { cwd, absolute: false, ignore: ["**/node_modules/**", "**/.git/**"] });
643
+ return files.join("\n");
644
+ },
645
+ grepContent: async (pattern: string, glob?: string) => {
646
+ const args = ["grep", "-rn", pattern, cwd];
647
+ if (glob) args.push(`--include=${glob}`);
648
+ const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
649
+ const out = await new Response(proc.stdout).text();
650
+ return out.split("\n").slice(0, 50).join("\n");
651
+ },
652
+ };
653
+ const discovered = await scanCodebase(scanCtx, DEFAULT_CONFIG.scanTypes);
654
+ workQueue.addItems(discovered);
655
+ const totalApproved = workQueue.approveAll();
656
+ await workQueue.save();
657
+ addOutput(theme.success(` Found ${discovered.length} issues, approved ${totalApproved}\n`));
658
+
659
+ if (totalApproved === 0) {
660
+ addOutput(theme.success(" ✨ Codebase is clean! Nothing to fix.\n"));
661
+ await run("git checkout main 2>/dev/null || git checkout master");
662
+ await run(`git branch -D ${branch} 2>/dev/null`);
663
+ isProcessing = false;
664
+ update();
665
+ return true;
666
+ }
667
+
668
+ // 3. Fix each item
669
+ let fixed = 0;
670
+ let failed = 0;
671
+ let consecutiveFails = 0;
672
+ const maxFails = 3;
673
+
674
+ while (true) {
675
+ const next = workQueue.getNextApproved();
676
+ if (!next || consecutiveFails >= maxFails) break;
677
+
678
+ workQueue.startItem(next.id);
679
+ addOutput(theme.accent(`\n [${fixed + failed + 1}/${totalApproved}] ${next.title}`));
680
+ spinnerText = next.title;
681
+ update();
682
+
683
+ try {
684
+ // Execute fix
685
+ const prompt = `Fix this issue:\nType: ${next.type}\nFile: ${next.file}${next.line ? `:${next.line}` : ""}\nDescription: ${next.description}\n\nMake the minimal fix. Do not change unrelated code.`;
686
+ await runTurnInk(prompt);
687
+
688
+ // Run tests — check exit code, not string matching
689
+ const testResult = await run("bun test 2>&1");
690
+ const testsPass = testResult.code === 0;
691
+
692
+ if (testsPass) {
693
+ // Commit the fix (safe — no shell interpolation of title)
694
+ await git("add", "-A");
695
+ await git("commit", "-m", `fix(autopilot): ${next.title}`);
696
+ workQueue.completeItem(next.id);
697
+ fixed++;
698
+ consecutiveFails = 0;
699
+ addOutput(theme.success(` ✓ Fixed and committed`));
700
+ } else {
701
+ // Revert and skip
702
+ await run("git checkout -- . && git clean -fd 2>/dev/null || true");
703
+ workQueue.failItem(next.id, "Tests failed after fix");
704
+ failed++;
705
+ consecutiveFails++;
706
+ addOutput(theme.error(` ✗ Tests failed, reverted`));
707
+ }
708
+ } catch (err) {
709
+ await run("git checkout -- . 2>/dev/null || true");
710
+ workQueue.failItem(next.id, String(err));
711
+ failed++;
712
+ consecutiveFails++;
713
+ addOutput(theme.error(` ✗ Execution failed`));
714
+ }
715
+
716
+ await workQueue.save();
717
+ update();
718
+ }
719
+
720
+ // 4. Create PR and auto-merge
721
+ if (fixed > 0) {
722
+ addOutput(theme.accent("\n 📋 Creating PR...\n"));
723
+ // Push the branch to remote first (PR needs remote commits)
724
+ await git("push", "-u", "origin", branch);
725
+
726
+ const prTitle = `fix(autopilot): ${fixed} automated fixes`;
727
+ const prBody = `## Autopilot Fixes\n\nFixed ${fixed} issues automatically:\n${workQueue.getByStatus("completed").slice(-fixed).map(i => `- ${i.title}`).join("\n")}\n\nGenerated by AshlrCode Autopilot.`;
728
+ // Use Bun.spawn for safe PR creation (no shell injection from titles)
729
+ const prProc = Bun.spawn(["gh", "pr", "create", "--title", prTitle, "--body", prBody], { cwd, stdout: "pipe", stderr: "pipe" });
730
+ const prResult = (await new Response(prProc.stdout).text()).trim();
731
+ await prProc.exited;
732
+
733
+ if (prResult.includes("github.com")) {
734
+ addOutput(theme.success(` PR created: ${prResult.split("\n").pop()}`));
735
+
736
+ // Auto-merge if tests pass
737
+ const mergeProc = Bun.spawn(["gh", "pr", "merge", "--auto", "--squash"], { cwd, stdout: "pipe", stderr: "pipe" });
738
+ const mergeResult = (await new Response(mergeProc.stdout).text()).trim();
739
+ await mergeProc.exited;
740
+ if (mergeResult.includes("auto-merge")) {
741
+ addOutput(theme.success(` Auto-merge enabled — will merge when checks pass`));
742
+ } else {
743
+ addOutput(theme.secondary(` PR ready for review (auto-merge not available)`));
744
+ }
745
+ } else {
746
+ addOutput(theme.secondary(` PR creation: ${prResult.slice(0, 200)}`));
747
+ }
748
+ }
749
+
750
+ // 5. Switch back to main + clean up branch
751
+ await run("git checkout main 2>/dev/null || git checkout master 2>/dev/null || true");
752
+ await run(`git branch -D ${branch} 2>/dev/null || true`);
753
+
754
+ // Summary
755
+ addOutput(theme.accent(`\n ═══ Autopilot Summary ═══`));
756
+ addOutput(theme.success(` Fixed: ${fixed}`));
757
+ if (failed > 0) addOutput(theme.error(` Failed: ${failed}`));
758
+ addOutput(theme.secondary(` Skipped: ${totalApproved - fixed - failed}`));
759
+ if (consecutiveFails >= maxFails) {
760
+ addOutput(theme.warning(` Stopped after ${maxFails} consecutive failures`));
761
+ }
762
+ addOutput("");
763
+
764
+ } catch (err) {
765
+ addOutput(theme.error(`\n Autopilot error: ${err instanceof Error ? err.message : String(err)}\n`));
766
+ // Try to get back to main
767
+ try {
768
+ const proc = Bun.spawn(["bash", "-c", "git checkout main 2>/dev/null || git checkout master"], { cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe" });
769
+ await proc.exited;
770
+ } catch {}
771
+ }
772
+
773
+ isProcessing = false;
774
+ update();
775
+ return true;
776
+ }
777
+
778
+ // Help
779
+ addOutput(theme.accent("\n 🤖 Autopilot — autonomous work discovery\n"));
780
+ addOutput(theme.secondary(" /autopilot scan — scan codebase for issues"));
781
+ addOutput(theme.secondary(" /autopilot queue — show work queue"));
782
+ addOutput(theme.secondary(" /autopilot approve all — approve all discovered items"));
783
+ addOutput(theme.secondary(" /autopilot run — execute next approved item"));
784
+ addOutput(theme.secondary(" /autopilot auto — FULL AUTO: scan → fix → test → PR → merge"));
785
+ addOutput(theme.tertiary("\n Manual: scan → queue → approve → run"));
786
+ addOutput(theme.tertiary(" Auto: /autopilot auto (does everything)\n"));
787
+ return true;
788
+ }
789
+
790
+ case "/keybindings": {
791
+ const binds = getBindings();
792
+ const kbLines = binds.map(b =>
793
+ ` ${b.key.padEnd(18)} ${b.action.padEnd(16)} ${b.description ?? ""}`
794
+ );
795
+ addOutput(`\n Keybindings:\n${kbLines.join("\n")}\n`);
796
+ addOutput(theme.tertiary(" Customize: ~/.ashlrcode/keybindings.json\n"));
797
+ return true;
798
+ }
799
+
800
+
801
+ case "/undercover": {
802
+ const { isUndercoverMode, setUndercoverMode } = await import("./config/undercover.ts");
803
+ setUndercoverMode(!isUndercoverMode());
804
+ addOutput(isUndercoverMode() ? theme.warning("\n 🕶 Undercover mode ON\n") : theme.success("\n Undercover mode OFF\n"));
805
+ return true;
806
+ }
807
+
808
+ case "/patches": {
809
+ const { listPatches, getModelPatches } = await import("./agent/model-patches.ts");
810
+ const currentModel = state.router.currentProvider.config.model;
811
+ const { names } = getModelPatches(currentModel);
812
+ const allPatches = listPatches();
813
+ const patchLines = allPatches.map(p => {
814
+ const active = names.includes(p.name);
815
+ return ` ${active ? theme.success("●") : theme.tertiary("○")} ${p.name} ${theme.tertiary(`(${p.pattern})`)}`;
816
+ });
817
+ addOutput(`\n Model Patches (${currentModel}):\n${patchLines.join("\n")}\n`);
818
+ return true;
819
+ }
820
+ case "/features": {
821
+ const flags = listFeatures();
822
+ const lines = Object.entries(flags).map(([k, v]) =>
823
+ ` ${v ? theme.success("✓") : theme.error("✗")} ${k}`
824
+ );
825
+ addOutput(`\n Feature Flags:\n${lines.join("\n")}\n`);
826
+ return true;
827
+ }
828
+
829
+ case "/remote": {
830
+ const rs = getRemoteSettings();
831
+ if (!rs) {
832
+ addOutput(theme.tertiary("\n No remote settings configured.\n Set AC_REMOTE_SETTINGS_URL env var or remoteSettingsUrl in settings.json.\n"));
833
+ return true;
834
+ }
835
+ addOutput(`\n Remote Settings (fetched ${new Date(rs.fetchedAt).toLocaleString()}):`);
836
+ if (rs.features) addOutput(` Features: ${JSON.stringify(rs.features)}`);
837
+ if (rs.modelOverride) addOutput(` Model override: ${rs.modelOverride}`);
838
+ if (rs.effortOverride) addOutput(` Effort override: ${rs.effortOverride}`);
839
+ if (rs.killswitches) addOutput(` Killswitches: ${JSON.stringify(rs.killswitches)}`);
840
+ if (rs.message) addOutput(theme.warning(` Message: ${rs.message}`));
841
+ addOutput("");
842
+ return true;
843
+ }
844
+
845
+ case "/telemetry": {
846
+ const events = await readRecentEvents(20);
847
+ addOutput(`\n Recent events (${events.length}):\n${formatEvents(events)}\n`);
848
+ return true;
849
+ }
850
+
851
+ case "/undo": {
852
+ const fh = getFileHistory();
853
+ if (!fh || fh.undoCount === 0) {
854
+ addOutput(theme.tertiary("\n Nothing to undo.\n"));
855
+ return true;
856
+ }
857
+ const result = await fh.undoLast();
858
+ if (result) {
859
+ addOutput(theme.success(`\n Restored: ${result.filePath}\n`));
860
+ addOutput(theme.tertiary(` ${fh.undoCount} more undo(s) available\n`));
861
+ }
862
+ return true;
863
+ }
864
+
865
+ case "/history": {
866
+ const fhHist = getFileHistory();
867
+ if (!fhHist || fhHist.undoCount === 0) {
868
+ addOutput(theme.tertiary("\n No file history.\n"));
869
+ return true;
870
+ }
871
+ const snaps = fhHist.getHistory();
872
+ addOutput(theme.secondary("\n File History (newest first):\n"));
873
+ for (const snap of snaps.slice(0, 20)) {
874
+ const time = new Date(snap.timestamp).toLocaleTimeString();
875
+ const label = snap.content === "" ? "(new file)" : "(modified)";
876
+ addOutput(` ${theme.tertiary(time)} ${snap.tool.padEnd(6)} ${label} ${snap.filePath}\n`);
877
+ }
878
+ if (snaps.length > 20) {
879
+ addOutput(theme.tertiary(` ... and ${snaps.length - 20} more\n`));
880
+ }
881
+ addOutput(theme.tertiary(`\n ${fhHist.undoCount} undo(s) available. Use /undo to restore.\n`));
882
+ return true;
883
+ }
884
+
885
+ case "/kairos": {
886
+ if (!arg || arg === "stop") {
887
+ if (kairos?.isRunning()) {
888
+ await kairos.stop();
889
+ kairos = null;
890
+ } else {
891
+ addOutput(theme.tertiary("\n KAIROS not running\n"));
892
+ }
893
+ return true;
894
+ }
895
+ if (kairos?.isRunning()) {
896
+ addOutput(theme.warning("\n KAIROS already running. /kairos stop first.\n"));
897
+ return true;
898
+ }
899
+ kairos = new KairosLoop({
900
+ router: state.router,
901
+ toolRegistry: state.registry,
902
+ toolContext: state.toolContext,
903
+ systemPrompt: state.baseSystemPrompt,
904
+ heartbeatIntervalMs: 30_000,
905
+ maxAutonomousIterations: 5,
906
+ onOutput: (text) => { addOutput(text); },
907
+ onToolStart: (name) => { addOutput(` * ${name}`); update(); },
908
+ onToolEnd: (_name, result, isError) => {
909
+ addOutput(isError ? ` x ${result.slice(0, 80)}` : ` > ${result.split("\n")[0]?.slice(0, 80)}`);
910
+ update();
911
+ },
912
+ });
913
+ await kairos.start(arg);
914
+ return true;
915
+ }
916
+
917
+ case "/trigger": {
918
+ const [sub, ...triggerRest] = (arg ?? "").split(" ");
919
+
920
+ if (sub === "add") {
921
+ const [schedule, ...promptParts] = triggerRest;
922
+ if (!schedule || promptParts.length === 0) {
923
+ addOutput(theme.tertiary("\n Usage: /trigger add <schedule> <prompt>\n Schedule: 30s, 5m, 1h, 2d\n Example: /trigger add 5m run tests\n"));
924
+ return true;
925
+ }
926
+ try {
927
+ const t = await createTrigger("trigger", schedule!, promptParts.join(" "), state.toolContext.cwd);
928
+ addOutput(theme.success(`\n Trigger created: ${t.id} (every ${t.schedule})\n`));
929
+ } catch (e: any) {
930
+ addOutput(theme.error(`\n ${e.message}\n`));
931
+ }
932
+ return true;
933
+ }
934
+
935
+ if (sub === "list" || !sub) {
936
+ const triggers = await listTriggers();
937
+ if (triggers.length === 0) {
938
+ addOutput(theme.tertiary("\n No triggers. Use /trigger add <schedule> <prompt>\n"));
939
+ return true;
940
+ }
941
+ addOutput(theme.secondary("\n Scheduled Triggers:\n"));
942
+ for (const t of triggers) {
943
+ const status = t.enabled ? theme.success("●") : theme.error("○");
944
+ const lastInfo = t.lastRun ? ` (ran ${t.runCount}x)` : " (never ran)";
945
+ addOutput(` ${status} ${t.id} — every ${t.schedule} — ${t.prompt.slice(0, 50)}${lastInfo}`);
946
+ }
947
+ addOutput("");
948
+ return true;
949
+ }
950
+
951
+ if (sub === "delete" && triggerRest[0]) {
952
+ const deleted = await deleteTrigger(triggerRest[0]!);
953
+ if (deleted) {
954
+ addOutput(theme.success(`\n Deleted ${triggerRest[0]}\n`));
955
+ } else {
956
+ addOutput(theme.error(`\n Trigger not found: ${triggerRest[0]}\n`));
957
+ }
958
+ return true;
959
+ }
960
+
961
+ if (sub === "toggle" && triggerRest[0]) {
962
+ const toggled = await toggleTrigger(triggerRest[0]!);
963
+ if (toggled) {
964
+ addOutput(theme.success(`\n ${toggled.id} is now ${toggled.enabled ? "enabled" : "disabled"}\n`));
965
+ } else {
966
+ addOutput(theme.error(`\n Trigger not found: ${triggerRest[0]}\n`));
967
+ }
968
+ return true;
969
+ }
970
+
971
+ addOutput(theme.tertiary("\n /trigger add <schedule> <prompt>\n /trigger list\n /trigger toggle <id>\n /trigger delete <id>\n"));
972
+ return true;
973
+ }
974
+
975
+ case "/btw": {
976
+ if (!arg) { addOutput(theme.tertiary("\n Usage: /btw <question>\n Ask a side question without interrupting the current task.\n")); return true; }
977
+ // Run the question in a sub-agent so it doesn't pollute main history
978
+ const { runSubAgent } = await import("./agent/sub-agent.ts");
979
+ addOutput(theme.accent(`\n 💬 Side question: ${arg}\n`));
980
+ isProcessing = true; spinnerText = "Thinking (side)"; update();
981
+ try {
982
+ const result = await runSubAgent({
983
+ name: "btw",
984
+ prompt: arg,
985
+ systemPrompt: state.baseSystemPrompt + "\n\nThis is a brief side question. Answer concisely (1-3 sentences). Do not modify any files.",
986
+ router: state.router,
987
+ toolRegistry: state.registry,
988
+ toolContext: state.toolContext,
989
+ readOnly: true,
990
+ maxIterations: 5,
991
+ });
992
+ addOutput(result.text + "\n");
993
+ } catch (err) {
994
+ addOutput(theme.error(` Error: ${err instanceof Error ? err.message : String(err)}\n`));
995
+ }
996
+ isProcessing = false; update();
997
+ return true;
998
+ }
999
+
1000
+ case "/voice": {
1001
+ if (!feature("VOICE_MODE")) {
1002
+ addOutput(theme.tertiary("\n Voice mode disabled. Set AC_FEATURE_VOICE_MODE=true\n"));
1003
+ return true;
1004
+ }
1005
+ const check = await checkVoiceAvailability();
1006
+ if (!check.available) {
1007
+ addOutput(theme.error(`\n ${check.details}\n`));
1008
+ return true;
1009
+ }
1010
+
1011
+ if (isRecording()) {
1012
+ addOutput(theme.accent(" Transcribing...\n"));
1013
+ const voiceConfig: VoiceConfig = {
1014
+ sttProvider: process.env.OPENAI_API_KEY ? "whisper-api" : "whisper-local",
1015
+ whisperApiKey: process.env.OPENAI_API_KEY,
1016
+ };
1017
+ try {
1018
+ const text = await transcribeRecording(voiceConfig);
1019
+ if (text) {
1020
+ addOutput(theme.success(` Voice: "${text}"\n`));
1021
+ await runTurnInk(text);
1022
+ } else {
1023
+ addOutput(theme.error(" Failed to transcribe\n"));
1024
+ }
1025
+ } catch (e: any) {
1026
+ addOutput(theme.error(` Transcription error: ${e.message}\n`));
1027
+ }
1028
+ } else {
1029
+ await startRecording();
1030
+ addOutput(theme.accent(" Recording... /voice again to stop and transcribe\n"));
1031
+ }
1032
+ return true;
1033
+ }
1034
+
1035
+ case "/compact": {
1036
+ addOutput(theme.tertiary(" [compacting context...]"));
1037
+ state.history = contextCollapse(state.history);
1038
+ state.history = snipCompact(state.history);
1039
+ state.history = await autoCompact(state.history, state.router);
1040
+ const summary = state.history.slice(-5).map(m => {
1041
+ const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
1042
+ return `${m.role}: ${c.slice(0, 150)}`;
1043
+ }).join("\n");
1044
+ await state.session.insertCompactBoundary(summary, state.history.length).catch(() => {});
1045
+ addOutput(theme.success(`\n ✓ Compacted to ${state.history.length} messages\n`));
1046
+ return true;
1047
+ }
1048
+ case "/status": {
1049
+ const ctxLimit = getProviderContextLimit(state.router.currentProvider.name);
1050
+ const ctxUsed = estimateTokens(state.history);
1051
+ addOutput(`\n Provider: ${state.router.currentProvider.name}:${state.router.currentProvider.config.model}`);
1052
+ addOutput(` Context: ${ctxUsed}/${ctxLimit} tokens (${Math.round(ctxUsed/ctxLimit*100)}%)`);
1053
+ addOutput(` Session: ${state.session.id}`);
1054
+ addOutput(` History: ${state.history.length} messages\n`);
1055
+ return true;
1056
+ }
1057
+ case "/sessions": {
1058
+ const { listSessions } = await import("./persistence/session.ts");
1059
+ const sessions = await listSessions(10);
1060
+ if (sessions.length === 0) { addOutput(theme.tertiary("\n No sessions found.\n")); return true; }
1061
+ for (const s of sessions) {
1062
+ addOutput(` ${s.id} — ${s.title ?? "(untitled)"} — ${new Date(s.updatedAt).toLocaleDateString()} — ${s.messageCount} msgs`);
1063
+ }
1064
+ addOutput("");
1065
+ return true;
1066
+ }
1067
+ case "/memory": {
1068
+ const { loadMemories } = await import("./persistence/memory.ts");
1069
+ const memories = await loadMemories(state.toolContext.cwd);
1070
+ if (memories.length === 0) { addOutput(theme.tertiary("\n No memory files.\n")); return true; }
1071
+ for (const m of memories) { addOutput(` [${m.type}] ${m.name} — ${m.description ?? m.filePath}`); }
1072
+ addOutput("");
1073
+ return true;
1074
+ }
1075
+ case "/diff": {
1076
+ const proc = Bun.spawn(["git", "diff", "--stat"], { cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe" });
1077
+ const output = (await new Response(proc.stdout).text()).trim();
1078
+ await proc.exited;
1079
+ addOutput(output ? `\n${output}\n` : theme.tertiary("\n No changes.\n"));
1080
+ return true;
1081
+ }
1082
+ case "/git": {
1083
+ const proc = Bun.spawn(["git", "log", "--oneline", "-10"], { cwd: state.toolContext.cwd, stdout: "pipe", stderr: "pipe" });
1084
+ const output = (await new Response(proc.stdout).text()).trim();
1085
+ await proc.exited;
1086
+ addOutput(output ? `\n${output}\n` : theme.tertiary("\n Not a git repo.\n"));
1087
+ return true;
1088
+ }
1089
+ case "/plan": {
1090
+ const { cycleMode: cm } = await import("./ui/mode.ts");
1091
+ cm();
1092
+ update();
1093
+ return true;
1094
+ }
1095
+ case "/restore": {
1096
+ const fh = getFileHistory();
1097
+ if (!fh || fh.undoCount === 0) { addOutput(theme.tertiary("\n Nothing to restore.\n")); return true; }
1098
+ addOutput(`\n ${fh.undoCount} snapshots available. Use /undo to restore.\n`);
1099
+ return true;
1100
+ }
1101
+
1102
+ case "/sync": {
1103
+ const [sub, ...syncRest] = (arg ?? "").split(" ");
1104
+ if (sub === "export") {
1105
+ const dir = syncRest[0] ?? join(state.toolContext.cwd, ".ashlrcode-sync");
1106
+ const manifest = await exportSettings(dir);
1107
+ addOutput(theme.success(`\n ✓ Exported ${manifest.files.length} files to ${dir}\n`));
1108
+ return true;
1109
+ }
1110
+ if (sub === "import") {
1111
+ const dir = syncRest[0];
1112
+ if (!dir) {
1113
+ addOutput(theme.tertiary("\n Usage: /sync import <path> [--overwrite] [--merge]\n"));
1114
+ return true;
1115
+ }
1116
+ const overwrite = syncRest.includes("--overwrite");
1117
+ const merge = syncRest.includes("--merge");
1118
+ const result = await importSettings(dir, { overwrite, merge });
1119
+ addOutput(theme.success(`\n ✓ Imported: ${result.imported.length}, Skipped: ${result.skipped.length}\n`));
1120
+ if (result.imported.length > 0) addOutput(theme.secondary(` ${result.imported.join(", ")}`));
1121
+ if (result.skipped.length > 0) addOutput(theme.tertiary(` Skipped: ${result.skipped.join(", ")}`));
1122
+ addOutput("");
1123
+ return true;
1124
+ }
1125
+ // Default: show sync status
1126
+ const status = await getSyncStatus();
1127
+ addOutput(`\n Syncable files:\n${status.files.map((f) => ` ${f}`).join("\n")}\n`);
1128
+ addOutput(theme.tertiary(" /sync export [path] — export settings\n /sync import <path> — import settings\n"));
1129
+ return true;
1130
+ }
1131
+
1132
+ case "/bridge": {
1133
+ const port = getBridgePort();
1134
+ if (port) {
1135
+ addOutput(`\n Bridge active on http://localhost:${port}\n`);
1136
+ } else {
1137
+ addOutput(theme.tertiary("\n Bridge not running. Set AC_BRIDGE_PORT=8743 to enable.\n"));
1138
+ }
1139
+ return true;
1140
+ }
1141
+
1142
+ default:
1143
+ if (cmd?.startsWith("/")) {
1144
+ addOutput(theme.tertiary(`\n Unknown command: ${cmd}\n`));
1145
+ return true;
1146
+ }
1147
+ return false;
1148
+ }
1149
+ }
1150
+
1151
+ async function runTurnInk(input: string, displayText?: string) {
1152
+ const turnStartTime = Date.now();
1153
+ isProcessing = true;
1154
+ spinnerText = "Thinking";
1155
+ update();
1156
+
1157
+ // Echo — use displayText for smart paste collapse
1158
+ const echo = displayText ?? input;
1159
+ addOutput("\n" + theme.accent(" ❯ ") + theme.primary(echo.length > 200 ? echo.slice(0, 197) + "..." : echo) + "\n");
1160
+
1161
+ try {
1162
+ const effortConfig = getEffortConfig();
1163
+ const { getUndercoverPrompt: getUcPrompt } = await import("./config/undercover.ts");
1164
+ const { getModelPatches: getMPatches } = await import("./agent/model-patches.ts");
1165
+ const turnModelPatches = getMPatches(state.router.currentProvider.config.model).combinedSuffix;
1166
+ const systemPrompt = state.baseSystemPrompt + getPlanModePrompt() + effortConfig.systemPromptSuffix + turnModelPatches + getUcPrompt();
1167
+ const systemTokens = Math.ceil(systemPrompt.length / 4);
1168
+ const contextLimit = getProviderContextLimit(state.router.currentProvider.name);
1169
+
1170
+ if (needsCompaction(state.history, systemTokens, { maxContextTokens: contextLimit })) {
1171
+ addOutput(theme.tertiary(" [compacting context...]"));
1172
+ state.history = contextCollapse(state.history);
1173
+ state.history = snipCompact(state.history);
1174
+ state.history = await autoCompact(state.history, state.router);
1175
+
1176
+ // Persist compact boundary to session log
1177
+ const summary = state.history.slice(-5).map(m => {
1178
+ const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
1179
+ return `${m.role}: ${c.slice(0, 150)}`;
1180
+ }).join("\n");
1181
+ await state.session.insertCompactBoundary(summary, state.history.length).catch(() => {});
1182
+ }
1183
+
1184
+ resetMarkdown();
1185
+ const preTurnMessageCount = state.history.length;
1186
+ turnToolCount = 0;
1187
+
1188
+ // Update turn number on context so file snapshots track which turn modified them
1189
+ state.toolContext.turnNumber = turnCount;
1190
+
1191
+ let responseText = "";
1192
+
1193
+ // Wrap agent loop in AsyncLocalStorage root context for sub-agent isolation
1194
+ const rootCtx: AgentContext = {
1195
+ agentId: `root-${state.session.id}-${turnCount}`,
1196
+ agentName: "main",
1197
+ cwd: state.toolContext.cwd,
1198
+ readOnly: isPlanMode(),
1199
+ depth: 0,
1200
+ startedAt: new Date().toISOString(),
1201
+ };
1202
+
1203
+ const result = await runWithAgentContext(rootCtx, () => runAgentLoop(input, state.history, {
1204
+ systemPrompt,
1205
+ maxIterations: effortConfig.maxIterations,
1206
+ router: state.router,
1207
+ toolRegistry: state.registry,
1208
+ toolContext: state.toolContext,
1209
+ readOnly: isPlanMode(),
1210
+ onText: (text) => {
1211
+ isProcessing = false;
1212
+ responseText += text;
1213
+ // Only flush COMPLETE lines to Static (they can't be re-rendered)
1214
+ const lines = responseText.split("\n");
1215
+ if (lines.length > 1) {
1216
+ for (let i = 0; i < lines.length - 1; i++) {
1217
+ addOutput(lines[i]!);
1218
+ }
1219
+ responseText = lines[lines.length - 1]!;
1220
+ }
1221
+ // Partial line stays in spinnerText for live display (re-renderable)
1222
+ spinnerText = responseText;
1223
+ update();
1224
+ },
1225
+ onToolStart: (name, toolInput) => {
1226
+ isProcessing = true;
1227
+ spinnerText = name;
1228
+ toolStartTime = Date.now();
1229
+ currentToolInput = toolInput as Record<string, unknown>;
1230
+ recordThinking(state.buddy);
1231
+ logEvent("tool_start", { tool: name }).catch(() => {});
1232
+ if (isFirstToolCall()) {
1233
+ addOutput(getBuddyReaction(state.buddy, "first_tool"));
1234
+ }
1235
+ update();
1236
+ },
1237
+ onToolEnd: (_name, result, isError) => {
1238
+ isProcessing = false;
1239
+ turnToolCount++;
1240
+ const durationMs = toolStartTime > 0 ? Date.now() - toolStartTime : undefined;
1241
+ lastToolName = _name;
1242
+ lastToolResult = result.slice(0, 50);
1243
+ logEvent(isError ? "tool_error" : "tool_end", { tool: _name }).catch(() => {});
1244
+ if (isError) { recordError(state.buddy); lastHadError = true; }
1245
+ else recordToolCallSuccess(state.buddy);
1246
+
1247
+ // Use message renderer for formatted output
1248
+ const rendered = formatToolExecution(_name, currentToolInput, result, isError, durationMs);
1249
+ for (const line of rendered) addOutput(line);
1250
+
1251
+ if (isError) addOutput(getBuddyReaction(state.buddy, "error"));
1252
+ toolStartTime = 0;
1253
+ currentToolInput = {};
1254
+ update();
1255
+ },
1256
+ }));
1257
+
1258
+ // Flush remaining text
1259
+ if (responseText) addOutput(responseText);
1260
+
1261
+ // Update history
1262
+ state.history.length = 0;
1263
+ state.history.push(...result.messages);
1264
+
1265
+ // Persist
1266
+ const newMessages = result.messages.slice(preTurnMessageCount);
1267
+ if (newMessages.length > 0) {
1268
+ await state.session.appendMessages(newMessages);
1269
+ }
1270
+
1271
+ // Turn separator
1272
+ turnCount++;
1273
+ logEvent("turn_end", { cost: state.router.costs.totalCostUSD, turn: turnCount }).catch(() => {});
1274
+
1275
+ // Auto-generate session title from first user message
1276
+ if (turnCount === 1 && input.length > 0) {
1277
+ const title = input.slice(0, 60).replace(/\n/g, " ").trim();
1278
+ state.session.setTitle(title).catch(() => {});
1279
+ }
1280
+ cachedQuip = getQuip(state.buddy.mood); // Update quip once per turn, not per render
1281
+ currentQuipType = "quip";
1282
+ const tc = state.history.filter(m => m.role === "user" && typeof m.content === "string").length;
1283
+ addOutput(formatTurnSeparator(tc, state.router.costs.totalCostUSD, state.buddy.name, turnToolCount));
1284
+
1285
+ // Desktop notification when terminal is not focused
1286
+ detectTerminalFocus().then(focus => {
1287
+ if (focus === "unfocused") {
1288
+ notifyTurnComplete(turnToolCount, Date.now() - turnStartTime).catch(() => {});
1289
+ }
1290
+ }).catch(() => {});
1291
+
1292
+ // Speech bubble — render buddy + bubble as Static output so it scrolls up with history
1293
+ const bubbleLines = renderBuddyWithBubble(cachedQuip, getBuddyArt(state.buddy), state.buddy.name);
1294
+ addOutput(theme.accentDim(bubbleLines.join("\n")));
1295
+
1296
+ // AI-powered buddy comment (every 5th turn, fire-and-forget)
1297
+ if (shouldUseAI(turnCount, lastHadError) && !aiCommentInFlight) {
1298
+ const gen = ++aiCommentGen;
1299
+ aiCommentInFlight = true;
1300
+ generateBuddyComment(
1301
+ { lastTool: lastToolName, lastResult: lastToolResult, mood: state.buddy.mood, errorOccurred: lastHadError },
1302
+ state.router.currentProvider.config.apiKey,
1303
+ state.router.currentProvider.config.baseURL
1304
+ ).then((comment) => {
1305
+ if (gen !== aiCommentGen) return; // Stale — a newer turn started
1306
+ currentQuipType = comment.type;
1307
+ cachedQuip = comment.text;
1308
+ const pool = QUIPS[state.buddy.mood] ?? [];
1309
+ if (!pool.includes(comment.text)) {
1310
+ QUIPS[state.buddy.mood] = [...pool, comment.text];
1311
+ }
1312
+ update();
1313
+ }).catch(() => {}).finally(() => { aiCommentInFlight = false; });
1314
+ }
1315
+ lastHadError = false;
1316
+
1317
+ } catch (err) {
1318
+ const error = err instanceof Error ? err : new Error(String(err));
1319
+ const categorized = categorizeError(error);
1320
+ addOutput(theme.error(`\n Error: ${categorized.message}\n`));
1321
+ logEvent("error", { message: categorized.message }).catch(() => {});
1322
+ notifyError(categorized.message).catch(() => {});
1323
+ lastHadError = true;
1324
+ }
1325
+
1326
+ isProcessing = false;
1327
+ update();
1328
+ }
1329
+
1330
+ // Wire deferred trigger callback now that runTurnInk is defined
1331
+ _triggerCallback = runTurnInk;
1332
+
1333
+ async function handleExit() {
1334
+ idleDetector.stop();
1335
+ triggerRunner.stop();
1336
+ stopRemotePolling();
1337
+ stopBridgeServer();
1338
+ stopBuddyAnimation();
1339
+ if (kairos?.isRunning()) await kairos.stop().catch(() => {});
1340
+ const { stopRecording: stopRec, isRecording: isRec } = await import("./voice/voice-mode.ts");
1341
+ if (isRec()) await stopRec().catch(() => {});
1342
+ state.buddy.mood = "sleepy";
1343
+ // Generate final dream on exit
1344
+ if (state.history.length > 4) {
1345
+ await generateDream(state.history, state.session.id).catch(() => {});
1346
+ }
1347
+ await saveBuddy(state.buddy).catch(() => {});
1348
+ await stopIPCServer().catch(() => {});
1349
+ await shutdownLSP().catch(() => {});
1350
+ await shutdownBrowser().catch(() => {});
1351
+ console.log("\n" + state.router.getCostSummary());
1352
+ process.exit(0);
1353
+ }
1354
+
1355
+ // Keybinding action callbacks
1356
+ const handleModeSwitch = () => { cycleMode(); update(); };
1357
+ const handleUndo = () => { handleCommand("/undo").catch(() => {}); };
1358
+ const handleEffortCycle = () => { cycleEffort(); update(); };
1359
+ const handleCompact = () => { handleCommand("/compact").catch(() => {}); };
1360
+ const handleClearScreen = () => { items = []; update(); };
1361
+ const handleVoiceToggle = () => { handleCommand("/voice").catch(() => {}); };
1362
+
1363
+ function appProps() {
1364
+ return {
1365
+ onSubmit: handleSubmit,
1366
+ onExit: handleExit,
1367
+ onModeSwitch: handleModeSwitch,
1368
+ onUndo: handleUndo,
1369
+ onEffortCycle: handleEffortCycle,
1370
+ onCompact: handleCompact,
1371
+ onClearScreen: handleClearScreen,
1372
+ onVoiceToggle: handleVoiceToggle,
1373
+ inputHistory,
1374
+ ...getDisplayProps(),
1375
+ };
1376
+ }
1377
+
1378
+ // Initial render
1379
+ const { rerender } = render(<App {...appProps()} />);
1380
+
1381
+ function update() {
1382
+ rerender(<App {...appProps()} />);
1383
+ }
1384
+ }