From d806bb34c42d3e12135e708d95f591b5985ac500 Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Tue, 9 Jun 2026 19:51:38 +0530 Subject: [PATCH 1/2] persistent stdin setup, raw OpenRouter API, `/model` slash command, agent mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace AI SDK with raw fetch-based OpenRouter API to support server tools (openrouter:web_search, openrouter:web_fetch) alongside function tools - Add write_file, run_command tool definitions with permission gating for agent mode - Implement persistent single stdin setup with module-level streamAbort controller — Escape cancels streaming across all modes/providers - Add AbortSignal plumbing to all providers (Google, OpenRouter, NVIDIA, server-proxy) with silent AbortError re-throw - Add /model slash command with codebase-native model lists - Change default provider to OpenRouter; auto-switch on tool/agent mode - Rewrite system prompt for autonomous agent workflows - Pause MiniMax with 402 balance error handling - Bump maxSteps from 5 to 25 across all providers --- .../server/src/cli/ai/chat/chat.ts | 340 +++++++++++------- .../server/src/cli/ai/google-service.ts | 14 +- .../server/src/cli/ai/minimax-service.ts | 15 +- .../server/src/cli/ai/nvidia-service.ts | 5 +- .../server/src/cli/ai/openrouter-service.ts | 220 +++++++++--- .../server/src/cli/ai/provider.ts | 27 +- .../server/src/cli/ai/server-proxy-service.ts | 2 + .../server/src/cli/commands/ai/init.ts | 2 +- .../src/cli/commands/slashCommands/index.ts | 38 ++ .../src/cli/commands/slashCommands/model.ts | 79 ++++ .../supercode-cli/server/src/cli/utils/tui.ts | 2 +- .../server/src/cli/workspace/context.ts | 66 +++- .../server/src/config/tools.config.ts | 16 + apps/supercode-cli/server/src/index.ts | 161 ++++++++- .../src/tools/definitions/run-command.ts | 71 ++++ .../src/tools/definitions/write-file.ts | 52 +++ .../server/src/tools/permission-manager.ts | 203 +++++++++++ .../server/src/tools/registry.ts | 25 +- 18 files changed, 1105 insertions(+), 233 deletions(-) create mode 100644 apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts create mode 100644 apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts create mode 100644 apps/supercode-cli/server/src/tools/definitions/run-command.ts create mode 100644 apps/supercode-cli/server/src/tools/definitions/write-file.ts create mode 100644 apps/supercode-cli/server/src/tools/permission-manager.ts diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts index ff33ecd..c74a9d3 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts @@ -29,6 +29,7 @@ import type { WorkspaceInfo } from "src/cli/workspace/scanner.ts" import { buildSystemPrompt } from "src/cli/workspace/context.ts" import { tools } from "src/tools/registry.ts" import { renderWorkspaceBanner } from "src/cli/workspace/format.ts" +import { handleSlashCommand, isSlashCommand } from "src/cli/commands/slashCommands/index.ts" async function getUserFromToken() { const token = await getStoredToken() @@ -72,7 +73,7 @@ async function streamAIResponse( conversationId: string, mode: string, workspaceInfo?: WorkspaceInfo, -): Promise<{ content: string; elapsed: number; usage: any }> { +): Promise<{ content: string; elapsed: number; usage: any; aborted?: boolean }> { const dbMessages = await getMessages(conversationId) let aiMessages = formatMessagesForAI(dbMessages as any) @@ -93,12 +94,13 @@ async function streamAIResponse( const thinking = createThinking() let toolsToUse: Record | undefined - if (mode === "tool" || mode === "agent") { - toolsToUse = { ...tools } - } else if (workspaceInfo) { + if (workspaceInfo) { toolsToUse = { ...tools } } + const abortController = new AbortController() + streamAbort = abortController + try { const result = await provider.sendMessage( aiMessages as ModelMessage[], @@ -117,6 +119,7 @@ async function streamAIResponse( const argPreview = (args as any)?.path || (args as any)?.pattern || (args as any)?.query || (args as any)?.url || "" thinking.setLabel(`${toolName}(${argPreview})`) }, + abortController.signal, ) const elapsed = Date.now() - startTime @@ -124,9 +127,15 @@ async function streamAIResponse( console.log() return { content: fullResponse, elapsed, usage } - } catch (error) { + } catch (error: any) { + if (error?.name === "AbortError" || abortController.signal.aborted) { + console.log() + return { content: fullResponse || "(cancelled)", elapsed: Date.now() - startTime, usage: {}, aborted: true } + } thinking.fail("Response failed") throw error + } finally { + streamAbort = null } } @@ -145,154 +154,187 @@ interface Conversation { updatedAt: Date } -async function chatInput( - currentMode: string, -): Promise<{ input: string | null; mode: string }> { - return new Promise((resolve) => { - const stdin = process.stdin - const wasRaw = stdin.isRaw - - readline.emitKeypressEvents(stdin) - if (stdin.isTTY) { - stdin.setRawMode(true) - } - stdin.resume() +const modes = ["chat", "tool", "agent"] +const modeColors: Record = { + chat: theme.cyan, + tool: theme.green, + agent: theme.warning, +} +const modeDisplay: Record = { + chat: "chat", + tool: "tools", + agent: "agent", +} - const modes = ["chat", "tool", "agent"] - const modeColors: Record = { - chat: theme.cyan, - tool: theme.green, - agent: theme.warning, - } - const modeDisplay: Record = { - chat: "chat", - tool: "tools", - agent: "agent", - } +// Persistent stdin state +let streamAbort: AbortController | null = null +let stdinInput = "" +let stdinCursor = 0 +let stdinMode = "chat" +let stdinResolve: ((value: { input: string; mode: string }) => void) | null = null +let stdinPromptLen = 0 +let stdinPrevWrapLines = 1 + +function promptText(): string { + const color = chalk.hex(modeColors[stdinMode] ?? theme.cyan) + return `${chalk.hex(theme.cyan)("┃ [")}${color(modeDisplay[stdinMode] ?? stdinMode)}${chalk.hex(theme.cyan)("] ")}` +} - let input = "" - let cursor = 0 - let mode = modes.includes(currentMode) ? currentMode : "chat" +function getStdoutPromptLen(): number { + return stripAnsi(promptText()).length +} - function promptText(): string { - const color = chalk.hex(modeColors[mode] ?? theme.cyan) - return `${chalk.hex(theme.cyan)("┃ [")}${color(modeDisplay[mode] ?? mode)}${chalk.hex(theme.cyan)("] ")}` +function renderInput() { + const cols = process.stdout.columns || 80 + const promptLen = getStdoutPromptLen() + stdinPromptLen = promptLen + const totalChars = promptLen + stdinInput.length + const wrapLines = Math.max(1, Math.ceil(totalChars / cols)) + + for (let i = 0; i < stdinPrevWrapLines; i++) { + readline.cursorTo(process.stdout, 0) + readline.clearLine(process.stdout, 0) + if (i < stdinPrevWrapLines - 1) { + readline.moveCursor(process.stdout, 0, -1) } + } + readline.cursorTo(process.stdout, 0) + process.stdout.write(promptText() + stdinInput) + stdinPrevWrapLines = wrapLines - function getPromptLen(): number { - return stripAnsi(promptText()).length - } + const absPos = promptLen + stdinCursor + if (absPos !== promptLen + stdinInput.length) { + readline.cursorTo(process.stdout, absPos) + } +} - function render() { - readline.clearLine(process.stdout, 0) - readline.cursorTo(process.stdout, 0) - process.stdout.write(promptText() + input) - const absPos = getPromptLen() + cursor - if (absPos !== getPromptLen() + input.length) { - readline.cursorTo(process.stdout, absPos) - } - } +function stdinKeypress(_str: string, key: any) { + if (!key) return - render() + // If streaming, Escape cancels + if (key.name === "escape" && streamAbort) { + streamAbort.abort() + return + } - function cleanup() { - stdin.removeListener("keypress", onKeypress) - if (stdin.isTTY) { - stdin.setRawMode(wasRaw ?? false) - } - stdin.pause() - } + // No input handler active + if (!stdinResolve) return - function onKeypress(_str: string, key: any) { - if (!key) return + // Tab to cycle modes + if (key.name === "tab") { + const idx = modes.indexOf(stdinMode) + stdinMode = modes[(idx + 1) % modes.length]! + renderInput() + return + } - if (key.name === "tab") { - const idx = modes.indexOf(mode) - mode = modes[(idx + 1) % modes.length]! - render() - return - } + if (key.name === "return" || key.name === "enter") { + const resolve = stdinResolve + stdinResolve = null + resolve({ input: stdinInput, mode: stdinMode }) + return + } - if (key.name === "return" || key.name === "enter") { - cleanup() - resolve({ input, mode }) - return - } + if (key.name === "escape") { + stdinInput = "" + stdinCursor = 0 + renderInput() + return + } - if (key.name === "escape" || (key.ctrl && key.name === "c")) { - cleanup() - resolve({ input: null, mode }) - return - } + if (key.ctrl && key.name === "c") { + process.exit(0) + return + } - if (key.name === "backspace") { - if (cursor > 0) { - input = input.slice(0, cursor - 1) + input.slice(cursor) - cursor-- - render() - } - return - } + if (key.name === "backspace") { + if (stdinCursor > 0) { + stdinInput = stdinInput.slice(0, stdinCursor - 1) + stdinInput.slice(stdinCursor) + stdinCursor-- + renderInput() + } + return + } - if (key.name === "delete" || key.name === "del") { - if (cursor < input.length) { - input = input.slice(0, cursor) + input.slice(cursor + 1) - render() - } - return - } + if (key.name === "delete" || key.name === "del") { + if (stdinCursor < stdinInput.length) { + stdinInput = stdinInput.slice(0, stdinCursor) + stdinInput.slice(stdinCursor + 1) + renderInput() + } + return + } - if (key.name === "left") { - if (cursor > 0) { - cursor-- - readline.cursorTo(process.stdout, getPromptLen() + cursor) - } - return - } + if (key.name === "left") { + if (stdinCursor > 0) { + stdinCursor-- + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + } + return + } - if (key.name === "right") { - if (cursor < input.length) { - cursor++ - readline.cursorTo(process.stdout, getPromptLen() + cursor) - } - return - } + if (key.name === "right") { + if (stdinCursor < stdinInput.length) { + stdinCursor++ + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + } + return + } - if (key.name === "home") { - cursor = 0 - readline.cursorTo(process.stdout, getPromptLen()) - return - } + if (key.name === "home") { + stdinCursor = 0 + readline.cursorTo(process.stdout, stdinPromptLen) + return + } - if (key.name === "end") { - cursor = input.length - readline.cursorTo(process.stdout, getPromptLen() + cursor) - return - } + if (key.name === "end") { + stdinCursor = stdinInput.length + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + return + } - if (_str && _str.length === 1 && !key.ctrl && !key.meta) { - input = input.slice(0, cursor) + _str + input.slice(cursor) - cursor++ - readline.clearLine(process.stdout, 0) - readline.cursorTo(process.stdout, 0) - process.stdout.write(promptText() + input) - readline.cursorTo(process.stdout, getPromptLen() + cursor) - return - } - } + if (_str && _str.length === 1 && !key.ctrl && !key.meta) { + stdinInput = stdinInput.slice(0, stdinCursor) + _str + stdinInput.slice(stdinCursor) + stdinCursor++ + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 0) + process.stdout.write(promptText() + stdinInput) + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + return + } +} + +function setupStdin() { + const stdin = process.stdin + readline.emitKeypressEvents(stdin) + if (stdin.isTTY) { + stdin.setRawMode(true) + } + stdin.resume() + stdin.on("keypress", stdinKeypress) +} - stdin.on("keypress", onKeypress) +async function chatInput(currentMode: string): Promise<{ input: string; mode: string }> { + stdinMode = modes.includes(currentMode) ? currentMode : "chat" + stdinInput = "" + stdinCursor = 0 + stdinPrevWrapLines = 1 + renderInput() + return new Promise((resolve) => { + stdinResolve = resolve }) } export async function chatLoop( - provider: AIProvider, + initialProvider: AIProvider, conversation: Conversation, workspaceInfo?: WorkspaceInfo, ) { + setupStdin() + let messageCount = 0 let sessionTokens = 0 - const contextWindow = getContextWindow(provider.modelName) + let provider = initialProvider + let contextWindow = getContextWindow(provider.modelName) while (true) { const { input: userInput, mode } = await chatInput(conversation.mode) @@ -300,13 +342,14 @@ export async function chatLoop( if (mode !== conversation.mode) { conversation.mode = mode await updateConversationMode(conversation.id, mode) - } - if (userInput === null) { - console.log() - console.log(chalk.hex(theme.amber)(` ╰─ `) + chalk.hex(theme.muted)("session ended")) - console.log() - process.exit(0) + if ((mode === "tool" || mode === "agent") && provider.name !== "openrouter") { + const orProvider = createProvider("openrouter") + provider = orProvider + contextWindow = getContextWindow(provider.modelName) + console.log(` ${chalk.hex(theme.blue)("◆")} switched to ${chalk.hex(theme.cyan)(provider.modelName)} for ${mode} mode`) + console.log() + } } const trimmed = userInput.trim() @@ -319,6 +362,24 @@ export async function chatLoop( if (trimmed.length === 0) continue + if (isSlashCommand(trimmed)) { + const result = await handleSlashCommand(trimmed) + if (result?.type === "model_change") { + const newProvider = result.provider ? createProvider(result.provider, result.model) : null + if (newProvider) { + provider = newProvider + contextWindow = getContextWindow(provider.modelName) + const label = result.label || provider.modelName + console.log(` ${chalk.hex(theme.green)("◆")} switched to ${chalk.hex(theme.cyan)(label)}`) + console.log() + } + } else if (result?.type === "unknown") { + console.log(` ${chalk.hex(theme.red)("◆")} unknown slash command: ${trimmed.split(" ")[0]}`) + console.log() + } + continue + } + userMessage(trimmed) messageCount++ @@ -327,6 +388,16 @@ export async function chatLoop( try { const result = await streamAIResponse(provider, conversation.id, conversation.mode, workspaceInfo) + + if (result.aborted) { + if (result.content && result.content !== "(cancelled)") { + await addMessage(conversation.id, "assistant", result.content) + } + console.log(` ${chalk.hex(theme.amber)("◆")} cancelled`) + console.log() + continue + } + await addMessage(conversation.id, "assistant", result.content) const responseTokens = result.usage?.totalTokens ?? 0 @@ -351,7 +422,7 @@ export async function chatLoop( } export async function startChat( - provider: ModelProvider = "google", + provider: ModelProvider = "openrouter", model?: string, conversationId?: string | null, workspaceInfo?: WorkspaceInfo, @@ -381,7 +452,12 @@ export async function startChat( const user = await getUserFromToken() const conversation = await initConversation(user.id, conversationId, initialMode) - await chatLoop(aiProvider, conversation, workspaceInfo) + + const activeProvider = (initialMode === "tool" || initialMode === "agent") && provider !== "openrouter" + ? createProvider("openrouter") + : aiProvider + + await chatLoop(activeProvider, conversation, workspaceInfo) } catch (error: any) { console.log() console.log(` ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(error?.message ?? "Error")}`) diff --git a/apps/supercode-cli/server/src/cli/ai/google-service.ts b/apps/supercode-cli/server/src/cli/ai/google-service.ts index 9beceb4..405dc51 100644 --- a/apps/supercode-cli/server/src/cli/ai/google-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/google-service.ts @@ -5,17 +5,20 @@ import chalk from "chalk"; export class AIService { model: ReturnType> + readonly modelName: string - constructor() { + constructor(modelName?: string) { if (!config.googleApiKey) { throw new Error("Google Gemini is not configured.\n\n Set GOOGLE_GENERATIVE_AI_API_KEY in your environment:\n export GOOGLE_GENERATIVE_AI_API_KEY=\n\n Get a key at: https://aistudio.google.com/apikey"); } + this.modelName = modelName || config.model + const google = createGoogleGenerativeAI({ apiKey: config.googleApiKey, }) - this.model = google(config.model) + this.model = google(this.modelName) } async generateStructured( @@ -43,6 +46,7 @@ export class AIService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ) { try { const systemMessages = messages.filter(m => m.role === "system") @@ -52,6 +56,7 @@ export class AIService { const streamOptions: any = { model: this.model, messages: nonSystemMessages, + abortSignal: signal, } if (system) { @@ -60,7 +65,7 @@ export class AIService { if (tools && Object.keys(tools).length > 0) { streamOptions.tools = tools - streamOptions.maxSteps = 5 // allow limit tool calling + streamOptions.maxSteps = 25 if (onToolCall) { streamOptions.experimental_onToolCallStart = (event: any) => { const tc = event.toolCall @@ -110,7 +115,8 @@ export class AIService { toolResults, step: fullResult.steps } - } catch (error) { + } catch (error: any) { + if (error?.name === "AbortError") throw error console.error(chalk.red("AI Service Error:"), error instanceof Error ? error.message : String(error)) throw error } diff --git a/apps/supercode-cli/server/src/cli/ai/minimax-service.ts b/apps/supercode-cli/server/src/cli/ai/minimax-service.ts index cd8590c..7de81a6 100644 --- a/apps/supercode-cli/server/src/cli/ai/minimax-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/minimax-service.ts @@ -32,6 +32,7 @@ export class MinimaxService { const streamOptions: any = { model: this.model, messages: nonSystemMessages, + maxTokens: Number(process.env.MINIMAX_MAX_TOKENS) || 4096, } if (system) { @@ -40,7 +41,7 @@ export class MinimaxService { if (tools && Object.keys(tools).length > 0) { streamOptions.tools = tools - streamOptions.maxSteps = 5 + streamOptions.maxSteps = 25 if (onToolCall) { streamOptions.experimental_onToolCallStart = (event: any) => { const tc = event.toolCall @@ -64,7 +65,17 @@ export class MinimaxService { usage: result.usage, } } catch (error) { - console.error(chalk.red("MiniMax Service Error:"), error instanceof Error ? error.message : String(error)) + const message = error instanceof Error ? error.message : String(error) + if (message.includes("insufficient balance") || message.includes("402") || message.includes("1008")) { + console.error(chalk.red("MiniMax API Error:"), "Insufficient balance. Top up at https://platform.minimax.ai") + throw new Error( + "MiniMax API: insufficient balance (402).\n\n" + + " Your MiniMax account has insufficient credits.\n" + + " Top up at: https://platform.minimax.ai\n" + + " Or switch to a different provider." + ) + } + console.error(chalk.red("MiniMax Service Error:"), message) throw error } } diff --git a/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts b/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts index 2a7769d..d5fb902 100644 --- a/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts @@ -22,6 +22,7 @@ export class NvidiaService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ) { try { const bodyObj: any = { @@ -46,6 +47,7 @@ export class NvidiaService { "Content-Type": "application/json", }, body: JSON.stringify(bodyObj), + signal, }) if (!response.ok) { @@ -139,7 +141,8 @@ export class NvidiaService { finishResponse: Promise.resolve(finishReason), usage: Promise.resolve(usage), } - } catch (error) { + } catch (error: any) { + if (error?.name === "AbortError") throw error console.error(chalk.red("NVIDIA Service Error:"), error instanceof Error ? error.message : String(error)) throw error } diff --git a/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts b/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts index e87f95b..cbf1b33 100644 --- a/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts @@ -1,11 +1,27 @@ -import { createOpenRouter } from "@openrouter/ai-sdk-provider" -import { streamText, type ModelMessage } from "ai" +import { type ModelMessage } from "ai" import { openRouterConfig } from "../../config/openrouter.config.ts" import chalk from "chalk" +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions" + +function isServerTool(name: string): boolean { + return name === "web_search" || name === "url_fetch" +} + +function serverTool(name: string): any { + if (name === "web_search") return { type: "openrouter:web_search" } + if (name === "url_fetch") return { type: "openrouter:web_fetch" } + return null +} + +function zodToJsonSchema(schema: any): any { + return typeof schema === "object" && "toJSON" in (schema as any) + ? (schema as any).toJSON() + : schema +} + export class OpenRouterService { readonly modelName: string - model: any constructor(model?: string) { if (!openRouterConfig.apiKey) { @@ -13,12 +29,6 @@ export class OpenRouterService { } this.modelName = model || openRouterConfig.model - - const openrouter = createOpenRouter({ - apiKey: openRouterConfig.apiKey, - }) - - this.model = openrouter(this.modelName) } async sendMessage( @@ -26,64 +36,180 @@ export class OpenRouterService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ) { - try { - const systemMessages = messages.filter(m => m.role === "system") - const nonSystemMessages = messages.filter(m => m.role !== "system") - const system = systemMessages.map(m => m.content).join("\n") - - const streamOptions: any = { - model: this.model, - messages: nonSystemMessages, + const systemMessages = messages.filter(m => m.role === "system") + const nonSystemMessages = messages.filter(m => m.role !== "system") + const system = systemMessages.map(m => m.content).join("\n") + + const apiMessages: any[] = [] + if (system) apiMessages.push({ role: "system", content: system }) + for (const m of nonSystemMessages) { + if (m.role === "assistant" && (m as any).tool_calls) { + const msg: any = { role: "assistant", content: m.content } + msg.tool_calls = (m as any).tool_calls + apiMessages.push(msg) + } else { + apiMessages.push({ role: m.role, content: m.content as string }) } + } + + const apiTools: any[] = [] + const functionTools: Record = {} + if (tools) { + for (const [name, def] of Object.entries(tools as Record)) { + if (isServerTool(name)) { + apiTools.push(serverTool(name)) + } else { + apiTools.push({ + type: "function", + function: { + name, + description: def.description || "", + parameters: zodToJsonSchema(def.parameters), + }, + }) + functionTools[name] = def + } + } + } + const allMessages = [...apiMessages] + const maxToolIterations = 25 + let fullResponse = "" + + for (let iter = 0; iter < maxToolIterations; iter++) { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") + if (fullResponse) onChunk?.("\n\n") + + const body: any = { + model: this.modelName, + messages: allMessages, + stream: true, + } + if (apiTools.length > 0) body.tools = apiTools if (this.modelName.includes("minimax-m3") || this.modelName.includes("glm-5.1")) { - streamOptions.maxOutputTokens = 8192 + body.max_tokens = 8192 } - if (system) { - streamOptions.system = system + const res = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${openRouterConfig.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal, + }) + + if (!res.ok) { + const errText = await res.text().catch(() => "unknown error") + throw new Error(`OpenRouter API ${res.status}: ${errText.slice(0, 500)}`) } - if (tools && Object.keys(tools).length > 0) { - streamOptions.tools = tools - streamOptions.maxSteps = 5 - if (onToolCall) { - streamOptions.experimental_onToolCallStart = (event: any) => { - const tc = event.toolCall - onToolCall({ toolName: tc.toolName, args: tc.input as Record }) - } + const reader = res.body?.getReader() + if (!reader) throw new Error("No response body") + + const decoder = new TextDecoder() + let buffer = "" + let toolCalls: Array<{ id: string; type: string; function: { name: string; arguments: string } }> = [] + let finishReason = "" + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith("data: ")) continue + const jsonStr = trimmed.slice(6) + if (jsonStr === "[DONE]") continue + + try { + const data = JSON.parse(jsonStr) + const delta = data.choices?.[0]?.delta + const finish = data.choices?.[0]?.finish_reason + + if (finish) finishReason = finish + + if (delta?.content) { + fullResponse += delta.content + onChunk?.(delta.content) + } + + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = toolCalls.find(t => t.id === tc.id) + if (existing) { + if (tc.function?.arguments) existing.function.arguments += tc.function.arguments + } else { + toolCalls.push({ + id: tc.id, + type: tc.type || "function", + function: { + name: tc.function?.name || "", + arguments: tc.function?.arguments || "", + }, + }) + } + } + } + } catch { /* skip malformed */ } } } - const result = streamText(streamOptions) + if (finishReason === "tool_calls" && toolCalls.length > 0) { + const assistantMsg: any = { role: "assistant", content: null } + assistantMsg.tool_calls = toolCalls.map(tc => ({ + id: tc.id, + type: tc.type, + function: { name: tc.function.name, arguments: tc.function.arguments }, + })) + allMessages.push(assistantMsg) - let fullResponse = "" + for (const tc of toolCalls) { + const toolName = tc.function.name + const toolDef = functionTools[toolName] + let toolResult: string - for await (const chunk of result.textStream) { - fullResponse += chunk - onChunk?.(chunk) - } + if (toolDef?.execute) { + let args: any = {} + try { args = JSON.parse(tc.function.arguments || "{}") } catch { /* */ } + onToolCall?.({ toolName, args }) + try { toolResult = await toolDef.execute(args) } catch (err: any) { toolResult = `Error: ${err.message || String(err)}` } + } else { + toolResult = `Tool "${toolName}" is not available locally` + } - return { - content: fullResponse, - finishResponse: result.finishReason, - usage: result.usage, - } - } catch (error) { - console.error(chalk.red("OpenRouter Service Error:"), error instanceof Error ? error.message : String(error)) - if (error instanceof Error && "cause" in error) { - console.error(chalk.red(" Cause:"), String((error as any).cause)) + allMessages.push({ + role: "tool", + tool_call_id: tc.id, + content: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult), + }) + } + + toolCalls = [] + finishReason = "" + continue } - throw error + + break + } + + return { + content: fullResponse, + finishResponse: Promise.resolve("stop" as any), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 } as any), } } async getMessage(messages: ModelMessage[], tools?: any) { let fullResponse = "" - await this.sendMessage(messages, (chunk) => { - fullResponse += chunk - }) + await this.sendMessage(messages, (chunk) => { fullResponse += chunk }, tools) return fullResponse } } diff --git a/apps/supercode-cli/server/src/cli/ai/provider.ts b/apps/supercode-cli/server/src/cli/ai/provider.ts index 697c91e..62ac0b3 100644 --- a/apps/supercode-cli/server/src/cli/ai/provider.ts +++ b/apps/supercode-cli/server/src/cli/ai/provider.ts @@ -20,6 +20,7 @@ export interface AIProvider { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ): Promise<{ content: string finishResponse: PromiseLike @@ -50,28 +51,19 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi return { name: provider, modelName: model || meta.defaultModel, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), generateObject: (schema, prompt) => svc.generateObject(schema, prompt), } } switch (provider) { case "google": { - const svc = new AIService() + const svc = new AIService(model) return { name: "google", - modelName: "gemini-2.5-flash", - model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), - } - } - case "minimax": { - const svc = new MinimaxService() - return { - name: "minimax", - modelName: "MiniMax-M2", + modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), } } case "openrouter": { @@ -79,8 +71,8 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi return { name: "openrouter", modelName: svc.modelName, - model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + model: null, + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), } } case "nvidia": { @@ -89,8 +81,11 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi name: "nvidia", modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), } } + default: { + throw new Error(`Provider "${provider}" is paused or unavailable`) + } } } diff --git a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts index f40e9d5..dc378c4 100644 --- a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts @@ -17,6 +17,7 @@ export class ServerProxyService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: (call: { toolName: string; args: Record }) => void, + signal?: AbortSignal, ) { const token = await getStoredToken() if (!token?.access_token) { @@ -35,6 +36,7 @@ export class ServerProxyService { model: this.modelName, tools, }), + signal, }) if (!res.ok) { diff --git a/apps/supercode-cli/server/src/cli/commands/ai/init.ts b/apps/supercode-cli/server/src/cli/commands/ai/init.ts index 6097211..74b3bb1 100644 --- a/apps/supercode-cli/server/src/cli/commands/ai/init.ts +++ b/apps/supercode-cli/server/src/cli/commands/ai/init.ts @@ -67,7 +67,7 @@ export const wakeUpAction = async () => { options: [ // { value: "server", label: "Supercloud", hint: "server-hosted · no API key needed (Recommended)" }, { value: "google", label: "Gemini 2.5 Flash", hint: "free · fast" }, - { value: "minimax", label: "MiniMax M2", hint: "reasoning · powerful" }, + // { value: "minimax", label: "MiniMax M2", hint: "reasoning · powerful" }, { value: "openrouter", label: "OpenRouter", hint: "multi-provider · bring your own key" }, { value: "nvidia", label: "NVIDIA NIM", hint: "free API" }, ], diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts new file mode 100644 index 0000000..75f0e55 --- /dev/null +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts @@ -0,0 +1,38 @@ +import { pickModel, formatModelChange } from "./model.ts" +import type { ModelProvider } from "src/cli/ai/provider.ts" + +export interface SlashCommandResult { + type: "model_change" | "unknown" + provider?: ModelProvider + model?: string + label?: string +} + +const handlers: Record Promise> = { + model: async () => { + const result = await pickModel() + return { + type: "model_change", + provider: result.provider, + model: result.model, + label: formatModelChange(result.provider, result.model), + } + }, +} + +export async function handleSlashCommand(input: string): Promise { + const match = input.match(/^\/(\w+)\s*(.*)$/) + if (!match) return null + + const [, cmd = "", args = ""] = match + + + const handler = handlers[cmd.toLowerCase()] + if (!handler) return { type: "unknown" } + + return handler(args.trim()) +} + +export function isSlashCommand(input: string): boolean { + return /^\//.test(input.trim()) +} diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts new file mode 100644 index 0000000..609f298 --- /dev/null +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts @@ -0,0 +1,79 @@ +import { select, isCancel } from "@clack/prompts" +import chalk from "chalk" +import { theme } from "src/cli/utils/tui.ts" +import { createProvider, type ModelProvider } from "src/cli/ai/provider.ts" + +const openRouterModels = { + "openai/gpt-oss-120b:free": "GPT OSS 120B (free)", + "deepseek/deepseek-v4-flash": "DeepSeek V4 Flash", + "minimax/minimax-m3": "MiniMax M3", + "z-ai/glm-5.1": "GLM 5.1", + "moonshotai/kimi-k2.6:free": "Kimi K2.6 (free)", +} as const + +const nvidiaModels = { + "minimaxai/minimax-m2.7": "MiniMax M2.7", + "deepseek-ai/deepseek-v4-flash": "DeepSeek V4 Flash", + "meta/llama-3.3-70b-instruct": "Llama 3.3 70B", +} as const + +const googleModels = { + "gemini-2.5-flash": "Gemini 2.5 Flash", + "gemini-2.5-pro": "Gemini 2.5 Pro", +} as const + +function defaultModel(provider: ModelProvider): string | undefined { + if (provider === "google") return "gemini-2.5-flash" + if (provider === "openrouter") return "openai/gpt-oss-120b:free" + if (provider === "nvidia") return "minimaxai/minimax-m2.7" + return undefined +} + +export async function pickModel(): Promise<{ provider: ModelProvider; model?: string }> { + const providerChoice = await select({ + message: chalk.hex(theme.cyan)("switch model"), + options: [ + { value: "google", label: "Google Gemini", hint: "default" }, + { value: "openrouter", label: "OpenRouter", hint: "multi-provider" }, + { value: "nvidia", label: "NVIDIA NIM", hint: "free tier" }, + ], + }) + + if (isCancel(providerChoice)) { + return { provider: "google", model: "gemini-2.5-flash" } + } + + if (providerChoice === "google") { + const model = await select({ + message: chalk.hex(theme.cyan)("select Gemini model"), + options: Object.entries(googleModels).map(([value, label]) => ({ value, label })), + }) + if (isCancel(model)) return { provider: "google", model: defaultModel("google") } + return { provider: "google", model: model as string } + } + + if (providerChoice === "openrouter") { + const model = await select({ + message: chalk.hex(theme.cyan)("select OpenRouter model"), + options: Object.entries(openRouterModels).map(([value, label]) => ({ value, label })), + }) + if (isCancel(model)) return { provider: "openrouter", model: defaultModel("openrouter") } + return { provider: "openrouter", model: model as string } + } + + if (providerChoice === "nvidia") { + const model = await select({ + message: chalk.hex(theme.cyan)("select NVIDIA NIM model"), + options: Object.entries(nvidiaModels).map(([value, label]) => ({ value, label })), + }) + if (isCancel(model)) return { provider: "nvidia", model: defaultModel("nvidia") } + return { provider: "nvidia", model: model as string } + } + + return { provider: providerChoice as ModelProvider } +} + +export function formatModelChange(p: ModelProvider, m?: string): string { + const label = p === "google" ? "Gemini" : p === "nvidia" ? "NVIDIA" : "OpenRouter" + return `${label}${m ? ` · ${m}` : ""}` +} diff --git a/apps/supercode-cli/server/src/cli/utils/tui.ts b/apps/supercode-cli/server/src/cli/utils/tui.ts index 40ce364..5716be4 100644 --- a/apps/supercode-cli/server/src/cli/utils/tui.ts +++ b/apps/supercode-cli/server/src/cli/utils/tui.ts @@ -525,7 +525,7 @@ export function sessionSummary(conversation: { id: string; title: string | null; export function chatHelp() { const lines = [ ` ${chalk.hex(theme.cyan)("Enter")} send message`, - ` ${chalk.hex(theme.cyan)("Esc")} cancel / exit`, + ` ${chalk.hex(theme.cyan)("Esc")} clear input / cancel response`, ` ${chalk.hex(theme.cyan)("↑/↓")} navigate history`, ] return panel(lines.join("\n"), { title: "keys", borderColor: theme.dim }) diff --git a/apps/supercode-cli/server/src/cli/workspace/context.ts b/apps/supercode-cli/server/src/cli/workspace/context.ts index 2b057b3..a3401c9 100644 --- a/apps/supercode-cli/server/src/cli/workspace/context.ts +++ b/apps/supercode-cli/server/src/cli/workspace/context.ts @@ -3,8 +3,25 @@ import type { WorkspaceInfo } from "./scanner.ts" export function buildSystemPrompt(info: WorkspaceInfo): string { const lines: string[] = [] - lines.push("You are Supercode, an AI coding assistant running in the user's terminal.") - lines.push("You have full awareness of the user's current workspace and project structure.") + lines.push("You are a senior software engineer running in the user's terminal. You work") + lines.push("autonomously to complete software engineering tasks.") + lines.push("") + lines.push("## Core Principles") + lines.push("") + lines.push("1. **Do, don't suggest.** When the user asks you to create an app, fix a bug,") + lines.push(" or add a feature — just do it. Use your tools to read, write, and execute.") + lines.push("") + lines.push("2. **Explain as you work.** Tell the user what you're doing and why.") + lines.push(" \"I need to check the existing code first\" -> read_file.") + lines.push(" \"I'll create the component now\" -> write_file.") + lines.push(" \"Let me install dependencies\" -> run_command.") + lines.push("") + lines.push("3. **Multi-step workflows are normal.** Plans often require 10+ steps:") + lines.push(" read -> search -> write -> run -> write -> run. Execute the full workflow") + lines.push(" without stopping to ask \"should I continue?\"") + lines.push("") + lines.push("4. **Handle errors gracefully.** If a command fails, diagnose and fix it.") + lines.push(" Do not hand the problem back to the user.") lines.push("") lines.push(`## Workspace: ${info.projectName || info.dirName}`) lines.push(`- Path: ${info.fullPath}`) @@ -33,22 +50,47 @@ export function buildSystemPrompt(info: WorkspaceInfo): string { lines.push("## Available Tools") lines.push("") - lines.push("You have access to the following tools to explore and modify the workspace:") + lines.push("You have full access to create, modify, and delete files in the workspace.") + lines.push("You can run shell commands to install packages, run builds, and start dev servers.") + lines.push("Do not ask the user for permission for routine operations — just execute them.") lines.push("") - lines.push("1. `read_file(path, maxLines?)` — Read the contents of any file in the workspace.") - lines.push(" Use this to examine source code, configs, or any file the user asks about.") + lines.push("1. `read_file(path, maxLines?)` — Read file contents from the workspace.") + lines.push(" Use this to examine source code, configs, or any file.") lines.push("") lines.push("2. `search_files(pattern, include?, maxResults?)` — Search for text patterns") - lines.push(" across workspace files. Use this to find relevant code, function definitions,") - lines.push(" imports, or any text in the codebase.") + lines.push(" across workspace files. Use this to find relevant code or definitions.") + lines.push("") + lines.push("3. `write_file(path, content, description?)` — Create or overwrite files.") + lines.push(" Use for: new components, fixing bugs, adding features, config changes.") + lines.push("") + lines.push("4. `run_command(command, description?, timeout?)` — Execute shell commands.") + lines.push(" Use for: npm install, npm run build, git operations, running tests.") + lines.push("") + lines.push("5. Fetch content from URLs for reference using the built-in web fetch tool.") + lines.push("") + lines.push("6. Search the web for current information using the built-in web search tool.") + lines.push("") + lines.push("7. `code_exec(code)` — Run JavaScript/TypeScript in a sandbox for calculations.") + lines.push("") + lines.push("## Example Workflows") + lines.push("") + lines.push("### Creating a new app:") + lines.push(" run_command(\"npm create vite@latest . -- --template react\")") + lines.push(" run_command(\"npm install\")") + lines.push(" write_file(\"src/App.jsx\", ...)") + lines.push(" write_file(\"src/components/NoteCard.jsx\", ...)") + lines.push(" run_command(\"npm run dev\")") + lines.push("") + lines.push("### Fixing a bug:") + lines.push(" read_file(\"src/components/BuggyComponent.tsx\")") + lines.push(" search_files(\"relatedFunction\", \"*.ts\")") + lines.push(" write_file(\"src/components/BuggyComponent.tsx\", ...)") + lines.push(" run_command(\"npm run test\")") lines.push("") - lines.push("## Guidelines") + lines.push("## Working Directory") lines.push("") - lines.push("- When the user asks about code, use read_file or search_files to investigate.") - lines.push("- When suggesting changes, reference specific file paths and line numbers.") - lines.push("- If you need more context, use the tools to explore the codebase.") lines.push("- The workspace root is the base for all relative file paths.") - lines.push("- Answer questions about the codebase accurately based on what you find.") + lines.push("- All commands execute in the workspace root unless cwd is specified.") return lines.join("\n") } diff --git a/apps/supercode-cli/server/src/config/tools.config.ts b/apps/supercode-cli/server/src/config/tools.config.ts index d7cb0f1..bb5e1e7 100644 --- a/apps/supercode-cli/server/src/config/tools.config.ts +++ b/apps/supercode-cli/server/src/config/tools.config.ts @@ -34,6 +34,22 @@ export const availableTools: ToolConfig[] = [ getTool: () => registryTools.url_fetch as unknown as Record, enabled: false, }, + { + id: "write_file", + name: "Write File", + description: + "Create and modify files in the workspace", + getTool: () => registryTools.write_file as unknown as Record, + enabled: true, + }, + { + id: "run_command", + name: "Run Command", + description: + "Execute shell commands in the workspace", + getTool: () => registryTools.run_command as unknown as Record, + enabled: true, + }, ] function tryGetConfigTools( diff --git a/apps/supercode-cli/server/src/index.ts b/apps/supercode-cli/server/src/index.ts index 7bde636..be2305c 100644 --- a/apps/supercode-cli/server/src/index.ts +++ b/apps/supercode-cli/server/src/index.ts @@ -209,20 +209,135 @@ app.post("/api/ai/chat", async (req, res) => { const apiKey = process.env.OPENROUTER_API_KEY if (!apiKey) { res.status(500).json({ error: "OpenRouter not configured on server" }); return } const modelName = modelParam || process.env.OPENROUTER_MODEL || "openai/gpt-oss-120b:free" - const { createOpenRouter } = await import("@openrouter/ai-sdk-provider") - const { streamText } = await import("ai") - const openrouter = createOpenRouter({ apiKey }) - const opts: any = { model: openrouter(modelName), messages: nonSystemMessages } - if (modelName.includes("minimax-m3") || modelName.includes("glm-5.1")) opts.maxOutputTokens = 8192 - if (system) opts.system = system - if (tools) { opts.tools = tools; opts.maxSteps = 5 } - const result = streamText(opts) - for await (const chunk of result.textStream) { - res.write(JSON.stringify({ type: "text", content: chunk }) + "\n") + + const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions" + + const apiMessages: any[] = [] + if (system) apiMessages.push({ role: "system", content: system }) + for (const m of nonSystemMessages) { + apiMessages.push({ role: m.role, content: m.content as string }) + } + + const apiTools: any[] = [] + if (tools) { + for (const key of Object.keys(tools)) { + if (key === "web_search") { + apiTools.push({ type: "openrouter:web_search" }) + } else if (key === "url_fetch") { + apiTools.push({ type: "openrouter:web_fetch" }) + } else { + const def = (tools as any)[key] + const params = def.parameters + ? (typeof def.parameters === "object" && "toJSON" in (def.parameters as any) + ? (def.parameters as any).toJSON() + : def.parameters) + : undefined + apiTools.push({ + type: "function", + function: { name: key, description: def.description || "", parameters: params }, + }) + } + } + } + + const allMessages = [...apiMessages] + const maxIter = 10 + + for (let iter = 0; iter < maxIter; iter++) { + const body: any = { model: modelName, messages: allMessages, stream: true } + if (apiTools.length > 0) body.tools = apiTools + if (modelName.includes("minimax-m3") || modelName.includes("glm-5.1")) body.max_tokens = 8192 + + const orRes = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + if (!orRes.ok) { + const errText = await orRes.text().catch(() => "unknown error") + res.status(orRes.status).json({ error: `OpenRouter API ${orRes.status}: ${errText.slice(0, 500)}` }) + return + } + + const reader = orRes.body?.getReader() + if (!reader) { res.status(500).json({ error: "No response body" }); return } + + const decoder = new TextDecoder() + let buffer = "" + let toolCalls: Array<{ id: string; type: string; function: { name: string; arguments: string } }> = [] + let finishReason = "" + let hasContent = false + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith("data: ")) continue + const jsonStr = trimmed.slice(6) + if (jsonStr === "[DONE]") continue + try { + const data = JSON.parse(jsonStr) + const delta = data.choices?.[0]?.delta + const finish = data.choices?.[0]?.finish_reason + if (finish) finishReason = finish + if (delta?.content) { + hasContent = true + res.write(JSON.stringify({ type: "text", content: delta.content }) + "\n") + } + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = toolCalls.find(t => t.id === tc.id) + if (existing) { + if (tc.function?.arguments) existing.function.arguments += tc.function.arguments + } else { + toolCalls.push({ + id: tc.id, + type: tc.type || "function", + function: { name: tc.function?.name || "", arguments: tc.function?.arguments || "" }, + }) + } + } + } + } catch { /* skip */ } + } + } + + if (finishReason === "tool_calls" && toolCalls.length > 0) { + const assistantMsg: any = { role: "assistant", content: null } + assistantMsg.tool_calls = toolCalls.map(tc => ({ + id: tc.id, type: tc.type, + function: { name: tc.function.name, arguments: tc.function.arguments }, + })) + allMessages.push(assistantMsg) + + for (const tc of toolCalls) { + const toolName = tc.function.name + const toolDef = (tools as any)?.[toolName] + const resultStr = toolDef + ? `Tool "${toolName}" requires client-side execution` : `Tool "${toolName}" is not available` + allMessages.push({ role: "tool", tool_call_id: tc.id, content: resultStr }) + } + + toolCalls = [] + finishReason = "" + continue + } + + if (!hasContent) { + res.write(JSON.stringify({ type: "text", content: "" }) + "\n") + } + res.write(JSON.stringify({ + type: "finish", reason: "stop", + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }) + "\n") + res.end() + break } - const usage = await result.usage - res.write(JSON.stringify({ type: "finish", reason: await result.finishReason, usage }) + "\n") - res.end() break } case "minimax": { @@ -232,7 +347,11 @@ app.post("/api/ai/chat", async (req, res) => { const { createMinimax } = await import("vercel-minimax-ai-provider") const { streamText } = await import("ai") const minimax = createMinimax({ apiKey }) - const opts: any = { model: minimax(modelName), messages: nonSystemMessages } + const opts: any = { + model: minimax(modelName), + messages: nonSystemMessages, + maxTokens: Number(process.env.MINIMAX_MAX_TOKENS) || 4096, + } if (system) opts.system = system if (tools) { opts.tools = tools; opts.maxSteps = 5 } const result = streamText(opts) @@ -312,7 +431,12 @@ app.post("/api/ai/chat", async (req, res) => { } } } catch (error) { - res.status(500).json({ error: String(error) }) + const msg = String(error) + if (msg.includes("insufficient balance") || msg.includes("402")) { + res.status(402).json({ error: "MiniMax API: insufficient balance. Top up at https://platform.minimax.ai" }) + } else { + res.status(500).json({ error: msg }) + } } }) @@ -368,7 +492,12 @@ app.post("/api/ai/generate-object", async (req, res) => { } } } catch (error) { - res.status(500).json({ error: String(error) }) + const msg = String(error) + if (msg.includes("insufficient balance") || msg.includes("402")) { + res.status(402).json({ error: "MiniMax API: insufficient balance. Top up at https://platform.minimax.ai" }) + } else { + res.status(500).json({ error: msg }) + } } }) diff --git a/apps/supercode-cli/server/src/tools/definitions/run-command.ts b/apps/supercode-cli/server/src/tools/definitions/run-command.ts new file mode 100644 index 0000000..dd839d2 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/definitions/run-command.ts @@ -0,0 +1,71 @@ +import { z } from "zod" +import { exec } from "node:child_process" +import path from "node:path" + +const runCommandSchema = z.object({ + command: z.string().describe("Shell command to execute (e.g. 'npm install', 'npm run build', 'git status')"), + description: z + .string() + .optional() + .describe("Purpose of this command (for display in permission prompt)"), + timeout: z + .number() + .optional() + .default(120_000) + .describe("Timeout in milliseconds (default: 120000)"), + cwd: z + .string() + .optional() + .describe("Working directory relative to workspace root (defaults to workspace root)"), +}) + +export type RunCommandArgs = z.infer + +export const runCommandTool = { + description: + "Execute a shell command in the workspace. Use this to install dependencies, run builds, start dev servers, run tests, or any other terminal operation.", + parameters: runCommandSchema, + execute: async ({ command, timeout, cwd: subdir }: RunCommandArgs) => { + const workspaceRoot = process.env.SUPERCODE_WORKSPACE_ROOT || process.cwd() + + let resolvedCwd = workspaceRoot + if (subdir) { + resolvedCwd = path.resolve(workspaceRoot, subdir) + if (!resolvedCwd.startsWith(workspaceRoot)) { + throw new Error(`Working directory "${subdir}" is outside workspace root`) + } + } + + return new Promise((resolve) => { + const child = exec( + command, + { + cwd: resolvedCwd, + encoding: "utf-8", + timeout, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" }, + }, + (error, stdout, stderr) => { + const result: Record = { + exitCode: error?.code ?? 0, + stdout: stdout || "", + stderr: stderr || "", + } + + if (error && error.killed) { + result.signal = error.signal || "SIGTERM" + result.stderr = (result.stderr as string) + `\nCommand timed out after ${timeout}ms` + } + + if (error && error.code === undefined && !error.killed) { + result.exitCode = -1 + result.stderr = (result.stderr as string) + `\n${error.message}` + } + + resolve(JSON.stringify(result)) + }, + ) + }) + }, +} diff --git a/apps/supercode-cli/server/src/tools/definitions/write-file.ts b/apps/supercode-cli/server/src/tools/definitions/write-file.ts new file mode 100644 index 0000000..aee3461 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/definitions/write-file.ts @@ -0,0 +1,52 @@ +import { z } from "zod" +import path from "node:path" +import { mkdir, writeFile } from "node:fs/promises" + +const MAX_FILE_SIZE = 1_000_000 + +const writeFileSchema = z.object({ + path: z.string().describe("Relative path from workspace root (e.g. 'src/App.jsx', 'package.json')"), + content: z.string().describe("Complete file content to write"), + description: z + .string() + .optional() + .describe("Brief description of what this file does (for display)"), +}) + +export type WriteFileArgs = z.infer + +export const writeFileTool = { + description: + "Create a new file or overwrite an existing file in the workspace. Use this to create components, add features, fix bugs, or modify configuration files.", + parameters: writeFileSchema, + execute: async ({ path: filePath, content }: WriteFileArgs) => { + const workspaceRoot = process.env.SUPERCODE_WORKSPACE_ROOT || process.cwd() + const fullPath = path.resolve(workspaceRoot, filePath) + + if (!fullPath.startsWith(workspaceRoot)) { + throw new Error(`Path "${filePath}" is outside workspace root`) + } + + if (content.length > MAX_FILE_SIZE) { + throw new Error(`File "${filePath}" exceeds maximum size of 1MB`) + } + + if (content.includes("\0")) { + throw new Error(`File "${filePath}" contains binary content and cannot be written`) + } + + const fileDir = path.dirname(fullPath) + await mkdir(fileDir, { recursive: true }) + + await writeFile(fullPath, content, "utf-8") + + const existingSize = content.length + const action = "created" + + return JSON.stringify({ + path: filePath, + size: existingSize, + action, + }) + }, +} diff --git a/apps/supercode-cli/server/src/tools/permission-manager.ts b/apps/supercode-cli/server/src/tools/permission-manager.ts new file mode 100644 index 0000000..5e40141 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/permission-manager.ts @@ -0,0 +1,203 @@ +import chalk from "chalk" +import * as readline from "readline" +import boxen from "boxen" +import { theme } from "src/cli/utils/tui.ts" + +export type PermissionAction = "allow" | "ask" | "deny" + +interface PermissionRule { + action: PermissionAction + rememberAlways: boolean +} + +const DANGEROUS_PATTERNS: RegExp[] = [ + /rm\s+-rf/, + /\bDROP\s+TABLE\b/i, + /\bDROP\s+DATABASE\b/i, + /git\s+push\s+.*--force/, + /git\s+push\s+.*-f\b/, + /chmod\s+-R\s*777/, + /\bsudo\b/, + /\bdd\s+if=/, + />\s*\/dev\/sd/, + /mkfs\.\w+/, + /:()\s*\{.*:\s*\}.*:/, + /curl\s+.*\|\s*bash/, + /wget\s+.*\|\s*bash/, + /\\x[0-9a-fA-F]{2}.*;.*;.*;/, + /pkill\s+-9/, + /killall\s+/, + /shutdown\s+now/, + /reboot\b/, + /init\s+0/, + /init\s+6/, +] + +const DEFAULT_RULES: Record = { + read_file: { action: "allow", rememberAlways: false }, + search_files: { action: "allow", rememberAlways: false }, + url_fetch: { action: "allow", rememberAlways: false }, + web_search: { action: "allow", rememberAlways: false }, + code_exec: { action: "ask", rememberAlways: true }, + write_file: { action: "ask", rememberAlways: true }, + run_command: { action: "ask", rememberAlways: true }, +} + +export class PermissionManager { + private rules: Map + private alwaysCache: Map> = new Map() + private sessionLevel: "allow" | "ask" | "deny" | null = null + + constructor() { + this.rules = new Map(Object.entries(DEFAULT_RULES)) + } + + setSessionLevel(level: "allow" | "ask" | "deny"): void { + this.sessionLevel = level + } + + getSessionLevel(): "allow" | "ask" | "deny" | null { + return this.sessionLevel + } + + isDangerousCommand(command: string): boolean { + return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command)) + } + + async check(toolName: string, args: Record): Promise { + if (this.sessionLevel === "allow") return true + if (this.sessionLevel === "deny") return false + + const rule = this.rules.get(toolName) + if (!rule || rule.action === "allow") return true + if (rule.action === "deny") return false + + if (rule.rememberAlways && this.isAlwaysAllowed(toolName, args)) { + return true + } + + const isDangerous = toolName === "run_command" && this.isDangerousCommand(String(args.command || "")) + + return this.promptUser(toolName, args, isDangerous, rule.rememberAlways) + } + + private isAlwaysAllowed(toolName: string, args: Record): boolean { + const cache = this.alwaysCache.get(toolName) + if (!cache) return false + + if (toolName === "run_command") { + const command = String(args.command || "") + for (const prefix of cache) { + if (command.startsWith(prefix)) return true + } + return false + } + + if (toolName === "write_file") { + const path = String(args.path || "") + for (const pattern of cache) { + if (path.startsWith(pattern)) return true + if (pattern.endsWith("/*") && path.startsWith(pattern.slice(0, -2))) return true + } + return false + } + + return false + } + + private addAlwaysCache(toolName: string, args: Record, alwaysPattern: string): void { + if (!this.alwaysCache.has(toolName)) { + this.alwaysCache.set(toolName, new Set()) + } + this.alwaysCache.get(toolName)!.add(alwaysPattern) + } + + private async promptUser( + toolName: string, + args: Record, + isDangerous: boolean, + canRememberAlways: boolean, + ): Promise { + const stdin = process.stdin + const wasRaw = stdin.isRaw + + if (stdin.isTTY) { + stdin.setRawMode(false) + } + + const borderColor = isDangerous ? theme.red : theme.warning + const header = isDangerous ? " DANGEROUS OPERATION " : " Permission Request " + + let content = "" + if (toolName === "write_file") { + content = `Supercode wants to write:\n ${chalk.cyan(String(args.path || ""))}` + if (args.description) { + content += `\n ${chalk.dim(String(args.description))}` + } + } else if (toolName === "run_command") { + content = `Run:\n $ ${chalk.cyan(String(args.command || ""))}` + if (args.description) { + content += `\n ${chalk.dim(String(args.description))}` + } + } else if (toolName === "code_exec") { + const code = String(args.code || "") + const preview = code.length > 80 ? code.slice(0, 77) + "..." : code + content = `Execute code:\n ${chalk.cyan(preview)}` + } + + if (isDangerous) { + content += `\n\n${chalk.red("This operation is potentially destructive.")}` + } + + const box = boxen(content, { + title: header, + borderColor, + padding: 1, + margin: 1, + }) + console.log(box) + + return new Promise((resolve) => { + const rl = readline.createInterface({ input: stdin, output: process.stdout }) + + const prompt = isDangerous + ? "Allow this operation? (y/N): " + : canRememberAlways + ? "[y] Once [a] Always for session [n] Deny: " + : "Allow? (y/N): " + + rl.question(prompt, (answer) => { + rl.close() + + if (stdin.isTTY && wasRaw) { + stdin.setRawMode(true) + } + + const a = answer.trim().toLowerCase() + + if (a === "y" || a === "yes") { + resolve(true) + } else if ((a === "a" || a === "always") && canRememberAlways && !isDangerous) { + let alwaysPattern = "*" + if (toolName === "run_command") { + const cmd = String(args.command || "") + const parts = cmd.split(/\s+/) + if (parts.length > 0) { + alwaysPattern = parts[0] + " " + } + } else if (toolName === "write_file") { + const path = String(args.path || "") + const lastSlash = path.lastIndexOf("/") + alwaysPattern = lastSlash >= 0 ? path.slice(0, lastSlash + 1) : "" + } + this.addAlwaysCache(toolName, args, alwaysPattern) + resolve(true) + } else { + resolve(false) + } + }) + }) + } +} + +export const permissionManager = new PermissionManager() diff --git a/apps/supercode-cli/server/src/tools/registry.ts b/apps/supercode-cli/server/src/tools/registry.ts index bce0294..ebc81a0 100644 --- a/apps/supercode-cli/server/src/tools/registry.ts +++ b/apps/supercode-cli/server/src/tools/registry.ts @@ -1,13 +1,36 @@ import { readFileTool } from "./definitions/read-file.ts" import { searchFilesTool } from "./definitions/search-files.ts" +import { writeFileTool } from "./definitions/write-file.ts" +import { runCommandTool } from "./definitions/run-command.ts" import { urlFetchTool } from "./definitions/url-fetch.ts" import { webSearchTool } from "./definitions/web-search.ts" import { codeExecTool } from "./definitions/code-exec.ts" +import { permissionManager } from "./permission-manager.ts" + +function withPermission(tool: Record): Record { + const originalExecute = tool.execute as ((args: any) => Promise) | undefined + if (!originalExecute) return tool + return { + ...tool, + execute: async (args: any) => { + const allowed = await permissionManager.check( + (tool.name || tool.description) as string, + args, + ) + if (!allowed) { + return JSON.stringify({ cancelled: true, reason: "Permission denied by user" }) + } + return originalExecute(args) + }, + } +} export const tools = { read_file: readFileTool, search_files: searchFilesTool, + write_file: withPermission(writeFileTool as unknown as Record), + run_command: withPermission(runCommandTool as unknown as Record), url_fetch: urlFetchTool, web_search: webSearchTool, - code_exec: codeExecTool, + code_exec: withPermission(codeExecTool as unknown as Record), } From c89c782b097448b56278383897e9128112d93424 Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Wed, 10 Jun 2026 12:19:00 +0530 Subject: [PATCH 2/2] refactor(cli): agent stability, tool system rewrite, and conditional system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Await streamText promises inside try-catch across all 5 service files - Add global unhandledRejection/uncaughtException handlers - Wrap chat while-loop in try-catch with stdin/state recovery - Re-register stdin keypress handler after @clack/prompts interaction - Make ## Tools section conditional on hasTools flag (chat mode) - Rewrite PermissionManager with opencode-style wildcard rule matching - Rewrite runCommandTool from exec to spawn with interactive mode - Add read_instructions tool - Replace generateApplication with ToolLoopAgent-based createAppAgent - Bump version 0.1.0 → 0.1.5, add @ai-sdk/openai-compatible dep --- .gitignore | 4 + apps/supercode-cli/server/package.json | 6 +- .../server/src/cli/ai/chat/chat.ts | 233 ++++++++------ .../server/src/cli/ai/chat/chatAgent.ts | 88 +++--- .../server/src/cli/ai/chat/thinking.ts | 89 ++++++ .../server/src/cli/ai/google-service.ts | 93 +++--- .../server/src/cli/ai/minimax-service.ts | 70 +++-- .../server/src/cli/ai/nvidia-service.ts | 213 +++++-------- .../server/src/cli/ai/openrouter-service.ts | 251 +++++---------- .../server/src/cli/ai/provider.ts | 17 +- .../server/src/cli/ai/server-proxy-service.ts | 8 +- .../server/src/cli/ai/tool-executor.ts | 161 ++++++++++ apps/supercode-cli/server/src/cli/main.ts | 15 + .../server/src/cli/workspace/context.ts | 133 ++++---- .../server/src/config/agent-config.ts | 212 +++---------- .../server/src/config/openrouter.config.ts | 2 +- .../tools/definitions/read-instructions.ts | 50 +++ .../src/tools/definitions/run-command.ts | 160 ++++++++-- .../server/src/tools/permission-manager.ts | 290 ++++++++++++------ .../server/src/tools/registry.ts | 15 +- bun.lock | 32 +- 21 files changed, 1245 insertions(+), 897 deletions(-) create mode 100644 apps/supercode-cli/server/src/cli/ai/chat/thinking.ts create mode 100644 apps/supercode-cli/server/src/cli/ai/tool-executor.ts create mode 100644 apps/supercode-cli/server/src/tools/definitions/read-instructions.ts diff --git a/.gitignore b/.gitignore index 2190677..dddd4cb 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ supercode-openmodel.md # supercode-cli plan files apps/supercode-cli/plan + + +# testing dir +/supercode-test \ No newline at end of file diff --git a/apps/supercode-cli/server/package.json b/apps/supercode-cli/server/package.json index c90463c..6dd3dd0 100644 --- a/apps/supercode-cli/server/package.json +++ b/apps/supercode-cli/server/package.json @@ -1,6 +1,6 @@ { "name": "supercode-cli", - "version": "0.1.5", + "version": "0.1.6", "description": "AI-powered coding agent CLI", "main": "dist/main.js", "bin": { @@ -39,7 +39,9 @@ "engines": { "node": ">=18" }, - "dependencies": {}, + "dependencies": { + "@ai-sdk/openai-compatible": "^2.0.48" + }, "devDependencies": { "@ai-sdk/google": "^3.0.80", "@clack/prompts": "^1.5.0", diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts index c74a9d3..a2f8a23 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts @@ -12,6 +12,7 @@ import { } from "src/lib/api-client.ts" import type { ModelMessage } from "ai" import { createProvider, type ModelProvider, type AIProvider } from "src/cli/ai/provider.ts" +import { permissionManager } from "src/tools/permission-manager.ts" export type { ModelProvider } from "src/cli/ai/provider.ts" import { theme, @@ -24,6 +25,7 @@ import { stripAnsi, formatTokenCount, } from "src/cli/utils/tui.ts" +import { ThinkingDisplay } from "./thinking.ts" import { getContextWindow } from "src/cli/ai/context-windows.ts" import type { WorkspaceInfo } from "src/cli/workspace/scanner.ts" import { buildSystemPrompt } from "src/cli/workspace/context.ts" @@ -79,7 +81,8 @@ async function streamAIResponse( if (workspaceInfo) { process.env.SUPERCODE_WORKSPACE_ROOT = workspaceInfo.workspaceRoot - const systemPrompt = buildSystemPrompt(workspaceInfo) + const hasTools = mode === "tool" || mode === "agent" + const systemPrompt = buildSystemPrompt(workspaceInfo, hasTools) aiMessages = [ { role: "system", content: systemPrompt }, ...aiMessages, @@ -88,16 +91,25 @@ async function streamAIResponse( let fullResponse = "" let isFirstChunk = true + let hasOutputHeader = false let firstChunkTime = 0 const startTime = Date.now() - const thinking = createThinking() + const thinking = new ThinkingDisplay() + thinking.start("thinking") let toolsToUse: Record | undefined - if (workspaceInfo) { + if (workspaceInfo && (mode === "tool" || mode === "agent")) { toolsToUse = { ...tools } } + function emitHeader() { + if (hasOutputHeader) return + hasOutputHeader = true + thinking.stop() + streamHeader(provider.modelName) + } + const abortController = new AbortController() streamAbort = abortController @@ -105,34 +117,38 @@ async function streamAIResponse( const result = await provider.sendMessage( aiMessages as ModelMessage[], (chunk) => { - if (isFirstChunk) { - thinking.stop() - firstChunkTime = Date.now() - startTime - streamHeader(provider.modelName) + if (isFirstChunk && !hasOutputHeader) { + emitHeader() isFirstChunk = false } process.stdout.write(chunk) fullResponse += chunk }, toolsToUse, - async ({ toolName, args }: { toolName: string; args: Record }) => { - const argPreview = (args as any)?.path || (args as any)?.pattern || (args as any)?.query || (args as any)?.url || "" - thinking.setLabel(`${toolName}(${argPreview})`) + async ({ toolName }: { toolName: string }) => { + if (!hasOutputHeader) emitHeader() + thinking.showToolCall(toolName) }, abortController.signal, + (reasoningChunk) => { + if (!hasOutputHeader) emitHeader() + thinking.showReasoning(reasoningChunk) + }, ) const elapsed = Date.now() - startTime const usage = await result.usage + thinking.stop() console.log() return { content: fullResponse, elapsed, usage } } catch (error: any) { if (error?.name === "AbortError" || abortController.signal.aborted) { + thinking.stop() console.log() return { content: fullResponse || "(cancelled)", elapsed: Date.now() - startTime, usage: {}, aborted: true } } - thinking.fail("Response failed") + thinking.stop() throw error } finally { streamAbort = null @@ -224,6 +240,11 @@ function stdinKeypress(_str: string, key: any) { if (key.name === "tab") { const idx = modes.indexOf(stdinMode) stdinMode = modes[(idx + 1) % modes.length]! + if (stdinMode === "agent" || stdinMode === "tool") { + permissionManager.setSessionLevel("allow") + } else { + permissionManager.setSessionLevel(null) + } renderInput() return } @@ -231,6 +252,7 @@ function stdinKeypress(_str: string, key: any) { if (key.name === "return" || key.name === "enter") { const resolve = stdinResolve stdinResolve = null + process.stdout.write("\r\n") resolve({ input: stdinInput, mode: stdinMode }) return } @@ -303,22 +325,39 @@ function stdinKeypress(_str: string, key: any) { } } -function setupStdin() { +function ensureStdinHandler() { const stdin = process.stdin readline.emitKeypressEvents(stdin) if (stdin.isTTY) { - stdin.setRawMode(true) + try { stdin.setRawMode(true) } catch {} } stdin.resume() - stdin.on("keypress", stdinKeypress) + const hasHandler = stdin.listeners("keypress").includes(stdinKeypress) + if (!hasHandler) { + stdin.on("keypress", stdinKeypress) + } +} + +function setupStdin() { + ensureStdinHandler() } async function chatInput(currentMode: string): Promise<{ input: string; mode: string }> { stdinMode = modes.includes(currentMode) ? currentMode : "chat" + if (stdinMode === "agent" || stdinMode === "tool") { + permissionManager.setSessionLevel("allow") + } else { + permissionManager.setSessionLevel(null) + } stdinInput = "" stdinCursor = 0 stdinPrevWrapLines = 1 - renderInput() + ensureStdinHandler() + try { + renderInput() + } catch { + // Terminal state may be corrupted after tool output; reset gracefully + } return new Promise((resolve) => { stdinResolve = resolve }) @@ -329,6 +368,13 @@ export async function chatLoop( conversation: Conversation, workspaceInfo?: WorkspaceInfo, ) { + const exitHandler = (code: number) => { + try { + process.stderr.write(`\n[chatLoop exit] code=${code}\n`) + } catch {} + } + process.on("exit", exitHandler) + setupStdin() let messageCount = 0 @@ -337,86 +383,101 @@ export async function chatLoop( let contextWindow = getContextWindow(provider.modelName) while (true) { - const { input: userInput, mode } = await chatInput(conversation.mode) - - if (mode !== conversation.mode) { - conversation.mode = mode - await updateConversationMode(conversation.id, mode) - - if ((mode === "tool" || mode === "agent") && provider.name !== "openrouter") { - const orProvider = createProvider("openrouter") - provider = orProvider - contextWindow = getContextWindow(provider.modelName) - console.log(` ${chalk.hex(theme.blue)("◆")} switched to ${chalk.hex(theme.cyan)(provider.modelName)} for ${mode} mode`) - console.log() + try { + const { input: userInput, mode } = await chatInput(conversation.mode) + + if (mode !== conversation.mode) { + conversation.mode = mode + await updateConversationMode(conversation.id, mode) } - } - const trimmed = userInput.trim() - if (trimmed.toLowerCase() === "exit") { - console.log() - console.log(chalk.hex(theme.amber)(` ╰─ `) + chalk.hex(theme.muted)("session ended")) - console.log() - process.exit(0) - } + const trimmed = userInput.trim() + if (trimmed.toLowerCase() === "exit") { + process.stdout.write("\r\n") + process.stdout.write(chalk.hex(theme.amber)(` ╰─ `) + chalk.hex(theme.muted)("session ended") + "\r\n") + process.exit(0) + } - if (trimmed.length === 0) continue - - if (isSlashCommand(trimmed)) { - const result = await handleSlashCommand(trimmed) - if (result?.type === "model_change") { - const newProvider = result.provider ? createProvider(result.provider, result.model) : null - if (newProvider) { - provider = newProvider - contextWindow = getContextWindow(provider.modelName) - const label = result.label || provider.modelName - console.log(` ${chalk.hex(theme.green)("◆")} switched to ${chalk.hex(theme.cyan)(label)}`) - console.log() + if (trimmed.length === 0) continue + + if (isSlashCommand(trimmed)) { + if (process.stdin.isTTY) process.stdin.setRawMode(false) + const result = await handleSlashCommand(trimmed) + stdinInput = "" + stdinCursor = 0 + stdinPrevWrapLines = 1 + if (process.stdin.isTTY) process.stdin.setRawMode(true) + ensureStdinHandler() + if (result?.type === "model_change") { + const newProvider = result.provider ? createProvider(result.provider, result.model) : null + if (newProvider) { + provider = newProvider + contextWindow = getContextWindow(provider.modelName) + const label = result.label || provider.modelName + process.stdout.write(`\r\n ${chalk.hex(theme.green)("◆")} switched to ${chalk.hex(theme.cyan)(label)}\r\n\n`) + } + } else if (result?.type === "unknown") { + process.stdout.write(`\r\n ${chalk.hex(theme.red)("◆")} unknown slash command: ${trimmed.split(" ")[0]}\r\n\n`) + } else { + process.stdout.write("\r\n") } - } else if (result?.type === "unknown") { - console.log(` ${chalk.hex(theme.red)("◆")} unknown slash command: ${trimmed.split(" ")[0]}`) - console.log() + readline.cursorTo(process.stdout, 0) + continue } - continue - } - userMessage(trimmed) - messageCount++ + userMessage(trimmed) + messageCount++ - await addMessage(conversation.id, "user", trimmed) - await trySetAutoTitle(conversation.id, trimmed, messageCount) + await addMessage(conversation.id, "user", trimmed) + await trySetAutoTitle(conversation.id, trimmed, messageCount) - try { - const result = await streamAIResponse(provider, conversation.id, conversation.mode, workspaceInfo) + try { + const result = await streamAIResponse(provider, conversation.id, conversation.mode, workspaceInfo) - if (result.aborted) { - if (result.content && result.content !== "(cancelled)") { - await addMessage(conversation.id, "assistant", result.content) + if (result.aborted) { + if (result.content && result.content !== "(cancelled)") { + await addMessage(conversation.id, "assistant", result.content) + } + process.stdout.write(`\r\n ${chalk.hex(theme.amber)("◆")} cancelled\r\n`) + continue } - console.log(` ${chalk.hex(theme.amber)("◆")} cancelled`) - console.log() - continue - } - - await addMessage(conversation.id, "assistant", result.content) - const responseTokens = result.usage?.totalTokens ?? 0 - sessionTokens += responseTokens - - chatStatusBar({ - mode: conversation.mode, - model: provider.modelName, - usage: result.usage, - elapsed: result.elapsed, - cumulativeTokens: sessionTokens, - contextWindow, - }) - console.log() + await addMessage(conversation.id, "assistant", result.content) + + const responseTokens = result.usage?.totalTokens ?? 0 + sessionTokens += responseTokens + + try { + chatStatusBar({ + mode: conversation.mode, + model: provider.modelName, + usage: result.usage, + elapsed: result.elapsed, + cumulativeTokens: sessionTokens, + contextWindow, + }) + process.stdout.write("\r\n") + } catch { + // status bar may fail after tool output; continue loop + } + } catch (error: any) { + const errMsg = error?.message ?? "Unknown error" + process.stdout.write(`\r\n ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(errMsg)}\r\n\n`) + } } catch (error: any) { - const errMsg = error?.message ?? "Unknown error" - console.log() - console.log(` ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(errMsg)}`) - console.log() + // Catch-all: prevent any error from crashing the chat loop + try { + process.stdout.write(`\r\n ${chalk.hex(theme.amber)("◆")} ${chalk.hex(theme.muted)(error?.message ?? "unexpected error, continuing")}\r\n`) + } catch { + // Terminal may be in a bad state; just try to continue + } + stdinResolve = null + stdinInput = "" + stdinCursor = 0 + stdinPrevWrapLines = 1 + if (process.stdin.isTTY) { + try { process.stdin.setRawMode(true) } catch {} + } } } } @@ -453,11 +514,7 @@ export async function startChat( const user = await getUserFromToken() const conversation = await initConversation(user.id, conversationId, initialMode) - const activeProvider = (initialMode === "tool" || initialMode === "agent") && provider !== "openrouter" - ? createProvider("openrouter") - : aiProvider - - await chatLoop(activeProvider, conversation, workspaceInfo) + await chatLoop(aiProvider, conversation, workspaceInfo) } catch (error: any) { console.log() console.log(` ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(error?.message ?? "Error")}`) diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts b/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts index 57bd123..c78d502 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts @@ -5,7 +5,7 @@ import { createThinking, theme, frame, panel, userMessage, streamFooter } from " import { getStoredToken } from "src/lib/token" import { ChatService } from "src/service/chat-service" import { createProvider, type ModelProvider } from "src/cli/ai/provider" -import { generateApplication } from "src/config/agent-config" +import { createAppAgent } from "src/config/agent-config" let _chatService: ChatService @@ -51,7 +51,7 @@ async function initAgentConversation(userId: string, conversationId: string | nu const detail = [ chalk.hex(theme.text).bold(conversation.title ?? "Untitled"), chalk.hex(theme.muted)(`${conversation.id.slice(0, 12)} · agent mode`), - chalk.hex(theme.warning)("generates complete applications from descriptions"), + chalk.hex(theme.warning)("creates apps by executing commands step-by-step"), ] console.log() @@ -72,7 +72,7 @@ interface Conversation { async function agentLoop( conversation: Conversation, - modelOrGenerate: import("ai").LanguageModel | ((schema: any, prompt: string) => Promise<{ object: unknown }>), + model: import("ai").LanguageModel, ) { console.log(` ${chalk.hex(theme.warning)("◆")} ${chalk.hex(theme.muted)("Describe an application to generate")}`) console.log(` ${chalk.hex(theme.muted)('•')} Type "exit" to end`) @@ -110,33 +110,49 @@ async function agentLoop( const startTime = Date.now() try { - const result = await generateApplication(userInput, modelOrGenerate, process.cwd()) + const agent = createAppAgent(model) + + const thinking = createThinking("generating") + + let lastToolCall = "" + + const result = await agent.generate({ + prompt: userInput, + onStepFinish: async ({ stepNumber, text, toolCalls, finishReason }) => { + if (toolCalls?.length) { + for (const tc of toolCalls) { + const label = `${tc.toolName}(${JSON.stringify((tc as any).input)})` + if (label !== lastToolCall) { + lastToolCall = label + thinking.setLabel(tc.toolName) + } + } + } + }, + }) - if (result && result.success) { - const responseMessage = - `Generated application: ${result.folderName}\n` + - `Files created: ${result.files.length}\n` + - `Location: ${result.appDir}\n\n` + - `Setup commands:\n${result.commands.join("\n")}` + thinking.succeed("done") + console.log() + console.log(chalk.hex(theme.green)(`┏━━ ${chalk.hex(theme.green).bold("Result")}`)) + console.log(chalk.white(result.text || "Application created successfully.")) + console.log() - await saveMessage(conversation.id, "assistant", responseMessage) + const responseMessage = result.text || "Application created successfully." + await saveMessage(conversation.id, "assistant", responseMessage) - const elapsed = Date.now() - startTime - streamFooter(undefined, elapsed) - console.log() + const elapsed = Date.now() - startTime + streamFooter(undefined, elapsed) + console.log() - const continuePrompt = await confirm({ - message: chalk.hex(theme.cyan)("Would you like to generate another application?"), - initialValue: false, - }) + const continueApp = await confirm({ + message: chalk.hex(theme.cyan)("Would you like to generate another application?"), + initialValue: false, + }) - if (isCancel(continuePrompt) || !continuePrompt) { - console.log() - console.log(chalk.hex(theme.green)("◆") + " " + chalk.hex(theme.muted)("Check your new application!")) - break - } - } else { - throw new Error("Generation returned no result") + if (isCancel(continueApp) || !continueApp) { + console.log() + console.log(chalk.hex(theme.green)("◆") + " " + chalk.hex(theme.muted)("Check your new application!")) + break } } catch (error: any) { console.log() @@ -165,12 +181,6 @@ async function saveMessage(conversationId: string, role: string, content: string return getChatService().addMessage(conversationId, role, content) } -async function updateConversationTitle(conversationId: string, userInput: string, messageCount: number) { - if (messageCount !== 1) return - const baseTitle = userInput.slice(0, 50) - await getChatService().updateTitle(conversationId, userInput.length > 50 ? `${baseTitle}...` : baseTitle) -} - export async function startAgentChat( provider: ModelProvider = "google", model?: string, @@ -189,7 +199,7 @@ export async function startAgentChat( console.log() const confirmPrompt = await confirm({ - message: chalk.hex(theme.amber)("The agent will create files and folders in the current directory. Continue?"), + message: chalk.hex(theme.amber)("The agent will create files and run commands in the current directory. Continue?"), initialValue: true, }) @@ -199,17 +209,15 @@ export async function startAgentChat( } const aiProvider = createProvider(provider, model) - let agentModel: import("ai").LanguageModel | ((schema: any, prompt: string) => Promise<{ object: unknown }>) - if (aiProvider.model) { - agentModel = aiProvider.model as import("ai").LanguageModel - } else if (aiProvider.generateObject) { - agentModel = (schema, prompt) => aiProvider.generateObject!(schema, prompt) - } else { - console.log(chalk.hex(theme.red)(`Agent mode is not supported with ${provider} provider (no structured output support)`)) + const languageModel = aiProvider.model as import("ai").LanguageModel | null + + if (!languageModel) { + console.log(chalk.hex(theme.red)(`Agent mode requires a model with tool support. ${provider} provider does not export a compatible model.`)) process.exit(1) } + const conversation = await initAgentConversation(user.id, conversationId) - await agentLoop(conversation, agentModel) + await agentLoop(conversation, languageModel) console.log() console.log(chalk.hex(theme.green)("◆") + " " + chalk.hex(theme.muted)("agent session ended")) diff --git a/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts b/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts new file mode 100644 index 0000000..067f44e --- /dev/null +++ b/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts @@ -0,0 +1,89 @@ +import chalk from "chalk" +import { theme } from "src/cli/utils/tui" + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +export function reasoningSummary(text: string) { + const content = text.trim() + const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/) + if (!match || !match[1]) return { title: null, body: content } + return { title: match[1].trim(), body: content.slice(match[0]!.length).trimEnd() } +} + +const TOOL_ICONS: Record = { + run_command: "\u25B8", + read_file: "\u2192", + write_file: "\u2190", + edit_file: "\u2190", + glob: "\u2731", + grep: "\u2731", + webfetch: "%", + websearch: "\u25C6", + task: "\u2713", + question: "?", + skill: "\u2699", + apply_patch: "\u2190", +} + +export function getToolIcon(toolName: string): string { + return TOOL_ICONS[toolName] || "\u2699" +} + +export class ThinkingDisplay { + private i = 0 + private intervalId: ReturnType | null = null + private currentLabel = "" + private running = false + + start(label: string) { + this.currentLabel = label + if (this.running) return + this.running = true + this.i = 0 + this.intervalId = setInterval(() => { + if (!this.running) return + process.stdout.write(`\r${chalk.hex(theme.cyan)(SPINNER_FRAMES[this.i])} ${chalk.hex(theme.muted)(this.currentLabel)}`) + this.i = (this.i + 1) % SPINNER_FRAMES.length + }, 80) + } + + setLabel(label: string) { + this.currentLabel = label + if (this.running) { + process.stdout.write(`\r${chalk.hex(theme.cyan)(SPINNER_FRAMES[this.i])} ${chalk.hex(theme.muted)(label)}`) + } + } + + stop() { + this.running = false + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = null + } + process.stdout.write("\r\n") + } + + succeed(text?: string) { + this.stop() + if (text) console.log(` ${chalk.hex(theme.green)("\u25C6")} ${chalk.hex(theme.muted)(text)}`) + } + + fail(text?: string) { + this.stop() + if (text) console.log(` ${chalk.hex(theme.red)("\u25C6")} ${chalk.hex(theme.red)(text)}`) + } + + showToolCall(toolName: string) { + this.stop() + const icon = getToolIcon(toolName) + const line = ` ${chalk.hex(theme.cyan)(icon)} ${chalk.hex(theme.muted)(toolName)}` + process.stdout.write(line + "\n") + } + + showReasoning(content: string) { + const summary = reasoningSummary(content) + const title = summary.title || "thinking" + if (!this.running) this.start(`think: ${title}`) + else this.setLabel(`think: ${title}`) + } +} diff --git a/apps/supercode-cli/server/src/cli/ai/google-service.ts b/apps/supercode-cli/server/src/cli/ai/google-service.ts index 405dc51..05030b7 100644 --- a/apps/supercode-cli/server/src/cli/ai/google-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/google-service.ts @@ -1,5 +1,5 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google"; -import { streamText, generateObject, type ModelMessage } from "ai"; +import { streamText, generateObject, stepCountIs, type ModelMessage } from "ai"; import { config } from "../../config/google.config.ts"; import chalk from "chalk"; @@ -47,73 +47,73 @@ export class AIService { tools?: any, onToolCall?: any, signal?: AbortSignal, + onReasoning?: (chunk: string) => void, ) { try { const systemMessages = messages.filter(m => m.role === "system") const nonSystemMessages = messages.filter(m => m.role !== "system") const system = systemMessages.map(m => m.content).join("\n") - const streamOptions: any = { - model: this.model, - messages: nonSystemMessages, - abortSignal: signal, - } + const hasTools = tools && Object.keys(tools).length > 0 - if (system) { - streamOptions.system = system - } + if (!hasTools) { + const result = streamText({ + model: this.model, + messages: nonSystemMessages, + system, + abortSignal: signal, + }) - if (tools && Object.keys(tools).length > 0) { - streamOptions.tools = tools - streamOptions.maxSteps = 25 - if (onToolCall) { - streamOptions.experimental_onToolCallStart = (event: any) => { - const tc = event.toolCall - onToolCall({ toolName: tc.toolName, args: tc.input as Record }) - } + let fullResponse = "" + for await (const chunk of result.textStream) { + fullResponse += chunk + onChunk?.(chunk) } - } - const result = streamText(streamOptions) + const [finishReason, usage] = await Promise.all([ + result.finishReason, + result.usage, + ]) - let fullResponse = "" - - for await (const chunk of result.textStream) { - fullResponse += chunk - onChunk?.(chunk) + return { + content: fullResponse, + finishReason, + usage, + } } - const fullResult = result; - - const toolCalls = []; - const toolResults = []; - - if (fullResult.steps && Array.isArray(fullResult.steps)) { - for (const step of fullResult.steps) { - if (step.toolCalls && step.toolCalls.length > 0) { - for (const toolCall of step.toolCalls) { - toolCalls.push(toolCall); + let fullResponse = "" - if (onToolCall) { - onToolCall({ toolName: toolCall.toolName, args: toolCall.input as Record }); - } + const result = streamText({ + model: this.model, + messages: nonSystemMessages, + system, + tools, + stopWhen: stepCountIs(25), + abortSignal: signal, + onStepFinish: async (event) => { + if (event.toolCalls?.length) { + for (const tc of event.toolCalls) { + onToolCall?.({ toolName: tc.toolName, args: (tc as any).input as Record }) } } + }, + }) - if (step.toolResults && step.toolResults.length > 0) { - toolResults.push(...step.toolResults); - } - } + for await (const chunk of result.textStream) { + fullResponse += chunk + onChunk?.(chunk) } + const [finishReason, usage] = await Promise.all([ + result.finishReason, + result.usage, + ]) return { content: fullResponse, - finishResponse: result.finishReason, - usage: result.usage, - toolCalls, - toolResults, - step: fullResult.steps + finishReason, + usage, } } catch (error: any) { if (error?.name === "AbortError") throw error @@ -127,7 +127,6 @@ export class AIService { const result = await this.sendMessage(messages, (chunk) => { fullResponse += chunk }) - return result.content; } } diff --git a/apps/supercode-cli/server/src/cli/ai/minimax-service.ts b/apps/supercode-cli/server/src/cli/ai/minimax-service.ts index 7de81a6..aa3878f 100644 --- a/apps/supercode-cli/server/src/cli/ai/minimax-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/minimax-service.ts @@ -1,7 +1,8 @@ import { createMinimax } from "vercel-minimax-ai-provider" -import { streamText, type ModelMessage } from "ai" +import { streamText, type ModelMessage, type FinishReason } from "ai" import { minimaxConfig } from "../../config/minimax.config.ts" import chalk from "chalk" +import { executeToolLoop } from "./tool-executor.ts" export class MinimaxService { model: ReturnType> @@ -23,46 +24,77 @@ export class MinimaxService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, + onReasoning?: (chunk: string) => void, ) { try { const systemMessages = messages.filter(m => m.role === "system") const nonSystemMessages = messages.filter(m => m.role !== "system") const system = systemMessages.map(m => m.content).join("\n") + if (tools && Object.keys(tools).length > 0) { + const { content, usage } = await executeToolLoop( + this.model, + nonSystemMessages, + system, + tools, + { onChunk, onToolCall, onReasoning, signal }, + ) + return { + content, + finishReason: "stop" as FinishReason, + usage, + } + } + const streamOptions: any = { model: this.model, messages: nonSystemMessages, maxTokens: Number(process.env.MINIMAX_MAX_TOKENS) || 4096, + abortSignal: signal, } - if (system) { - streamOptions.system = system - } + if (system) streamOptions.system = system - if (tools && Object.keys(tools).length > 0) { - streamOptions.tools = tools - streamOptions.maxSteps = 25 - if (onToolCall) { - streamOptions.experimental_onToolCallStart = (event: any) => { - const tc = event.toolCall - onToolCall({ toolName: tc.toolName, args: tc.input as Record }) + const result = streamText(streamOptions) + + let fullResponse = "" + + const processReasoning = async () => { + const stream = (result as any).reasoningStream || (result as any).reasoningText + if (stream && typeof stream === "object" && onReasoning) { + try { + if (Symbol.asyncIterator in stream) { + for await (const chunk of stream) { + onReasoning(typeof chunk === "string" ? chunk : String(chunk)) + } + } else if (typeof stream === "string" && stream.length > 0) { + onReasoning(stream) + } + } catch { + // reasoning stream may not be supported } } } - const result = streamText(streamOptions) + const processText = async () => { + for await (const chunk of result.textStream) { + fullResponse += chunk + onChunk?.(chunk) + } + } - let fullResponse = "" + await Promise.all([processReasoning(), processText()]) - for await (const chunk of result.textStream) { - fullResponse += chunk - onChunk?.(chunk) - } + const [finishReason, usage] = await Promise.all([ + result.finishReason, + result.usage, + ]) return { content: fullResponse, - finishResponse: result.finishReason, - usage: result.usage, + finishReason, + usage, } } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts b/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts index d5fb902..f9b56a8 100644 --- a/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts @@ -1,20 +1,28 @@ -import chalk from "chalk" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { streamText, stepCountIs, type ModelMessage, type LanguageModel } from "ai" import { nvidiaConfig } from "../../config/nvidia.config.ts" -import type { ModelMessage, FinishReason, LanguageModelUsage } from "ai" -import { zodToJsonSchema } from "zod-to-json-schema" +import chalk from "chalk" export class NvidiaService { + model: LanguageModel readonly modelName: string - readonly model = null - private readonly baseUrl: string - constructor(model?: string) { + constructor(modelName?: string) { if (!nvidiaConfig.apiKey) { - throw new Error("NVIDIA NIM is not configured.\n\n Set NVIDIA_API_KEY in your environment:\n export NVIDIA_API_KEY=") + throw new Error("NVIDIA NIM is not configured.\n\n Set NVIDIA_API_KEY in your environment:\n export NVIDIA_API_KEY=\n\n Get free credits at: https://build.nvidia.com") } - this.modelName = model || nvidiaConfig.model - this.baseUrl = nvidiaConfig.baseUrl + this.modelName = modelName || nvidiaConfig.model + + const nim = createOpenAICompatible({ + name: "nim", + baseURL: nvidiaConfig.baseUrl, + headers: { + Authorization: `Bearer ${nvidiaConfig.apiKey}`, + }, + }) + + this.model = nim.chatModel(this.modelName) } async sendMessage( @@ -23,123 +31,73 @@ export class NvidiaService { tools?: any, onToolCall?: any, signal?: AbortSignal, + onReasoning?: (chunk: string) => void, ) { try { - const bodyObj: any = { - model: this.modelName, - messages: messages.map((m) => ({ role: m.role, content: String(m.content) })), - max_tokens: nvidiaConfig.maxTokens, - temperature: nvidiaConfig.temperature, - top_p: nvidiaConfig.topP, - stream: true, - } + const systemMessages = messages.filter(m => m.role === "system") + const nonSystemMessages = messages.filter(m => m.role !== "system") + const system = systemMessages.map(m => m.content).join("\n") + + const hasTools = tools && Object.keys(tools).length > 0 + + if (!hasTools) { + const result = streamText({ + model: this.model, + messages: nonSystemMessages, + system, + abortSignal: signal, + }) + + let fullResponse = "" + for await (const chunk of result.textStream) { + fullResponse += chunk + onChunk?.(chunk) + } - const seenToolCallIds = new Set() + const [finishReason, usage] = await Promise.all([ + result.finishReason, + result.usage, + ]) - if (tools && Object.keys(tools).length > 0) { - bodyObj.tools = toolsToOpenAI(tools) - } - - const response = await fetch(`${this.baseUrl}/chat/completions`, { - method: "POST", - headers: { - "Authorization": `Bearer ${nvidiaConfig.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(bodyObj), - signal, - }) - - if (!response.ok) { - const errText = await response.text().catch(() => "unknown error") - throw new Error(`NVIDIA API ${response.status}: ${errText}`) + return { + content: fullResponse, + finishReason, + usage, + } } - const reader = response.body?.getReader() - if (!reader) throw new Error("No response body") - - const decoder = new TextDecoder() - let buffer = "" let fullResponse = "" - let finishReason: FinishReason = "stop" - let inputTokens = 0 - let outputTokens = 0 - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || !trimmed.startsWith("data: ")) continue - - const jsonStr = trimmed.slice(6) - if (jsonStr === "[DONE]") break - - try { - const data = JSON.parse(jsonStr) - const delta = data.choices?.[0]?.delta - if (delta?.content) { - fullResponse += delta.content - onChunk?.(delta.content) + const result = streamText({ + model: this.model, + messages: nonSystemMessages, + system, + tools, + stopWhen: stepCountIs(25), + abortSignal: signal, + onStepFinish: async (event) => { + if (event.toolCalls?.length) { + for (const tc of event.toolCalls) { + onToolCall?.({ toolName: tc.toolName, args: (tc as any).input as Record }) } - - if (delta?.tool_calls && onToolCall) { - for (const tc of delta.tool_calls) { - if (tc.id && !seenToolCallIds.has(tc.id)) { - seenToolCallIds.add(tc.id) - const name = tc.function?.name || "unknown" - let args: Record = {} - try { - if (tc.function?.arguments) { - args = JSON.parse(tc.function.arguments) - } - } catch { - // partial streaming JSON - } - onToolCall({ toolName: name, args }) - } - } - } - - if (data.choices?.[0]?.finish_reason) { - finishReason = mapFinishReason(data.choices[0].finish_reason) - } - - if (data.usage) { - inputTokens = data.usage.prompt_tokens ?? 0 - outputTokens = data.usage.completion_tokens ?? 0 - } - } catch { - // skip malformed JSON lines } - } - } - - const usage: LanguageModelUsage = { - inputTokens, - inputTokenDetails: { - noCacheTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, }, - outputTokens, - outputTokenDetails: { - textTokens: outputTokens, - reasoningTokens: 0, - }, - totalTokens: inputTokens + outputTokens, + }) + + for await (const chunk of result.textStream) { + fullResponse += chunk + onChunk?.(chunk) } + const [finishReason, usage] = await Promise.all([ + result.finishReason, + result.usage, + ]) + return { content: fullResponse, - finishResponse: Promise.resolve(finishReason), - usage: Promise.resolve(usage), + finishReason, + usage, } } catch (error: any) { if (error?.name === "AbortError") throw error @@ -148,38 +106,11 @@ export class NvidiaService { } } - async getMessage(messages: ModelMessage[], _tools?: any) { + async getMessage(messages: ModelMessage[], tools?: any) { let fullResponse = "" - await this.sendMessage(messages, (chunk) => { + const result = await this.sendMessage(messages, (chunk) => { fullResponse += chunk }) - return fullResponse - } -} - -function toolsToOpenAI(tools: Record): any[] { - return Object.entries(tools).map(([name, tool]) => ({ - type: "function", - function: { - name, - description: tool.description || "", - parameters: zodToJsonSchema(tool.parameters), - }, - })) -} - -function mapFinishReason(reason: string): FinishReason { - switch (reason) { - case "stop": - return "stop" - case "length": - case "max_tokens": - return "length" - case "tool_calls": - return "tool-calls" - case "content_filter": - return "content-filter" - default: - return "stop" + return result.content } } diff --git a/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts b/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts index cbf1b33..7ab6666 100644 --- a/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts @@ -1,34 +1,24 @@ -import { type ModelMessage } from "ai" +import { createOpenRouter } from "@openrouter/ai-sdk-provider" +import { streamText, stepCountIs, type ModelMessage, type LanguageModel } from "ai" import { openRouterConfig } from "../../config/openrouter.config.ts" import chalk from "chalk" -const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions" - -function isServerTool(name: string): boolean { - return name === "web_search" || name === "url_fetch" -} - -function serverTool(name: string): any { - if (name === "web_search") return { type: "openrouter:web_search" } - if (name === "url_fetch") return { type: "openrouter:web_fetch" } - return null -} - -function zodToJsonSchema(schema: any): any { - return typeof schema === "object" && "toJSON" in (schema as any) - ? (schema as any).toJSON() - : schema -} - export class OpenRouterService { + model: LanguageModel readonly modelName: string - constructor(model?: string) { + constructor(modelName?: string) { if (!openRouterConfig.apiKey) { throw new Error("OpenRouter is not configured.\n\n Set OPENROUTER_API_KEY in your environment:\n export OPENROUTER_API_KEY=\n\n Get a key at: https://openrouter.ai/keys") } - this.modelName = model || openRouterConfig.model + this.modelName = modelName || openRouterConfig.model + + const openrouter = createOpenRouter({ + apiKey: openRouterConfig.apiKey, + }) + + this.model = openrouter.chat(this.modelName) } async sendMessage( @@ -37,179 +27,86 @@ export class OpenRouterService { tools?: any, onToolCall?: any, signal?: AbortSignal, + onReasoning?: (chunk: string) => void, ) { - const systemMessages = messages.filter(m => m.role === "system") - const nonSystemMessages = messages.filter(m => m.role !== "system") - const system = systemMessages.map(m => m.content).join("\n") - - const apiMessages: any[] = [] - if (system) apiMessages.push({ role: "system", content: system }) - for (const m of nonSystemMessages) { - if (m.role === "assistant" && (m as any).tool_calls) { - const msg: any = { role: "assistant", content: m.content } - msg.tool_calls = (m as any).tool_calls - apiMessages.push(msg) - } else { - apiMessages.push({ role: m.role, content: m.content as string }) - } - } - - const apiTools: any[] = [] - const functionTools: Record = {} - if (tools) { - for (const [name, def] of Object.entries(tools as Record)) { - if (isServerTool(name)) { - apiTools.push(serverTool(name)) - } else { - apiTools.push({ - type: "function", - function: { - name, - description: def.description || "", - parameters: zodToJsonSchema(def.parameters), - }, - }) - functionTools[name] = def + try { + const systemMessages = messages.filter(m => m.role === "system") + const nonSystemMessages = messages.filter(m => m.role !== "system") + const system = systemMessages.map(m => m.content).join("\n") + + const hasTools = tools && Object.keys(tools).length > 0 + + if (!hasTools) { + const result = streamText({ + model: this.model, + messages: nonSystemMessages, + system, + abortSignal: signal, + }) + + let fullResponse = "" + for await (const chunk of result.textStream) { + fullResponse += chunk + onChunk?.(chunk) } - } - } - - const allMessages = [...apiMessages] - const maxToolIterations = 25 - let fullResponse = "" - for (let iter = 0; iter < maxToolIterations; iter++) { - if (signal?.aborted) throw new DOMException("Aborted", "AbortError") - if (fullResponse) onChunk?.("\n\n") + const [finishReason, usage] = await Promise.all([ + result.finishReason, + result.usage, + ]) - const body: any = { - model: this.modelName, - messages: allMessages, - stream: true, - } - if (apiTools.length > 0) body.tools = apiTools - if (this.modelName.includes("minimax-m3") || this.modelName.includes("glm-5.1")) { - body.max_tokens = 8192 + return { + content: fullResponse, + finishReason, + usage, + } } - const res = await fetch(OPENROUTER_API_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${openRouterConfig.apiKey}`, - "Content-Type": "application/json", + let fullResponse = "" + + const result = streamText({ + model: this.model, + messages: nonSystemMessages, + system, + tools, + stopWhen: stepCountIs(25), + abortSignal: signal, + onStepFinish: async (event) => { + if (event.toolCalls?.length) { + for (const tc of event.toolCalls) { + onToolCall?.({ toolName: tc.toolName, args: (tc as any).input as Record }) + } + } }, - body: JSON.stringify(body), - signal, }) - if (!res.ok) { - const errText = await res.text().catch(() => "unknown error") - throw new Error(`OpenRouter API ${res.status}: ${errText.slice(0, 500)}`) + for await (const chunk of result.textStream) { + fullResponse += chunk + onChunk?.(chunk) } - const reader = res.body?.getReader() - if (!reader) throw new Error("No response body") + const [finishReason, usage] = await Promise.all([ + result.finishReason, + result.usage, + ]) - const decoder = new TextDecoder() - let buffer = "" - let toolCalls: Array<{ id: string; type: string; function: { name: string; arguments: string } }> = [] - let finishReason = "" - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || !trimmed.startsWith("data: ")) continue - const jsonStr = trimmed.slice(6) - if (jsonStr === "[DONE]") continue - - try { - const data = JSON.parse(jsonStr) - const delta = data.choices?.[0]?.delta - const finish = data.choices?.[0]?.finish_reason - - if (finish) finishReason = finish - - if (delta?.content) { - fullResponse += delta.content - onChunk?.(delta.content) - } - - if (delta?.tool_calls) { - for (const tc of delta.tool_calls) { - const existing = toolCalls.find(t => t.id === tc.id) - if (existing) { - if (tc.function?.arguments) existing.function.arguments += tc.function.arguments - } else { - toolCalls.push({ - id: tc.id, - type: tc.type || "function", - function: { - name: tc.function?.name || "", - arguments: tc.function?.arguments || "", - }, - }) - } - } - } - } catch { /* skip malformed */ } - } + return { + content: fullResponse, + finishReason, + usage, } - - if (finishReason === "tool_calls" && toolCalls.length > 0) { - const assistantMsg: any = { role: "assistant", content: null } - assistantMsg.tool_calls = toolCalls.map(tc => ({ - id: tc.id, - type: tc.type, - function: { name: tc.function.name, arguments: tc.function.arguments }, - })) - allMessages.push(assistantMsg) - - for (const tc of toolCalls) { - const toolName = tc.function.name - const toolDef = functionTools[toolName] - let toolResult: string - - if (toolDef?.execute) { - let args: any = {} - try { args = JSON.parse(tc.function.arguments || "{}") } catch { /* */ } - onToolCall?.({ toolName, args }) - try { toolResult = await toolDef.execute(args) } catch (err: any) { toolResult = `Error: ${err.message || String(err)}` } - } else { - toolResult = `Tool "${toolName}" is not available locally` - } - - allMessages.push({ - role: "tool", - tool_call_id: tc.id, - content: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult), - }) - } - - toolCalls = [] - finishReason = "" - continue - } - - break - } - - return { - content: fullResponse, - finishResponse: Promise.resolve("stop" as any), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 } as any), + } catch (error: any) { + if (error?.name === "AbortError") throw error + console.error(chalk.red("OpenRouter Service Error:"), error instanceof Error ? error.message : String(error)) + throw error } } async getMessage(messages: ModelMessage[], tools?: any) { let fullResponse = "" - await this.sendMessage(messages, (chunk) => { fullResponse += chunk }, tools) - return fullResponse + const result = await this.sendMessage(messages, (chunk) => { + fullResponse += chunk + }) + return result.content } } diff --git a/apps/supercode-cli/server/src/cli/ai/provider.ts b/apps/supercode-cli/server/src/cli/ai/provider.ts index 62ac0b3..98ed465 100644 --- a/apps/supercode-cli/server/src/cli/ai/provider.ts +++ b/apps/supercode-cli/server/src/cli/ai/provider.ts @@ -14,17 +14,18 @@ export type ModelProvider = "google" | "minimax" | "openrouter" | "nvidia" export interface AIProvider { readonly name: string readonly modelName: string - readonly model?: object | null + readonly model?: any sendMessage( messages: ModelMessage[], onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, signal?: AbortSignal, + onReasoning?: (chunk: string) => void, ): Promise<{ content: string - finishResponse: PromiseLike - usage: PromiseLike + finishReason: FinishReason + usage: LanguageModelUsage }> generateObject?(schema: any, prompt: string): Promise<{ object: unknown }> } @@ -51,7 +52,7 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi return { name: provider, modelName: model || meta.defaultModel, - sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning), generateObject: (schema, prompt) => svc.generateObject(schema, prompt), } } @@ -63,7 +64,7 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi name: "google", modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning), } } case "openrouter": { @@ -71,8 +72,8 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi return { name: "openrouter", modelName: svc.modelName, - model: null, - sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), + model: svc.model, + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning), } } case "nvidia": { @@ -81,7 +82,7 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi name: "nvidia", modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning), } } default: { diff --git a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts index dc378c4..d1531a9 100644 --- a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts @@ -18,6 +18,7 @@ export class ServerProxyService { tools?: any, onToolCall?: (call: { toolName: string; args: Record }) => void, signal?: AbortSignal, + onReasoning?: (chunk: string) => void, ) { const token = await getStoredToken() if (!token?.access_token) { @@ -77,6 +78,9 @@ export class ServerProxyService { fullResponse += event.content onChunk?.(event.content) break + case "reasoning": + onReasoning?.(event.content) + break case "tool-call": onToolCall?.({ toolName: event.toolName, args: event.args }) break @@ -91,8 +95,8 @@ export class ServerProxyService { return { content: fullResponse, - finishResponse: Promise.resolve(finishReason), - usage: Promise.resolve(usage), + finishReason, + usage, } } diff --git a/apps/supercode-cli/server/src/cli/ai/tool-executor.ts b/apps/supercode-cli/server/src/cli/ai/tool-executor.ts new file mode 100644 index 0000000..d691815 --- /dev/null +++ b/apps/supercode-cli/server/src/cli/ai/tool-executor.ts @@ -0,0 +1,161 @@ +import { streamText, type ModelMessage, type ToolSet } from "ai" +import { z } from "zod" + +export interface ToolExecutorCallbacks { + onChunk?: (chunk: string) => void + onToolCall?: (params: { toolName: string; args: Record }) => void + onReasoning?: (chunk: string) => void + signal?: AbortSignal +} + +export type ToolSetDefinition = Record | Record + execute?: (args: any) => Promise +}> + +function getFunctions(tools: ToolSet | undefined): ToolSetDefinition | null { + if (!tools || typeof tools !== "object") return null + const funcs: ToolSetDefinition = {} + for (const [key, val] of Object.entries(tools)) { + funcs[key] = val as any + } + return funcs +} + +export async function executeToolLoop( + model: any, + initialMessages: ModelMessage[], + system: string | undefined, + tools: ToolSet | undefined, + callbacks: ToolExecutorCallbacks, +): Promise<{ content: string; usage: Promise }> { + const functions = getFunctions(tools) + const maxIterations = 25 + let messages = [...initialMessages] + + let accumulatedContent = "" + let accumulatedUsage: any = {} + + for (let iter = 0; iter < maxIterations; iter++) { + if (callbacks.signal?.aborted) throw new DOMException("Aborted", "AbortError") + + if (accumulatedContent) callbacks.onChunk?.("\n\n") + + const streamOptions: any = { + model, + messages, + abortSignal: callbacks.signal, + maxSteps: 1, + } + + if (system) streamOptions.system = system + if (tools && Object.keys(tools).length > 0) { + streamOptions.tools = tools + } + + const result = streamText(streamOptions) + + const processReasoning = async () => { + const stream = (result as any).reasoningStream || (result as any).reasoningText + if (stream && typeof stream === "object" && callbacks.onReasoning) { + try { + if (Symbol.asyncIterator in stream) { + for await (const chunk of stream) { + callbacks.onReasoning(typeof chunk === "string" ? chunk : String(chunk)) + } + } else if (typeof stream === "string" && stream.length > 0) { + callbacks.onReasoning(stream) + } + } catch { + // reasoning stream may not be supported + } + } + } + + const processText = async () => { + for await (const chunk of result.textStream) { + accumulatedContent += chunk + callbacks.onChunk?.(chunk) + } + } + + await Promise.all([processReasoning(), processText()]) + + const fullResult = result as any + let toolCalls: Array<{ toolCallId: string; toolName: string; args: Record }> = [] + let toolResults: Array<{ toolCallId: string; toolName: string; args: any; result: any }> = [] + + if (fullResult.steps && Array.isArray(fullResult.steps)) { + for (const step of fullResult.steps) { + if (step.toolCalls && step.toolCalls.length > 0) { + for (const tc of step.toolCalls) { + const args = tc.args || (tc as any).input || {} + toolCalls.push({ + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: args as Record, + }) + } + } + if (step.toolResults && step.toolResults.length > 0) { + toolResults.push(...step.toolResults) + } + } + } + + if (toolCalls.length === 0) { + break + } + + ;(messages as any).push({ + role: "assistant", + content: "", + tool_calls: toolCalls.map((tc) => ({ + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: tc.args, + })), + }) + + // Execute each tool call + for (const tc of toolCalls) { + callbacks.onToolCall?.({ toolName: tc.toolName, args: tc.args }) + + const toolDef = functions?.[tc.toolName] + let resultStr: string + if (toolDef?.execute) { + try { + resultStr = await toolDef.execute(tc.args) + } catch (err: any) { + resultStr = `Error: ${err.message || String(err)}` + } + } else { + resultStr = `Tool "${tc.toolName}" is not available locally` + } + + ;(messages as any).push({ + role: "tool", + content: resultStr, + tool_call_id: tc.toolCallId, + }) + } + + // Merge usage data from this iteration + try { + const stepUsage = await result.usage + if (stepUsage) { + accumulatedUsage = { + inputTokens: (accumulatedUsage.inputTokens || 0) + ((stepUsage as any).promptTokens || (stepUsage as any).inputTokens || 0), + outputTokens: (accumulatedUsage.outputTokens || 0) + ((stepUsage as any).completionTokens || (stepUsage as any).outputTokens || 0), + totalTokens: (accumulatedUsage.totalTokens || 0) + (stepUsage.totalTokens || 0), + } + } + } catch { /* usage may fail */ } + } + + return { + content: accumulatedContent, + usage: Promise.resolve(accumulatedUsage), + } +} diff --git a/apps/supercode-cli/server/src/cli/main.ts b/apps/supercode-cli/server/src/cli/main.ts index d1d3900..7382086 100755 --- a/apps/supercode-cli/server/src/cli/main.ts +++ b/apps/supercode-cli/server/src/cli/main.ts @@ -19,6 +19,21 @@ import { } from "./utils/tui" import { supercodeInit } from "./commands/ai/init" +process.on("unhandledRejection", (reason) => { + try { + process.stderr.write(`\n[debug] unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}\n`) + } catch { + // stderr might be unavailable + } +}) +process.on("uncaughtException", (error) => { + try { + process.stderr.write(`\n[debug] uncaught exception: ${error.message}\n`) + } catch { + // stderr might be unavailable + } +}) + async function main() { console.clear() diff --git a/apps/supercode-cli/server/src/cli/workspace/context.ts b/apps/supercode-cli/server/src/cli/workspace/context.ts index a3401c9..c7068ea 100644 --- a/apps/supercode-cli/server/src/cli/workspace/context.ts +++ b/apps/supercode-cli/server/src/cli/workspace/context.ts @@ -1,37 +1,57 @@ import type { WorkspaceInfo } from "./scanner.ts" -export function buildSystemPrompt(info: WorkspaceInfo): string { +export function buildSystemPrompt(info: WorkspaceInfo, hasTools = false): string { const lines: string[] = [] - lines.push("You are a senior software engineer running in the user's terminal. You work") - lines.push("autonomously to complete software engineering tasks.") + lines.push("You are supercode, an interactive CLI coding agent that helps users with software") + lines.push("engineering tasks. Use the instructions below and the tools available to you to") + lines.push("assist the user.") + lines.push("") + const envLines: string[] = [] + envLines.push(``) + envLines.push(` Working directory: ${process.cwd()}`) + envLines.push(` Workspace root folder: ${info.fullPath}`) + if (info.isMonorepo) envLines.push(` Structure: Monorepo`) + if (info.gitBranch) envLines.push(` Git branch: ${info.gitBranch}`) + envLines.push(` Platform: ${process.platform}`) + envLines.push(` Today's date: ${new Date().toDateString()}`) + envLines.push(``) + lines.push(envLines.join("\n")) lines.push("") + lines.push("## Core Principles") lines.push("") - lines.push("1. **Do, don't suggest.** When the user asks you to create an app, fix a bug,") - lines.push(" or add a feature — just do it. Use your tools to read, write, and execute.") + lines.push("1. **DO, don't suggest. THIS IS THE MOST IMPORTANT RULE.** When the user asks you to") + lines.push(" create an app, fix a bug, or add a feature — DO NOT explain what you will do.") + lines.push(" DO NOT output commands as text. Open your response with a tool call, not text.") + lines.push(" If your first output character is not `{` (start of a tool call JSON), you are") + lines.push(" doing it wrong. Every moment you spend explaining is time wasted. Execute") + lines.push(" immediately.") + lines.push("") + lines.push("2. **ABSOLUTELY NEVER output shell commands as text.** If your response contains a") + lines.push(" line starting with `$ ` (dollar-space), that is a BUG. You MUST use the") + lines.push(" `run_command` tool instead. There is never a valid reason to write `$ mkdir`,") + lines.push(" `$ npm`, `$ npx`, or any other `$`-prefixed command in your text output.") + lines.push(" Call `run_command({ command: \"npm install\" })` — do NOT write `$ npm install`.") lines.push("") - lines.push("2. **Explain as you work.** Tell the user what you're doing and why.") - lines.push(" \"I need to check the existing code first\" -> read_file.") - lines.push(" \"I'll create the component now\" -> write_file.") - lines.push(" \"Let me install dependencies\" -> run_command.") + lines.push("3. **Multi-step workflows in a single response.** Call multiple tools sequentially.") + lines.push(" 10+ tool calls in one response is normal. Do NOT stop after one step — scaffold,") + lines.push(" install, write files, build, then report the result. Never ask \"should I continue?\"") lines.push("") - lines.push("3. **Multi-step workflows are normal.** Plans often require 10+ steps:") - lines.push(" read -> search -> write -> run -> write -> run. Execute the full workflow") - lines.push(" without stopping to ask \"should I continue?\"") + lines.push("4. **Handle errors.** If a command fails, diagnose and fix it. Don't ask the user.") lines.push("") - lines.push("4. **Handle errors gracefully.** If a command fails, diagnose and fix it.") - lines.push(" Do not hand the problem back to the user.") + lines.push("5. **Create directories first.** mkdir -p before writing files or scaffolding.") lines.push("") + lines.push("6. **Fulfill thoroughly.** Include reasonable implied follow-ups. \"Create a todo app\"") + lines.push(" means scaffold + install deps + write source files + verify build + report.") + lines.push(" Do not stop at mkdir. Do not stop at scaffold. Complete the full workflow.") + lines.push("") + lines.push("7. **New files for new apps.** When creating a new app from scratch, create files.") + lines.push(" Don't try to \"edit\" nonexistent files. Write the full source code.") lines.push(`## Workspace: ${info.projectName || info.dirName}`) lines.push(`- Path: ${info.fullPath}`) - if (info.gitBranch) { - lines.push(`- Git branch: ${info.gitBranch}`) - } + if (info.gitBranch) lines.push(`- Git branch: ${info.gitBranch}`) lines.push(`- Files: ${info.fileCount}`) - if (info.isMonorepo) { - lines.push("- Structure: Monorepo") - } lines.push("") if (info.techStack.length > 0) { @@ -48,49 +68,54 @@ export function buildSystemPrompt(info: WorkspaceInfo): string { lines.push("") } - lines.push("## Available Tools") - lines.push("") - lines.push("You have full access to create, modify, and delete files in the workspace.") - lines.push("You can run shell commands to install packages, run builds, and start dev servers.") - lines.push("Do not ask the user for permission for routine operations — just execute them.") - lines.push("") - lines.push("1. `read_file(path, maxLines?)` — Read file contents from the workspace.") - lines.push(" Use this to examine source code, configs, or any file.") - lines.push("") - lines.push("2. `search_files(pattern, include?, maxResults?)` — Search for text patterns") - lines.push(" across workspace files. Use this to find relevant code or definitions.") - lines.push("") - lines.push("3. `write_file(path, content, description?)` — Create or overwrite files.") - lines.push(" Use for: new components, fixing bugs, adding features, config changes.") - lines.push("") - lines.push("4. `run_command(command, description?, timeout?)` — Execute shell commands.") - lines.push(" Use for: npm install, npm run build, git operations, running tests.") + if (hasTools) { + lines.push("## Tools") + lines.push("") + lines.push("You have full access to create, modify, and delete files in the workspace. Do not") + lines.push("ask permission for routine operations.") + lines.push("") + lines.push("- `read_file(path, maxLines?)` — Read file contents from the workspace.") + lines.push("- `search_files(pattern, include?, maxResults?)` — Search for text patterns") + lines.push(" across workspace files.") + lines.push("- `write_file(path, content, description?)` — Create or overwrite files.") + lines.push("- `run_command(command, description?, timeout?, cwd?, interactive?)` — Execute") + lines.push(" shell commands. Use for npm install, npm run build, git operations, running tests.") + lines.push(" **Use the `cwd` parameter instead of `cd` in the command string.**") + lines.push(" Set `interactive: true` for commands that prompt for input.") + lines.push("- `code_exec(code)` — Run JS/TS in a sandbox for calculations or one-off scripts.") + lines.push("- `read_instructions(path?)` — Read project instruction files") + lines.push(" (AGENTS.md, CLAUDE.md, README.md). Call this at session start to learn") + lines.push(" project conventions, build commands, and preferences.") + lines.push("") + } + lines.push("## Tone and Style") lines.push("") - lines.push("5. Fetch content from URLs for reference using the built-in web fetch tool.") - lines.push("") - lines.push("6. Search the web for current information using the built-in web search tool.") + lines.push("- Be concise and direct. Aim for fewer than 4 lines of text per response.") + lines.push("- Don't add explanations or summaries after completing work unless asked.") + lines.push("- Use GitHub-flavored markdown for formatting (rendered in monospace).") + lines.push("- Never add comments to code unless explicitly asked.") + lines.push("- Output text only to communicate with the user. Use tools for actions.") lines.push("") - lines.push("7. `code_exec(code)` — Run JavaScript/TypeScript in a sandbox for calculations.") + lines.push("## New App Workflow (Mandatory Sequence)") lines.push("") - lines.push("## Example Workflows") + lines.push("When creating a new application, follow this exact sequence using tool calls:") lines.push("") - lines.push("### Creating a new app:") - lines.push(" run_command(\"npm create vite@latest . -- --template react\")") - lines.push(" run_command(\"npm install\")") - lines.push(" write_file(\"src/App.jsx\", ...)") - lines.push(" write_file(\"src/components/NoteCard.jsx\", ...)") - lines.push(" run_command(\"npm run dev\")") + lines.push("1. `run_command({ command: \"mkdir -p apps/\" })`") + lines.push("2. `run_command({ command: \"npx --yes create-vite . --template react-ts\", cwd: \"apps/\", timeout: 120_000, interactive: true })`") + lines.push("3. `run_command({ command: \"npm install\", cwd: \"apps/\", timeout: 120_000 })`") + lines.push("4. `write_file` for each source file (App.tsx, index.css, etc.)") + lines.push("5. `run_command({ command: \"npm run build\", cwd: \"apps/\" })`") + lines.push("6. Text: \"Done. created with React + Vite. Build passes.\"") lines.push("") - lines.push("### Fixing a bug:") - lines.push(" read_file(\"src/components/BuggyComponent.tsx\")") - lines.push(" search_files(\"relatedFunction\", \"*.ts\")") - lines.push(" write_file(\"src/components/BuggyComponent.tsx\", ...)") - lines.push(" run_command(\"npm run test\")") + lines.push("IMPORTANT: Do NOT use `cd` in command strings. Use the `cwd` parameter instead.") + lines.push("Your response must start with step 1 — not with text explaining step 1.") lines.push("") lines.push("## Working Directory") lines.push("") lines.push("- The workspace root is the base for all relative file paths.") - lines.push("- All commands execute in the workspace root unless cwd is specified.") + lines.push("- All commands execute in the workspace root unless `cwd` is specified.") + lines.push("- Always use the `cwd` parameter for working in subdirectories.") + lines.push("- Never prefix commands with `cd &&` — pass `cwd: \"\"` instead.") return lines.join("\n") } diff --git a/apps/supercode-cli/server/src/config/agent-config.ts b/apps/supercode-cli/server/src/config/agent-config.ts index 57b7e33..d5fe56b 100644 --- a/apps/supercode-cli/server/src/config/agent-config.ts +++ b/apps/supercode-cli/server/src/config/agent-config.ts @@ -1,171 +1,47 @@ import { z } from "zod" -import { generateObject as aiGenerateObject } from "ai" -import chalk from "chalk" -import path from "node:path" -import { mkdir, writeFile } from "node:fs/promises" +import { ToolLoopAgent, stepCountIs, tool } from "ai" import type { LanguageModel } from "ai" - -const ApplicationSchema = z.object({ - folderName: z - .string() - .describe("Kebab-case folder name for the application"), - description: z - .string() - .describe("Brief description of what was created"), - files: z.array( - z - .object({ - path: z.string().describe("Relative file path (e.g. src/App.jsx)"), - content: z.string().describe("Complete file content"), - }) - .describe("All files needed for the application"), - ), - setupCommands: z.array( - z - .string() - .describe( - "Bash commands to setup and run (e.g. npm install, npm run dev)", - ), - ), - dependencies: z.record(z.string(), z.any()).optional().describe("NPM dependencies with versions"), -}) - -function displayFileTree(files: { path: string }[], folderName: string) { - console.log(chalk.cyan("\n 📁 Project Structure:")) - console.log(chalk.white(`${folderName}/`)) - - const filesByDir: Record = {} - for (const file of files) { - const parts = file.path.split("/") - const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "" - - if (!filesByDir[dir]) { - filesByDir[dir] = [] - } - filesByDir[dir]!.push(parts.at(-1)!) - } - - const sortedDirs = Object.keys(filesByDir).sort() - for (const dir of sortedDirs) { - if (dir) { - console.log(chalk.white(`├── ${dir}/`)) - for (const file of filesByDir[dir]!) { - console.log(chalk.white(`│ └── ${file}`)) - } - } else { - for (const file of filesByDir[dir]!) { - console.log(chalk.white(`├── ${file}`)) - } - } - } -} - -async function createApplicationFiles( - baseDir: string, - folderName: string, - files: { path: string; content: string }[], -) { - const appDir = path.join(baseDir, folderName) - - await mkdir(appDir, { recursive: true }) - console.log(chalk.cyan(`\n📁 Created directory: ${folderName}/`)) - - for (const file of files) { - const filePath = path.join(appDir, file.path) - const fileDir = path.dirname(filePath) - - await mkdir(fileDir, { recursive: true }) - await writeFile(filePath, file.content, "utf8") - console.log(chalk.green(` ✓ ${file.path}`)) - } - - return appDir -} - -export async function generateApplication( - description: string, - modelOrGenerate: LanguageModel | ((schema: any, prompt: string) => Promise<{ object: unknown }>), - cwd = process.cwd(), -) { - try { - console.log(chalk.cyan("\n🤖 Generating your application...\n")) - console.log(chalk.gray(`Request: ${description}\n`)) - - let application: unknown - const promptText = `Create a complete, production-ready application for: ${description} -CRITICAL REQUIREMENTS: -1. Generate ALL files needed for the application to run -2. Include package.json with ALL dependencies and correct versions -3. Include README.md with setup instructions -4. Include configuration files (.gitignore, etc.) -5. Write clean, well-commented, production-ready code -6. Include error handling and input validation -7. Use modern JavaScript/TypeScript best practices -8. Make sure all imports and paths are correct -9. NO PLACEHOLDERS - everything must be complete and working - -Provide: -- A meaningful kebab-case folder name -- All necessary files with complete content -- Setup commands (cd folder, npm install, npm run dev, etc.) -- All dependencies with versions` - if (typeof modelOrGenerate === "function") { - const result = await modelOrGenerate(ApplicationSchema, promptText) - application = result.object - } else { - const result = await aiGenerateObject({ - model: modelOrGenerate, - schema: ApplicationSchema, - prompt: promptText, - }) - application = result.object - } - - const app = application as z.infer - - console.log(chalk.green(`\n✅ Generated: ${app.folderName}\n`)) - console.log(chalk.gray(`Description: ${app.description}\n`)) - - if (app.files.length === 0) { - throw new Error("No files were generated") - } - - displayFileTree(app.files, app.folderName) - - console.log(chalk.cyan("\n📝 Creating files...\n")) - - const appDir = await createApplicationFiles( - cwd, - app.folderName, - app.files, - ) - - console.log(chalk.green.bold("\n✨ Application created successfully!\n")) - console.log(chalk.cyan(`📂 Location: ${chalk.bold(appDir)}\n`)) - - if (app.setupCommands.length > 0) { - console.log(chalk.cyan("📋 Next Steps:\n")) - console.log(chalk.white("```bash")) - for (const cmd of app.setupCommands) { - console.log(chalk.white(cmd)) - } - console.log(chalk.white("```\n")) - } - - return { - folderName: app.folderName, - appDir, - files: app.files.map((f: { path: string }) => f.path), - commands: app.setupCommands, - success: true, - } - } catch (error) { - console.log( - chalk.red(`\n❌ Error generating application: ${error instanceof Error ? error.message : String(error)}\n`), - ) - if (error instanceof Error && error.stack) { - console.log(chalk.dim(error.stack + "\n")) - } - throw error - } +import { writeFileTool } from "../tools/definitions/write-file" +import { runCommandTool } from "../tools/definitions/run-command" + +const agentInstructions = `You are a full-stack coding agent that creates complete, production-ready applications. + +YOUR WORKFLOW (follow exactly): +1. PLAN — Decide the project structure, tech stack, and all files needed +2. CREATE DIRS — Use run_command({ command: "mkdir -p " }) to create the directory structure +3. WRITE FILES — Use write_file for each source file with complete, working code +4. INSTALL DEPS — Use run_command({ command: "npm install", cwd: "" }) to install dependencies +5. BUILD — Use run_command({ command: "npm run build", cwd: "" }) to verify the build + +CRITICAL RULES: +- NEVER output shell commands as text. Use run_command tool instead. +- NEVER tell the user to "cd into the directory" — you already created it. +- Generate COMPLETE source files — no placeholders, no "// TODO", no "...rest of file". +- Include package.json with all dependencies and scripts. +- Include configuration files (tsconfig.json, vite.config.ts, .gitignore, etc.). +- After writing all files, run npm install. +- After install, run npm run build to verify. +- If build fails, fix the errors and rebuild. +- Only report success when the build passes. +- Use the cwd parameter in run_command — do NOT use "cd" in command strings. +- For scaffolding, use npx --yes with interactive: true.` + +export function createAppAgent(model: LanguageModel) { + return new ToolLoopAgent({ + model, + instructions: agentInstructions, + tools: { + write_file: tool({ + description: writeFileTool.description, + inputSchema: writeFileTool.parameters, + execute: async (input: any) => writeFileTool.execute(input), + }), + run_command: tool({ + description: runCommandTool.description, + inputSchema: runCommandTool.parameters, + execute: async (input: any) => runCommandTool.execute(input), + }), + }, + stopWhen: stepCountIs(50), + }) } diff --git a/apps/supercode-cli/server/src/config/openrouter.config.ts b/apps/supercode-cli/server/src/config/openrouter.config.ts index a126533..97c919b 100644 --- a/apps/supercode-cli/server/src/config/openrouter.config.ts +++ b/apps/supercode-cli/server/src/config/openrouter.config.ts @@ -1,7 +1,7 @@ export const openRouterConfig = { apiKey: process.env.OPENROUTER_API_KEY || "", - model: process.env.OPENROUTER_MODEL || "openai/gpt-oss-120b:free", + model: process.env.OPENROUTER_MODEL || "moonshotai/kimi-k2.6:free", maxTokens: Number(process.env.OPENROUTER_MAX_TOKENS) || 4096, siteUrl: process.env.OPENROUTER_SITE_URL || "", siteTitle: process.env.OPENROUTER_SITE_TITLE || "supercode", diff --git a/apps/supercode-cli/server/src/tools/definitions/read-instructions.ts b/apps/supercode-cli/server/src/tools/definitions/read-instructions.ts new file mode 100644 index 0000000..429d779 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/definitions/read-instructions.ts @@ -0,0 +1,50 @@ +import { z } from "zod" +import path from "node:path" +import { readFile, access } from "node:fs/promises" + +const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", "README.md", "CONTRIBUTING.md"] + +async function fileExists(p: string): Promise { + try { await access(p); return true } catch { return false } +} + +const readInstructionsSchema = z.object({ + path: z.string().optional().describe("Specific instruction file to read (omit to read all known files: AGENTS.md, CLAUDE.md, README.md, CONTRIBUTING.md)"), +}) + +export type ReadInstructionsArgs = z.infer + +export const readInstructionsTool = { + description: "Read project instruction files (AGENTS.md, CLAUDE.md, README.md) from the workspace root. Use this at the start of a session to understand project conventions, build commands, code style, and workflow preferences.", + parameters: readInstructionsSchema, + execute: async ({ path: specificPath }: ReadInstructionsArgs) => { + const workspaceRoot = process.env.SUPERCODE_WORKSPACE_ROOT || process.cwd() + const results: string[] = [] + + if (specificPath) { + const fullPath = path.resolve(workspaceRoot, specificPath) + if (!fullPath.startsWith(workspaceRoot)) { + throw new Error(`Path "${specificPath}" is outside workspace root`) + } + if (await fileExists(fullPath)) { + const content = await readFile(fullPath, "utf-8") + return `# ${specificPath}\n\n${content}` + } + return `No file found at "${specificPath}"` + } + + for (const name of INSTRUCTION_FILES) { + const fullPath = path.resolve(workspaceRoot, name) + if (await fileExists(fullPath)) { + const content = await readFile(fullPath, "utf-8") + results.push(`# ${name}\n\n${content}`) + } + } + + if (results.length === 0) { + return "No instruction files found in workspace root." + } + + return results.join("\n\n---\n\n") + }, +} diff --git a/apps/supercode-cli/server/src/tools/definitions/run-command.ts b/apps/supercode-cli/server/src/tools/definitions/run-command.ts index dd839d2..b99609d 100644 --- a/apps/supercode-cli/server/src/tools/definitions/run-command.ts +++ b/apps/supercode-cli/server/src/tools/definitions/run-command.ts @@ -1,7 +1,8 @@ import { z } from "zod" -import { exec } from "node:child_process" +import { spawn } from "node:child_process" import path from "node:path" + const runCommandSchema = z.object({ command: z.string().describe("Shell command to execute (e.g. 'npm install', 'npm run build', 'git status')"), description: z @@ -11,21 +12,38 @@ const runCommandSchema = z.object({ timeout: z .number() .optional() - .default(120_000) - .describe("Timeout in milliseconds (default: 120000)"), + .default(300_000) + .describe("Timeout in milliseconds (default: 300000 — increase for installs/scaffolding)"), cwd: z .string() .optional() - .describe("Working directory relative to workspace root (defaults to workspace root)"), + .describe("Working directory RELATIVE to workspace root (defaults to workspace root). Do NOT use 'cd' in the command — use this param instead."), + interactive: z + .boolean() + .optional() + .default(false) + .describe("Set to true if the command requires interactive input (prompts, selections). When true, stdin stays open and 'y' is piped on each prompt."), + autoYes: z + .boolean() + .optional() + .default(true) + .describe("Auto-answer 'y' to prompts. Set false to let the user interact directly."), }) export type RunCommandArgs = z.infer export const runCommandTool = { description: - "Execute a shell command in the workspace. Use this to install dependencies, run builds, start dev servers, run tests, or any other terminal operation.", + `Execute a shell command in the workspace. Use this to install dependencies, run builds, start dev servers, run tests, or any other terminal operation. + +IMPORTANT RULES: + • Do NOT use 'cd' in commands — use the 'cwd' parameter instead. Example: run_command({ command: "npm install", cwd: "packages/foo" }) + • For scaffolding (npm create, npx), set CI=true is already in env. Use the 'cwd' parameter, NOT 'cd &&' chains. + • If the command may prompt interactively, set interactive: true so stdin stays open. + • For any npm/npx scaffolding command, prefer: npx --yes (avoids install prompt) + • Check exitCode in the result — zero means success.`, parameters: runCommandSchema, - execute: async ({ command, timeout, cwd: subdir }: RunCommandArgs) => { + execute: async ({ command, timeout, cwd: subdir, interactive, autoYes }: RunCommandArgs) => { const workspaceRoot = process.env.SUPERCODE_WORKSPACE_ROOT || process.cwd() let resolvedCwd = workspaceRoot @@ -36,36 +54,118 @@ export const runCommandTool = { } } + process.stdout.write(`$ ${command}\n`) + return new Promise((resolve) => { - const child = exec( - command, - { - cwd: resolvedCwd, - encoding: "utf-8", - timeout, - maxBuffer: 10 * 1024 * 1024, - env: { ...process.env, PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" }, + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + let killed = false + let done = false + + const child = spawn("/bin/sh", ["-c", command], { + cwd: resolvedCwd, + env: { + ...process.env, + PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", + CI: "true", + npm_config_yes: "true", + YES: "1", + NONINTERACTIVE: "1", + TERM: "dumb", + GIT_TERMINAL_PROMPT: "0", + HOMEBREW_NO_AUTO_UPDATE: "1", }, - (error, stdout, stderr) => { - const result: Record = { - exitCode: error?.code ?? 0, - stdout: stdout || "", - stderr: stderr || "", - } + stdio: ["pipe", "pipe", "pipe"], + }) - if (error && error.killed) { - result.signal = error.signal || "SIGTERM" - result.stderr = (result.stderr as string) + `\nCommand timed out after ${timeout}ms` + if (child.stdin) { + if (autoYes) { + if (interactive) { + // Keep stdin open and pipe "y\n" on each read to handle multiple prompts + const pipeYes = () => { + try { + child.stdin!.write("y\n") + } catch {} + } + const interval = setInterval(pipeYes, 500) + child.on("close", () => clearInterval(interval)) + } else { + // Write once for simple confirmation prompts + child.stdin.write("y\n".repeat(20)) + child.stdin.end() } - - if (error && error.code === undefined && !error.killed) { - result.exitCode = -1 - result.stderr = (result.stderr as string) + `\n${error.message}` + } else { + // Let user interact directly — pipe stdin through + if (interactive && process.stdin.isTTY) { + process.stdin.pipe(child.stdin) + } else { + child.stdin.end() } + } + } - resolve(JSON.stringify(result)) - }, - ) + const timer = setTimeout(() => { + killed = true + child.kill("SIGTERM") + }, timeout) + + child.stdout?.on("data", (data: Buffer) => { + const text = data.toString() + stdoutChunks.push(text) + process.stdout.write(text.replace(/\n/g, "\r\n")) + }) + + child.stderr?.on("data", (data: Buffer) => { + const text = data.toString() + stderrChunks.push(text) + process.stderr.write(text.replace(/\n/g, "\r\n")) + }) + + child.on("close", (code) => { + if (done) return + done = true + clearTimeout(timer) + + process.stdout.write("\r\n") + + const stdout = stdoutChunks.join("") + const stderr = stderrChunks.join("") + const exitCode = code ?? 0 + + const result: Record = { + exitCode, + stdout, + stderr, + success: exitCode === 0, + cancelled: killed, + } + + if (killed) { + result.signal = "SIGTERM" + result.summary = `Command timed out after ${(timeout / 1000).toFixed(0)}s` + } else if (exitCode !== 0) { + result.summary = `Command failed with exit code ${exitCode}` + } else { + result.summary = "Command completed successfully" + } + + resolve(JSON.stringify(result)) + }) + + child.on("error", (err) => { + if (done) return + done = true + clearTimeout(timer) + process.stdout.write("\r\n") + resolve(JSON.stringify({ + exitCode: -1, + stdout: "", + stderr: err.message, + success: false, + cancelled: true, + summary: `Failed to start command: ${err.message}`, + })) + }) }) }, } diff --git a/apps/supercode-cli/server/src/tools/permission-manager.ts b/apps/supercode-cli/server/src/tools/permission-manager.ts index 5e40141..19a8341 100644 --- a/apps/supercode-cli/server/src/tools/permission-manager.ts +++ b/apps/supercode-cli/server/src/tools/permission-manager.ts @@ -3,13 +3,53 @@ import * as readline from "readline" import boxen from "boxen" import { theme } from "src/cli/utils/tui.ts" -export type PermissionAction = "allow" | "ask" | "deny" +// ---- Types (aligned with opencode) ---- -interface PermissionRule { - action: PermissionAction - rememberAlways: boolean +export type Effect = "allow" | "deny" | "ask" + +export interface Rule { + action: string + resource: string + effect: Effect +} + +export type Ruleset = Rule[] + +export type Reply = "once" | "always" | "reject" + +interface PendingRequest { + id: string + action: string + resource: string + resolve: (value: boolean) => void + reject: () => void +} + +// ---- Wildcard matching (from opencode) ---- + +function wildcardMatch(input: string, pattern: string): boolean { + let escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, ".") + + return new RegExp("^" + escaped + "$", "s").test(input) +} + +// ---- Evaluate: find last matching rule (opencode pattern) ---- + +function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule { + const match = rulesets + .flat() + .findLast( + (rule) => wildcardMatch(action, rule.action) && wildcardMatch(resource, rule.resource), + ) + + return match ?? { action, resource: "*", effect: "ask" } } +// ---- Dangerous patterns ---- + const DANGEROUS_PATTERNS: RegExp[] = [ /rm\s+-rf/, /\bDROP\s+TABLE\b/i, @@ -33,30 +73,55 @@ const DANGEROUS_PATTERNS: RegExp[] = [ /init\s+6/, ] -const DEFAULT_RULES: Record = { - read_file: { action: "allow", rememberAlways: false }, - search_files: { action: "allow", rememberAlways: false }, - url_fetch: { action: "allow", rememberAlways: false }, - web_search: { action: "allow", rememberAlways: false }, - code_exec: { action: "ask", rememberAlways: true }, - write_file: { action: "ask", rememberAlways: true }, - run_command: { action: "ask", rememberAlways: true }, -} +// ---- Default rulesets ---- + +const DEFAULT_RULES: Ruleset = [ + // Read-only tools: always allowed + { action: "read_file", resource: "*", effect: "allow" }, + { action: "search_files", resource: "*", effect: "allow" }, + { action: "url_fetch", resource: "*", effect: "allow" }, + { action: "web_search", resource: "*", effect: "allow" }, + { action: "read_instructions", resource: "*", effect: "allow" }, + { action: "todo_read", resource: "*", effect: "allow" }, + { action: "todo_write", resource: "*", effect: "allow" }, + + // Write/execute tools: ask by default + { action: "write_file", resource: "*", effect: "ask" }, + { action: "run_command", resource: "*", effect: "ask" }, + { action: "code_exec", resource: "*", effect: "ask" }, +] -export class PermissionManager { - private rules: Map - private alwaysCache: Map> = new Map() - private sessionLevel: "allow" | "ask" | "deny" | null = null +// ---- In-memory saved rules (persisted for the session) ---- + +let sessionSavedRules: Ruleset = [] - constructor() { - this.rules = new Map(Object.entries(DEFAULT_RULES)) +// ---- Resource extraction ---- + +function getResource(toolName: string, args: Record): string { + switch (toolName) { + case "write_file": + return String(args.path || "") + case "run_command": + return String(args.command || "") + case "code_exec": + return String(args.code || "").slice(0, 80) + default: + return String(args.path || args.command || args.pattern || "*") } +} - setSessionLevel(level: "allow" | "ask" | "deny"): void { +// ---- PermissionManager ---- + +export class PermissionManager { + private pendingRequests: Map = new Map() + private requestCounter = 0 + private sessionLevel: "allow" | "deny" | "ask" | null = null + + setSessionLevel(level: "allow" | "deny" | "ask" | null): void { this.sessionLevel = level } - getSessionLevel(): "allow" | "ask" | "deny" | null { + getSessionLevel(): "allow" | "deny" | "ask" | null { return this.sessionLevel } @@ -65,59 +130,57 @@ export class PermissionManager { } async check(toolName: string, args: Record): Promise { + // 1. Session-level override if (this.sessionLevel === "allow") return true if (this.sessionLevel === "deny") return false - const rule = this.rules.get(toolName) - if (!rule || rule.action === "allow") return true - if (rule.action === "deny") return false + // 2. Extract resource + const resource = getResource(toolName, args) - if (rule.rememberAlways && this.isAlwaysAllowed(toolName, args)) { - return true - } + // 3. Evaluate rules (opencode pattern: findLast match) + const allRules: Ruleset = [...DEFAULT_RULES, ...sessionSavedRules] + const rule = evaluate(toolName, resource, allRules) - const isDangerous = toolName === "run_command" && this.isDangerousCommand(String(args.command || "")) + // 4. Short-circuit + if (rule.effect === "allow") return true + if (rule.effect === "deny") return false - return this.promptUser(toolName, args, isDangerous, rule.rememberAlways) + // 5. Ask the user + const isDangerous = toolName === "run_command" && this.isDangerousCommand(resource) + return this.promptUser(toolName, resource, args, isDangerous) } - private isAlwaysAllowed(toolName: string, args: Record): boolean { - const cache = this.alwaysCache.get(toolName) - if (!cache) return false - - if (toolName === "run_command") { - const command = String(args.command || "") - for (const prefix of cache) { - if (command.startsWith(prefix)) return true - } - return false - } - - if (toolName === "write_file") { - const path = String(args.path || "") - for (const pattern of cache) { - if (path.startsWith(pattern)) return true - if (pattern.endsWith("/*") && path.startsWith(pattern.slice(0, -2))) return true - } - return false - } - - return false - } + private promptUser( + toolName: string, + resource: string, + args: Record, + isDangerous: boolean, + ): Promise { + return new Promise((resolve, reject) => { + const id = `req_${++this.requestCounter}` + + this.pendingRequests.set(id, { + id, + action: toolName, + resource, + resolve, + reject: () => { + resolve(false) + }, + }) - private addAlwaysCache(toolName: string, args: Record, alwaysPattern: string): void { - if (!this.alwaysCache.has(toolName)) { - this.alwaysCache.set(toolName, new Set()) - } - this.alwaysCache.get(toolName)!.add(alwaysPattern) + this.renderPrompt(toolName, resource, args, isDangerous, id, resolve) + }) } - private async promptUser( + private renderPrompt( toolName: string, + resource: string, args: Record, isDangerous: boolean, - canRememberAlways: boolean, - ): Promise { + requestId: string, + resolve: (value: boolean) => void, + ) { const stdin = process.stdin const wasRaw = stdin.isRaw @@ -130,18 +193,17 @@ export class PermissionManager { let content = "" if (toolName === "write_file") { - content = `Supercode wants to write:\n ${chalk.cyan(String(args.path || ""))}` + content = `Supercode wants to write:\n ${chalk.cyan(resource)}` if (args.description) { content += `\n ${chalk.dim(String(args.description))}` } } else if (toolName === "run_command") { - content = `Run:\n $ ${chalk.cyan(String(args.command || ""))}` + content = `Run:\n $ ${chalk.cyan(resource)}` if (args.description) { content += `\n ${chalk.dim(String(args.description))}` } } else if (toolName === "code_exec") { - const code = String(args.code || "") - const preview = code.length > 80 ? code.slice(0, 77) + "..." : code + const preview = resource.length > 80 ? resource.slice(0, 77) + "..." : resource content = `Execute code:\n ${chalk.cyan(preview)}` } @@ -157,47 +219,77 @@ export class PermissionManager { }) console.log(box) - return new Promise((resolve) => { - const rl = readline.createInterface({ input: stdin, output: process.stdout }) + const rl = readline.createInterface({ input: stdin, output: process.stdout }) - const prompt = isDangerous - ? "Allow this operation? (y/N): " - : canRememberAlways - ? "[y] Once [a] Always for session [n] Deny: " - : "Allow? (y/N): " + const prompt = isDangerous + ? "Allow this operation? (y/N): " + : "[y] Once [a] Always for session [n] Deny: " - rl.question(prompt, (answer) => { - rl.close() + rl.question(prompt, (answer) => { + rl.close() - if (stdin.isTTY && wasRaw) { - stdin.setRawMode(true) - } + if (stdin.isTTY && wasRaw) { + stdin.setRawMode(true) + } - const a = answer.trim().toLowerCase() - - if (a === "y" || a === "yes") { - resolve(true) - } else if ((a === "a" || a === "always") && canRememberAlways && !isDangerous) { - let alwaysPattern = "*" - if (toolName === "run_command") { - const cmd = String(args.command || "") - const parts = cmd.split(/\s+/) - if (parts.length > 0) { - alwaysPattern = parts[0] + " " - } - } else if (toolName === "write_file") { - const path = String(args.path || "") - const lastSlash = path.lastIndexOf("/") - alwaysPattern = lastSlash >= 0 ? path.slice(0, lastSlash + 1) : "" - } - this.addAlwaysCache(toolName, args, alwaysPattern) - resolve(true) - } else { - resolve(false) - } - }) + const a = answer.trim().toLowerCase() + + if (a === "y" || a === "yes") { + this.pendingRequests.delete(requestId) + this.onReplied(toolName, resource, "once") + resolve(true) + } else if ((a === "a" || a === "always") && !isDangerous) { + // Save as "always allow" rule for this action+resource pattern + sessionSavedRules.push({ action: toolName, resource: this.alwaysPattern(toolName, resource), effect: "allow" }) + this.pendingRequests.delete(requestId) + this.onReplied(toolName, resource, "always") + resolve(true) + } else { + this.pendingRequests.delete(requestId) + this.onReplied(toolName, resource, "reject") + resolve(false) + } }) } + + private alwaysPattern(toolName: string, resource: string): string { + if (toolName === "run_command") { + const parts = resource.split(/\s+/) + return (parts[0] ?? "") + " *" + } + if (toolName === "write_file") { + const lastSlash = resource.lastIndexOf("/") + return lastSlash >= 0 ? resource.slice(0, lastSlash + 1) + "*" : "*" + } + return resource + } + + // ---- Cascade (opencode pattern) ---- + // When a user says "always allow", auto-approve pending requests + // that also match the saved rules. + // When a user says "reject", reject all pending requests for this tool. + + private onReplied(action: string, resource: string, reply: Reply): void { + if (reply === "always") { + const allRules: Ruleset = [...DEFAULT_RULES, ...sessionSavedRules] + for (const [id, pending] of this.pendingRequests) { + if (pending.action !== action) continue + const rule = evaluate(pending.action, pending.resource, allRules) + if (rule.effect === "allow") { + pending.resolve(true) + this.pendingRequests.delete(id) + } + } + } + + if (reply === "reject") { + for (const [id, pending] of this.pendingRequests) { + if (pending.action !== action) continue + pending.reject() + this.pendingRequests.delete(id) + } + } + } } export const permissionManager = new PermissionManager() diff --git a/apps/supercode-cli/server/src/tools/registry.ts b/apps/supercode-cli/server/src/tools/registry.ts index ebc81a0..5999cb9 100644 --- a/apps/supercode-cli/server/src/tools/registry.ts +++ b/apps/supercode-cli/server/src/tools/registry.ts @@ -5,18 +5,16 @@ import { runCommandTool } from "./definitions/run-command.ts" import { urlFetchTool } from "./definitions/url-fetch.ts" import { webSearchTool } from "./definitions/web-search.ts" import { codeExecTool } from "./definitions/code-exec.ts" +import { readInstructionsTool } from "./definitions/read-instructions.ts" import { permissionManager } from "./permission-manager.ts" -function withPermission(tool: Record): Record { +function withPermission(name: string, tool: Record): Record { const originalExecute = tool.execute as ((args: any) => Promise) | undefined if (!originalExecute) return tool return { ...tool, execute: async (args: any) => { - const allowed = await permissionManager.check( - (tool.name || tool.description) as string, - args, - ) + const allowed = await permissionManager.check(name, args) if (!allowed) { return JSON.stringify({ cancelled: true, reason: "Permission denied by user" }) } @@ -28,9 +26,10 @@ function withPermission(tool: Record): Record export const tools = { read_file: readFileTool, search_files: searchFilesTool, - write_file: withPermission(writeFileTool as unknown as Record), - run_command: withPermission(runCommandTool as unknown as Record), + write_file: withPermission("write_file", writeFileTool as unknown as Record), + run_command: withPermission("run_command", runCommandTool as unknown as Record), url_fetch: urlFetchTool, web_search: webSearchTool, - code_exec: withPermission(codeExecTool as unknown as Record), + code_exec: withPermission("code_exec", codeExecTool as unknown as Record), + read_instructions: readInstructionsTool, } diff --git a/bun.lock b/bun.lock index 5ef0f83..03b9d3b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "super-platform", "dependencies": { - "-": "^0.0.1", "@pinecone-database/pinecone": "^7.0.0", "g": "^2.0.1", "inngest": "^3.54.1", @@ -90,17 +89,25 @@ }, "apps/supercode-cli/server": { "name": "supercode-cli", - "version": "0.1.0", + "version": "0.1.5", "bin": { "supercode": "dist/main.js", }, "dependencies": { + "@ai-sdk/openai-compatible": "^2.0.48", + }, + "devDependencies": { "@ai-sdk/google": "^3.0.80", "@clack/prompts": "^1.5.0", "@openrouter/ai-sdk-provider": "^2.9.0", "@openrouter/sdk": "^0.12.79", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@super/db-terminal": "workspace:*", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/pg": "^8.18.0", "ai": "^6.0.195", "api": "^6.1.3", "better-auth": "^1.5.5", @@ -115,20 +122,13 @@ "oas": "^34.0.1", "open": "^11.0.0", "pg": "^8.20.0", + "prisma": "^7.4.2", + "typescript": "^5.7.0", "vercel-minimax-ai-provider": "^0.0.2", "yocto-spinner": "^1.2.0", "zod": "^3.25.2", "zod-to-json-schema": "^3.25.2", }, - "devDependencies": { - "@super/db-terminal": "workspace:*", - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", - "@types/node": "^22.0.0", - "@types/pg": "^8.18.0", - "prisma": "^7.4.2", - "typescript": "^5.7.0", - }, }, "apps/video": { "name": "video", @@ -311,14 +311,14 @@ "unrs-resolver", ], "packages": { - "-": ["-@0.0.1", "", {}, "sha512-3HfneK3DGAm05fpyj20sT3apkNcvPpCuccOThOPdzz8sY7GgQGe0l93XH9bt+YzibcTIgUAIMoyVJI740RtgyQ=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@0.0.56", "", { "dependencies": { "@ai-sdk/provider": "0.0.26", "@ai-sdk/provider-utils": "1.0.22" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-FC/XbeFANFp8rHH+zEZF34cvRu9T42rQxw9QnUzJ1LXTi1cWjxYOx2Zo4vfg0iofxxqgOe4fT94IdT2ERQ89bA=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.61", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zxB5bqe7tx+tX15RHTFdnm7G3PVxh9l6WfeCgnFhhYZ8PfbeLgb3x/YgxO5vwRnaDpfArMXyYjSFjJ9d+I9ZGw=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VKguP0x/PUYpdQyuA/uy5pDGJy6reL0X/yDKxHfL207aCUXpFIBmyMhVs4US39dkEVhtmIFSwXauY0Pt170JRw=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q=="], + "@ai-sdk/provider": ["@ai-sdk/provider@0.0.26", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "0.0.26", "eventsource-parser": "^1.1.2", "nanoid": "^3.3.7", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.0.0" }, "optionalPeers": ["zod"] }, "sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ=="], @@ -3457,6 +3457,10 @@ "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="], + "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@antfu/ni/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -4297,6 +4301,8 @@ "@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "@apidevtools/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],