@heysalad/cheri-cli 0.10.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heysalad/cheri-cli",
3
- "version": "0.10.0",
3
+ "version": "1.0.0",
4
4
  "description": "Cheri CLI - AI-powered cloud IDE by HeySalad. Like Claude Code, but for cloud workspaces.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,10 @@
39
39
  "dependencies": {
40
40
  "chalk": "^5.3.0",
41
41
  "commander": "^12.1.0",
42
+ "diff": "^8.0.3",
42
43
  "inquirer": "^9.2.23",
44
+ "marked": "^15.0.12",
45
+ "marked-terminal": "^7.3.0",
43
46
  "ora": "^8.0.1"
44
47
  }
45
48
  }
@@ -1,20 +1,29 @@
1
- import { apiClient } from "../lib/api-client.js";
2
1
  import { log } from "../lib/logger.js";
2
+ import { getConfigValue } from "../lib/config-store.js";
3
3
  import { getToolDefinitions, executeTool as executeLocalTool, requiresConfirmation as toolRequiresConfirmation } from "../lib/tools/index.js";
4
4
  import { generateSessionId, saveSession, loadSession, listSessions } from "../lib/sessions/index.js";
5
- import { compactMessages, shouldCompact, estimateMessagesTokens } from "../lib/context.js";
5
+ import { compactMessages, shouldCompact, estimateMessagesTokens, getContextStats } from "../lib/context.js";
6
6
  import { loadHooks, runHooks } from "../lib/hooks/index.js";
7
7
  import { loadPlugins, getSkillContext, getSlashCommand } from "../lib/plugins/index.js";
8
- import { checkPermission, loadPermissions } from "../lib/permissions.js";
8
+ import { loadPermissions, checkPermission } from "../lib/permissions.js";
9
9
  import { getMemoryContext } from "../lib/memory.js";
10
+ import { createProvider } from "../lib/providers/index.js";
11
+ import { renderMarkdown, renderDiff } from "../lib/markdown.js";
12
+ import { classifyCommand, getSafetyLabel } from "../lib/command-safety.js";
13
+ import { sandboxExec, SandboxLevel, getSandboxInfo } from "../lib/sandbox.js";
14
+ import { snapshotFile, recordChange, getSessionChanges, generateDiff, rollbackChange, rollbackAll, clearSessionChanges } from "../lib/diff-tracker.js";
15
+ import { shouldApprove, promptApproval, getApprovalMode } from "../lib/approval.js";
16
+ import { McpManager } from "../lib/mcp/client.js";
17
+ import { AgentOrchestrator } from "../lib/multi-agent.js";
10
18
  import chalk from "chalk";
11
19
  import readline from "readline";
12
20
 
13
21
  const SYSTEM_PROMPT = `You are Cheri, an AI coding assistant by HeySalad. You are a powerful agentic coding tool that can read, write, edit, and search code, execute shell commands, and manage cloud workspaces.
14
22
 
15
- You have two categories of tools:
16
- 1. LOCAL CODING TOOLS — read_file, write_file, edit_file, run_command, search_files, search_content, list_directory. Use these to work directly with the user's local codebase.
17
- 2. CLOUD PLATFORM TOOLS — get_account_info, list_workspaces, create_workspace, stop_workspace, get_workspace_status, get_memory, add_memory, clear_memory, get_usage, get_config, set_config. Use these to manage the Cheri cloud platform.
23
+ You have these tool categories:
24
+ 1. LOCAL CODING TOOLS — read_file, write_file, edit_file, run_command, search_files, search_content, list_directory
25
+ 2. CLOUD PLATFORM TOOLS — get_account_info, list_workspaces, create_workspace, stop_workspace, get_workspace_status, get_memory, add_memory, clear_memory, get_usage, get_config, set_config
26
+ 3. AGENT TOOLS — spawn_agent (delegate subtasks to child agents), get_changes (view file changes this session), rollback_change (undo a file change)
18
27
 
19
28
  Guidelines:
20
29
  - Read files before editing them. Understand existing code before making changes.
@@ -24,9 +33,10 @@ Guidelines:
24
33
  - For shell commands, prefer specific commands over broad ones.
25
34
  - Be concise. Show what you did and the result.
26
35
  - Never guess file contents — always read first.
36
+ - Use spawn_agent for independent subtasks that can run in parallel.
27
37
  - Current working directory: ${process.cwd()}`;
28
38
 
29
- // Cloud platform tools
39
+ // ── Cloud platform tools ──────────────────────────────────────────────────────
30
40
  const CLOUD_TOOLS = [
31
41
  { name: "get_account_info", description: "Get the current user's account information", parameters: { type: "object", properties: {}, required: [] } },
32
42
  { name: "list_workspaces", description: "List all cloud workspaces for the current user", parameters: { type: "object", properties: {}, required: [] } },
@@ -41,22 +51,37 @@ const CLOUD_TOOLS = [
41
51
  { name: "set_config", description: "Set a configuration value", parameters: { type: "object", properties: { key: { type: "string", description: "Config key" }, value: { type: "string", description: "Value to set" } }, required: ["key", "value"] } },
42
52
  ];
43
53
 
44
- const CLOUD_TOOL_NAMES = new Set(CLOUD_TOOLS.map((t) => t.name));
54
+ // ── Agent meta-tools ────────────────────────────────────────────────────────
55
+ const AGENT_TOOLS = [
56
+ { name: "spawn_agent", description: "Spawn a child agent to work on a subtask independently. Use for tasks that can be parallelized.", parameters: { type: "object", properties: { name: { type: "string", description: "Short name for this sub-agent" }, task: { type: "string", description: "The task description for the sub-agent" } }, required: ["name", "task"] } },
57
+ { name: "get_changes", description: "View all file changes made this session with diffs", parameters: { type: "object", properties: {}, required: [] } },
58
+ { name: "rollback_change", description: "Undo a specific file change by index", parameters: { type: "object", properties: { index: { type: "number", description: "Change index (from get_changes)" } }, required: ["index"] } },
59
+ ];
60
+
61
+ const CLOUD_TOOL_NAMES = new Set(CLOUD_TOOLS.map(t => t.name));
62
+ const AGENT_TOOL_NAMES = new Set(AGENT_TOOLS.map(t => t.name));
45
63
 
46
- function buildTools() {
47
- const localDefs = getToolDefinitions().map((t) => ({
48
- type: "function",
49
- function: { name: t.name, description: t.description, parameters: t.parameters },
64
+ function buildTools(mcpTools = []) {
65
+ const localDefs = getToolDefinitions().map(t => ({
66
+ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters },
67
+ }));
68
+ const cloudDefs = CLOUD_TOOLS.map(t => ({
69
+ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters },
50
70
  }));
51
- const cloudDefs = CLOUD_TOOLS.map((t) => ({
52
- type: "function",
53
- function: { name: t.name, description: t.description, parameters: t.parameters },
71
+ const agentDefs = AGENT_TOOLS.map(t => ({
72
+ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters },
54
73
  }));
55
- return [...localDefs, ...cloudDefs];
74
+ const mcpDefs = mcpTools.map(t => ({
75
+ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters },
76
+ }));
77
+ return [...localDefs, ...cloudDefs, ...agentDefs, ...mcpDefs];
56
78
  }
57
79
 
80
+ // ── Cloud tool executor ───────────────────────────────────────────────────────
58
81
  async function executeCloudTool(name, args) {
59
82
  try {
83
+ // Dynamic import to avoid circular dependency
84
+ const { apiClient } = await import("../lib/api-client.js");
60
85
  switch (name) {
61
86
  case "get_account_info": return await apiClient.getMe();
62
87
  case "list_workspaces": return await apiClient.listWorkspaces();
@@ -67,10 +92,7 @@ async function executeCloudTool(name, args) {
67
92
  case "add_memory": return await apiClient.addMemory(args.content, args.category);
68
93
  case "clear_memory": return await apiClient.clearMemory();
69
94
  case "get_usage": return await apiClient.getUsage();
70
- case "get_config": {
71
- const { getConfigValue } = await import("../lib/config-store.js");
72
- return { key: args.key, value: getConfigValue(args.key) };
73
- }
95
+ case "get_config": return { key: args.key, value: getConfigValue(args.key) };
74
96
  case "set_config": {
75
97
  const { setConfigValue } = await import("../lib/config-store.js");
76
98
  setConfigValue(args.key, args.value);
@@ -83,28 +105,96 @@ async function executeCloudTool(name, args) {
83
105
  }
84
106
  }
85
107
 
86
- async function confirmAction(toolName, input) {
87
- const desc = toolName === "run_command" ? input.command : JSON.stringify(input);
88
- return new Promise((resolve) => {
89
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
90
- rl.question(chalk.yellow(` Allow ${chalk.cyan(toolName)}: ${chalk.dim(truncate(desc, 60))}? [Y/n] `), (answer) => {
91
- rl.close();
92
- resolve(answer.trim().toLowerCase() !== "n");
93
- });
94
- });
108
+ // ── Agent meta-tool executor ──────────────────────────────────────────────────
109
+ async function executeAgentTool(name, args, orchestrator, allTools, parseSSE) {
110
+ switch (name) {
111
+ case "spawn_agent": {
112
+ if (!orchestrator) return { error: "Agent orchestration not available" };
113
+ orchestrator.createAgent(args.name, args.task);
114
+ const result = await orchestrator.runAgent(args.name, args.task, allTools, parseSSE);
115
+ return { agent: args.name, result: result.slice(0, 4000) };
116
+ }
117
+ case "get_changes": {
118
+ const changes = getSessionChanges();
119
+ if (changes.length === 0) return { changes: [], message: "No file changes this session" };
120
+ return { changes };
121
+ }
122
+ case "rollback_change": {
123
+ return rollbackChange(args.index);
124
+ }
125
+ default:
126
+ return { error: `Unknown agent tool: ${name}` };
127
+ }
95
128
  }
96
129
 
97
- async function executeTool(name, args) {
98
- // Check permission rules
99
- const permission = checkPermission(name, args);
100
- if (permission === "deny") {
101
- return { error: `Tool ${name} is denied by permission rules` };
130
+ // ── Enhanced local tool executor with sandbox + diff tracking ─────────────────
131
+ async function executeLocalToolEnhanced(name, args) {
132
+ // Sandbox for run_command
133
+ if (name === "run_command") {
134
+ const sandboxLevel = getConfigValue("sandbox.level") || "basic";
135
+ const allowNetwork = getConfigValue("sandbox.allowNetwork") || false;
136
+
137
+ if (sandboxLevel !== "none") {
138
+ const result = await sandboxExec(args.command, {
139
+ cwd: args.cwd || process.cwd(),
140
+ timeout: args.timeout || 120000,
141
+ level: sandboxLevel === "strict" ? SandboxLevel.STRICT : SandboxLevel.BASIC,
142
+ allowNetwork,
143
+ });
144
+ return {
145
+ stdout: result.stdout,
146
+ stderr: result.stderr,
147
+ exitCode: result.exitCode,
148
+ ...(result.timedOut ? { timedOut: true } : {}),
149
+ sandbox: getSandboxInfo(sandboxLevel),
150
+ };
151
+ }
152
+ }
153
+
154
+ // Diff tracking for file modifications
155
+ if (name === "write_file" || name === "edit_file") {
156
+ const filePath = args.path || args.file_path;
157
+ const snapshot = snapshotFile(filePath);
158
+ const result = await executeLocalTool(name, args);
159
+ if (!result.error) {
160
+ const { readFileSync, existsSync } = await import("fs");
161
+ const absPath = filePath.startsWith("/") ? filePath : `${process.cwd()}/${filePath}`;
162
+ if (existsSync(absPath)) {
163
+ const newContent = readFileSync(absPath, "utf-8");
164
+ const change = recordChange(snapshot, newContent, name === "write_file" ? "write" : "edit");
165
+ result.diff = generateDiff(change).split("\n").slice(0, 20).join("\n");
166
+ }
167
+ }
168
+ return result;
102
169
  }
103
170
 
104
- // Run PreToolUse hooks
171
+ return executeLocalTool(name, args);
172
+ }
173
+
174
+ // ── Main tool execution with approval + hooks + permissions ───────────────────
175
+ let sessionAutoApprove = false;
176
+
177
+ async function executeTool(name, args, orchestrator, allTools, parseSSE, mcpManager) {
178
+ // Permission rules check
179
+ const permission = checkPermission(name, args);
180
+ if (permission === "deny") return { error: `Tool ${name} is denied by permission rules` };
181
+
182
+ // Pre-tool hooks
105
183
  const hookResult = await runHooks("PreToolUse", { toolName: name, input: args });
106
- if (!hookResult.allowed) {
107
- return { error: hookResult.reason || `Blocked by hook` };
184
+ if (!hookResult.allowed) return { error: hookResult.reason || "Blocked by hook" };
185
+
186
+ // MCP tools
187
+ if (mcpManager?.isMcpTool(name)) {
188
+ const result = await mcpManager.callTool(name, args);
189
+ await runHooks("PostToolUse", { toolName: name, input: args, result });
190
+ return result;
191
+ }
192
+
193
+ // Agent meta-tools
194
+ if (AGENT_TOOL_NAMES.has(name)) {
195
+ const result = await executeAgentTool(name, args, orchestrator, allTools, parseSSE);
196
+ await runHooks("PostToolUse", { toolName: name, input: args, result });
197
+ return result;
108
198
  }
109
199
 
110
200
  // Cloud tools
@@ -114,31 +204,42 @@ async function executeTool(name, args) {
114
204
  return result;
115
205
  }
116
206
 
117
- // Local tools — check if confirmation needed
118
- if (permission === "ask" || (permission !== "allow" && toolRequiresConfirmation(name))) {
119
- const allowed = await confirmAction(name, args);
120
- if (!allowed) return { error: "User denied execution" };
207
+ // Local tools — enhanced approval system
208
+ if (!sessionAutoApprove) {
209
+ const decision = shouldApprove(name, args);
210
+ if (decision === "deny") return { error: "Denied by approval policy" };
211
+ if (decision === "ask" || decision === "suggest") {
212
+ // Override with permission rules
213
+ if (permission === "allow") {
214
+ // Permission rules say allow, skip approval
215
+ } else {
216
+ const approved = await promptApproval(name, args, decision);
217
+ if (approved === "auto") {
218
+ sessionAutoApprove = true;
219
+ } else if (!approved) {
220
+ return { error: "User denied execution" };
221
+ }
222
+ }
223
+ }
121
224
  }
122
225
 
123
- const result = await executeLocalTool(name, args);
226
+ const result = await executeLocalToolEnhanced(name, args);
124
227
  await runHooks("PostToolUse", { toolName: name, input: args, result });
125
228
  return result;
126
229
  }
127
230
 
231
+ // ── SSE Stream Parser ─────────────────────────────────────────────────────────
128
232
  async function* parseSSEStream(response) {
129
233
  const reader = response.body.getReader();
130
234
  const decoder = new TextDecoder();
131
235
  let buffer = "";
132
-
133
236
  try {
134
237
  while (true) {
135
238
  const { done, value } = await reader.read();
136
239
  if (done) break;
137
-
138
240
  buffer += decoder.decode(value, { stream: true });
139
241
  const lines = buffer.split("\n");
140
242
  buffer = lines.pop() || "";
141
-
142
243
  for (const line of lines) {
143
244
  if (line.startsWith("data: ")) {
144
245
  const data = line.slice(6).trim();
@@ -152,7 +253,7 @@ async function* parseSSEStream(response) {
152
253
  }
153
254
  }
154
255
 
155
- // Active session state
256
+ // ── Active session state ──────────────────────────────────────────────────────
156
257
  let currentSession = null;
157
258
 
158
259
  export async function runAgent(userRequest, options = {}) {
@@ -163,17 +264,28 @@ export async function runAgent(userRequest, options = {}) {
163
264
  const memoryContext = getMemoryContext();
164
265
  const skillContext = getSkillContext(plugins.skills, userRequest);
165
266
 
166
- // Check for slash commands
267
+ // Initialize MCP servers
268
+ const mcpManager = new McpManager();
269
+ const mcpServers = getConfigValue("mcp.servers") || {};
270
+ for (const [name, config] of Object.entries(mcpServers)) {
271
+ if (config.command) await mcpManager.addServer(name, config);
272
+ }
273
+
274
+ // Create provider
275
+ const providerName = getConfigValue("agent.provider") || getConfigValue("ai.provider") || "cheri";
276
+ const provider = createProvider(providerName);
277
+ const agentModel = getConfigValue("agent.model") || getConfigValue("ai.model") || "";
278
+
279
+ // Handle slash commands
167
280
  if (userRequest.startsWith("/")) {
168
281
  const cmdName = userRequest.slice(1).split(/\s+/)[0];
169
282
  const cmdArgs = userRequest.slice(1 + cmdName.length).trim();
170
283
 
171
- // Built-in slash commands
172
284
  if (cmdName === "sessions" || cmdName === "history") {
173
285
  const sessions = listSessions();
174
286
  if (sessions.length === 0) { log.info("No saved sessions."); return; }
175
287
  log.brand("Sessions");
176
- sessions.slice(0, 10).forEach((s) => {
288
+ sessions.slice(0, 10).forEach(s => {
177
289
  console.log(` ${chalk.dim(s.id.slice(0, 12))} ${s.title} ${chalk.dim(`(${s.messageCount} msgs)`)}`);
178
290
  });
179
291
  return;
@@ -181,7 +293,7 @@ export async function runAgent(userRequest, options = {}) {
181
293
  if (cmdName === "resume") {
182
294
  const sessions = listSessions();
183
295
  if (sessions.length === 0) { log.info("No sessions to resume."); return; }
184
- const target = cmdArgs ? sessions.find((s) => s.id.includes(cmdArgs)) : sessions[0];
296
+ const target = cmdArgs ? sessions.find(s => s.id.includes(cmdArgs)) : sessions[0];
185
297
  if (!target) { log.error("Session not found."); return; }
186
298
  const data = loadSession(target.id);
187
299
  if (data) {
@@ -202,9 +314,68 @@ export async function runAgent(userRequest, options = {}) {
202
314
  }
203
315
  if (cmdName === "new") {
204
316
  currentSession = null;
317
+ clearSessionChanges();
318
+ sessionAutoApprove = false;
205
319
  log.success("Started new session.");
206
320
  return;
207
321
  }
322
+ if (cmdName === "changes") {
323
+ const changes = getSessionChanges();
324
+ if (changes.length === 0) { log.info("No file changes this session."); return; }
325
+ log.brand("File Changes");
326
+ changes.forEach((c, i) => {
327
+ console.log(` ${chalk.dim(`[${i}]`)} ${chalk.cyan(c.operation)} ${c.path} ${chalk.dim(`(${c.linesAdded > 0 ? "+" : ""}${c.linesAdded} lines)`)}`);
328
+ });
329
+ return;
330
+ }
331
+ if (cmdName === "rollback") {
332
+ if (cmdArgs === "all") {
333
+ const results = rollbackAll();
334
+ log.success(`Rolled back ${results.length} changes.`);
335
+ } else {
336
+ const idx = parseInt(cmdArgs);
337
+ if (isNaN(idx)) { log.error("Usage: /rollback <index> or /rollback all"); return; }
338
+ const result = rollbackChange(idx);
339
+ if (result.error) log.error(result.error);
340
+ else log.success(`Rolled back: ${result.path}`);
341
+ }
342
+ return;
343
+ }
344
+ if (cmdName === "stats") {
345
+ if (!currentSession) { log.info("No active session."); return; }
346
+ const stats = getContextStats(currentSession.messages);
347
+ log.brand("Session Stats");
348
+ log.keyValue("Tokens", `~${Math.round(stats.tokens / 1000)}k`);
349
+ log.keyValue("Messages", stats.messageCount);
350
+ log.keyValue("User turns", stats.turns);
351
+ log.keyValue("Tool calls", stats.toolCalls);
352
+ log.keyValue("Provider", providerName);
353
+ log.keyValue("Model", agentModel || "(default)");
354
+ log.keyValue("Approval", getApprovalMode());
355
+ log.keyValue("Sandbox", getConfigValue("sandbox.level") || "basic");
356
+ return;
357
+ }
358
+ if (cmdName === "mode") {
359
+ if (!cmdArgs) {
360
+ log.info(`Current approval mode: ${chalk.cyan(getApprovalMode())}`);
361
+ log.dim(" Modes: auto, suggest, ask, deny");
362
+ return;
363
+ }
364
+ const { setConfigValue } = await import("../lib/config-store.js");
365
+ setConfigValue("approval.mode", cmdArgs);
366
+ log.success(`Approval mode set to: ${cmdArgs}`);
367
+ return;
368
+ }
369
+ if (cmdName === "model") {
370
+ if (!cmdArgs) {
371
+ log.info(`Current model: ${chalk.cyan(agentModel || "(provider default)")}`);
372
+ return;
373
+ }
374
+ const { setConfigValue } = await import("../lib/config-store.js");
375
+ setConfigValue("agent.model", cmdArgs);
376
+ log.success(`Agent model set to: ${cmdArgs}`);
377
+ return;
378
+ }
208
379
 
209
380
  // Plugin slash commands
210
381
  const cmd = getSlashCommand(plugins.commands, cmdName);
@@ -214,10 +385,8 @@ export async function runAgent(userRequest, options = {}) {
214
385
  }
215
386
  }
216
387
 
217
- // Run SessionStart hooks on first message
218
- if (!currentSession) {
219
- await runHooks("SessionStart", { cwd: process.cwd() });
220
- }
388
+ // Run SessionStart hooks
389
+ if (!currentSession) await runHooks("SessionStart", { cwd: process.cwd() });
221
390
 
222
391
  // Build or continue session
223
392
  if (!currentSession) {
@@ -227,20 +396,15 @@ export async function runAgent(userRequest, options = {}) {
227
396
  title: userRequest.slice(0, 60),
228
397
  createdAt: Date.now(),
229
398
  };
230
- } else {
231
- // Inject fresh skill context if relevant
232
- if (skillContext) {
233
- const sysMsg = currentSession.messages.find((m) => m.role === "system");
234
- if (sysMsg && !sysMsg.content.includes(skillContext)) {
235
- sysMsg.content += skillContext;
236
- }
237
- }
399
+ } else if (skillContext) {
400
+ const sysMsg = currentSession.messages.find(m => m.role === "system");
401
+ if (sysMsg && !sysMsg.content.includes(skillContext)) sysMsg.content += skillContext;
238
402
  }
239
403
 
240
404
  currentSession.messages.push({ role: "user", content: userRequest });
241
405
  currentSession.updatedAt = Date.now();
242
406
 
243
- // Auto-compact if conversation is too long
407
+ // Auto-compact if needed
244
408
  if (shouldCompact(currentSession.messages)) {
245
409
  const { messages, compacted } = compactMessages(currentSession.messages);
246
410
  if (compacted) {
@@ -249,11 +413,22 @@ export async function runAgent(userRequest, options = {}) {
249
413
  }
250
414
  }
251
415
 
252
- const ALL_TOOLS = buildTools();
253
- const MAX_ITERATIONS = 15;
416
+ // Build tools including MCP
417
+ const mcpTools = mcpManager.getAllToolDefinitions();
418
+ const ALL_TOOLS = buildTools(mcpTools);
419
+
420
+ // Create orchestrator for sub-agents
421
+ const orchestrator = new AgentOrchestrator(
422
+ (msgs, tools) => provider.chatStream(msgs, tools, { model: agentModel || undefined }),
423
+ (name, args) => executeTool(name, args, null, ALL_TOOLS, parseSSEStream, mcpManager)
424
+ );
425
+
426
+ const MAX_ITERATIONS = getConfigValue("agent.maxIterations") || 15;
254
427
 
255
428
  for (let i = 0; i < MAX_ITERATIONS; i++) {
256
- const response = await apiClient.chatStream(currentSession.messages, ALL_TOOLS);
429
+ const response = await provider.chatStream(currentSession.messages, ALL_TOOLS, {
430
+ model: agentModel || undefined,
431
+ });
257
432
 
258
433
  let fullText = "";
259
434
  const toolCalls = {};
@@ -283,49 +458,83 @@ export async function runAgent(userRequest, options = {}) {
283
458
  const toolCallList = Object.values(toolCalls);
284
459
 
285
460
  if (toolCallList.length === 0) {
286
- if (fullText) process.stdout.write("\n");
287
- // Save assistant response to session
461
+ // Render markdown for final text output
462
+ if (fullText) {
463
+ process.stdout.write("\r\x1b[K"); // clear streaming line
464
+ console.log(renderMarkdown(fullText));
465
+ }
288
466
  currentSession.messages.push({ role: "assistant", content: fullText });
289
467
  saveSession(currentSession.id, currentSession);
468
+ mcpManager.disconnectAll();
290
469
  return;
291
470
  }
292
471
 
293
472
  if (fullText) process.stdout.write("\n");
294
473
 
295
474
  const assistantMsg = { role: "assistant", content: fullText || null };
296
- assistantMsg.tool_calls = toolCallList.map((tc) => ({
475
+ assistantMsg.tool_calls = toolCallList.map(tc => ({
297
476
  id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
298
477
  }));
299
478
  currentSession.messages.push(assistantMsg);
300
479
 
480
+ // Execute tools — parallel for safe tools, sequential for others
481
+ const safeTools = [];
482
+ const unsafeTools = [];
483
+
301
484
  for (const tc of toolCallList) {
302
485
  let input = {};
303
486
  try { input = JSON.parse(tc.arguments); } catch {}
487
+ const isSafe = tc.name === "read_file" || tc.name === "list_directory" ||
488
+ tc.name === "search_files" || tc.name === "search_content" ||
489
+ CLOUD_TOOL_NAMES.has(tc.name);
490
+ (isSafe ? safeTools : unsafeTools).push({ tc, input });
491
+ }
304
492
 
305
- const isLocal = !CLOUD_TOOL_NAMES.has(tc.name);
306
- const prefix = isLocal ? chalk.magenta("local") : chalk.blue("cloud");
307
- log.info(`${prefix} ${chalk.cyan(tc.name)}${Object.keys(input).length ? chalk.dim(" " + truncate(JSON.stringify(input), 80)) : ""}`);
493
+ // Execute safe tools in parallel
494
+ const executeOne = async ({ tc, input }) => {
495
+ const isMcp = mcpManager.isMcpTool(tc.name);
496
+ const isLocal = !CLOUD_TOOL_NAMES.has(tc.name) && !AGENT_TOOL_NAMES.has(tc.name) && !isMcp;
497
+ const prefix = isMcp ? chalk.magenta("mcp") : isLocal ? chalk.magenta("local") : chalk.blue("cloud");
498
+
499
+ // Command safety label
500
+ let safetyStr = "";
501
+ if (tc.name === "run_command" && input.command) {
502
+ const safety = getSafetyLabel(input.command);
503
+ safetyStr = ` ${chalk[safety.color](`[${safety.label}]`)}`;
504
+ }
308
505
 
309
- const result = await executeTool(tc.name, input);
506
+ log.info(`${prefix} ${chalk.cyan(tc.name)}${safetyStr}${Object.keys(input).length ? chalk.dim(" " + truncate(JSON.stringify(input), 80)) : ""}`);
310
507
 
311
- if (result.error) {
312
- log.error(result.error);
313
- } else {
314
- log.success(tc.name);
315
- }
508
+ const result = await executeTool(tc.name, input, orchestrator, ALL_TOOLS, parseSSEStream, mcpManager);
509
+
510
+ if (result.error) log.error(result.error);
511
+ else log.success(tc.name);
512
+
513
+ return { id: tc.id, result };
514
+ };
515
+
516
+ // Parallel execution for safe tools
517
+ const safeResults = safeTools.length > 0 ? await Promise.all(safeTools.map(executeOne)) : [];
316
518
 
519
+ // Sequential execution for unsafe tools
520
+ const unsafeResults = [];
521
+ for (const item of unsafeTools) {
522
+ unsafeResults.push(await executeOne(item));
523
+ }
524
+
525
+ // Add all results to messages
526
+ for (const { id, result } of [...safeResults, ...unsafeResults]) {
317
527
  const resultStr = JSON.stringify(result);
318
528
  const truncatedResult = resultStr.length > 8000 ? resultStr.slice(0, 8000) + "...(truncated)" : resultStr;
319
-
320
- currentSession.messages.push({ role: "tool", tool_call_id: tc.id, content: truncatedResult });
529
+ currentSession.messages.push({ role: "tool", tool_call_id: id, content: truncatedResult });
321
530
  }
322
531
 
323
- // Save session after each tool round
324
532
  saveSession(currentSession.id, currentSession);
325
533
  }
326
534
 
327
- log.warn("Agent reached maximum iterations (15). Stopping.");
535
+ log.warn("Agent reached maximum iterations. Stopping.");
328
536
  await runHooks("Stop", { reason: "max_iterations" });
537
+ mcpManager.disconnectAll();
329
538
  }
330
539
 
331
540
  function truncate(str, max) {
@@ -337,9 +546,32 @@ export function registerAgentCommand(program) {
337
546
  .command("agent")
338
547
  .argument("<request...>")
339
548
  .description("AI coding agent — natural language command interface")
340
- .action(async (requestParts) => {
549
+ .option("-p, --provider <name>", "AI provider (cheri, openai, anthropic, deepseek, ollama, ...)")
550
+ .option("-m, --model <id>", "Model ID to use")
551
+ .option("--auto", "Auto-approve all tool executions")
552
+ .option("--sandbox <level>", "Sandbox level: none, basic, strict")
553
+ .action(async (requestParts, opts) => {
341
554
  const request = requestParts.join(" ");
342
- try { await runAgent(request); }
343
- catch (err) { log.error(err.message); }
555
+ try {
556
+ if (opts.provider) {
557
+ const { setConfigValue } = await import("../lib/config-store.js");
558
+ setConfigValue("agent.provider", opts.provider);
559
+ }
560
+ if (opts.model) {
561
+ const { setConfigValue } = await import("../lib/config-store.js");
562
+ setConfigValue("agent.model", opts.model);
563
+ }
564
+ if (opts.auto) {
565
+ const { setConfigValue } = await import("../lib/config-store.js");
566
+ setConfigValue("approval.mode", "auto");
567
+ }
568
+ if (opts.sandbox) {
569
+ const { setConfigValue } = await import("../lib/config-store.js");
570
+ setConfigValue("sandbox.level", opts.sandbox);
571
+ }
572
+ await runAgent(request);
573
+ } catch (err) {
574
+ log.error(err.message);
575
+ }
344
576
  });
345
577
  }