aegiscode 3.1.8 → 3.1.10

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 (206) hide show
  1. package/README.md +23 -15
  2. package/dist/main.js +549 -552
  3. package/package.json +4 -2
  4. package/src/agent/Agent.ts +903 -0
  5. package/src/agent/SimpleAgent.ts +48 -0
  6. package/src/agent/index.ts +54 -0
  7. package/src/agent/orchestrator/AppBuilder.ts +443 -0
  8. package/src/agent/orchestrator/CouncilAgent.ts +310 -0
  9. package/src/agent/orchestrator/DiscussionRoom.ts +462 -0
  10. package/src/agent/orchestrator/OrchestratorAgent.ts +637 -0
  11. package/src/agent/orchestrator/index.ts +38 -0
  12. package/src/agent/orchestrator/utils.ts +397 -0
  13. package/src/agent/pricing.ts +115 -0
  14. package/src/agent/router.ts +74 -0
  15. package/src/agent/routerStats.ts +121 -0
  16. package/src/agent/types.ts +318 -0
  17. package/src/auth/login.ts +383 -0
  18. package/src/cli/config.ts +189 -0
  19. package/src/cli/index.ts +17 -0
  20. package/src/cli/middleware.ts +119 -0
  21. package/src/cli/types.ts +75 -0
  22. package/src/config/ConfigManager.ts +587 -0
  23. package/src/config/index.ts +7 -0
  24. package/src/config/types.ts +584 -0
  25. package/src/context/CompactionService.ts +300 -0
  26. package/src/context/ContextManager.ts +450 -0
  27. package/src/context/FileAnalyzer.ts +267 -0
  28. package/src/context/TokenCounter.ts +265 -0
  29. package/src/context/index.ts +27 -0
  30. package/src/context/storage/CacheStore.ts +176 -0
  31. package/src/context/storage/JSONLStore.ts +201 -0
  32. package/src/context/storage/MemoryStore.ts +205 -0
  33. package/src/context/storage/PersistentStore.ts +327 -0
  34. package/src/context/storage/index.ts +9 -0
  35. package/src/context/storage/pathUtils.ts +114 -0
  36. package/src/context/test.ts +309 -0
  37. package/src/context/types.ts +268 -0
  38. package/src/hooks/HookExecutor.ts +434 -0
  39. package/src/hooks/HookManager.ts +596 -0
  40. package/src/hooks/HookService.ts +269 -0
  41. package/src/hooks/Matcher.ts +157 -0
  42. package/src/hooks/index.ts +63 -0
  43. package/src/hooks/types.ts +424 -0
  44. package/src/main.tsx +596 -0
  45. package/src/mcp/HealthMonitor.ts +150 -0
  46. package/src/mcp/McpClient.ts +491 -0
  47. package/src/mcp/McpRegistry.ts +321 -0
  48. package/src/mcp/createMcpTool.ts +251 -0
  49. package/src/mcp/index.ts +15 -0
  50. package/src/mcp/server.ts +334 -0
  51. package/src/mcp/test-server.ts +88 -0
  52. package/src/mcp/test.ts +372 -0
  53. package/src/mcp/types.ts +247 -0
  54. package/src/memory/AgentMemoryBus.ts +432 -0
  55. package/src/memory/CloudSync.ts +99 -0
  56. package/src/memory/DriveSync.ts +106 -0
  57. package/src/memory/SharedMemory.ts +951 -0
  58. package/src/memory/index.ts +14 -0
  59. package/src/memory/machineFingerprint.ts +40 -0
  60. package/src/orchestrator/SubAgentMetadata.ts +136 -0
  61. package/src/prompts/builder.ts +213 -0
  62. package/src/prompts/default.ts +144 -0
  63. package/src/prompts/index.ts +16 -0
  64. package/src/prompts/plan.ts +64 -0
  65. package/src/prompts/test.ts +78 -0
  66. package/src/services/AnthropicChatService.ts +341 -0
  67. package/src/services/ChatService.ts +347 -0
  68. package/src/services/ClaudeCliChatService.ts +256 -0
  69. package/src/services/CloudSync.ts +168 -0
  70. package/src/services/CostLedger.ts +211 -0
  71. package/src/services/Heartbeat.ts +135 -0
  72. package/src/services/LearningCollector.ts +291 -0
  73. package/src/services/OllamaInstaller.ts +342 -0
  74. package/src/services/VersionChecker.ts +445 -0
  75. package/src/services/index.ts +57 -0
  76. package/src/services/streaming/OpenAIEventAdapter.ts +126 -0
  77. package/src/services/streaming/RenderingProfile.ts +90 -0
  78. package/src/services/streaming/StreamEventParser.ts +181 -0
  79. package/src/services/streaming/ThrottledRenderer.ts +139 -0
  80. package/src/services/streaming/TranscriptBuffer.ts +574 -0
  81. package/src/services/streaming/eventStatusMap.ts +52 -0
  82. package/src/services/streaming/index.ts +46 -0
  83. package/src/services/streaming/renderFormatting.ts +79 -0
  84. package/src/services/streaming/types.ts +234 -0
  85. package/src/skills/SkillLoader.ts +126 -0
  86. package/src/skills/SkillRegistry.ts +366 -0
  87. package/src/skills/index.ts +48 -0
  88. package/src/skills/types.ts +146 -0
  89. package/src/slash-commands/billing.ts +70 -0
  90. package/src/slash-commands/build.ts +413 -0
  91. package/src/slash-commands/builtinCommands.ts +2733 -0
  92. package/src/slash-commands/clone.ts +242 -0
  93. package/src/slash-commands/council.ts +125 -0
  94. package/src/slash-commands/custom/CustomCommandExecutor.ts +143 -0
  95. package/src/slash-commands/custom/CustomCommandLoader.ts +251 -0
  96. package/src/slash-commands/custom/CustomCommandRegistry.ts +144 -0
  97. package/src/slash-commands/custom/index.ts +7 -0
  98. package/src/slash-commands/debate.ts +254 -0
  99. package/src/slash-commands/gmail.ts +105 -0
  100. package/src/slash-commands/index.ts +388 -0
  101. package/src/slash-commands/mcpCommand.ts +205 -0
  102. package/src/slash-commands/types.ts +201 -0
  103. package/src/store/index.ts +76 -0
  104. package/src/store/selectors.ts +246 -0
  105. package/src/store/slices/appSlice.ts +205 -0
  106. package/src/store/slices/commandSlice.ts +115 -0
  107. package/src/store/slices/configSlice.ts +46 -0
  108. package/src/store/slices/focusSlice.ts +64 -0
  109. package/src/store/slices/index.ts +9 -0
  110. package/src/store/slices/sessionSlice.ts +424 -0
  111. package/src/store/streaming-buffer.ts +425 -0
  112. package/src/store/test.ts +296 -0
  113. package/src/store/types.ts +274 -0
  114. package/src/store/vanilla.ts +186 -0
  115. package/src/tools/builtin/bash.ts +236 -0
  116. package/src/tools/builtin/council.ts +105 -0
  117. package/src/tools/builtin/edit.ts +213 -0
  118. package/src/tools/builtin/glob.ts +136 -0
  119. package/src/tools/builtin/grep.ts +263 -0
  120. package/src/tools/builtin/index.ts +61 -0
  121. package/src/tools/builtin/memory.ts +66 -0
  122. package/src/tools/builtin/read.ts +168 -0
  123. package/src/tools/builtin/skill.ts +97 -0
  124. package/src/tools/builtin/snapshot.ts +40 -0
  125. package/src/tools/builtin/task.ts +106 -0
  126. package/src/tools/builtin/write.ts +134 -0
  127. package/src/tools/createTool.ts +221 -0
  128. package/src/tools/execution/ExecutionPipeline.ts +263 -0
  129. package/src/tools/execution/index.ts +40 -0
  130. package/src/tools/execution/stages/CacheStage.ts +131 -0
  131. package/src/tools/execution/stages/ConfirmationStage.ts +203 -0
  132. package/src/tools/execution/stages/DiscoveryStage.ts +46 -0
  133. package/src/tools/execution/stages/ExecutionStage.ts +48 -0
  134. package/src/tools/execution/stages/FormattingStage.ts +44 -0
  135. package/src/tools/execution/stages/HookStage.ts +72 -0
  136. package/src/tools/execution/stages/PermissionStage.ts +287 -0
  137. package/src/tools/execution/stages/PostHookStage.ts +71 -0
  138. package/src/tools/execution/stages/index.ts +12 -0
  139. package/src/tools/execution/test.ts +266 -0
  140. package/src/tools/execution/types.ts +273 -0
  141. package/src/tools/index.ts +81 -0
  142. package/src/tools/registry.ts +304 -0
  143. package/src/tools/schemas.ts +109 -0
  144. package/src/tools/test.ts +220 -0
  145. package/src/tools/types.ts +175 -0
  146. package/src/tools/validation/PermissionChecker.ts +242 -0
  147. package/src/tools/validation/SensitiveFileDetector.ts +210 -0
  148. package/src/tools/validation/index.ts +11 -0
  149. package/src/ui/App.tsx +166 -0
  150. package/src/ui/components/AegisInterface.tsx +484 -0
  151. package/src/ui/components/common/ChatSearch.tsx +150 -0
  152. package/src/ui/components/common/ErrorBoundary.tsx +82 -0
  153. package/src/ui/components/common/ExitMessage.tsx +120 -0
  154. package/src/ui/components/common/LoadingIndicator.tsx +49 -0
  155. package/src/ui/components/common/index.ts +6 -0
  156. package/src/ui/components/dialog/ConfirmationPrompt.tsx +208 -0
  157. package/src/ui/components/dialog/InteractiveSelector.tsx +149 -0
  158. package/src/ui/components/dialog/SetupWizard.tsx +297 -0
  159. package/src/ui/components/dialog/UpdatePrompt.tsx +155 -0
  160. package/src/ui/components/dialog/index.ts +8 -0
  161. package/src/ui/components/index.ts +28 -0
  162. package/src/ui/components/input/CommandSuggestions.tsx +139 -0
  163. package/src/ui/components/input/CustomTextInput.tsx +220 -0
  164. package/src/ui/components/input/InputArea.tsx +361 -0
  165. package/src/ui/components/input/PromptSuggestions.tsx +66 -0
  166. package/src/ui/components/input/index.ts +6 -0
  167. package/src/ui/components/layout/ChatStatusBar.tsx +90 -0
  168. package/src/ui/components/layout/ContextBar.tsx +79 -0
  169. package/src/ui/components/layout/MessageArea.tsx +96 -0
  170. package/src/ui/components/layout/MessageList.tsx +647 -0
  171. package/src/ui/components/layout/MessageSeparator.tsx +26 -0
  172. package/src/ui/components/layout/WelcomeMessage.tsx +93 -0
  173. package/src/ui/components/layout/index.ts +7 -0
  174. package/src/ui/components/markdown/CodeHighlighter.tsx +292 -0
  175. package/src/ui/components/markdown/MessageRenderer.tsx +1211 -0
  176. package/src/ui/components/markdown/index.ts +8 -0
  177. package/src/ui/components/markdown/parser.ts +336 -0
  178. package/src/ui/components/markdown/types.ts +66 -0
  179. package/src/ui/focus/FocusManager.ts +137 -0
  180. package/src/ui/focus/index.ts +13 -0
  181. package/src/ui/focus/types.ts +54 -0
  182. package/src/ui/focus/useFocus.ts +75 -0
  183. package/src/ui/hooks/index.ts +11 -0
  184. package/src/ui/hooks/useAgent.ts +284 -0
  185. package/src/ui/hooks/useCommandHistory.ts +87 -0
  186. package/src/ui/hooks/useCommandProcessor.ts +443 -0
  187. package/src/ui/hooks/useConfirmation.ts +99 -0
  188. package/src/ui/hooks/useCtrlCHandler.ts +100 -0
  189. package/src/ui/hooks/useInputBuffer.ts +122 -0
  190. package/src/ui/hooks/useTerminalSize.ts +68 -0
  191. package/src/ui/hooks/useTerminalWidth.ts +5 -0
  192. package/src/ui/hooks/useWindowedList.ts +118 -0
  193. package/src/ui/render-debugger.ts +621 -0
  194. package/src/ui/test.ts +189 -0
  195. package/src/ui/themes/ThemeManager.ts +332 -0
  196. package/src/ui/themes/aegisTheme.ts +87 -0
  197. package/src/ui/themes/darkTheme.ts +87 -0
  198. package/src/ui/themes/defaultTheme.ts +85 -0
  199. package/src/ui/themes/index.ts +10 -0
  200. package/src/ui/themes/lightTheme.ts +89 -0
  201. package/src/ui/themes/popularThemes.ts +187 -0
  202. package/src/ui/themes/types.ts +130 -0
  203. package/src/utils/clipboard.ts +48 -0
  204. package/src/utils/debug.ts +43 -0
  205. package/src/utils/environment.ts +68 -0
  206. package/src/utils/index.ts +10 -0
@@ -0,0 +1,2733 @@
1
+ /**
2
+ * Built-in slash commands
3
+ */
4
+
5
+ import type { SlashCommand, SlashCommandResult, SlashCommandContext } from './types.js';
6
+ import type { AgentConfig } from '../agent/types.js';
7
+ import { buildCommand } from './build.js';
8
+ import { cloneCommand } from './clone.js';
9
+ import { debateCommand } from './debate.js';
10
+ import { sessionActions, getState, getConfig } from '../store/index.js';
11
+ import {
12
+ OrchestratorAgent,
13
+ CouncilAgent,
14
+ requireModelConfig,
15
+ buildSourceContext,
16
+ createBuiltinApps,
17
+ runApp,
18
+ getApp,
19
+ getRegisteredApps,
20
+ AppBuilder,
21
+ type AppDefinition,
22
+ type SubAgentConfig,
23
+ } from '../agent/orchestrator/index.js';
24
+ import { getOllamaModels, isLocalOllamaUrl, type OllamaModelInfo } from '../services/OllamaInstaller.js';
25
+ import { copyToClipboard } from '../utils/clipboard.js';
26
+
27
+ // ─── Auto-register AppBuilder apps ───
28
+ const BUILTIN_APPS: AppDefinition[] = createBuiltinApps();
29
+
30
+ /**
31
+ * /help - 显示所有可用命令
32
+ */
33
+ export const helpCommand: SlashCommand = {
34
+ name: 'help',
35
+ aliases: ['?', 'h'],
36
+ description: 'Show available commands',
37
+ category: 'general',
38
+ usage: '/help [command]',
39
+
40
+ async handler(args: string, _context: SlashCommandContext): Promise<SlashCommandResult> {
41
+ // 延迟导入避免循环依
42
+ const { getRegisteredCommands, getCommand } = await import('./index.js');
43
+
44
+ const trimmedArgs = args.trim();
45
+
46
+ // 查看特定命令的帮
47
+ if (trimmedArgs) {
48
+ const cmd = getCommand(trimmedArgs);
49
+ if (cmd) {
50
+ let content = `## /${cmd.name}\n\n`;
51
+ content += `${cmd.fullDescription || cmd.description}\n\n`;
52
+
53
+ if (cmd.usage) {
54
+ content += `**usage:** \`${cmd.usage}\`\n\n`;
55
+ }
56
+
57
+ if (cmd.aliases && cmd.aliases.length > 0) {
58
+ content += `**aliases:** ${cmd.aliases.map(a => `/${a}`).join(', ')}\n\n`;
59
+ }
60
+
61
+ if (cmd.examples && cmd.examples.length > 0) {
62
+ content += `**examples:**\n`;
63
+ for (const example of cmd.examples) {
64
+ content += `- \`${example}\`\n`;
65
+ }
66
+ }
67
+
68
+ return { success: true, type: 'info', content };
69
+ }
70
+
71
+ return {
72
+ success: false,
73
+ type: 'error',
74
+ error: `unknown command: /${trimmedArgs}`,
75
+ };
76
+ }
77
+
78
+ // 显示所有命
79
+ const commands = getRegisteredCommands();
80
+
81
+ // 按分类分
82
+ const grouped: Record<string, SlashCommand[]> = {};
83
+ for (const cmd of commands) {
84
+ const category = cmd.category || 'general';
85
+ if (!grouped[category]) {
86
+ grouped[category] = [];
87
+ }
88
+ grouped[category].push(cmd);
89
+ }
90
+
91
+ // 分类名称映
92
+ const categoryNames: Record<string, string> = {
93
+ general: 'general',
94
+ session: 'session',
95
+ config: 'config',
96
+ skills: 'skills',
97
+ hooks: 'hooks',
98
+ git: 'git',
99
+ custom: 'custom',
100
+ };
101
+
102
+ let content = '## Commands\n\n';
103
+
104
+ for (const [category, cmds] of Object.entries(grouped)) {
105
+ const categoryName = categoryNames[category] || category;
106
+ content += `### ${categoryName}\n\n`;
107
+
108
+ for (const cmd of cmds) {
109
+ const aliases = cmd.aliases?.length
110
+ ? ` (${cmd.aliases.map(a => `/${a}`).join(', ')})`
111
+ : '';
112
+ content += `- \`/${cmd.name}\`${aliases} - ${cmd.description}\n`;
113
+ }
114
+ content += '\n';
115
+ }
116
+
117
+ content += `/help <cmd> for details\n`;
118
+
119
+ return { success: true, type: 'info', content };
120
+ },
121
+ };
122
+
123
+ /**
124
+ * /clear - 清除对话历史
125
+ */
126
+ export const clearCommand: SlashCommand = {
127
+ name: 'clear',
128
+ aliases: ['cls'],
129
+ description: 'Clear chat history',
130
+ category: 'session',
131
+ usage: '/clear',
132
+
133
+ async handler(): Promise<SlashCommandResult> {
134
+ sessionActions().clearMessages();
135
+
136
+ return {
137
+ success: true,
138
+ type: 'success',
139
+ message: 'cleared',
140
+ };
141
+ },
142
+ };
143
+
144
+ /**
145
+ * /compact - 手动压缩上下文
146
+ */
147
+ export const compactCommand: SlashCommand = {
148
+ name: 'compact',
149
+ description: 'Compact context manually',
150
+ category: 'session',
151
+ usage: '/compact',
152
+ fullDescription: 'Trigger manual context compaction, summarizing conversation history to save tokens.',
153
+
154
+ async handler(_args: string, context: SlashCommandContext): Promise<SlashCommandResult> {
155
+ const { contextManager, chatService, modelName } = context;
156
+
157
+ if (!contextManager) {
158
+ return {
159
+ success: false,
160
+ type: 'error',
161
+ error: 'context manager unavailable',
162
+ };
163
+ }
164
+
165
+ try {
166
+ // 标记开始压
167
+ sessionActions().setCompacting(true);
168
+
169
+ const contextMessages = contextManager.getMessages();
170
+ const currentTokens = contextManager.getTokenCount();
171
+
172
+ if (contextMessages.length < 4) {
173
+ sessionActions().setCompacting(false);
174
+ return {
175
+ success: true,
176
+ type: 'info',
177
+ message: 'history too short, skipping compaction',
178
+ };
179
+ }
180
+
181
+ // 动态导入避免循环依
182
+ const { CompactionService } = await import('../context/CompactionService.js');
183
+
184
+ // 获取 maxContextTokens 配
185
+ const state = getState();
186
+ const runtimeConfig = state.config.config;
187
+ const maxContextTokens = runtimeConfig?.maxContextTokens || 200000;
188
+
189
+ // 转换消息格
190
+ const messages = contextMessages.map((m: { role: string; content: string }) => ({
191
+ role: m.role as 'user' | 'assistant' | 'system' | 'tool',
192
+ content: m.content,
193
+ }));
194
+
195
+ const result = await CompactionService.compact(messages, {
196
+ modelName: modelName || 'claude-sonnet-4-6',
197
+ maxContextTokens,
198
+ chatService,
199
+ trigger: 'manual',
200
+ actualPreTokens: currentTokens,
201
+ });
202
+
203
+ if (result.success) {
204
+ // 将 Message[] 转换为 ContextMessage[] 格
205
+ const { nanoid } = await import('nanoid');
206
+ const compactedContextMessages = result.compactedMessages.map(m => ({
207
+ id: nanoid(),
208
+ role: m.role as 'user' | 'assistant' | 'system' | 'tool',
209
+ content: m.content,
210
+ timestamp: Date.now(),
211
+ }));
212
+
213
+ // 更新 ContextManager 中的消
214
+ contextManager.replaceMessages(compactedContextMessages);
215
+
216
+ // 更新 token 统
217
+ contextManager.updateTokenCount(result.postTokens);
218
+
219
+ const savedTokens = result.preTokens - result.postTokens;
220
+ const savedPercent = Math.round((savedTokens / result.preTokens) * 100);
221
+
222
+ return {
223
+ success: true,
224
+ type: 'success',
225
+ content: `## Context compacted
226
+
227
+ | metric | value |
228
+ |--------|-------|
229
+ | before | ${result.preTokens.toLocaleString()} tokens |
230
+ | after | ${result.postTokens.toLocaleString()} tokens |
231
+ | saved | ${savedTokens.toLocaleString()} tokens (${savedPercent}%) |
232
+ | files | ${result.filesIncluded.length} |
233
+
234
+ conversation continues normally.`,
235
+ };
236
+ } else {
237
+ return {
238
+ success: false,
239
+ type: 'error',
240
+ error: `compaction failed: ${result.error || 'unknown'}`,
241
+ };
242
+ }
243
+ } catch (error) {
244
+ return {
245
+ success: false,
246
+ type: 'error',
247
+ error: `compaction error: ${error instanceof Error ? error.message : String(error)}`,
248
+ };
249
+ } finally {
250
+ sessionActions().setCompacting(false);
251
+ }
252
+ },
253
+ };
254
+
255
+ /**
256
+ * /version - 显示版本信息
257
+ */
258
+ export const versionCommand: SlashCommand = {
259
+ name: 'version',
260
+ aliases: ['v'],
261
+ description: 'Show version info',
262
+ category: 'general',
263
+ usage: '/version',
264
+
265
+ async handler(): Promise<SlashCommandResult> {
266
+ // 从 package.json 获取版
267
+ let version = 'unknown';
268
+ try {
269
+ const fs = await import('fs');
270
+ const path = await import('path');
271
+ const packagePath = path.join(process.cwd(), 'package.json');
272
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
273
+ version = packageJson.version || 'unknown';
274
+ } catch {
275
+ // 忽略错
276
+ }
277
+
278
+ const content = `## AEGIS v${version}
279
+
280
+ runtime: ${process.version} · ${process.platform} ${process.arch}
281
+ `;
282
+
283
+ return {
284
+ success: true,
285
+ type: 'info',
286
+ content,
287
+ };
288
+ },
289
+ };
290
+
291
+ /**
292
+ * /model - 显示或切换模型
293
+ */
294
+ export const modelCommand: SlashCommand = {
295
+ name: 'model',
296
+ aliases: ['m'],
297
+ description: 'Show, switch, add or remove models',
298
+ category: 'config',
299
+ usage: '/model [id] | /model add <id> <name> <model> <baseURL> <apiKey> | /model remove <id> | /model list',
300
+ examples: ['/model', '/model claude-sonnet-4', '/model add mygpt gpt-4o gpt-4o https://api.openai.com/v1 sk-...', '/model remove mygpt'],
301
+ fullDescription: 'Manage models. No args = interactive selector. add/remove = manage config.',
302
+
303
+ async handler(args: string, context: SlashCommandContext): Promise<SlashCommandResult> {
304
+ const state = getState();
305
+ const config = state.config.config;
306
+ const models = config?.models || [];
307
+ const currentModelId = config?.currentModelId;
308
+ const defaultModel = config?.default;
309
+
310
+ const parts = args.trim().split(/\s+/);
311
+ const subcommand = parts[0]?.toLowerCase();
312
+
313
+ // ── /model add <id> <name> <model> <baseURL> <apiKey> ──
314
+ if (subcommand === 'add') {
315
+ const [, id, name, model, baseURL, apiKey] = parts;
316
+ if (!id || !model || !baseURL) {
317
+ return {
318
+ success: false,
319
+ type: 'error',
320
+ content: 'usage: /model add <id> <name> <model> <baseURL> <apiKey>\nexample: /model add mygpt "GPT-4o" gpt-4o https://api.openai.com/v1 sk-...',
321
+ };
322
+ }
323
+ if (models.find(m => m.id === id)) {
324
+ return { success: false, type: 'error', content: `model id \`${id}\` already exists. remove it first.` };
325
+ }
326
+ const newModel = { id, name: name || id, model, baseURL, apiKey: apiKey || '', provider: 'openai-compatible' as const };
327
+ const updatedModels = [...models, newModel];
328
+ const { configActions } = await import('../store/index.js');
329
+ configActions().updateConfig({ models: updatedModels });
330
+ try {
331
+ const fs = await import('fs');
332
+ const path = await import('path');
333
+ const os = await import('os');
334
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
335
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
336
+ cfg.models = updatedModels;
337
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
338
+ } catch { /* non-fatal */ }
339
+ return { success: true, type: 'success', message: `added model \`${id}\` (${model})` };
340
+ }
341
+
342
+ // ── /model remove <id> ──
343
+ if (subcommand === 'remove' || subcommand === 'rm') {
344
+ const id = parts[1];
345
+ if (!id) return { success: false, type: 'error', content: 'usage: /model remove <id>' };
346
+ if (!models.find(m => m.id === id)) {
347
+ return { success: false, type: 'error', content: `model \`${id}\` not found` };
348
+ }
349
+ const updatedModels = models.filter(m => m.id !== id);
350
+ const { configActions } = await import('../store/index.js');
351
+ configActions().updateConfig({ models: updatedModels });
352
+ try {
353
+ const fs = await import('fs');
354
+ const path = await import('path');
355
+ const os = await import('os');
356
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
357
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
358
+ cfg.models = updatedModels;
359
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
360
+ } catch { /* non-fatal */ }
361
+ return { success: true, type: 'success', message: `removed model \`${id}\`` };
362
+ }
363
+
364
+ // ── /model list ──
365
+ if (subcommand === 'list') {
366
+ if (models.length === 0) return { success: true, type: 'info', content: 'no models configured.' };
367
+ const lines = models.map(m =>
368
+ `${m.id === currentModelId ? '▶' : ' '} \`${m.id}\` ${m.name || ''} (${m.model || ''})`
369
+ );
370
+ return { success: true, type: 'info', content: '## models\n\n' + lines.join('\n') };
371
+ }
372
+
373
+ // ── /model <id> — switch ──
374
+ const trimmedArgs = args.trim();
375
+ if (trimmedArgs && subcommand !== 'add' && subcommand !== 'remove' && subcommand !== 'list') {
376
+ const targetModel = models.find(
377
+ m => m.id === trimmedArgs || m.model === trimmedArgs || m.name === trimmedArgs
378
+ );
379
+ if (targetModel) {
380
+ const { configActions, appActions } = await import('../store/index.js');
381
+ configActions().updateConfig({ currentModelId: targetModel.id });
382
+ // A manual pick wins over the auto-router for the rest of the session
383
+ appActions().setManualModelOverride(true);
384
+ appActions().setAutoRouterActiveModel(null);
385
+ try {
386
+ const fs = await import('fs');
387
+ const path = await import('path');
388
+ const os = await import('os');
389
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
390
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
391
+ cfg.currentModelId = targetModel.id;
392
+ cfg.default = {
393
+ ...cfg.default,
394
+ model: targetModel.model || targetModel.id,
395
+ baseURL: targetModel.baseURL || (targetModel as any).baseUrl || cfg.default?.baseURL,
396
+ apiKey: targetModel.apiKey || cfg.default?.apiKey,
397
+ };
398
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
399
+ } catch { /* non-fatal */ }
400
+ return { success: true, type: 'success', message: `model -> ${(targetModel as any).label || targetModel.name || targetModel.model || targetModel.id}` };
401
+ }
402
+ let errorContent = `unknown model: \`${trimmedArgs}\`\n\n`;
403
+ if (models.length > 0) {
404
+ errorContent += `available:\n` + models.map(m => `- \`${m.id}\` ${m.name || m.model || ''}`).join('\n');
405
+ } else {
406
+ errorContent += 'no models configured. use /model add to add one.';
407
+ }
408
+ return { success: false, type: 'error', content: errorContent };
409
+ }
410
+
411
+ // ── no args — interactive selector ──
412
+ const OLLAMA_DEFAULT = 'http://localhost:11434/v1';
413
+
414
+ // Always scan local Ollama (even if no Ollama models are configured yet)
415
+ const ollamaInfoByName = new Map<string, OllamaModelInfo>();
416
+ const ollamaBaseURLs = [...new Set([
417
+ OLLAMA_DEFAULT,
418
+ ...models
419
+ .filter((m: any) => isLocalOllamaUrl(m.baseURL || m.baseUrl))
420
+ .map((m: any) => m.baseURL || m.baseUrl),
421
+ ])];
422
+ await Promise.all(
423
+ ollamaBaseURLs.map(async (url: string) => {
424
+ const infos = await getOllamaModels(url).catch(() => []);
425
+ for (const info of infos) {
426
+ ollamaInfoByName.set(info.name, info);
427
+ ollamaInfoByName.set(info.name.split(':')[0], info);
428
+ }
429
+ })
430
+ );
431
+
432
+ // Build configured model entries
433
+ const configuredIds = new Set(models.map((m: any) => m.model || m.id));
434
+ const configuredOptions = models.map((m: any) => {
435
+ const modelName: string = m.model || m.id;
436
+ const ollamaInfo = ollamaInfoByName.get(modelName) ?? ollamaInfoByName.get(modelName.split(':')[0]);
437
+ let description: string;
438
+ if (ollamaInfo) {
439
+ const p: string[] = [];
440
+ p.push(ollamaInfo.isLoaded ? '● loaded' : '○ cold');
441
+ if (ollamaInfo.sizeGB !== undefined) p.push(`${ollamaInfo.sizeGB.toFixed(1)} GB`);
442
+ if (ollamaInfo.supportsTools) p.push('[tools]');
443
+ description = p.join(' · ');
444
+ } else {
445
+ description = m.model || m.baseURL || '';
446
+ }
447
+ return {
448
+ value: m.id,
449
+ label: (m as any).label || m.name || m.model || m.id,
450
+ description,
451
+ isCurrent: m.id === currentModelId,
452
+ };
453
+ });
454
+
455
+ // Auto-discovered local Ollama models not yet in config
456
+ const discoveredOptions = [...ollamaInfoByName.values()]
457
+ .filter(info => !configuredIds.has(info.name) && !configuredIds.has(info.name.split(':')[0]))
458
+ .filter((info, idx, arr) => arr.findIndex(x => x.name === info.name) === idx)
459
+ .map(info => {
460
+ const p: string[] = ['○ ollama · not saved'];
461
+ if (info.sizeGB !== undefined) p.push(`${info.sizeGB.toFixed(1)} GB`);
462
+ if (info.supportsTools) p.push('[tools]');
463
+ return {
464
+ value: `__ollama__${info.name}`,
465
+ label: info.name,
466
+ description: p.join(' · '),
467
+ isCurrent: false,
468
+ };
469
+ });
470
+
471
+ const allOptions = [...configuredOptions, ...discoveredOptions];
472
+
473
+ if (allOptions.length === 0) {
474
+ const modelInfo = defaultModel?.model || currentModelId || 'unknown';
475
+ return { success: true, type: 'info', content: `## model\n\ncurrent: \`${modelInfo}\`\n\nno models configured. use /model add <id> <name> <model> <baseURL> <apiKey>` };
476
+ }
477
+
478
+ return {
479
+ success: true,
480
+ type: 'selector',
481
+ selector: {
482
+ title: 'Select model',
483
+ options: allOptions,
484
+ handler: 'model',
485
+ },
486
+ };
487
+ },
488
+ };
489
+
490
+ /**
491
+ * /router - 自动路由:按任务复杂度自动选择模
492
+ */
493
+ export const routerCommand: SlashCommand = {
494
+ name: 'router',
495
+ description: 'Auto-pick a model per message based on task complexity',
496
+ category: 'config',
497
+ usage: '/router [on|off|set <simple|medium|complex> <modelId>|stats]',
498
+ examples: ['/router', '/router on', '/router off', '/router set simple deepseek-chat', '/router stats'],
499
+ fullDescription:
500
+ 'Classifies each message as simple/medium/complex (cheap heuristics, no extra LLM call) ' +
501
+ 'and picks the cheapest configured model that fits, unless /model has been used this session. ' +
502
+ 'When no tier is set explicitly, learns from outcomes (a model that keeps getting aborted for a ' +
503
+ 'tier loses ground to the next cheapest one over time) — see /router stats for the learned data.',
504
+
505
+ async handler(args: string): Promise<SlashCommandResult> {
506
+ const { configActions, appActions, getState } = await import('../store/index.js');
507
+ const state = getState();
508
+ const config = state.config.config;
509
+ const autoRouter = config?.autoRouter || { enabled: false, tiers: {} };
510
+ const models = config?.models || [];
511
+
512
+ const persist = async (next: typeof autoRouter) => {
513
+ configActions().updateConfig({ autoRouter: next });
514
+ try {
515
+ const fs = await import('fs');
516
+ const path = await import('path');
517
+ const os = await import('os');
518
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
519
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
520
+ cfg.autoRouter = next;
521
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
522
+ } catch { /* non-fatal */ }
523
+ };
524
+
525
+ const parts = args.trim().split(/\s+/).filter(Boolean);
526
+ const subcommand = parts[0]?.toLowerCase();
527
+
528
+ if (subcommand === 'on') {
529
+ await persist({ ...autoRouter, enabled: true });
530
+ appActions().setManualModelOverride(false);
531
+ return { success: true, type: 'success', message: 'auto-router: on' };
532
+ }
533
+
534
+ if (subcommand === 'off') {
535
+ await persist({ ...autoRouter, enabled: false });
536
+ appActions().setAutoRouterActiveModel(null);
537
+ return { success: true, type: 'success', message: 'auto-router: off' };
538
+ }
539
+
540
+ if (subcommand === 'set') {
541
+ const tier = parts[1]?.toLowerCase();
542
+ const modelId = parts[2];
543
+ if (!tier || !['simple', 'medium', 'complex'].includes(tier) || !modelId) {
544
+ return {
545
+ success: false,
546
+ type: 'error',
547
+ content: 'usage: /router set <simple|medium|complex> <modelId>',
548
+ };
549
+ }
550
+ if (!models.find(m => m.id === modelId)) {
551
+ return { success: false, type: 'error', content: `unknown model id: \`${modelId}\` — see /model list` };
552
+ }
553
+ await persist({ ...autoRouter, tiers: { ...autoRouter.tiers, [tier]: modelId } });
554
+ return { success: true, type: 'success', message: `auto-router: ${tier} -> ${modelId}` };
555
+ }
556
+
557
+ if (subcommand === 'stats') {
558
+ const { getRouterStats } = await import('../agent/routerStats.js');
559
+ const stats = getRouterStats();
560
+ const tierNames: Array<'simple' | 'medium' | 'complex'> = ['simple', 'medium', 'complex'];
561
+ const lines = ['learned outcomes (success / aborted-or-errored), per tier:'];
562
+ for (const tier of tierNames) {
563
+ const tierStats = stats[tier];
564
+ if (!tierStats || Object.keys(tierStats).length === 0) {
565
+ lines.push(` ${tier.padEnd(8)} no data yet`);
566
+ continue;
567
+ }
568
+ lines.push(` ${tier}:`);
569
+ for (const [modelId, s] of Object.entries(tierStats)) {
570
+ const total = s.success + s.failure;
571
+ const rate = total > 0 ? Math.round((s.success / total) * 100) : 0;
572
+ lines.push(` ${modelId.padEnd(20)} ${s.success}/${total} (${rate}%)`);
573
+ }
574
+ }
575
+ lines.push('', 'Aborting a response counts against the model that was handling it — this is what nudges future picks.');
576
+ return { success: true, type: 'info', content: lines.join('\n') };
577
+ }
578
+
579
+ // ── no args — status ──
580
+ const tiers = autoRouter.tiers || {};
581
+ const lines = [
582
+ `auto-router: ${autoRouter.enabled ? 'on' : 'off'}${state.app.manualModelOverride ? ' (backed off — /model set manually this session)' : ''}`,
583
+ ` simple ${tiers.simple || '(auto)'}`,
584
+ ` medium ${tiers.medium || '(auto)'}`,
585
+ ` complex ${tiers.complex || '(auto)'}`,
586
+ ];
587
+ return { success: true, type: 'info', content: lines.join('\n') };
588
+ },
589
+ };
590
+
591
+ /**
592
+ * /effort - extended-thinking budget tier (native Anthropic transport only)
593
+ */
594
+ export const effortCommand: SlashCommand = {
595
+ name: 'effort',
596
+ description: 'Set Claude\'s extended-thinking effort level',
597
+ category: 'config',
598
+ usage: '/effort [off|low|medium|high|max]',
599
+ examples: ['/effort', '/effort high', '/effort off'],
600
+ fullDescription:
601
+ 'Controls Claude\'s adaptive thinking depth via output_config.effort. Higher levels reason more ' +
602
+ 'before answering — better for hard problems, slower and more expensive for simple ones. ' +
603
+ 'Only takes effect on the native Anthropic API path (not OpenAI-compatible providers, and not Haiku models).',
604
+
605
+ async handler(args: string): Promise<SlashCommandResult> {
606
+ const { configActions, getState } = await import('../store/index.js');
607
+ const state = getState();
608
+ const config = state.config.config;
609
+ const current = config?.thinking?.budget || 'off';
610
+
611
+ const persist = async (budget: typeof current) => {
612
+ configActions().updateConfig({ thinking: { budget } });
613
+ try {
614
+ const fs = await import('fs');
615
+ const path = await import('path');
616
+ const os = await import('os');
617
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
618
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
619
+ cfg.thinking = { budget };
620
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
621
+ } catch { /* non-fatal */ }
622
+ };
623
+
624
+ const arg = args.trim().toLowerCase();
625
+ if (!arg) {
626
+ return { success: true, type: 'info', content: `thinking effort: ${current}` };
627
+ }
628
+
629
+ const valid = ['off', 'low', 'medium', 'high', 'max'];
630
+ if (!valid.includes(arg)) {
631
+ return { success: false, type: 'error', content: `usage: /effort [${valid.join('|')}]` };
632
+ }
633
+
634
+ await persist(arg as typeof current);
635
+ return { success: true, type: 'success', message: `thinking effort: ${arg}` };
636
+ },
637
+ };
638
+
639
+ /**
640
+ * /theme - 切换主题
641
+ */
642
+ export const themeCommand: SlashCommand = {
643
+ name: 'theme',
644
+ aliases: ['t'],
645
+ description: 'Show or switch theme',
646
+ category: 'config',
647
+ usage: '/theme [theme-name]',
648
+ examples: ['/theme', '/theme dark', '/theme ocean'],
649
+ fullDescription: 'Show current theme or switch to a specified theme. Without args, opens interactive selector.',
650
+
651
+ async handler(args: string): Promise<SlashCommandResult> {
652
+ const { themeManager } = await import('../ui/themes/index.js');
653
+
654
+ const trimmedArgs = args.trim().toLowerCase();
655
+ const themePresets = themeManager.getThemePresets();
656
+ const currentThemeName = themeManager.getCurrentThemeName();
657
+
658
+ // 如果指定了主题名称,直接切
659
+ if (trimmedArgs) {
660
+ const targetTheme = themePresets.find(t => t.id === trimmedArgs || t.name.toLowerCase() === trimmedArgs);
661
+
662
+ if (targetTheme) {
663
+ themeManager.setTheme(targetTheme.id);
664
+ return {
665
+ success: true,
666
+ type: 'success',
667
+ message: `theme -> ${targetTheme.name}`,
668
+ };
669
+ }
670
+
671
+ return {
672
+ success: false,
673
+ type: 'error',
674
+ error: `unknown theme: ${trimmedArgs}\navailable: ${themePresets.map(t => t.id).join(', ')}`,
675
+ };
676
+ }
677
+
678
+ // 无参数时,返回选择器配
679
+ return {
680
+ success: true,
681
+ type: 'selector',
682
+ selector: {
683
+ title: 'Select theme',
684
+ options: themePresets.map(t => ({
685
+ value: t.id,
686
+ label: t.name,
687
+ description: t.description,
688
+ isCurrent: t.id === currentThemeName || t.name === currentThemeName,
689
+ })),
690
+ handler: 'theme',
691
+ },
692
+ };
693
+ },
694
+ };
695
+
696
+ /**
697
+ * /status - 显示会话状态
698
+ */
699
+ export const statusCommand: SlashCommand = {
700
+ name: 'status',
701
+ aliases: ['st'],
702
+ description: 'Show session status',
703
+ category: 'session',
704
+ usage: '/status',
705
+
706
+ async handler(): Promise<SlashCommandResult> {
707
+ const state = getState();
708
+ const { session, config } = state;
709
+ const runtimeConfig = config.config;
710
+
711
+ let content = `## status\n\n`;
712
+ content += `| key | value |\n`;
713
+ content += `|-----|-------|\n`;
714
+ content += `| sid | \`${session.sessionId || 'N/A'}\` |\n`;
715
+ content += `| messages | ${session.messages.length} |\n`;
716
+ content += `| tokens in | ${session.tokenUsage.inputTokens} |\n`;
717
+ content += `| tokens out | ${session.tokenUsage.outputTokens} |\n`;
718
+ content += `| model | ${runtimeConfig?.currentModelId || 'N/A'} |\n`;
719
+ content += `| thinking | ${session.isThinking ? 'yes' : 'no'} |\n`;
720
+
721
+ return {
722
+ success: true,
723
+ type: 'info',
724
+ content,
725
+ };
726
+ },
727
+ };
728
+
729
+ // ─── /tokens helpers ────────────────────────────────────────────────
730
+
731
+ function tokFmt(n: number): string {
732
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
733
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
734
+ return String(n);
735
+ }
736
+
737
+ function tokBar(ratio: number, width: number): string {
738
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
739
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
740
+ }
741
+
742
+ function tokCost(model: string, inputTok: number, outputTok: number): number | null {
743
+ const m = model.toLowerCase();
744
+ // per-MTok prices
745
+ let ip: number, op: number;
746
+ if (m.includes('opus-4')) { ip = 15; op = 75; }
747
+ else if (m.includes('sonnet-4')) { ip = 3; op = 15; }
748
+ else if (m.includes('haiku-4')) { ip = 0.8; op = 4; }
749
+ else if (m.includes('claude')) { ip = 3; op = 15; }
750
+ else if (m.includes('gpt-4o')) { ip = 2.5; op = 10; }
751
+ else if (m.includes('gpt-4')) { ip = 30; op = 60; }
752
+ else if (m.includes('gpt-3.5')) { ip = 0.5; op = 1.5; }
753
+ else return null;
754
+ return (inputTok * ip + outputTok * op) / 1_000_000;
755
+ }
756
+
757
+ function tokShortModel(model: string): string {
758
+ const m = model.toLowerCase();
759
+ if (m.includes('claude-sonnet-4-6')) return 'claude-sonnet-4.6';
760
+ if (m.includes('claude-sonnet-4')) return 'claude-sonnet-4';
761
+ if (m.includes('claude-opus-4')) return 'claude-opus-4';
762
+ if (m.includes('claude-haiku-4')) return 'claude-haiku-4';
763
+ return model.length > 24 ? model.slice(0, 21) + '...' : model;
764
+ }
765
+
766
+ /**
767
+ * /tokens - token usage graph and estimated cost
768
+ */
769
+ export const tokensCommand: SlashCommand = {
770
+ name: 'tokens',
771
+ aliases: ['tok'],
772
+ description: 'Show token usage graph and estimated spend',
773
+ category: 'session',
774
+ usage: '/tokens',
775
+ fullDescription: 'Visualises token consumption for this session as an ASCII bar chart, including input/output split, context-window usage, estimated USD cost, and a per-turn breakdown.',
776
+
777
+ async handler(_args: string, context: SlashCommandContext): Promise<SlashCommandResult> {
778
+ const state = getState();
779
+ const { session, config } = state;
780
+ const usage = session.tokenUsage;
781
+ const messages = session.messages.filter(m => !m.isStreaming);
782
+ const runtimeConfig = config.config;
783
+ const maxCtx = (runtimeConfig as any)?.maxContextTokens ?? 200_000;
784
+ const model = context.modelName || runtimeConfig?.currentModelId || 'claude-sonnet-4-6';
785
+
786
+ const totalIn = usage.inputTokens || 0;
787
+ const totalOut = usage.outputTokens || 0;
788
+ const total = totalIn + totalOut;
789
+ const BAR = 28;
790
+ const DIVIDER = `${'─'.repeat(BAR + 12)}`;
791
+
792
+ // shared price lookup
793
+ const priceFor = (mdl: string): [number, number] => {
794
+ const m = mdl.toLowerCase();
795
+ if (m.includes('opus-4')) return [15, 75 ];
796
+ else if (m.includes('sonnet-4')) return [3, 15 ];
797
+ else if (m.includes('haiku-4')) return [0.8, 4 ];
798
+ else if (m.includes('fable-5')) return [5, 25 ];
799
+ else if (m.includes('claude')) return [3, 15 ];
800
+ else if (m.includes('gpt-4o')) return [2.5, 10 ];
801
+ else if (m.includes('gpt-4')) return [30, 60 ];
802
+ else if (m.includes('gpt-3.5')) return [0.5, 1.5 ];
803
+ else if (m.includes('deepseek')) return [0.14, 0.28 ];
804
+ else if (m.includes('llama') || m.includes('groq')) return [0.06, 0.06];
805
+ else if (m.includes('gemini-2.5-pro')) return [1.25, 10 ];
806
+ else if (m.includes('gemini-2.5-flash')) return [0.15, 0.6 ];
807
+ else return [1, 3 ];
808
+ };
809
+
810
+ const fmtCost = (v: number) =>
811
+ v === 0 ? '$0.0000' :
812
+ v < 0.00001 ? '<$0.00001' :
813
+ v < 0.01 ? `$${v.toFixed(5)}` :
814
+ `$${v.toFixed(4)}`;
815
+
816
+ // ── context meter ──
817
+ const ctxTokens = context.contextManager?.getTokenCount?.() ?? 0;
818
+ const ctxRatio = maxCtx > 0 ? Math.min(1, ctxTokens / maxCtx) : 0;
819
+
820
+ // ── per-turn data ──
821
+ const turns = messages
822
+ .filter(m => m.role === 'user' || m.role === 'assistant')
823
+ .map((m, i) => ({
824
+ n: i + 1,
825
+ role: m.role as 'user' | 'assistant',
826
+ est: Math.max(1, Math.ceil((m.content?.length ?? 0) / 4)),
827
+ ts: m.timestamp,
828
+ }));
829
+
830
+ // ── session cost ──
831
+ const [ip, op] = priceFor(model);
832
+ const sessionCost = (totalIn * ip + totalOut * op) / 1_000_000;
833
+
834
+ // ── build output ──
835
+ const L: string[] = [];
836
+ L.push('## ◆ token usage');
837
+ L.push('');
838
+
839
+ if (ctxTokens > 0) {
840
+ const pct = `${Math.round(ctxRatio * 100)}%`;
841
+ L.push(`context ${tokBar(ctxRatio, BAR)} ${pct} · ${tokFmt(ctxTokens)} / ${tokFmt(maxCtx)}`);
842
+ L.push('');
843
+ }
844
+
845
+ const maxIO = Math.max(totalIn, totalOut, 1);
846
+ L.push(`in ${tokBar(totalIn / maxIO, BAR)} ${tokFmt(totalIn)}`);
847
+ L.push(`out ${tokBar(totalOut / maxIO, BAR)} ${tokFmt(totalOut)}`);
848
+ L.push(` ${'─'.repeat(BAR + 2)}`);
849
+ L.push(`total ${' '.repeat(BAR)} ${tokFmt(total)}`);
850
+ if (sessionCost > 0) {
851
+ L.push(`cost ${' '.repeat(BAR)} ~${fmtCost(sessionCost)} · ${tokShortModel(model)}`);
852
+ }
853
+
854
+ // ─────────────────────────────────────────────────────────────
855
+ // $ cost over turns — line graph (needs ≥ 4 turns)
856
+ // ─────────────────────────────────────────────────────────────
857
+ if (turns.length >= 4) {
858
+ const shown = turns.slice(-30);
859
+ const skipped = turns.length - shown.length;
860
+
861
+ let cum = 0;
862
+ const cumCosts = shown.map(t => {
863
+ cum += (t.est * (t.role === 'user' ? ip : op)) / 1_000_000;
864
+ return cum;
865
+ });
866
+ const maxCum = cumCosts[cumCosts.length - 1] || 0.000001;
867
+
868
+ const W = 54, H = 10, YW = 9;
869
+
870
+ const colFor = (i: number) =>
871
+ shown.length < 2 ? 0 : Math.round(i * (W - 1) / (shown.length - 1));
872
+ const rowFor = (c: number) =>
873
+ H - 1 - Math.round((c / maxCum) * (H - 1));
874
+
875
+ const grid: string[][] = Array.from({length: H}, () => Array(W).fill(' '));
876
+
877
+ for (let i = 0; i < shown.length - 1; i++) {
878
+ let x0 = colFor(i), y0 = rowFor(cumCosts[i]);
879
+ const x1 = colFor(i+1), y1 = rowFor(cumCosts[i+1]);
880
+ const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
881
+ const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
882
+ let err = dx + dy;
883
+ while (true) {
884
+ if (grid[y0][x0] === ' ') grid[y0][x0] = '·';
885
+ if (x0 === x1 && y0 === y1) break;
886
+ const e2 = 2 * err;
887
+ if (e2 >= dy) { err += dy; x0 += sx; }
888
+ if (e2 <= dx) { err += dx; y0 += sy; }
889
+ }
890
+ }
891
+ for (let i = 0; i < shown.length; i++) grid[rowFor(cumCosts[i])][colFor(i)] = '◆';
892
+
893
+ L.push(''); L.push(DIVIDER); L.push('');
894
+ L.push('$ cost over turns (cumulative)');
895
+ L.push('');
896
+ if (skipped > 0) L.push(` ··· ${skipped} earlier turns not shown`);
897
+
898
+ // 3 y-ticks: top, mid, bottom — │ on all other rows
899
+ const yTicks = new Map([[0, maxCum], [Math.round((H-1)/2), maxCum/2], [H-1, 0]]);
900
+ for (let r = 0; r < H; r++) {
901
+ const yLabel = yTicks.has(r)
902
+ ? fmtCost(yTicks.get(r)!).padStart(YW)
903
+ : ' '.repeat(YW);
904
+ const ax = yTicks.has(r) ? (r === H-1 ? '┴' : '┤') : '│';
905
+ L.push(` ${yLabel} ${ax}${grid[r].join('')}`);
906
+ }
907
+ L.push(` ${' '.repeat(YW)} └${'─'.repeat(W + 1)}`);
908
+
909
+ // x-axis: turn numbers every ~6 turns, min 4 chars apart
910
+ const xChars = Array(W).fill(' ');
911
+ const xStep = Math.max(2, Math.round(shown.length / 6));
912
+ for (let i = 0; i < shown.length; i += xStep) {
913
+ const pos = colFor(i);
914
+ const num = String(shown[i].n);
915
+ if (pos + num.length < W) {
916
+ for (let j = 0; j < num.length; j++) xChars[pos + j] = num[j];
917
+ }
918
+ }
919
+ L.push(` ${' '.repeat(YW + 2)}${xChars.join('')} turn`);
920
+ }
921
+
922
+ // ─────────────────────────────────────────────────────────────
923
+ // stacked bar chart — models with ≥ 5 % of total tokens, max 3
924
+ // ─────────────────────────────────────────────────────────────
925
+ const breakdown = usage.modelBreakdown ?? {};
926
+ const allModels = Object.keys(breakdown);
927
+ if (allModels.length >= 2) {
928
+ const totalTok = allModels.reduce(
929
+ (s, m) => s + breakdown[m].inputTokens + breakdown[m].outputTokens, 0
930
+ );
931
+
932
+ const modelCosts = allModels
933
+ .filter(mdl => (breakdown[mdl].inputTokens + breakdown[mdl].outputTokens) / Math.max(totalTok, 1) >= 0.05)
934
+ .map(mdl => {
935
+ const { inputTokens: iT, outputTokens: oT } = breakdown[mdl];
936
+ const [mip, mop] = priceFor(mdl);
937
+ const inCost = (iT * mip) / 1_000_000;
938
+ const outCost = (oT * mop) / 1_000_000;
939
+ return { mdl, inCost, outCost, total: inCost + outCost };
940
+ })
941
+ .sort((a, b) => b.total - a.total)
942
+ .slice(0, 3);
943
+
944
+ if (modelCosts.length >= 2) {
945
+ const maxBar = Math.max(...modelCosts.map(m => m.total), 0.000001);
946
+ const BAR_H = 8, BAR_W = 12, GAP = 4, LBL_W = 9;
947
+ const CHART_W = modelCosts.length * (BAR_W + GAP) - GAP;
948
+
949
+ L.push(''); L.push(DIVIDER); L.push('');
950
+ L.push('$ by model (session, ≥ 5 % usage)');
951
+ L.push('');
952
+
953
+ const yTicks3 = new Map([
954
+ [BAR_H, maxBar],
955
+ [Math.ceil(BAR_H / 2), maxBar / 2],
956
+ [1, 0],
957
+ ]);
958
+
959
+ for (let row = BAR_H; row >= 1; row--) {
960
+ let line = '';
961
+ for (let mi = 0; mi < modelCosts.length; mi++) {
962
+ if (mi > 0) line += ' '.repeat(GAP);
963
+ const { inCost, outCost } = modelCosts[mi];
964
+ const inRows = Math.round((inCost / maxBar) * BAR_H);
965
+ const totRows = Math.min(Math.round(((inCost + outCost) / maxBar) * BAR_H), BAR_H);
966
+ if (row <= inRows) line += '▓'.repeat(BAR_W);
967
+ else if (row <= totRows) line += '░'.repeat(BAR_W);
968
+ else line += ' '.repeat(BAR_W);
969
+ }
970
+ const yLabel = yTicks3.has(row)
971
+ ? fmtCost(yTicks3.get(row)!).padStart(LBL_W)
972
+ : ' '.repeat(LBL_W);
973
+ const ax = yTicks3.has(row) ? (row === 1 ? '┴' : '┤') : '│';
974
+ L.push(` ${yLabel} ${ax} ${line}`);
975
+ }
976
+ L.push(` ${' '.repeat(LBL_W)} └${'─'.repeat(CHART_W + 2)}`);
977
+
978
+ // centered model labels + cost
979
+ for (let mi = 0; mi < modelCosts.length; mi++) {
980
+ if (mi === 0) process.stdout.write(''); // no-op to set up spacing
981
+ }
982
+ let nameRow = ' ' + ' '.repeat(LBL_W + 2);
983
+ let costRow = ' ' + ' '.repeat(LBL_W + 2);
984
+ for (let mi = 0; mi < modelCosts.length; mi++) {
985
+ if (mi > 0) { nameRow += ' '.repeat(GAP); costRow += ' '.repeat(GAP); }
986
+ const short = modelCosts[mi].mdl
987
+ .replace(/claude-/, '').replace(/openai-/, '').replace(/-\d{8,}$/, '')
988
+ .slice(0, BAR_W);
989
+ nameRow += short.padEnd(BAR_W);
990
+ costRow += fmtCost(modelCosts[mi].total).padEnd(BAR_W).slice(0, BAR_W);
991
+ }
992
+ L.push(nameRow);
993
+ L.push(costRow);
994
+ L.push('');
995
+ L.push(` ${' '.repeat(LBL_W + 2)}▓ input ░ output`);
996
+ }
997
+ }
998
+
999
+ // ─────────────────────────────────────────────────────────────
1000
+ // performance — response time + benchmark vs claude-sonnet-4.6
1001
+ // ─────────────────────────────────────────────────────────────
1002
+ if (turns.length >= 2) {
1003
+ // pair user → assistant to get response durations
1004
+ const responseTimes: number[] = [];
1005
+ const outEstimates: number[] = [];
1006
+ for (let i = 1; i < turns.length; i++) {
1007
+ if (turns[i].role === 'assistant' && turns[i-1].role === 'user') {
1008
+ const dt = turns[i].ts - turns[i-1].ts;
1009
+ if (dt > 0 && dt < 300_000) { // sanity: 0–5 min
1010
+ responseTimes.push(dt);
1011
+ outEstimates.push(turns[i].est);
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ const avgMs = responseTimes.length
1017
+ ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
1018
+ : 0;
1019
+ const avgTokPerSec = avgMs > 0 && outEstimates.length
1020
+ ? outEstimates.reduce((a, b) => a + b, 0) / outEstimates.length / (avgMs / 1000)
1021
+ : 0;
1022
+ const turnCount = Math.floor(turns.length / 2);
1023
+ const costPerTurn = turnCount > 0 ? sessionCost / turnCount : 0;
1024
+
1025
+ // benchmark: claude-sonnet-4.6
1026
+ const [bip, bop] = priceFor('claude-sonnet-4-6');
1027
+ const benchCost = (totalIn * bip + totalOut * bop) / 1_000_000;
1028
+ const ratio = benchCost > 0 ? sessionCost / benchCost : 1;
1029
+ const ratioStr = ratio <= 1
1030
+ ? `${(ratio).toFixed(2)}× (${Math.round((1 - ratio) * 100)}% cheaper)`
1031
+ : `${(ratio).toFixed(2)}× (${Math.round((ratio - 1) * 100)}% more expensive)`;
1032
+
1033
+ const COL1 = 18, COL2 = 16, COL3 = 20;
1034
+ const row = (label: string, cur: string, bench?: string) =>
1035
+ ` ${label.padEnd(COL1)}${cur.padEnd(COL2)}${bench ?? ''}`;
1036
+
1037
+ L.push(''); L.push(DIVIDER); L.push('');
1038
+ L.push('performance');
1039
+ L.push('');
1040
+ L.push(row('', 'this session', 'vs sonnet-4.6'));
1041
+ L.push(` ${'─'.repeat(COL1 + COL2 + COL3)}`);
1042
+ if (avgMs > 0) {
1043
+ const secStr = `${(avgMs / 1000).toFixed(1)}s avg`;
1044
+ L.push(row('response time', secStr));
1045
+ }
1046
+ if (avgTokPerSec > 0) {
1047
+ L.push(row('est. tok/sec', `~${Math.round(avgTokPerSec)} t/s`));
1048
+ }
1049
+ L.push(row('turns', `${turnCount}`));
1050
+ L.push(row('cost/turn', `~${fmtCost(costPerTurn)}`));
1051
+ L.push(row('session total', `~${fmtCost(sessionCost)}`, `~${fmtCost(benchCost)}`));
1052
+ L.push(row('vs benchmark', ratioStr));
1053
+ }
1054
+
1055
+ // Stream into the existing streaming message so everything lands in one
1056
+ // render pass — avoids the empty-streaming-msg + batch-content split that
1057
+ // required two Enter presses to see the full output.
1058
+ if (context.onContentDelta) {
1059
+ context.onContentDelta(L.join('\n'));
1060
+ return { success: true, type: 'silent' };
1061
+ }
1062
+ return { success: true, type: 'info', content: L.join('\n') };
1063
+ },
1064
+ };
1065
+
1066
+ /**
1067
+ * /skills - Skills 管理
1068
+ */
1069
+ export const skillsCommand: SlashCommand = {
1070
+ name: 'skills',
1071
+ aliases: ['sk'],
1072
+ description: 'List and manage skills',
1073
+ category: 'skills',
1074
+ usage: '/skills [name|refresh]',
1075
+ examples: ['/skills', '/skills commit-message', '/skills refresh'],
1076
+ fullDescription: 'List all available skills, view skill details, or refresh the skills list.',
1077
+
1078
+ async handler(args: string): Promise<SlashCommandResult> {
1079
+ const { getSkillRegistry } = await import('../skills/index.js');
1080
+ const registry = getSkillRegistry();
1081
+
1082
+ if (!registry.isInitialized()) {
1083
+ return {
1084
+ success: false,
1085
+ type: 'error',
1086
+ error: 'skills system not initialized',
1087
+ };
1088
+ }
1089
+
1090
+ const trimmedArgs = args.trim().toLowerCase();
1091
+
1092
+ // 刷
1093
+ if (trimmedArgs === 'refresh' || trimmedArgs === 'reload') {
1094
+ const result = await registry.refresh();
1095
+
1096
+ let content = `## skills refreshed\n\n`;
1097
+ content += `loaded **${result.count}** skills: `;
1098
+ content += `${result.bySource.user} user · ${result.bySource.project} project · ${result.bySource.builtin} builtin\n`;
1099
+
1100
+ if (result.errors.length > 0) {
1101
+ content += `\n### errors\n\n`;
1102
+ for (const err of result.errors) {
1103
+ content += `- \`${err.path}\`: ${err.error}\n`;
1104
+ }
1105
+ }
1106
+
1107
+ return { success: true, type: 'success', content };
1108
+ }
1109
+
1110
+ // 查看特定 Skill 详
1111
+ if (trimmedArgs && trimmedArgs !== 'list') {
1112
+ const skill = registry.getSkill(trimmedArgs);
1113
+
1114
+ if (!skill) {
1115
+ const allSkills = registry.getAllSkills();
1116
+ const suggestions = allSkills
1117
+ .filter(s => s.name.includes(trimmedArgs) || s.description.toLowerCase().includes(trimmedArgs))
1118
+ .slice(0, 5);
1119
+
1120
+ let errorContent = `unknown skill: \`${trimmedArgs}\`\n\n`;
1121
+ if (suggestions.length > 0) {
1122
+ errorContent += `similar:\n`;
1123
+ for (const s of suggestions) {
1124
+ errorContent += `- \`${s.name}\` ${s.description}\n`;
1125
+ }
1126
+ }
1127
+
1128
+ return { success: false, type: 'error', content: errorContent };
1129
+ }
1130
+
1131
+ // 显示 Skill 详
1132
+ let content = `## ${skill.name}\n\n`;
1133
+ content += `${skill.description}\n\n`;
1134
+ content += `| key | value |\n`;
1135
+ content += `|-----|-------|\n`;
1136
+ content += `| source | ${skill.source} |\n`;
1137
+ content += `| path | \`${skill.path}\` |\n`;
1138
+ content += `| invocable | ${skill.userInvocable ? 'yes' : 'no'} |\n`;
1139
+ content += `| no-model | ${skill.disableModelInvocation ? 'yes' : 'no'} |\n`;
1140
+
1141
+ if (skill.allowedTools && skill.allowedTools.length > 0) {
1142
+ content += `| tools | ${skill.allowedTools.join(', ')} |\n`;
1143
+ }
1144
+ if (skill.whenToUse) {
1145
+ content += `\n### when to use\n\n${skill.whenToUse}\n`;
1146
+ }
1147
+ if (skill.argumentHint) {
1148
+ content += `\n### args\n\n${skill.argumentHint}\n`;
1149
+ }
1150
+
1151
+ return { success: true, type: 'info', content };
1152
+ }
1153
+
1154
+ // 列出所
1155
+ const allSkills = registry.getAllSkills();
1156
+
1157
+ if (allSkills.length === 0) {
1158
+ return {
1159
+ success: true,
1160
+ type: 'info',
1161
+ content: `## skills\n\nno skills found.\n\nadd SKILL.md files to:\n- ~/.claude/skills/ (user)\n- ~/.aegis/skills/ (user)\n- .claude/skills/ (project)\n- .aegis/skills/ (project)`,
1162
+ };
1163
+ }
1164
+
1165
+ // 按来源分
1166
+ const grouped: Record<string, typeof allSkills> = {
1167
+ builtin: [],
1168
+ user: [],
1169
+ project: [],
1170
+ };
1171
+
1172
+ for (const skill of allSkills) {
1173
+ grouped[skill.source].push(skill);
1174
+ }
1175
+
1176
+ let content = `## skills (${allSkills.length})\n\n`;
1177
+
1178
+ // 内
1179
+ if (grouped.builtin.length > 0) {
1180
+ content += `### builtin\n\n`;
1181
+ for (const skill of grouped.builtin) {
1182
+ content += `- \`${skill.name}\` ${skill.description}\n`;
1183
+ }
1184
+ content += '\n';
1185
+ }
1186
+
1187
+ // 用
1188
+ if (grouped.user.length > 0) {
1189
+ content += `### user\n\n`;
1190
+ for (const skill of grouped.user) {
1191
+ const tag = skill.userInvocable ? ' *' : '';
1192
+ content += `- \`${skill.name}\`${tag} ${skill.description}\n`;
1193
+ }
1194
+ content += '\n';
1195
+ }
1196
+
1197
+ // 项
1198
+ if (grouped.project.length > 0) {
1199
+ content += `### project\n\n`;
1200
+ for (const skill of grouped.project) {
1201
+ const tag = skill.userInvocable ? ' *' : '';
1202
+ content += `- \`${skill.name}\`${tag} ${skill.description}\n`;
1203
+ }
1204
+ content += '\n';
1205
+ }
1206
+
1207
+ content += `/skills <name> for details · * = invocable\n`;
1208
+
1209
+ return { success: true, type: 'info', content };
1210
+ },
1211
+ };
1212
+
1213
+ /**
1214
+ * /hooks - Hooks 管理
1215
+ */
1216
+ export const hooksCommand: SlashCommand = {
1217
+ name: 'hooks',
1218
+ description: 'View and manage hooks',
1219
+ category: 'hooks',
1220
+ usage: '/hooks [status|list]',
1221
+ examples: ['/hooks', '/hooks status', '/hooks list'],
1222
+ fullDescription: 'View hooks configuration status and configured hook list.',
1223
+
1224
+ async handler(args: string): Promise<SlashCommandResult> {
1225
+ const { getHookManager, HookEvent } = await import('../hooks/index.js');
1226
+ const manager = getHookManager();
1227
+
1228
+ const trimmedArgs = args.trim().toLowerCase();
1229
+
1230
+ // 显示状
1231
+ if (trimmedArgs === 'status' || trimmedArgs === '') {
1232
+ const enabled = manager.isEnabled();
1233
+ const counts = manager.getHookCounts();
1234
+ const totalHooks = Object.values(counts).reduce((a, b) => a + b, 0);
1235
+ const configuredEvents = manager.getConfiguredEvents();
1236
+
1237
+ let content = `## hooks\n\n`;
1238
+ content += `| key | value |\n`;
1239
+ content += `|-----|-------|\n`;
1240
+ content += `| status | ${enabled ? 'enabled' : 'disabled'} |\n`;
1241
+ content += `| hooks | ${totalHooks} |\n`;
1242
+ content += `| events | ${configuredEvents.length} |\n`;
1243
+
1244
+ if (totalHooks > 0) {
1245
+ content += `\n### by event\n\n`;
1246
+ for (const [event, count] of Object.entries(counts)) {
1247
+ content += `- **${event}** ${count}\n`;
1248
+ }
1249
+ }
1250
+
1251
+ content += `/hooks list for full config\n`;
1252
+
1253
+ return { success: true, type: 'info', content };
1254
+ }
1255
+
1256
+ // 列出所有配
1257
+ if (trimmedArgs === 'list') {
1258
+ const config = manager.getConfig();
1259
+ const events = Object.values(HookEvent);
1260
+
1261
+ let content = `## hooks config\n\n`;
1262
+
1263
+ let hasAny = false;
1264
+ for (const event of events) {
1265
+ const matchers = config[event];
1266
+ if (!matchers || !Array.isArray(matchers) || matchers.length === 0) {
1267
+ continue;
1268
+ }
1269
+
1270
+ hasAny = true;
1271
+ content += `### ${event}\n\n`;
1272
+
1273
+ for (const matcher of matchers) {
1274
+ const name = matcher.name || '(unnamed)';
1275
+ content += `**${name}**\n`;
1276
+
1277
+ if (matcher.matcher) {
1278
+ if (matcher.matcher.tools) {
1279
+ content += `- tools: \`${matcher.matcher.tools}\`\n`;
1280
+ }
1281
+ if (matcher.matcher.paths) {
1282
+ content += `- paths: \`${matcher.matcher.paths}\`\n`;
1283
+ }
1284
+ if (matcher.matcher.commands) {
1285
+ content += `- commands: \`${matcher.matcher.commands}\`\n`;
1286
+ }
1287
+ }
1288
+
1289
+ content += `- hooks: ${matcher.hooks?.length || 0}\n`;
1290
+ content += '\n';
1291
+ }
1292
+ }
1293
+
1294
+ if (!hasAny) {
1295
+ content += `no hooks configured.\n\n`;
1296
+ content += `add hooks to settings.json:\n`;
1297
+ content += `- ~/.aegis/settings.json (user)\n`;
1298
+ content += `- .aegis/settings.json (project)\n`;
1299
+ }
1300
+
1301
+ return { success: true, type: 'info', content };
1302
+ }
1303
+
1304
+ return {
1305
+ success: false,
1306
+ type: 'error',
1307
+ error: `unknown subcommand: ${trimmedArgs}\navailable: status, list`,
1308
+ };
1309
+ },
1310
+ };
1311
+
1312
+ /**
1313
+ * /copy - 复制代码块或文本到剪贴板
1314
+ *
1315
+ * /copy — kopiera senaste kodblocket
1316
+ * /copy N — kopiera kodblock N från slutet
1317
+ * /copy list — lista alla kodblock
1318
+ * /copy last — kopiera senaste assistent-svaret (plain text)
1319
+ * /copy raw <N|last> — som ovan, men skriv ut i terminalen för manuell kopiering
1320
+ */
1321
+ export const copyCommand: SlashCommand = {
1322
+ name: 'copy',
1323
+ aliases: ['cp'],
1324
+ description: 'Copy code block or text to clipboard — /copy | /copy N | /copy last | /copy list',
1325
+ category: 'general',
1326
+ usage: '/copy [n | last | list | raw]',
1327
+ examples: ['/copy', '/copy 2', '/copy last', '/copy list', '/copy raw'],
1328
+ fullDescription: `Copy code blocks or assistant responses to clipboard.
1329
+
1330
+ /copy — copy the last code block to clipboard
1331
+ /copy N — copy code block N from end (1=last)
1332
+ /copy last — copy the last assistant response as plain text (markdown stripped)
1333
+ /copy list — show all code blocks with index
1334
+ /copy raw — print the last assistant response as plain text in terminal for manual copy
1335
+ /copy raw N — print assistant response N from end as plain text`,
1336
+
1337
+ async handler(args: string): Promise<SlashCommandResult> {
1338
+ const state = getState();
1339
+ const messages = state.session.messages;
1340
+
1341
+ const { parseMarkdown } = await import('../ui/components/markdown/parser.js');
1342
+
1343
+ const trimmedArgs = args.trim().toLowerCase();
1344
+
1345
+ // === /copy raw — skriv ut plain text direkt till stdout (förbi Ink) ===
1346
+ if (trimmedArgs === 'raw' || trimmedArgs.startsWith('raw ')) {
1347
+ const parts = trimmedArgs.split(' ');
1348
+ let n = 1;
1349
+ if (parts.length > 1 && parts[1]) {
1350
+ const parsed = parseInt(parts[1], 10);
1351
+ if (!isNaN(parsed) && parsed > 0) n = parsed;
1352
+ }
1353
+ const assistantMsgs = messages.filter(m => m.role === 'assistant');
1354
+ if (assistantMsgs.length === 0) {
1355
+ return { success: false, type: 'error', error: 'no assistant messages' };
1356
+ }
1357
+ const idx = assistantMsgs.length - n;
1358
+ if (idx < 0) {
1359
+ return { success: false, type: 'error', error: `only ${assistantMsgs.length} assistant messages` };
1360
+ }
1361
+ const target = assistantMsgs[idx];
1362
+
1363
+ // Strip markdown: return only the text content
1364
+ const { stripMarkdown } = await import('../ui/components/markdown/parser.js');
1365
+ const blocks = parseMarkdown(target.content);
1366
+ const textParts = blocks.map(b => {
1367
+ if (b.type === 'empty') return '';
1368
+ if (b.type === 'code') return b.content;
1369
+ if (b.type === 'heading') return b.content;
1370
+ if (b.type === 'list') return `${b.marker || '•'} ${b.content}`;
1371
+ return b.content;
1372
+ }).filter(Boolean);
1373
+ const plainText = textParts.map(t => stripMarkdown(t)).join('\n\n');
1374
+
1375
+ // Write directly to stdout to bypass Ink rendering
1376
+ const separator = '─'.repeat(60);
1377
+ process.stdout.write(`\n${separator}\n`);
1378
+ process.stdout.write(`/copy raw — assistant message #${n} (pure text, can copy below)\n`);
1379
+ process.stdout.write(`${separator}\n\n`);
1380
+ process.stdout.write(plainText);
1381
+ process.stdout.write(`\n\n${separator}\n\n`);
1382
+
1383
+ return { success: true, type: 'silent' };
1384
+ }
1385
+
1386
+ // === /copy last — kopiera senaste assistent-svaret som plain text ===
1387
+ if (trimmedArgs === 'last') {
1388
+ const assistantMsgs = messages.filter(m => m.role === 'assistant');
1389
+ if (assistantMsgs.length === 0) {
1390
+ return { success: false, type: 'error', error: 'no assistant messages' };
1391
+ }
1392
+ const target = assistantMsgs[assistantMsgs.length - 1];
1393
+
1394
+ // Extract text content (strip markdown)
1395
+ const { stripMarkdown } = await import('../ui/components/markdown/parser.js');
1396
+ const blocks = parseMarkdown(target.content);
1397
+ const textParts = blocks.map(b => {
1398
+ if (b.type === 'empty') return '';
1399
+ if (b.type === 'code') return b.content;
1400
+ return b.content;
1401
+ }).filter(Boolean);
1402
+ const plainText = textParts.map(t => stripMarkdown(t)).join('\n\n');
1403
+
1404
+ try {
1405
+ await copyToClipboard(plainText);
1406
+ const lines = plainText.split('\n').length;
1407
+ return {
1408
+ success: true,
1409
+ type: 'success',
1410
+ message: `Copied ${lines} lines from assistant reply`,
1411
+ };
1412
+ } catch (err) {
1413
+ return {
1414
+ success: false,
1415
+ type: 'error',
1416
+ error: `clipboard: ${err instanceof Error ? err.message : String(err)}`,
1417
+ };
1418
+ }
1419
+ }
1420
+
1421
+ // === Extract all code blocks from messages ===
1422
+ const codeBlocks: Array<{ content: string; language?: string; filePath?: string }> = [];
1423
+
1424
+ for (const msg of messages) {
1425
+ if (!msg.content) continue;
1426
+ const blocks = parseMarkdown(msg.content);
1427
+ for (const block of blocks) {
1428
+ if (block.type === 'code' && block.content.trim()) {
1429
+ codeBlocks.push({
1430
+ content: block.content,
1431
+ language: block.language,
1432
+ filePath: block.filePath,
1433
+ });
1434
+ }
1435
+ }
1436
+ }
1437
+
1438
+ if (codeBlocks.length === 0) {
1439
+ return {
1440
+ success: false,
1441
+ type: 'error',
1442
+ error: 'no code blocks in conversation',
1443
+ };
1444
+ }
1445
+
1446
+ // /copy list - show all blocks
1447
+ if (trimmedArgs === 'list' || trimmedArgs === 'ls') {
1448
+ let content = `${codeBlocks.length} code blocks (newest first)\n\n`;
1449
+ for (let i = codeBlocks.length - 1; i >= 0; i--) {
1450
+ const n = codeBlocks.length - i;
1451
+ const b = codeBlocks[i];
1452
+ const label = b.filePath || b.language || 'code';
1453
+ const lines = b.content.split('\n').length;
1454
+ const preview = b.content.split('\n')[0].slice(0, 50);
1455
+ content += ` ${n}. ${label} (${lines}L) ${preview}${preview.length >= 50 ? '...' : ''}\n`;
1456
+ }
1457
+ content += `\nuse /copy N to copy a block · /copy last for assistant reply`;
1458
+ return { success: true, type: 'info', content };
1459
+ }
1460
+
1461
+ // Determine which block to copy
1462
+ let targetIndex: number;
1463
+
1464
+ if (!trimmedArgs) {
1465
+ targetIndex = codeBlocks.length - 1;
1466
+ } else {
1467
+ const n = parseInt(trimmedArgs, 10);
1468
+ if (isNaN(n) || n < 1) {
1469
+ return {
1470
+ success: false,
1471
+ type: 'error',
1472
+ error: `invalid: ${trimmedArgs}. use /copy [N], /copy last, /copy list, or /copy raw`,
1473
+ };
1474
+ }
1475
+ targetIndex = codeBlocks.length - n;
1476
+ if (targetIndex < 0) {
1477
+ return {
1478
+ success: false,
1479
+ type: 'error',
1480
+ error: `only ${codeBlocks.length} blocks. use /copy list`,
1481
+ };
1482
+ }
1483
+ }
1484
+
1485
+ const target = codeBlocks[targetIndex];
1486
+
1487
+ // Copy to clipboard
1488
+ try {
1489
+ await copyToClipboard(target.content);
1490
+ } catch (err) {
1491
+ return {
1492
+ success: false,
1493
+ type: 'error',
1494
+ error: `clipboard: ${err instanceof Error ? err.message : String(err)}`,
1495
+ };
1496
+ }
1497
+
1498
+ // Build confirmation — Claude Code style
1499
+ const lines = target.content.split('\n').length;
1500
+ const label = target.filePath || target.language || 'code';
1501
+ const hint = codeBlocks.length > 1 ? ` · ${codeBlocks.length} blocks total` : '';
1502
+
1503
+ return {
1504
+ success: true,
1505
+ type: 'success',
1506
+ message: `Copied ${lines} lines from ${label}${hint}`,
1507
+ };
1508
+ },
1509
+ };
1510
+
1511
+ /**
1512
+ * /thinking - 切换思考块展开/折叠
1513
+ */
1514
+ export const thinkingCommand: SlashCommand = {
1515
+ name: 'thinking',
1516
+ description: 'Toggle thinking blocks expand/collapse',
1517
+ category: 'config',
1518
+ usage: '/thinking',
1519
+ fullDescription: 'Toggle global expand/collapse for all thinking blocks in messages.',
1520
+
1521
+ async handler(): Promise<SlashCommandResult> {
1522
+ const { appActions, getState } = await import('../store/index.js');
1523
+ appActions().toggleShowAllThinking();
1524
+
1525
+ const expanded = getState().app.showAllThinking;
1526
+ return {
1527
+ success: true,
1528
+ type: 'success',
1529
+ message: `thinking blocks: ${expanded ? 'expanded' : 'collapsed'}`,
1530
+ };
1531
+ },
1532
+ };
1533
+
1534
+ /**
1535
+ *
1536
+ */
1537
+
1538
+
1539
+ /**
1540
+ * Detect task type from the task string.
1541
+ * Returns 'scaffold', 'refactor', 'review', or 'default'.
1542
+ */
1543
+ function detectTaskType(task: string): 'scaffold' | 'refactor' | 'review' | 'default' {
1544
+ const lower = task.toLowerCase();
1545
+ if (/^(build|create|new|scaffold|generate|make|init|bootstrap)\b/.test(lower)) return 'scaffold';
1546
+ if (/^(refactor|restructure|reorganize|redesign|rewrite)\b/.test(lower)) return 'refactor';
1547
+ if (/^(review|audit|inspect|check|analyze|security|vulnerab)\b/.test(lower)) return 'review';
1548
+ return 'default';
1549
+ }
1550
+
1551
+ /**
1552
+ * Build agent configs dynamically based on task type.
1553
+ * Scaffold mode agents get full Write/Edit/Bash access for building new apps.
1554
+ * Each mode ends with a synthesizer agent for result fusion.
1555
+ */
1556
+ function buildMultiAgents(type: 'scaffold' | 'refactor' | 'review' | 'default', config: AgentConfig): SubAgentConfig[] {
1557
+ const agentConfig = { ...config, timeout: 180000 };
1558
+
1559
+ // Shared synthesizer used by all modes
1560
+ const synthesizer: SubAgentConfig = {
1561
+ name: 'synthesizer',
1562
+ role: 'Technical Lead',
1563
+ systemPrompt: `You are a senior technical lead. Given analysis from multiple specialist agents, synthesize their findings into a clear, actionable summary.
1564
+ Structure your response as: key findings, recommended approach, top action items.
1565
+ Be direct, concrete, and avoid repeating everything the agents said.
1566
+ Focus on delivering a decision-ready synthesis.`,
1567
+ config: agentConfig,
1568
+ };
1569
+
1570
+ switch (type) {
1571
+ case 'scaffold':
1572
+ return [
1573
+ {
1574
+ name: 'architect',
1575
+ role: 'System Architect',
1576
+ systemPrompt: `You are a System Architect. Design the new application architecture.
1577
+ Define: project structure, tech stack, directory layout, key modules, data flow, API design.
1578
+ Consider: scalability, maintainability, testing strategy, deployment.
1579
+ Output a concrete file tree and architecture decisions log. Be specific.`,
1580
+ config: agentConfig,
1581
+ tools: ['Read', 'Grep', 'Glob'],
1582
+ },
1583
+ {
1584
+ name: 'scaffolder',
1585
+ role: 'Project Scaffolder',
1586
+ systemPrompt: `You are a Project Scaffolder. Build the complete application from scratch.
1587
+
1588
+ YOUR JOB IS TO CREATE ALL PROJECT FILES - not just describe them.
1589
+
1590
+ Use Write to create: package.json, tsconfig.json, source files, configs, tests.
1591
+ Generate COMPLETE, WORKING code - not stubs or placeholders.
1592
+ Set up build scripts, lint config, and any necessary tooling.
1593
+
1594
+ After creating files, use Bash to run: npm/pnpm install, then build/compile.
1595
+ Fix any errors until the project builds successfully.
1596
+
1597
+ Be thorough - a real, runnable project is the goal.`,
1598
+ config: agentConfig,
1599
+ tools: ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'Bash'],
1600
+ },
1601
+ {
1602
+ name: 'reviewer',
1603
+ role: 'Code Reviewer',
1604
+ systemPrompt: `You are a Code Reviewer. Review the scaffolded project.
1605
+ Check for: missing types, broken imports, misconfigured package.json, error handling gaps.
1606
+ Report issues with specific file paths and fix suggestions. Be concise.`,
1607
+ config: agentConfig,
1608
+ tools: ['Read', 'Grep', 'Glob'],
1609
+ },
1610
+ synthesizer,
1611
+ ];
1612
+
1613
+ case 'refactor':
1614
+ return [
1615
+ {
1616
+ name: 'analyzer',
1617
+ role: 'Code Analyzer',
1618
+ systemPrompt: `You are a Code Analyzer. Find refactoring opportunities.
1619
+ Look for: duplicated code, long functions (>20 lines), complex conditionals, unused imports,
1620
+ circular dependencies, inconsistent patterns. Report with file paths and line numbers.`,
1621
+ config: agentConfig,
1622
+ tools: ['Read', 'Grep', 'Glob'],
1623
+ },
1624
+ {
1625
+ name: 'planner',
1626
+ role: 'Refactoring Planner',
1627
+ systemPrompt: `You are a Refactoring Planner. Given the analyzer findings, create a step-by-step plan.
1628
+ Each step: file path, what to change, why, risk level (LOW/MEDIUM/HIGH).
1629
+ Include before/after snippets. Order by impact. Be concrete.`,
1630
+ config: agentConfig,
1631
+ tools: ['Read'],
1632
+ },
1633
+ {
1634
+ name: 'implementer',
1635
+ role: 'Implementation Engineer',
1636
+ systemPrompt: `You are an Implementation Engineer. Execute the refactoring plan.
1637
+ Use Edit and Write to make actual code changes.
1638
+ After each change, use Read to verify correctness. Keep existing code style.
1639
+ Run build commands to ensure nothing is broken.`,
1640
+ config: agentConfig,
1641
+ tools: ['Read', 'Edit', 'Write', 'Grep', 'Glob', 'Bash'],
1642
+ },
1643
+ synthesizer,
1644
+ ];
1645
+
1646
+ case 'review':
1647
+ return [
1648
+ {
1649
+ name: 'scanner',
1650
+ role: 'Vulnerability Scanner',
1651
+ systemPrompt: `You are a Security Vulnerability Scanner.
1652
+ Scan for: hardcoded API keys/secrets, SQL injection, XSS, unsafe eval/exec, path traversal.
1653
+ Use Grep with targeted patterns. Report every finding with: file path, severity (CRITICAL/HIGH/MEDIUM/LOW), line number.`,
1654
+ config: agentConfig,
1655
+ tools: ['Read', 'Grep', 'Glob'],
1656
+ },
1657
+ {
1658
+ name: 'reviewer',
1659
+ role: 'Code Reviewer',
1660
+ systemPrompt: `You are a Code Reviewer. Review for bugs, logic errors, and quality issues.
1661
+ Check: type safety, error handling, null safety, race conditions, performance patterns.
1662
+ Be critical but constructive. Prioritize correctness over style.`,
1663
+ config: agentConfig,
1664
+ tools: ['Read', 'Grep', 'Glob'],
1665
+ },
1666
+ {
1667
+ name: 'debugger',
1668
+ role: 'Debugging Specialist',
1669
+ systemPrompt: `You are a Debugging Specialist. Analyze potential runtime issues.
1670
+ Identify: error handling gaps, edge cases, resource leaks, async issues, testing blind spots.
1671
+ Suggest testing strategies for each risk area.`,
1672
+ config: agentConfig,
1673
+ tools: ['Read', 'Grep', 'Glob', 'Bash'],
1674
+ },
1675
+ synthesizer,
1676
+ ];
1677
+
1678
+ default:
1679
+ return [
1680
+ {
1681
+ name: 'architect',
1682
+ role: 'System Architect',
1683
+ systemPrompt: `You are a System Architect. Analyze the task from an architectural perspective.
1684
+ Evaluate: code structure, dependencies, design patterns, refactoring opportunities.
1685
+ Provide specific file paths and recommendations. Be concise and actionable.`,
1686
+ config: agentConfig,
1687
+ tools: ['Read', 'Grep', 'Glob'],
1688
+ },
1689
+ {
1690
+ name: 'implementer',
1691
+ role: 'Implementation Engineer',
1692
+ systemPrompt: `You are an Implementation Engineer. Write clean, production-ready code.
1693
+ Follow existing patterns. Provide complete code blocks with file paths.
1694
+ Focus on: correctness, error handling, TypeScript types, edge cases.
1695
+ Use Write/Edit to create or modify files as needed.`,
1696
+ config: agentConfig,
1697
+ tools: ['Read', 'Edit', 'Write', 'Grep', 'Glob', 'Bash'],
1698
+ },
1699
+ {
1700
+ name: 'reviewer',
1701
+ role: 'Code Reviewer',
1702
+ systemPrompt: `You are a Code Reviewer. Review the approach and code.
1703
+ Check: logic errors, type safety, error handling, performance, security.
1704
+ Be critical but constructive. Report specific issues with file paths.`,
1705
+ config: agentConfig,
1706
+ tools: ['Read', 'Grep', 'Glob'],
1707
+ },
1708
+ {
1709
+ name: 'debugger',
1710
+ role: 'Debugging Specialist',
1711
+ systemPrompt: `You are a Debugging Specialist. Analyze potential issues and edge cases.
1712
+ Identify: failure modes, error handling gaps, testing considerations.
1713
+ Think about what could go wrong and how to prevent it.`,
1714
+ config: agentConfig,
1715
+ tools: ['Read', 'Grep', 'Glob', 'Bash'],
1716
+ },
1717
+ synthesizer,
1718
+ ];
1719
+ }
1720
+ }
1721
+
1722
+ /**
1723
+ * Get a label+icon for the mode.
1724
+ */
1725
+ function modeMeta(type: string): { icon: string; label: string } {
1726
+ switch (type) {
1727
+ case 'scaffold': return { icon: '🏗', label: 'App Scaffolding' };
1728
+ case 'refactor': return { icon: '🔧', label: 'Code Refactoring' };
1729
+ case 'review': return { icon: '🔍', label: 'Code Review' };
1730
+ default: return { icon: '⬡', label: 'Multi-Agent Orchestration' };
1731
+ }
1732
+ }
1733
+
1734
+ const multiCommand: SlashCommand = {
1735
+ name: 'multi',
1736
+ description: 'Orchestrate multiple AI agents on a complex task — /multi <task>',
1737
+ category: 'general',
1738
+ usage: '/multi <task> [--save-as <name>] [--template <id>]',
1739
+ examples: [
1740
+ '/multi Refactor the auth module to use JWT',
1741
+ '/multi Review the codebase for security issues',
1742
+ '/multi Create a new CLI tool for managing TODO lists --save-as todo-app',
1743
+ '/multi Build a REST API server for a blog --template refactor',
1744
+ ],
1745
+ fullDescription: `Orchestrates multiple AI agents in parallel on a complex task, then synthesizes results.
1746
+
1747
+ Task types are auto-detected:
1748
+ build/create/new/scaffold -> scaffolding agents with full tool access (Write, Edit, Bash)
1749
+ refactor -> code analysis + planning + implementation agents
1750
+ review/audit -> security/code review agents
1751
+ default -> architect + implementer + reviewer + debugger
1752
+
1753
+ Flags:
1754
+ --save-as <name> Save this agent configuration as a reusable app (e.g. /myapp <task>)
1755
+ --template <id> Start from a template (audit, refactor, test-gen)`,
1756
+
1757
+ async handler(args: string, context: SlashCommandContext): Promise<SlashCommandResult> {
1758
+ const trimmed = args?.trim();
1759
+ if (!trimmed) return { success: false, type: 'error', error: 'Usage: /multi <task>' };
1760
+
1761
+ let modelConfig;
1762
+ try { modelConfig = requireModelConfig(); }
1763
+ catch (e) { return { success: false, type: 'error', error: (e as Error).message }; }
1764
+
1765
+ // Parse flags
1766
+ const saveAsMatch = trimmed.match(/--save-as\s+(\S+)/);
1767
+ const templateMatch = trimmed.match(/--template\s+(\S+)/);
1768
+ const saveAs = saveAsMatch ? saveAsMatch[1] : null;
1769
+ const templateId = templateMatch ? templateMatch[1] : null;
1770
+
1771
+ // Extract clean task (remove flags)
1772
+ const task = trimmed
1773
+ .replace(/--save-as\s+\S+/g, '')
1774
+ .replace(/--template\s+\S+/g, '')
1775
+ .trim();
1776
+
1777
+ if (!task) return { success: false, type: 'error', error: 'Usage: /multi <task>' };
1778
+
1779
+ try {
1780
+ const agentConfig: AgentConfig = {
1781
+ apiKey: modelConfig.apiKey,
1782
+ baseURL: modelConfig.baseURL,
1783
+ model: modelConfig.model,
1784
+ timeout: 180000,
1785
+ };
1786
+
1787
+ let agents: SubAgentConfig[];
1788
+ let modeType: string;
1789
+ let orchestratorName: string;
1790
+ let synthesizerName: string;
1791
+
1792
+ if (templateId) {
1793
+ // Use a built-in template
1794
+ const templateApp = getApp(templateId);
1795
+ if (!templateApp) {
1796
+ const available = getRegisteredApps().map(a => a.id).join(', ');
1797
+ return { success: false, type: 'error', error: `Template "${templateId}" not found. Available: ${available}` };
1798
+ }
1799
+ agents = templateApp.agents.map(a => ({
1800
+ ...a,
1801
+ config: { ...agentConfig, ...a.config },
1802
+ }));
1803
+ modeType = templateId;
1804
+ orchestratorName = templateApp.name;
1805
+ synthesizerName = templateApp.synthesizer || agents[agents.length - 1]?.name;
1806
+ } else {
1807
+ // Detect task type
1808
+ modeType = detectTaskType(task);
1809
+ orchestratorName = modeMeta(modeType).label;
1810
+ agents = buildMultiAgents(modeType as 'scaffold' | 'refactor' | 'review' | 'default', agentConfig);
1811
+ synthesizerName = agents[agents.length - 1]?.name;
1812
+ }
1813
+
1814
+ // Ask for a single upfront confirmation before any agents run
1815
+ if (context.confirmationHandler) {
1816
+ const agentNames = agents.filter(a => a.name !== 'synthesizer').map(a => a.name);
1817
+ const response = await context.confirmationHandler.requestConfirmation({
1818
+ title: `Start multi-agent task?`,
1819
+ message: task,
1820
+ details: `Agents: ${agentNames.join(', ')}`,
1821
+ });
1822
+ if (!response.approved) {
1823
+ return { success: false, type: 'info', content: 'Multi-agent task cancelled.' };
1824
+ }
1825
+ }
1826
+
1827
+ // Build orchestrator
1828
+ const orchestrator = new OrchestratorAgent(
1829
+ `Multi-${modeType}`,
1830
+ `You are ${orchestratorName}. Coordinate specialist agents to achieve the task.`,
1831
+ );
1832
+
1833
+ // Serialize confirmations so parallel agents don't race on the single dialog slot
1834
+ let confirmQueue: Promise<any> = Promise.resolve();
1835
+ const serialHandler = context.confirmationHandler
1836
+ ? { requestConfirmation: (details: any) => { confirmQueue = confirmQueue.then(() => context.confirmationHandler!.requestConfirmation(details)); return confirmQueue; } }
1837
+ : undefined;
1838
+
1839
+ for (const agent of agents) {
1840
+ orchestrator.registerAgent({
1841
+ ...agent,
1842
+ confirmationHandler: serialHandler,
1843
+ });
1844
+ }
1845
+
1846
+ // Build workspace source context so agents know what files exist
1847
+ const cwd = context.cwd || process.cwd();
1848
+ const sourceCtx = buildSourceContext(cwd);
1849
+ const codeContext = sourceCtx
1850
+ ? `\n\nWorkspace context (project metadata + file tree + structure summaries + git changes):\n${sourceCtx}\n\nUse Read / Grep / Glob to examine these — structure summaries show exports/classes in each file.`
1851
+ : '\n\nUse Read / Grep / Glob to explore the codebase before responding.';
1852
+
1853
+ // Build sub-tasks — each agent gets a focused assignment matching their role
1854
+ const subTasks: Record<string, string> = {};
1855
+
1856
+ // Mapping of agent names to focused sub-task descriptions
1857
+ const subTaskMap: Record<string, string> = {
1858
+ architect: `Focus on architecture and design. Evaluate the project structure, dependencies, data flow, and design patterns. Propose a concrete plan with specific file paths.${codeContext}`,
1859
+ scaffolder: `Focus on implementation. Build the complete project — create all files with working code, set up configs, install dependencies, and verify the build succeeds.${codeContext}`,
1860
+ reviewer: `Focus on code review. Examine the code for bugs, type safety, error handling gaps, and consistency issues. Report specific problems with file paths and fix suggestions.${codeContext}`,
1861
+ debugger: `Focus on runtime analysis. Identify potential failure modes, edge cases, resource leaks, and async issues. Think about what could go wrong and how to prevent it.${codeContext}`,
1862
+ scanner: `Focus on security. Scan for hardcoded secrets, injection flaws, XSS, unsafe eval/exec, and path traversal. Report severity and exact locations.${codeContext}`,
1863
+ analyzer: `Focus on code analysis. Find duplicated code, long functions, complex conditionals, unused imports, circular dependencies, and inconsistent patterns. Report with file paths.${codeContext}`,
1864
+ planner: `Focus on planning. Given the analysis findings, create a step-by-step refactoring plan with file paths, change descriptions, risk levels, and before/after snippets.${codeContext}`,
1865
+ implementer:`Focus on implementation. Write clean, production-ready code. Make actual file changes using Write/Edit. Verify correctness and run builds to ensure nothing is broken.${codeContext}`,
1866
+ synthesizer:`Focus on synthesis. You will receive all agent responses and produce a final summary. (Synthesis task is handled separately.)`,
1867
+ };
1868
+
1869
+ // Exclude the synthesizer from parallel sub-tasks — it only runs during synthesis phase
1870
+ for (const agent of agents) {
1871
+ if (agent.name === 'synthesizer') continue;
1872
+ subTasks[agent.name] = subTaskMap[agent.name] || `Analyze the task from a ${agent.role} perspective and provide recommendations.${codeContext}`;
1873
+ }
1874
+
1875
+ // Stream progress in real-time if context supports it
1876
+ if (context.onContentDelta) {
1877
+ const { icon, label } = modeMeta(modeType);
1878
+ context.onContentDelta(`## ${icon} ${label}\n`);
1879
+ context.onContentDelta(`**Task:** ${task}\n\n`);
1880
+ context.onContentDelta(`*Agents: ${agents.filter(a => a.name !== 'synthesizer').map(a => a.name).join(', ')}*\n\n`);
1881
+ context.onContentDelta(`*Tool calls will require confirmation*\n\n---\n\n`);
1882
+ }
1883
+
1884
+ // Run
1885
+ const result = await orchestrator.orchestrate(task, subTasks, synthesizerName, context.sessionId);
1886
+
1887
+ // If --save-as, register as reusable AppBuilder app
1888
+ if (saveAs) {
1889
+ const saveId = saveAs.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
1890
+
1891
+ // Check for conflicts
1892
+ if (getApp(saveId)) {
1893
+ return {
1894
+ success: false,
1895
+ type: 'error',
1896
+ error: `App "${saveId}" already exists. Choose a different name.`,
1897
+ };
1898
+ }
1899
+
1900
+ const builder = new AppBuilder(saveId, `${task.slice(0, 50)}...`)
1901
+ .describe(`Custom app created via /multi: ${task.slice(0, 120)}`)
1902
+ .use(`/${saveId} <task>`)
1903
+ .examples([`/${saveId} ${task.slice(0, 60)}`]);
1904
+
1905
+ for (const agent of agents) {
1906
+ builder.agent(agent.name, agent.role, agent.systemPrompt, agent.tools);
1907
+ }
1908
+
1909
+ builder.register();
1910
+ }
1911
+
1912
+ // Format output — stream each agent result if possible
1913
+ const { icon, label } = modeMeta(modeType);
1914
+ const lines: string[] = [];
1915
+
1916
+ if (!context.onContentDelta) {
1917
+ // Non-streaming: build full text as before
1918
+ lines.push(`## ${icon} ${label}`);
1919
+ lines.push(`**Task:** ${task}`);
1920
+ lines.push('');
1921
+ }
1922
+
1923
+ for (const response of result.responses) {
1924
+ const agentCfg = agents.find(a => a.name === response.agentName);
1925
+ const role = agentCfg?.role || response.agentName;
1926
+ const toolHint = response.metadata?.toolCallsCount
1927
+ ? ` [${response.metadata.toolCallsCount} tool calls]`
1928
+ : '';
1929
+ const header = `### ${role}${toolHint}`;
1930
+ const duration = response.metadata?.durationMs
1931
+ ? `*${(response.metadata.durationMs / 1000).toFixed(1)}s*`
1932
+ : '';
1933
+
1934
+ if (context.onContentDelta) {
1935
+ context.onContentDelta(`\n${header}\n`);
1936
+ if (duration) context.onContentDelta(`${duration}\n\n`);
1937
+ context.onContentDelta(`${response.content || '*No response*'}\n\n`);
1938
+ } else {
1939
+ lines.push(header);
1940
+ if (duration) lines.push(duration);
1941
+ lines.push('');
1942
+ lines.push(response.content || '*No response*');
1943
+ lines.push('');
1944
+ }
1945
+ }
1946
+
1947
+ if (!context.onContentDelta) {
1948
+ lines.push('---');
1949
+ lines.push('### Synthesized Summary');
1950
+ lines.push('');
1951
+ lines.push(result.summary);
1952
+ lines.push('');
1953
+ } else {
1954
+ context.onContentDelta(`---\n\n### Synthesized Summary\n\n${result.summary}\n\n`);
1955
+ }
1956
+
1957
+ const statusParts = [
1958
+ `${result.metadata.agentsUsed} agents`,
1959
+ `${(result.metadata.totalDurationMs / 1000).toFixed(1)}s`,
1960
+ ];
1961
+ if (result.metadata.totalTokens) {
1962
+ statusParts.push(`${result.metadata.totalTokens} tokens`);
1963
+ }
1964
+ if (saveAs) {
1965
+ const saveId = saveAs.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
1966
+ statusParts.push(`saved as /${saveId}`);
1967
+ }
1968
+ const statusLine = `*${statusParts.join(' \u00b7 ')}*`;
1969
+
1970
+ if (context.onContentDelta) {
1971
+ context.onContentDelta(`\n${statusLine}\n`);
1972
+ context.onContentDelta(`\n✅ **Multi-agent task complete!**\n`);
1973
+ } else {
1974
+ lines.push(statusLine);
1975
+ lines.push('');
1976
+ lines.push('✅ Multi-agent task complete!');
1977
+ return { success: true, type: 'info', content: lines.join('\n') };
1978
+ }
1979
+
1980
+ return { success: true, type: 'silent' };
1981
+ } catch (error) {
1982
+ return {
1983
+ success: false,
1984
+ type: 'error',
1985
+ error: `/multi failed: ${error instanceof Error ? error.message : String(error)}`,
1986
+ };
1987
+ }
1988
+ },
1989
+ };
1990
+
1991
+ /**
1992
+ * /multiyolo — same as /multi but with YOLO mode (auto-approve all tool calls)
1993
+ */
1994
+ const multiYoloCommand: SlashCommand = {
1995
+ name: 'multiyolo',
1996
+ description: 'Multi-agent orchestration with YOLO mode — /multiyolo <task>',
1997
+ category: 'general',
1998
+ usage: '/multiyolo <task>',
1999
+ examples: ['/multiyolo Refactor the auth module to use JWT', '/multiyolo Create a new CLI tool'],
2000
+ fullDescription: `Same as /multi but with YOLO mode enabled — all tool calls are auto-approved.
2001
+ Use with caution as this allows agents to write files and run commands without confirmation.`,
2002
+
2003
+ async handler(args: string, context: SlashCommandContext): Promise<SlashCommandResult> {
2004
+ const trimmed = args?.trim();
2005
+ if (!trimmed) return { success: false, type: 'error', error: 'Usage: /multiyolo <task>' };
2006
+
2007
+ let modelConfig;
2008
+ try { modelConfig = requireModelConfig(); }
2009
+ catch (e) { return { success: false, type: 'error', error: (e as Error).message }; }
2010
+
2011
+ // Parse flags
2012
+ const saveAsMatch = trimmed.match(/--save-as\s+(\S+)/);
2013
+ const templateMatch = trimmed.match(/--template\s+(\S+)/);
2014
+ const saveAs = saveAsMatch ? saveAsMatch[1] : null;
2015
+ const templateId = templateMatch ? templateMatch[1] : null;
2016
+
2017
+ // Extract clean task (remove flags)
2018
+ const task = trimmed
2019
+ .replace(/--save-as\s+\S+/g, '')
2020
+ .replace(/--template\s+\S+/g, '')
2021
+ .trim();
2022
+
2023
+ if (!task) return { success: false, type: 'error', error: 'Usage: /multiyolo <task>' };
2024
+
2025
+ try {
2026
+ const agentConfig: AgentConfig = {
2027
+ apiKey: modelConfig.apiKey,
2028
+ baseURL: modelConfig.baseURL,
2029
+ model: modelConfig.model,
2030
+ timeout: 180000,
2031
+ };
2032
+
2033
+ let agents: SubAgentConfig[];
2034
+ let modeType: string;
2035
+ let orchestratorName: string;
2036
+ let synthesizerName: string;
2037
+
2038
+ if (templateId) {
2039
+ const templateApp = getApp(templateId);
2040
+ if (!templateApp) {
2041
+ const available = getRegisteredApps().map(a => a.id).join(', ');
2042
+ return { success: false, type: 'error', error: `Template "${templateId}" not found. Available: ${available}` };
2043
+ }
2044
+ agents = templateApp.agents.map(a => ({
2045
+ ...a,
2046
+ config: { ...agentConfig, ...a.config },
2047
+ }));
2048
+ modeType = templateId;
2049
+ orchestratorName = templateApp.name;
2050
+ synthesizerName = templateApp.synthesizer || agents[agents.length - 1]?.name;
2051
+ } else {
2052
+ modeType = detectTaskType(task);
2053
+ orchestratorName = modeMeta(modeType).label;
2054
+ agents = buildMultiAgents(modeType as 'scaffold' | 'refactor' | 'review' | 'default', agentConfig);
2055
+ synthesizerName = agents[agents.length - 1]?.name;
2056
+ }
2057
+
2058
+ const orchestrator = new OrchestratorAgent(
2059
+ `Multi-YOLO-${modeType}`,
2060
+ `You are ${orchestratorName}. Coordinate specialist agents to achieve the task. (YOLO mode)`,
2061
+ );
2062
+
2063
+ // Attach with YOLO permission mode — all tool calls auto-approved
2064
+ for (const agent of agents) {
2065
+ orchestrator.registerAgent({
2066
+ ...agent,
2067
+ confirmationHandler: context.confirmationHandler,
2068
+ permissionMode: 'yolo' as any,
2069
+ });
2070
+ }
2071
+
2072
+ // Build sub-tasks
2073
+ const subTasks: Record<string, string> = {};
2074
+ const subTaskMap: Record<string, string> = {
2075
+ architect: `Focus on architecture and design. Evaluate the project structure, dependencies, data flow, and design patterns. Propose a concrete plan with specific file paths.`,
2076
+ scaffolder: `Focus on implementation. Build the complete project — create all files with working code, set up configs, install dependencies, and verify the build succeeds.`,
2077
+ reviewer: `Focus on code review. Examine the code for bugs, type safety, error handling gaps, and consistency issues. Report specific problems with file paths and fix suggestions.`,
2078
+ debugger: `Focus on runtime analysis. Identify potential failure modes, edge cases, resource leaks, and async issues. Think about what could go wrong and how to prevent it.`,
2079
+ scanner: `Focus on security. Scan for hardcoded secrets, injection flaws, XSS, unsafe eval/exec, and path traversal. Report severity and exact locations.`,
2080
+ analyzer: `Focus on code analysis. Find duplicated code, long functions, complex conditionals, unused imports, circular dependencies, and inconsistent patterns. Report with file paths.`,
2081
+ planner: `Focus on planning. Given the analysis findings, create a step-by-step refactoring plan with file paths, change descriptions, risk levels, and before/after snippets.`,
2082
+ implementer:`Focus on implementation. Write clean, production-ready code. Make actual file changes using Write/Edit. Verify correctness and run builds to ensure nothing is broken.`,
2083
+ synthesizer:`Focus on synthesis. You will receive all agent responses and produce a final summary. (Synthesis task is handled separately.)`,
2084
+ };
2085
+
2086
+ for (const agent of agents) {
2087
+ if (agent.name === 'synthesizer') continue;
2088
+ subTasks[agent.name] = subTaskMap[agent.name] || `Analyze the task from a ${agent.role} perspective and provide recommendations.`;
2089
+ }
2090
+
2091
+ // Stream progress
2092
+ if (context.onContentDelta) {
2093
+ context.onContentDelta(`## ⚡ Multi-Agent Orchestration (YOLO)\n`);
2094
+ context.onContentDelta(`**Task:** ${task}\n\n`);
2095
+ context.onContentDelta(`*Agents: ${agents.filter(a => a.name !== 'synthesizer').map(a => a.name).join(', ')}*\n\n`);
2096
+ context.onContentDelta(`*Permission mode: \`yolo\` — all tool calls auto-approved*\n\n---\n\n`);
2097
+ }
2098
+
2099
+ const result = await orchestrator.orchestrate(task, subTasks, synthesizerName);
2100
+
2101
+ if (saveAs) {
2102
+ const saveId = saveAs.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
2103
+ if (getApp(saveId)) {
2104
+ return {
2105
+ success: false,
2106
+ type: 'error',
2107
+ error: `App "${saveId}" already exists. Choose a different name.`,
2108
+ };
2109
+ }
2110
+ const builder = new AppBuilder(saveId, `${task.slice(0, 50)}...`)
2111
+ .describe(`Custom app created via /multiyolo: ${task.slice(0, 120)}`)
2112
+ .use(`/${saveId} <task>`)
2113
+ .examples([`/${saveId} ${task.slice(0, 60)}`]);
2114
+ for (const agent of agents) {
2115
+ builder.agent(agent.name, agent.role, agent.systemPrompt, agent.tools);
2116
+ }
2117
+ builder.register();
2118
+ }
2119
+
2120
+ const lines: string[] = [];
2121
+ if (!context.onContentDelta) {
2122
+ lines.push(`## ⚡ Multi-Agent Orchestration (YOLO)`);
2123
+ lines.push(`**Task:** ${task}`);
2124
+ lines.push('');
2125
+ }
2126
+
2127
+ for (const response of result.responses) {
2128
+ const agentCfg = agents.find(a => a.name === response.agentName);
2129
+ const role = agentCfg?.role || response.agentName;
2130
+ const toolHint = response.metadata?.toolCallsCount
2131
+ ? ` [${response.metadata.toolCallsCount} tool calls]`
2132
+ : '';
2133
+ const header = `### ${role}${toolHint}`;
2134
+ const duration = response.metadata?.durationMs
2135
+ ? `*${(response.metadata.durationMs / 1000).toFixed(1)}s*`
2136
+ : '';
2137
+
2138
+ if (context.onContentDelta) {
2139
+ context.onContentDelta(`\n${header}\n`);
2140
+ if (duration) context.onContentDelta(`${duration}\n\n`);
2141
+ context.onContentDelta(`${response.content || '*No response*'}\n\n`);
2142
+ } else {
2143
+ lines.push(header);
2144
+ if (duration) lines.push(duration);
2145
+ lines.push('');
2146
+ lines.push(response.content || '*No response*');
2147
+ lines.push('');
2148
+ }
2149
+ }
2150
+
2151
+ if (!context.onContentDelta) {
2152
+ lines.push('---');
2153
+ lines.push('### Synthesized Summary');
2154
+ lines.push('');
2155
+ lines.push(result.summary);
2156
+ lines.push('');
2157
+ } else {
2158
+ context.onContentDelta(`---\n\n### Synthesized Summary\n\n${result.summary}\n\n`);
2159
+ }
2160
+
2161
+ const statusParts = [
2162
+ `${result.metadata.agentsUsed} agents`,
2163
+ `${(result.metadata.totalDurationMs / 1000).toFixed(1)}s`,
2164
+ ];
2165
+ if (result.metadata.totalTokens) {
2166
+ statusParts.push(`${result.metadata.totalTokens} tokens`);
2167
+ }
2168
+ if (saveAs) {
2169
+ const saveId = saveAs.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
2170
+ statusParts.push(`saved as /${saveId}`);
2171
+ }
2172
+ statusParts.push('YOLO mode');
2173
+ const statusLine = `*${statusParts.join(' · ')}*`;
2174
+
2175
+ if (context.onContentDelta) {
2176
+ context.onContentDelta(`\n${statusLine}\n`);
2177
+ context.onContentDelta(`\n✅ **Multi-agent task complete! (YOLO mode)**\n`);
2178
+ } else {
2179
+ lines.push(statusLine);
2180
+ lines.push('');
2181
+ lines.push('✅ Multi-agent task complete! (YOLO mode)');
2182
+ return { success: true, type: 'info', content: lines.join('\n') };
2183
+ }
2184
+
2185
+ return { success: true, type: 'silent' };
2186
+ } catch (error) {
2187
+ return {
2188
+ success: false,
2189
+ type: 'error',
2190
+ error: `/multiyolo failed: ${error instanceof Error ? error.message : String(error)}`,
2191
+ };
2192
+ }
2193
+ },
2194
+ };
2195
+
2196
+ const researchCommand: SlashCommand = {
2197
+ name: 'research',
2198
+ description: 'Research a topic using multi-agent deliberation — /research <question>',
2199
+ category: 'general',
2200
+ usage: '/research <question>',
2201
+ examples: ['/research What are the tradeoffs of using WebSockets vs SSE?', '/research Compare PostgreSQL and SQLite for a CLI tool'],
2202
+ fullDescription: `Spawns a research council of AI agents with different perspectives:
2203
+ - Analyst — data-driven, empirical perspective
2204
+ - Architect — systems & design tradeoffs
2205
+ - Ethicist — safety, fairness, and societal impact
2206
+ - Pragmatist — practical, implemention-focused
2207
+
2208
+ Agents deliberate in parallel, then results are aggregated.`,
2209
+
2210
+ async handler(args: string, context: SlashCommandContext): Promise<SlashCommandResult> {
2211
+ const question = args?.trim();
2212
+ if (!question) return { success: false, type: 'error', error: 'Usage: /research <question>' };
2213
+
2214
+ let modelConfig;
2215
+ try { modelConfig = requireModelConfig(); }
2216
+ catch (e) { return { success: false, type: 'error', error: (e as Error).message }; }
2217
+
2218
+ // Build workspace source context so agents can reference real code
2219
+ const cwd = context.cwd || process.cwd();
2220
+ const sourceCtx = buildSourceContext(cwd);
2221
+
2222
+ const baseModelCfg = { model: modelConfig.model, baseURL: modelConfig.baseURL || undefined, apiKey: modelConfig.apiKey };
2223
+
2224
+ try {
2225
+ const council = new CouncilAgent('research-council', modelConfig, {
2226
+ rule: 'majority',
2227
+ maxTokensPerAgent: 800,
2228
+ enableIteration: false,
2229
+ });
2230
+
2231
+ const researchTools = ['Read', 'Grep', 'Glob'];
2232
+
2233
+ const researchNote = `\n\nWorkspace context (project metadata + file tree + structure summaries + git changes):\n${sourceCtx}\n\nRead files with Read tool, search with Grep, browse with Glob. Structure summaries show exports/classes/functions — use them to navigate. Git changes show what was recently modified.\nAlways state VOTE: approve, reject, or abstain and REASONING: with clear justification.`;
2234
+
2235
+ council.addMember('analyst', 'Data Analyst',
2236
+ `You are a Data Analyst on a research council. You reason from data, statistics, and empirical evidence.\nYou value measurable outcomes and quantitative reasoning.${researchNote}`,
2237
+ 1, baseModelCfg, researchTools);
2238
+
2239
+ council.addMember('architect', 'Systems Architect',
2240
+ `You are a Systems Architect on a research council. You evaluate designs, tradeoffs, and architectural decisions.\nYou focus on scalability, maintainability, and system coherence.${researchNote}`,
2241
+ 1, baseModelCfg, researchTools);
2242
+
2243
+ council.addMember('ethicist', 'Ethics & Safety Officer',
2244
+ `You are an Ethics & Safety Officer on a research council. You evaluate safety, fairness, privacy, and societal impact.\nYou raise concerns others might miss and advocate for responsible practices.${researchNote}`,
2245
+ 1, baseModelCfg, researchTools);
2246
+
2247
+ council.addMember('pragmatist', 'Pragmatic Engineer',
2248
+ `You are a Pragmatic Engineer on a research council. You evaluate practicality, implementation effort, and real-world constraints.\nYou balance idealism with what actually works in production.${researchNote}`,
2249
+ 1, baseModelCfg, researchTools);
2250
+
2251
+ const result = await council.deliberate(question, context.sessionId);
2252
+
2253
+ // Build research-focused output (not vote-centric)
2254
+ const lines: string[] = [];
2255
+ lines.push('## ⬡ AEGIS Research Council');
2256
+ lines.push(`**Question:** ${question}`);
2257
+ lines.push('');
2258
+ for (const v of result.voteResults) {
2259
+ lines.push(`### ${v.role}`);
2260
+ lines.push(v.reasoning);
2261
+ lines.push('');
2262
+ }
2263
+ lines.push('---');
2264
+ lines.push('### Synthesis');
2265
+ lines.push('');
2266
+ // Extract synthesis from summary (after the vote table)
2267
+ const summaryLines = result.summary.split('\n');
2268
+ const verdictIdx = summaryLines.findIndex(l => l.includes('APPROVED') || l.includes('REJECTED'));
2269
+ const synthesis = verdictIdx > -1
2270
+ ? summaryLines.slice(verdictIdx + 1).join('\n').trim()
2271
+ : result.summary;
2272
+ lines.push(synthesis || `${result.voteResults.length} perspectives gathered.`);
2273
+ lines.push('');
2274
+ lines.push(`*${result.voteResults.length} agents · ${result.rounds} round(s)*`);
2275
+
2276
+ return { success: true, type: 'info', content: lines.join('\n') };
2277
+ } catch (error) {
2278
+ return {
2279
+ success: false,
2280
+ type: 'error',
2281
+ error: `/research failed: ${error instanceof Error ? error.message : String(error)}`,
2282
+ };
2283
+ }
2284
+ },
2285
+ };
2286
+
2287
+ const billingCommand: SlashCommand = {
2288
+ name: 'billing',
2289
+ description: 'Show subscription and billing info',
2290
+ category: 'config',
2291
+ usage: '/billing',
2292
+ async handler(args: string): Promise<SlashCommandResult> {
2293
+ const { runBilling } = await import('./billing.js');
2294
+ const result = await runBilling(args);
2295
+ return { success: true, type: 'info', content: result };
2296
+ },
2297
+ };
2298
+
2299
+ const memoryCommand: SlashCommand = {
2300
+ name: 'memory',
2301
+ description: 'View or manage semantic memory — /memory stats | /memory load | /memory upload | /memory clear',
2302
+ category: 'config',
2303
+ usage: '/memory [stats | load <url|path> | upload | clear]',
2304
+ async handler(args: string): Promise<SlashCommandResult> {
2305
+ const fs = await import('fs');
2306
+ const path = await import('path');
2307
+ const os = await import('os');
2308
+ const { sharedMemory } = await import('../memory/SharedMemory.js');
2309
+
2310
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
2311
+ let cfg: any = {};
2312
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {}
2313
+ const apiKey = cfg?.aegiscloud?.api_key;
2314
+
2315
+ if (!apiKey) {
2316
+ return { success: false, type: 'error', error: 'Not logged in — run `/login` first' };
2317
+ }
2318
+
2319
+ // /memory load <url|path> — load aegis export into local memory
2320
+ if (args?.startsWith('load ')) {
2321
+ const target = args.replace('load ', '').trim();
2322
+ try {
2323
+ let data: any;
2324
+ if (target.startsWith('http')) {
2325
+ const { default: nodeFetch } = await import('node-fetch' as any).catch(() => ({ default: fetch }));
2326
+ const fetchFn: any = nodeFetch || fetch;
2327
+ const res = await fetchFn(target, { headers: { 'X-API-Key': apiKey } });
2328
+ data = await res.json();
2329
+ } else {
2330
+ data = JSON.parse(fs.readFileSync(target, 'utf8'));
2331
+ }
2332
+ const convs = data.conversations || data;
2333
+ if (!Array.isArray(convs) && !convs.length) {
2334
+ return { success: false, type: 'error', error: 'No conversations found in export' };
2335
+ }
2336
+ const memDir = path.join(os.homedir(), '.aegiscode', 'memory');
2337
+ const memFile = path.join(memDir, 'shared.json');
2338
+ try { fs.mkdirSync(memDir, { recursive: true }); } catch {}
2339
+ let existing: any[] = [];
2340
+ try { existing = JSON.parse(fs.readFileSync(memFile, 'utf8')); } catch {}
2341
+ const newEntries = (Array.isArray(convs) ? convs : convs.conversations || []).map((c: any) => ({
2342
+ id: c.id || Math.random().toString(36).slice(2),
2343
+ content: c.content || '',
2344
+ role: 'assistant',
2345
+ source: c.source || 'aegiscloud',
2346
+ timestamp: c.created_at || new Date().toISOString(),
2347
+ tags: [c.source || 'aegiscloud'],
2348
+ sessionId: 'imported',
2349
+ title: c.title || 'Untitled',
2350
+ }));
2351
+ const merged = [...existing, ...newEntries];
2352
+ fs.writeFileSync(memFile, JSON.stringify(merged, null, 2));
2353
+ return { success: true, type: 'success', message: `✓ Loaded ${newEntries.length} conversations into memory (${merged.length} total)` };
2354
+ } catch (e: any) {
2355
+ return { success: false, type: 'error', error: `Load failed: ${e.message}` };
2356
+ }
2357
+ }
2358
+
2359
+ // /memory upload — push every local memory to aegiscloud.org at once
2360
+ if (args?.trim() === 'upload') {
2361
+ const { total, pushed } = await sharedMemory.pushAll();
2362
+ if (total === 0) {
2363
+ return { success: true, type: 'info', message: 'No local memories to upload' };
2364
+ }
2365
+ if (pushed < total) {
2366
+ return { success: false, type: 'error', error: `Uploaded ${pushed}/${total} memories — some batches failed, try again` };
2367
+ }
2368
+ return { success: true, type: 'success', message: `✓ Uploaded ${pushed}/${total} memories to aegiscloud.org` };
2369
+ }
2370
+
2371
+ // /memory clear
2372
+ if (args?.trim() === 'clear') {
2373
+ sharedMemory.clear();
2374
+ return { success: true, type: 'success', message: 'Memory cleared' };
2375
+ }
2376
+
2377
+ // /memory stats (default view)
2378
+ const stats = sharedMemory.getStats();
2379
+ const recent = sharedMemory.recent(3);
2380
+
2381
+ const lines = [
2382
+ '## ⬡ AEGIS Memory',
2383
+ '',
2384
+ '**Status:** ✓ Active — Cross-session semantic memory enabled',
2385
+ ` ${stats.total} memories stored across ${stats.sessions} sessions`,
2386
+ ` ${stats.summaries} session summaries`,
2387
+ ];
2388
+ if (recent.length > 0) {
2389
+ lines.push(' Recent:');
2390
+ recent.slice(-3).reverse().forEach((e: any) => {
2391
+ lines.push(` [${e.source ?? 'aegis-cli'} · ${(e.timestamp ?? '').slice(0, 10)}]`);
2392
+ lines.push(` ${(e.content ?? '').slice(0, 100)}`);
2393
+ });
2394
+ }
2395
+ lines.push('', '`/memory upload` — push all local memories to aegiscloud.org', '`/memory clear` — wipe all memories');
2396
+
2397
+ return { success: true, type: 'info', content: lines.join('\n') };
2398
+ },
2399
+ };
2400
+
2401
+ const councilCommand: SlashCommand = {
2402
+ name: 'council',
2403
+ description: 'Submit a question to Claude, DeepSeek and Llama for majority vote',
2404
+ category: 'config',
2405
+ usage: '/council <question>',
2406
+ async handler(args: string): Promise<SlashCommandResult> {
2407
+ const question = args?.trim();
2408
+ if (!question) return { success: false, type: 'error', error: 'Usage: /council <question>' };
2409
+ const { runCouncil } = await import('./council.js');
2410
+ const result = await runCouncil(question);
2411
+ return { success: true, type: 'info', content: result };
2412
+ },
2413
+ };
2414
+
2415
+
2416
+ const cloudCommand: SlashCommand = {
2417
+ name: 'cloud',
2418
+ description: 'Manage AEGIS Cloud sync (aegiscloud.org)',
2419
+ category: 'config',
2420
+ usage: '/cloud [status | key <api_key> | activate | deactivate]',
2421
+ fullDescription: 'Connect aegis-cli to aegiscloud.org. Conversations are uploaded automatically on exit.',
2422
+ async handler(args: string): Promise<SlashCommandResult> {
2423
+ const fs = await import('fs');
2424
+ const path = await import('path');
2425
+ const os = await import('os');
2426
+
2427
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
2428
+ let cfg: any = {};
2429
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {}
2430
+ const cloud = cfg?.aegiscloud ?? {};
2431
+ const trimmed = (args ?? '').trim();
2432
+
2433
+ // /cloud key <api_key>
2434
+ if (trimmed.startsWith('key ')) {
2435
+ const apiKey = trimmed.replace('key ', '').trim();
2436
+ if (apiKey.length < 16) return { success: false, type: 'error', error: 'API key too short' };
2437
+ cfg.aegiscloud = { ...cloud, api_key: apiKey, syncConversations: true };
2438
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
2439
+ return {
2440
+ success: true,
2441
+ type: 'success',
2442
+ message: '✓ AEGIS Cloud key saved — conversations will sync on exit',
2443
+ };
2444
+ }
2445
+
2446
+ // /cloud sync on|off (also: activate / deactivate)
2447
+ if (trimmed === 'sync on' || trimmed === 'activate' || trimmed === 'sync off' || trimmed === 'deactivate') {
2448
+ const enable = trimmed === 'sync on' || trimmed === 'activate';
2449
+ cfg.aegiscloud = { ...cloud, syncConversations: enable };
2450
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
2451
+ return {
2452
+ success: true,
2453
+ type: 'success',
2454
+ message: `Cloud sync: ${enable ? '✓ activated — conversations will upload on exit' : '✗ deactivated'}`,
2455
+ };
2456
+ }
2457
+
2458
+ // /cloud status (default)
2459
+ const hasKey = !!cloud.api_key;
2460
+ const doSync = cloud.syncConversations !== false;
2461
+ const maskedKey = hasKey
2462
+ ? cloud.api_key.slice(0, 6) + '...' + cloud.api_key.slice(-4)
2463
+ : '—';
2464
+
2465
+ const lines = [
2466
+ '## ⬡ AEGIS Cloud',
2467
+ '',
2468
+ `**API Key:** ${maskedKey}`,
2469
+ `**Sync:** ${doSync && hasKey ? '✓ enabled' : '✗ disabled'}`,
2470
+ `**Endpoint:** https://aegiscloud.org/api/conversations`,
2471
+ '',
2472
+ ];
2473
+
2474
+ if (!hasKey) {
2475
+ lines.push('Connect your account:');
2476
+ lines.push(' 1. Log in at https://aegiscloud.org');
2477
+ lines.push(' 2. Copy your API key from Settings');
2478
+ lines.push(' 3. Run: `/cloud key <your_api_key>`');
2479
+ } else {
2480
+ lines.push('Commands:');
2481
+ lines.push(' `/cloud activate` — enable auto-upload on exit');
2482
+ lines.push(' `/cloud deactivate` — disable auto-upload');
2483
+ lines.push(' `/cloud key <k>` — update API key');
2484
+ }
2485
+
2486
+ return { success: true, type: 'info', content: lines.join('\n') };
2487
+ },
2488
+ };
2489
+
2490
+
2491
+ const confirmCommand: SlashCommand = {
2492
+ name: 'confirm',
2493
+ aliases: ['confirmations'],
2494
+ description: 'Toggle the tool-call confirmation prompt for a model',
2495
+ category: 'config',
2496
+ usage: '/confirm [on|off] [model-id|claude|deepseek|all]',
2497
+ fullDescription:
2498
+ 'Controls whether tool calls (Edit/Write/Bash) pause for your approval. ' +
2499
+ 'No args shows status for the current model. Target can be a model id, ' +
2500
+ '`claude` (all anthropic-provider models), `deepseek` (all deepseek models), ' +
2501
+ 'or `all`. Defaults to the current model.',
2502
+ examples: ['/confirm', '/confirm off', '/confirm off claude', '/confirm on all'],
2503
+
2504
+ async handler(args: string): Promise<SlashCommandResult> {
2505
+ const { getState, configActions } = await import('../store/index.js');
2506
+ const state = getState();
2507
+ const config = state.config.config;
2508
+ const models = config?.models || [];
2509
+ const currentModelId = config?.currentModelId;
2510
+
2511
+ const parts = args.trim().split(/\s+/).filter(Boolean);
2512
+ const arg = parts[0]?.toLowerCase();
2513
+ const target = parts[1]?.toLowerCase();
2514
+
2515
+ // ── no args — show status for current model ──
2516
+ if (!arg) {
2517
+ const current = models.find(m => m.id === currentModelId);
2518
+ const enabled = current?.requireConfirmation !== false;
2519
+ return {
2520
+ success: true,
2521
+ type: 'info',
2522
+ content: `confirmation prompts for \`${currentModelId || 'current model'}\`: ${enabled ? 'on' : 'off'}\n\nuse \`/confirm on|off [model-id|claude|deepseek|all]\` to change`,
2523
+ };
2524
+ }
2525
+
2526
+ if (arg !== 'on' && arg !== 'off') {
2527
+ return { success: false, type: 'error', content: 'usage: /confirm [on|off] [model-id|claude|deepseek|all]' };
2528
+ }
2529
+ const requireConfirmation = arg === 'on';
2530
+
2531
+ const matchesTarget = (m: typeof models[number]): boolean => {
2532
+ if (!target) return m.id === currentModelId;
2533
+ if (target === 'all') return true;
2534
+ if (target === 'claude' || target === 'anthropic') return m.provider === 'anthropic';
2535
+ if (target === 'deepseek') return /deepseek/i.test(m.id || '') || /deepseek/i.test(m.baseURL || '');
2536
+ return m.id === target || m.model === target || m.name?.toLowerCase() === target;
2537
+ };
2538
+
2539
+ const matched = models.filter(matchesTarget);
2540
+ if (matched.length === 0) {
2541
+ return { success: false, type: 'error', content: `no model matched \`${target || currentModelId}\`` };
2542
+ }
2543
+
2544
+ const matchedIds = new Set(matched.map(m => m.id));
2545
+ const updatedModels = models.map(m =>
2546
+ matchedIds.has(m.id) ? { ...m, requireConfirmation } : m
2547
+ );
2548
+
2549
+ configActions().updateConfig({ models: updatedModels });
2550
+ try {
2551
+ const fs = await import('fs');
2552
+ const path = await import('path');
2553
+ const os = await import('os');
2554
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
2555
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
2556
+ cfg.models = updatedModels;
2557
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
2558
+ } catch { /* non-fatal */ }
2559
+
2560
+ const names = matched.map(m => m.id).join(', ');
2561
+ return {
2562
+ success: true,
2563
+ type: 'success',
2564
+ message: `confirmation prompts ${requireConfirmation ? 'enabled' : 'disabled'} for ${names}`,
2565
+ };
2566
+ },
2567
+ };
2568
+
2569
+ const yoloCommand: SlashCommand = {
2570
+ name: 'yolo',
2571
+ description: 'Toggle YOLO mode — auto-approve all tool executions',
2572
+ category: 'config',
2573
+ usage: '/yolo [on|off]',
2574
+ async handler(args: string): Promise<SlashCommandResult> {
2575
+ const { getState, configActions } = await import('../store/index.js');
2576
+ const state = getState();
2577
+ const current = state.config.config?.defaultPermissionMode === 'yolo';
2578
+ const arg = args.trim().toLowerCase();
2579
+
2580
+ const enable = arg === 'on' ? true : arg === 'off' ? false : !current;
2581
+
2582
+ try {
2583
+ const fs = await import('fs');
2584
+ const path = await import('path');
2585
+ const os = await import('os');
2586
+ const cfgPath = path.join(os.homedir(), '.aegiscode', 'config.json');
2587
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
2588
+ cfg.defaultPermissionMode = enable ? 'yolo' : 'default';
2589
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
2590
+ } catch {}
2591
+
2592
+ // Update runtime state so next message picks up the change without reload
2593
+ configActions().updateConfig({ defaultPermissionMode: enable ? 'yolo' as const : 'default' as const });
2594
+ try {
2595
+ const { ConfigManager } = await import('../config/ConfigManager.js');
2596
+ ConfigManager.getInstance().setDefaultPermissionMode(enable ? 'yolo' : 'default');
2597
+ } catch {}
2598
+
2599
+ if (enable) {
2600
+ return {
2601
+ success: true,
2602
+ type: 'info',
2603
+ content: [
2604
+ '## ⚠ YOLO Mode ENABLED',
2605
+ '',
2606
+ 'Claude will execute ALL tool calls without confirmation.',
2607
+ 'This includes file writes, bash commands, and network requests.',
2608
+ '',
2609
+ 'Run `/yolo off` to disable.',
2610
+ ].join('\n'),
2611
+ };
2612
+ }
2613
+
2614
+ return {
2615
+ success: true,
2616
+ type: 'success',
2617
+ message: '✓ YOLO mode disabled — confirmations restored',
2618
+ };
2619
+ },
2620
+ };
2621
+
2622
+ // ─── AppBuilder-powered commands ────────────────────────────────────
2623
+
2624
+ function createAppCommand(app: AppDefinition): SlashCommand {
2625
+ return {
2626
+ name: app.id,
2627
+ description: app.description,
2628
+ category: 'general',
2629
+ usage: app.usage || `/${app.id} <task>`,
2630
+ examples: app.examples,
2631
+ fullDescription: `Runs a multi-agent app: **${app.name}**\n\nAgents: ${app.agents.map(a => `**${a.role}**`).join(' → ')}\n\nUses ${app.agents.length} specialist agents in parallel, then synthesizes results.`,
2632
+
2633
+ async handler(args: string, context: SlashCommandContext): Promise<SlashCommandResult> {
2634
+ const task = args?.trim();
2635
+ if (!task) {
2636
+ return { success: false, type: 'error', error: `Usage: /${app.id} <task>\n\n${app.description}` };
2637
+ }
2638
+ try {
2639
+ const result = await runApp(app.id, {
2640
+ task,
2641
+ confirmationHandler: context.confirmationHandler,
2642
+ });
2643
+ const lines: string[] = [];
2644
+
2645
+ // ── Status line ──
2646
+ const statusIcon = result.errorCount > 0 ? '⚠' : '✓';
2647
+ lines.push(`## ⬡ ${app.name}`);
2648
+ lines.push(`**Task:** ${task}`);
2649
+ lines.push(`*${result.responses.length} agents · ${(result.totalDurationMs / 1000).toFixed(1)}s · ${result.errorCount} error(s)*`);
2650
+ lines.push('');
2651
+
2652
+ // ── Agent responses ──
2653
+ for (const response of result.responses) {
2654
+ const appCfg = app.agents.find(a => a.name === response.agentName);
2655
+ const role = appCfg?.role || response.agentName;
2656
+ const icon = result.errorCount > 0 && response.content.startsWith('[Error:') ? '⚠' : '▸';
2657
+ const toolHint = response.toolCallsCount ? ` [${response.toolCallsCount} tool calls]` : '';
2658
+ const header = `### ${icon} ${role}${toolHint}`;
2659
+ const duration = response.durationMs ? `*${(response.durationMs / 1000).toFixed(1)}s*` : '';
2660
+
2661
+ if (context.onContentDelta) {
2662
+ context.onContentDelta(`\n${header}\n`);
2663
+ if (duration) context.onContentDelta(`${duration}\n\n`);
2664
+ context.onContentDelta(`${response.content || '*No response*'}\n\n`);
2665
+ } else {
2666
+ lines.push(header);
2667
+ if (duration) lines.push(duration);
2668
+ lines.push('');
2669
+ lines.push(response.content || '*No response*');
2670
+ lines.push('');
2671
+ }
2672
+ }
2673
+
2674
+ // ── Summary ──
2675
+ if (!context.onContentDelta) {
2676
+ lines.push('---');
2677
+ lines.push('### Synthesis');
2678
+ lines.push('');
2679
+ lines.push(result.summary);
2680
+ } else {
2681
+ context.onContentDelta(`---\n\n### Synthesis\n\n${result.summary}\n\n`);
2682
+ }
2683
+
2684
+ if (!context.onContentDelta) {
2685
+ return { success: true, type: 'info', content: lines.join('\n') };
2686
+ }
2687
+ return { success: true, type: 'silent' };
2688
+ } catch (error) {
2689
+ return {
2690
+ success: false,
2691
+ type: 'error',
2692
+ error: `/${app.id} failed: ${error instanceof Error ? error.message : String(error)}`,
2693
+ };
2694
+ }
2695
+ },
2696
+ };
2697
+ }
2698
+
2699
+
2700
+
2701
+ /** Generate SlashCommand wrappers for all AppBuilder apps */
2702
+ const appCommands: SlashCommand[] = BUILTIN_APPS.map(createAppCommand);
2703
+
2704
+ // ─── Export all commands ───────────────────────────────────────────
2705
+
2706
+ export const builtinCommands: SlashCommand[] = [
2707
+ helpCommand,
2708
+ clearCommand,
2709
+ compactCommand,
2710
+ versionCommand,
2711
+ modelCommand,
2712
+ routerCommand,
2713
+ effortCommand,
2714
+ themeCommand,
2715
+ statusCommand,
2716
+ tokensCommand,
2717
+ skillsCommand,
2718
+ hooksCommand,
2719
+ thinkingCommand,
2720
+ copyCommand,
2721
+ memoryCommand,
2722
+ councilCommand,
2723
+ billingCommand,
2724
+ yoloCommand,
2725
+ confirmCommand,
2726
+ multiCommand,
2727
+ multiYoloCommand,
2728
+ researchCommand,
2729
+ buildCommand,
2730
+ cloneCommand,
2731
+ debateCommand,
2732
+ ...appCommands,
2733
+ ];