import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Api, AssistantMessage, KnownProvider, Model } from "@mariozechner/pi-ai"; import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; import chalk from "chalk"; import { existsSync, readFileSync, statSync } from "fs"; import { homedir } from "os"; import { extname, join, resolve } from "path"; import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js"; import { calculateContextTokens, compact, shouldCompact } from "./compaction.js"; import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getModelsPath, getReadmePath, VERSION, } from "./config.js"; import { exportFromFile } from "./export-html.js"; import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js"; import { loadSessionFromEntries, SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js"; import { initTheme } from "./theme/theme.js"; import { allTools, codingTools, type ToolName } from "./tools/index.js"; import { ensureTool } from "./tools-manager.js"; import { SessionSelectorComponent } from "./tui/session-selector.js"; import { TuiRenderer } from "./tui/tui-renderer.js"; const defaultModelPerProvider: Record = { anthropic: "claude-sonnet-4-5", openai: "gpt-5.1-codex", google: "gemini-2.5-pro", openrouter: "openai/gpt-5.1-codex", xai: "grok-4-fast-non-reasoning", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.6", zai: "glm-4.6", }; type Mode = "text" | "json" | "rpc"; interface Args { provider?: string; model?: string; apiKey?: string; systemPrompt?: string; appendSystemPrompt?: string; thinking?: ThinkingLevel; continue?: boolean; resume?: boolean; help?: boolean; mode?: Mode; noSession?: boolean; session?: string; models?: string[]; tools?: ToolName[]; print?: boolean; export?: string; messages: string[]; fileArgs: string[]; } function parseArgs(args: string[]): Args { const result: Args = { messages: [], fileArgs: [], }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--help" || arg === "-h") { result.help = true; } else if (arg === "--mode" && i + 1 < args.length) { const mode = args[++i]; if (mode === "text" || mode === "json" || mode === "rpc") { result.mode = mode; } } else if (arg === "--continue" || arg === "-c") { result.continue = true; } else if (arg === "--resume" || arg === "-r") { result.resume = true; } else if (arg === "--provider" && i + 1 < args.length) { result.provider = args[++i]; } else if (arg === "--model" && i + 1 < args.length) { result.model = args[++i]; } else if (arg === "--api-key" && i + 1 < args.length) { result.apiKey = args[++i]; } else if (arg === "--system-prompt" && i + 1 < args.length) { result.systemPrompt = args[++i]; } else if (arg === "--append-system-prompt" && i + 1 < args.length) { result.appendSystemPrompt = args[++i]; } else if (arg === "--no-session") { result.noSession = true; } else if (arg === "--session" && i + 1 < args.length) { result.session = args[++i]; } else if (arg === "--models" && i + 1 < args.length) { result.models = args[++i].split(",").map((s) => s.trim()); } else if (arg === "--tools" && i + 1 < args.length) { const toolNames = args[++i].split(",").map((s) => s.trim()); const validTools: ToolName[] = []; for (const name of toolNames) { if (name in allTools) { validTools.push(name as ToolName); } else { console.error( chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`), ); } } result.tools = validTools; } else if (arg === "--thinking" && i + 1 < args.length) { const level = args[++i]; if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") { result.thinking = level; } else { console.error( chalk.yellow( `Warning: Invalid thinking level "${level}". Valid values: off, minimal, low, medium, high`, ), ); } } else if (arg === "--print" || arg === "-p") { result.print = true; } else if (arg === "--export" && i + 1 < args.length) { result.export = args[++i]; } else if (arg.startsWith("@")) { result.fileArgs.push(arg.slice(1)); // Remove @ prefix } else if (!arg.startsWith("-")) { result.messages.push(arg); } } return result; } /** * Map of file extensions to MIME types for common image formats */ const IMAGE_MIME_TYPES: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", }; /** * Check if a file is an image based on its extension */ function isImageFile(filePath: string): string | null { const ext = extname(filePath).toLowerCase(); return IMAGE_MIME_TYPES[ext] || null; } /** * Expand ~ to home directory */ function expandPath(filePath: string): string { if (filePath === "~") { return homedir(); } if (filePath.startsWith("~/")) { return homedir() + filePath.slice(1); } return filePath; } /** * Process @file arguments into text content and image attachments */ function processFileArguments(fileArgs: string[]): { textContent: string; imageAttachments: Attachment[] } { let textContent = ""; const imageAttachments: Attachment[] = []; for (const fileArg of fileArgs) { // Expand and resolve path const expandedPath = expandPath(fileArg); const absolutePath = resolve(expandedPath); // Check if file exists if (!existsSync(absolutePath)) { console.error(chalk.red(`Error: File not found: ${absolutePath}`)); process.exit(1); } // Check if file is empty const stats = statSync(absolutePath); if (stats.size === 0) { // Skip empty files continue; } const mimeType = isImageFile(absolutePath); if (mimeType) { // Handle image file const content = readFileSync(absolutePath); const base64Content = content.toString("base64"); const attachment: Attachment = { id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, type: "image", fileName: absolutePath.split("/").pop() || absolutePath, mimeType, size: stats.size, content: base64Content, }; imageAttachments.push(attachment); // Add text reference to image textContent += `\n`; } else { // Handle text file try { const content = readFileSync(absolutePath, "utf-8"); textContent += `\n${content}\n\n`; } catch (error: any) { console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${error.message}`)); process.exit(1); } } } return { textContent, imageAttachments }; } function printHelp() { console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools ${chalk.bold("Usage:")} ${APP_NAME} [options] [@files...] [messages...] ${chalk.bold("Options:")} --provider Provider name (default: google) --model Model ID (default: gemini-2.5-flash) --api-key API key (defaults to env vars) --system-prompt System prompt (default: coding assistant prompt) --append-system-prompt Append text or file contents to the system prompt --mode Output mode: text (default), json, or rpc --print, -p Non-interactive mode: process prompt and exit --continue, -c Continue previous session --resume, -r Select a session to resume --session Use specific session file --no-session Don't save session (ephemeral) --models Comma-separated model patterns for quick cycling with Ctrl+P --tools Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high --export Export session file to HTML and exit --help, -h Show this help ${chalk.bold("Examples:")} # Interactive mode ${APP_NAME} # Interactive mode with initial prompt ${APP_NAME} "List all .ts files in src/" # Include files in initial message ${APP_NAME} @prompt.md @image.png "What color is the sky?" # Non-interactive mode (process and exit) ${APP_NAME} -p "List all .ts files in src/" # Multiple messages (interactive) ${APP_NAME} "Read package.json" "What dependencies do we have?" # Continue previous session ${APP_NAME} --continue "What did we discuss?" # Use different model ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code" # Limit model cycling to specific models ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o # Cycle models with fixed thinking levels ${APP_NAME} --models sonnet:high,haiku:low # Start with a specific thinking level ${APP_NAME} --thinking high "Solve this complex problem" # Read-only mode (no file modifications possible) ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/" # Export a session file to HTML ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl ${APP_NAME} --export session.jsonl output.html ${chalk.bold("Environment Variables:")} ANTHROPIC_API_KEY - Anthropic Claude API key ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) OPENAI_API_KEY - OpenAI GPT API key GEMINI_API_KEY - Google Gemini API key GROQ_API_KEY - Groq API key CEREBRAS_API_KEY - Cerebras API key XAI_API_KEY - xAI Grok API key OPENROUTER_API_KEY - OpenRouter API key ZAI_API_KEY - ZAI API key ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) ${chalk.bold("Available Tools (default: read, bash, edit, write):")} read - Read file contents bash - Execute bash commands edit - Edit files with find/replace write - Write files (creates/overwrites) grep - Search file contents (read-only, off by default) find - Find files by glob pattern (read-only, off by default) ls - List directory contents (read-only, off by default) `); } // Tool descriptions for system prompt const toolDescriptions: Record = { read: "Read file contents", bash: "Execute bash commands (ls, grep, find, etc.)", edit: "Make surgical edits to files (find exact text and replace)", write: "Create or overwrite files", grep: "Search file contents for patterns (respects .gitignore)", find: "Find files by glob pattern (respects .gitignore)", ls: "List directory contents", }; function resolvePromptInput(input: string | undefined, description: string): string | undefined { if (!input) { return undefined; } if (existsSync(input)) { try { return readFileSync(input, "utf-8"); } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); return input; } } return input; } function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[], appendSystemPrompt?: string): string { const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt"); const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt"); const now = new Date(); const dateTime = now.toLocaleString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short", }); const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : ""; if (resolvedCustomPrompt) { let prompt = resolvedCustomPrompt; if (appendSection) { prompt += appendSection; } // Append project context files const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { prompt += "\n\n# Project Context\n\n"; prompt += "The following project context files have been loaded:\n\n"; for (const { path: filePath, content } of contextFiles) { prompt += `## ${filePath}\n\n${content}\n\n`; } } // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; prompt += `\nCurrent working directory: ${process.cwd()}`; return prompt; } // Get absolute path to README.md const readmePath = getReadmePath(); // Build tools list based on selected tools const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]); const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n"); // Build guidelines based on which tools are actually available const guidelinesList: string[] = []; const hasBash = tools.includes("bash"); const hasEdit = tools.includes("edit"); const hasWrite = tools.includes("write"); const hasGrep = tools.includes("grep"); const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); const hasRead = tools.includes("read"); // Read-only mode notice (no bash, edit, or write) if (!hasBash && !hasEdit && !hasWrite) { guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands"); } // Bash without edit/write = read-only bash mode if (hasBash && !hasEdit && !hasWrite) { guidelinesList.push( "Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files", ); } // File exploration guidelines if (hasBash && !hasGrep && !hasFind && !hasLs) { guidelinesList.push("Use bash for file operations like ls, grep, find"); } else if (hasBash && (hasGrep || hasFind || hasLs)) { guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)"); } // Read before edit guideline if (hasRead && hasEdit) { guidelinesList.push("Use read to examine files before editing"); } // Edit guideline if (hasEdit) { guidelinesList.push("Use edit for precise changes (old text must match exactly)"); } // Write guideline if (hasWrite) { guidelinesList.push("Use write only for new files or complete rewrites"); } // Output guideline (only when actually writing/executing) if (hasEdit || hasWrite) { guidelinesList.push( "When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did", ); } // Always include these guidelinesList.push("Be concise in your responses"); guidelinesList.push("Show file paths clearly when working with files"); const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n"); let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. Available tools: ${toolsList} Guidelines: ${guidelines} Documentation: - Your own documentation (including custom model setup and theme creation) is at: ${readmePath} - Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`; if (appendSection) { prompt += appendSection; } // Append project context files const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { prompt += "\n\n# Project Context\n\n"; prompt += "The following project context files have been loaded:\n\n"; for (const { path: filePath, content } of contextFiles) { prompt += `## ${filePath}\n\n${content}\n\n`; } } // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; prompt += `\nCurrent working directory: ${process.cwd()}`; return prompt; } /** * Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md) */ function loadContextFileFromDir(dir: string): { path: string; content: string } | null { const candidates = ["AGENTS.md", "CLAUDE.md"]; for (const filename of candidates) { const filePath = join(dir, filename); if (existsSync(filePath)) { try { return { path: filePath, content: readFileSync(filePath, "utf-8"), }; } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); } } } return null; } /** * Load all project context files in order: * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md * 2. Parent directories (top-most first) down to cwd * Each returns {path, content} for separate messages */ function loadProjectContextFiles(): Array<{ path: string; content: string }> { const contextFiles: Array<{ path: string; content: string }> = []; // 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/ const globalContextDir = getAgentDir(); const globalContext = loadContextFileFromDir(globalContextDir); if (globalContext) { contextFiles.push(globalContext); } // 2. Walk up from cwd to root, collecting all context files const cwd = process.cwd(); const ancestorContextFiles: Array<{ path: string; content: string }> = []; let currentDir = cwd; const root = resolve("/"); while (true) { const contextFile = loadContextFileFromDir(currentDir); if (contextFile) { // Add to beginning so we get top-most parent first ancestorContextFiles.unshift(contextFile); } // Stop if we've reached root if (currentDir === root) break; // Move up one directory const parentDir = resolve(currentDir, ".."); if (parentDir === currentDir) break; // Safety check currentDir = parentDir; } // Add ancestor files in order (top-most → cwd) contextFiles.push(...ancestorContextFiles); return contextFiles; } async function checkForNewVersion(currentVersion: string): Promise { try { const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest"); if (!response.ok) return null; const data = (await response.json()) as { version?: string }; const latestVersion = data.version; if (latestVersion && latestVersion !== currentVersion) { return latestVersion; } return null; } catch (error) { // Silently fail - don't disrupt the user experience return null; } } /** * Resolve model patterns to actual Model objects with optional thinking levels * Format: "pattern:level" where :level is optional * For each pattern, finds all matching models and picks the best version: * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929) * 2. If no alias, pick the latest dated version */ async function resolveModelScope( patterns: string[], ): Promise; thinkingLevel: ThinkingLevel }>> { const { models: availableModels, error } = await getAvailableModels(); if (error) { console.warn(chalk.yellow(`Warning: Error loading models: ${error}`)); return []; } const scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = []; for (const pattern of patterns) { // Parse pattern:level format const parts = pattern.split(":"); const modelPattern = parts[0]; let thinkingLevel: ThinkingLevel = "off"; if (parts.length > 1) { const level = parts[1]; if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") { thinkingLevel = level; } else { console.warn( chalk.yellow(`Warning: Invalid thinking level "${level}" in pattern "${pattern}". Using "off" instead.`), ); } } // Check for provider/modelId format (provider is everything before the first /) const slashIndex = modelPattern.indexOf("/"); if (slashIndex !== -1) { const provider = modelPattern.substring(0, slashIndex); const modelId = modelPattern.substring(slashIndex + 1); const providerMatch = availableModels.find( (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(), ); if (providerMatch) { if ( !scopedModels.find( (sm) => sm.model.id === providerMatch.id && sm.model.provider === providerMatch.provider, ) ) { scopedModels.push({ model: providerMatch, thinkingLevel }); } continue; } // No exact provider/model match - fall through to other matching } // Check for exact ID match (case-insensitive) const exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase()); if (exactMatch) { // Exact match found - use it directly if (!scopedModels.find((sm) => sm.model.id === exactMatch.id && sm.model.provider === exactMatch.provider)) { scopedModels.push({ model: exactMatch, thinkingLevel }); } continue; } // No exact match - fall back to partial matching const matches = availableModels.filter( (m) => m.id.toLowerCase().includes(modelPattern.toLowerCase()) || m.name?.toLowerCase().includes(modelPattern.toLowerCase()), ); if (matches.length === 0) { console.warn(chalk.yellow(`Warning: No models match pattern "${modelPattern}"`)); continue; } // Helper to check if a model ID looks like an alias (no date suffix) // Dates are typically in format: -20241022 or -20250929 const isAlias = (id: string): boolean => { // Check if ID ends with -latest if (id.endsWith("-latest")) return true; // Check if ID ends with a date pattern (-YYYYMMDD) const datePattern = /-\d{8}$/; return !datePattern.test(id); }; // Separate into aliases and dated versions const aliases = matches.filter((m) => isAlias(m.id)); const datedVersions = matches.filter((m) => !isAlias(m.id)); let bestMatch: Model; if (aliases.length > 0) { // Prefer alias - if multiple aliases, pick the one that sorts highest aliases.sort((a, b) => b.id.localeCompare(a.id)); bestMatch = aliases[0]; } else { // No alias found, pick latest dated version datedVersions.sort((a, b) => b.id.localeCompare(a.id)); bestMatch = datedVersions[0]; } // Avoid duplicates if (!scopedModels.find((sm) => sm.model.id === bestMatch.id && sm.model.provider === bestMatch.provider)) { scopedModels.push({ model: bestMatch, thinkingLevel }); } } return scopedModels; } async function selectSession(sessionManager: SessionManager): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); let resolved = false; const selector = new SessionSelectorComponent( sessionManager, (path: string) => { if (!resolved) { resolved = true; ui.stop(); resolve(path); } }, () => { if (!resolved) { resolved = true; ui.stop(); resolve(null); } }, ); ui.addChild(selector); ui.setFocus(selector.getSessionList()); ui.start(); }); } async function runInteractiveMode( agent: Agent, sessionManager: SessionManager, settingsManager: SettingsManager, version: string, changelogMarkdown: string | null = null, modelFallbackMessage: string | null = null, newVersion: string | null = null, scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [], initialMessages: string[] = [], initialMessage?: string, initialAttachments?: Attachment[], fdPath: string | null = null, ): Promise { const renderer = new TuiRenderer( agent, sessionManager, settingsManager, version, changelogMarkdown, newVersion, scopedModels, fdPath, ); // Initialize TUI (subscribes to agent events internally) await renderer.init(); // Render any existing messages (from --continue mode) renderer.renderInitialMessages(agent.state); // Show model fallback warning at the end of the chat if applicable if (modelFallbackMessage) { renderer.showWarning(modelFallbackMessage); } // Load file-based slash commands for expansion const fileCommands = loadSlashCommands(); // Process initial message with attachments if provided (from @file args) if (initialMessage) { try { await agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; renderer.showError(errorMessage); } } // Process remaining initial messages if provided (from CLI args) for (const message of initialMessages) { try { await agent.prompt(expandSlashCommand(message, fileCommands)); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; renderer.showError(errorMessage); } } // Interactive loop while (true) { const userInput = await renderer.getUserInput(); // Process the message - agent.prompt will add user message and trigger state updates try { await agent.prompt(userInput); } catch (error: unknown) { // Display error in the TUI by adding an error message to the chat const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; renderer.showError(errorMessage); } } } async function runSingleShotMode( agent: Agent, _sessionManager: SessionManager, messages: string[], mode: "text" | "json", initialMessage?: string, initialAttachments?: Attachment[], ): Promise { // Load file-based slash commands for expansion const fileCommands = loadSlashCommands(); if (mode === "json") { // Subscribe to all events and output as JSON agent.subscribe((event) => { // Output event as JSON (same format as session manager) console.log(JSON.stringify(event)); }); } // Send initial message with attachments if provided if (initialMessage) { await agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments); } // Send remaining messages for (const message of messages) { await agent.prompt(expandSlashCommand(message, fileCommands)); } // In text mode, only output the final assistant message if (mode === "text") { const lastMessage = agent.state.messages[agent.state.messages.length - 1]; if (lastMessage.role === "assistant") { const assistantMsg = lastMessage as AssistantMessage; // Check for error/aborted and output error message if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); process.exit(1); } for (const content of assistantMsg.content) { if (content.type === "text") { console.log(content.text); } } } } } async function runRpcMode( agent: Agent, sessionManager: SessionManager, settingsManager: SettingsManager, ): Promise { // Track if auto-compaction is in progress let autoCompactionInProgress = false; // Auto-compaction helper const checkAutoCompaction = async () => { if (autoCompactionInProgress) return; const settings = settingsManager.getCompactionSettings(); if (!settings.enabled) return; // Get last non-aborted assistant message const messages = agent.state.messages; let lastAssistant: AssistantMessage | null = null; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { const assistantMsg = msg as AssistantMessage; if (assistantMsg.stopReason !== "aborted") { lastAssistant = assistantMsg; break; } } } if (!lastAssistant) return; const contextTokens = calculateContextTokens(lastAssistant.usage); const contextWindow = agent.state.model.contextWindow; if (!shouldCompact(contextTokens, contextWindow, settings)) return; // Trigger auto-compaction autoCompactionInProgress = true; try { const apiKey = await getApiKeyForModel(agent.state.model); if (!apiKey) { throw new Error(`No API key for ${agent.state.model.provider}`); } const entries = sessionManager.loadEntries(); const compactionEntry = await compact(entries, agent.state.model, settings, apiKey); sessionManager.saveCompaction(compactionEntry); const loaded = loadSessionFromEntries(sessionManager.loadEntries()); agent.replaceMessages(loaded.messages); // Emit auto-compaction event console.log(JSON.stringify({ ...compactionEntry, auto: true })); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.log(JSON.stringify({ type: "error", error: `Auto-compaction failed: ${message}` })); } finally { autoCompactionInProgress = false; } }; // Subscribe to all events and output as JSON (same pattern as tui-renderer) agent.subscribe(async (event) => { console.log(JSON.stringify(event)); // Save messages to session if (event.type === "message_end") { sessionManager.saveMessage(event.message); // Yield to microtask queue to allow agent state to update // (tui-renderer does this implicitly via await handleEvent) await Promise.resolve(); // Check if we should initialize session now (after first user+assistant exchange) if (sessionManager.shouldInitializeSession(agent.state.messages)) { sessionManager.startSession(agent.state); } // Check for auto-compaction after assistant messages if (event.message.role === "assistant") { await checkAutoCompaction(); } } }); // Listen for JSON input on stdin const readline = await import("readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false, }); rl.on("line", async (line: string) => { try { const input = JSON.parse(line); // Handle different RPC commands if (input.type === "prompt" && input.message) { await agent.prompt(input.message, input.attachments); } else if (input.type === "abort") { agent.abort(); } else if (input.type === "compact") { // Handle compaction request try { const apiKey = await getApiKeyForModel(agent.state.model); if (!apiKey) { throw new Error(`No API key for ${agent.state.model.provider}`); } const entries = sessionManager.loadEntries(); const settings = settingsManager.getCompactionSettings(); const compactionEntry = await compact( entries, agent.state.model, settings, apiKey, undefined, input.customInstructions, ); // Save and reload sessionManager.saveCompaction(compactionEntry); const loaded = loadSessionFromEntries(sessionManager.loadEntries()); agent.replaceMessages(loaded.messages); // Emit compaction event (compactionEntry already has type: "compaction") console.log(JSON.stringify(compactionEntry)); } catch (error: any) { console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` })); } } } catch (error: any) { // Output error as JSON console.log(JSON.stringify({ type: "error", error: error.message })); } }); // Keep process alive return new Promise(() => {}); } export async function main(args: string[]) { const parsed = parseArgs(args); if (parsed.help) { printHelp(); return; } // Handle --export flag: convert session file to HTML and exit if (parsed.export) { try { // Use first message as output path if provided const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined; const result = exportFromFile(parsed.export, outputPath); console.log(`Exported to: ${result}`); return; } catch (error: any) { console.error(chalk.red(`Error: ${error.message || "Failed to export session"}`)); process.exit(1); } } // Validate: RPC mode doesn't support @file arguments if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); } // Process @file arguments if any let initialMessage: string | undefined; let initialAttachments: Attachment[] | undefined; if (parsed.fileArgs.length > 0) { const { textContent, imageAttachments } = processFileArguments(parsed.fileArgs); // Combine file content with first plain text message (if any) if (parsed.messages.length > 0) { initialMessage = textContent + parsed.messages[0]; parsed.messages.shift(); // Remove first message as it's been combined } else { initialMessage = textContent; } initialAttachments = imageAttachments.length > 0 ? imageAttachments : undefined; } // Initialize theme (before any TUI rendering) const settingsManager = new SettingsManager(); const themeName = settingsManager.getTheme(); initTheme(themeName); // Setup session manager const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session); // Disable session saving if --no-session flag is set if (parsed.noSession) { sessionManager.disable(); } // Handle --resume flag: show session selector if (parsed.resume) { const selectedSession = await selectSession(sessionManager); if (!selectedSession) { console.log(chalk.dim("No session selected")); return; } // Set the selected session as the active session sessionManager.setSessionFile(selectedSession); } // Resolve model scope early if provided (needed for initial model selection) let scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = []; if (parsed.models && parsed.models.length > 0) { scopedModels = await resolveModelScope(parsed.models); } // Determine initial model using priority system: // 1. CLI args (--provider and --model) // 2. First model from --models scope // 3. Restored from session (if --continue or --resume) // 4. Saved default from settings.json // 5. First available model with valid API key // 6. null (allowed in interactive mode) let initialModel: Model | null = null; let initialThinking: ThinkingLevel = "off"; if (parsed.provider && parsed.model) { // 1. CLI args take priority const { model, error } = findModel(parsed.provider, parsed.model); if (error) { console.error(chalk.red(error)); process.exit(1); } if (!model) { console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`)); process.exit(1); } initialModel = model; } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { // 2. Use first model from --models scope (skip if continuing/resuming session) initialModel = scopedModels[0].model; initialThinking = scopedModels[0].thinkingLevel; } else if (parsed.continue || parsed.resume) { // 3. Restore from session (will be handled below after loading session) // Leave initialModel as null for now } if (!initialModel) { // 3. Try saved default from settings const defaultProvider = settingsManager.getDefaultProvider(); const defaultModel = settingsManager.getDefaultModel(); if (defaultProvider && defaultModel) { const { model, error } = findModel(defaultProvider, defaultModel); if (error) { console.error(chalk.red(error)); process.exit(1); } initialModel = model; // Also load saved thinking level if we're using saved model const savedThinking = settingsManager.getDefaultThinkingLevel(); if (savedThinking) { initialThinking = savedThinking; } } } if (!initialModel) { // 4. Try first available model with valid API key // Prefer default model for each provider if available const { models: availableModels, error } = await getAvailableModels(); if (error) { console.error(chalk.red(error)); process.exit(1); } if (availableModels.length > 0) { // Try to find a default model from known providers for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { const defaultModelId = defaultModelPerProvider[provider]; const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId); if (match) { initialModel = match; break; } } // If no default found, use first available if (!initialModel) { initialModel = availableModels[0]; } } } // Determine mode early to know if we should print messages and fail early // Interactive mode: no --print flag and no --mode flag // Having initial messages doesn't make it non-interactive anymore const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; // Only print informational messages in interactive mode // Non-interactive modes (-p, --mode json, --mode rpc) should be silent except for output const shouldPrintMessages = isInteractive; // Non-interactive mode: fail early if no model available if (!isInteractive && !initialModel) { console.error(chalk.red("No models available.")); console.error(chalk.yellow("\nSet an API key environment variable:")); console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); process.exit(1); } // Non-interactive mode: validate API key exists if (!isInteractive && initialModel) { const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel)); if (!apiKey) { console.error(chalk.red(`No API key found for ${initialModel.provider}`)); process.exit(1); } } const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt); // Load previous messages if continuing or resuming // This may update initialModel if restoring from session if (parsed.continue || parsed.resume) { // Load and restore model (overrides initialModel if found and has API key) const savedModel = sessionManager.loadModel(); if (savedModel) { const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId); if (error) { console.error(chalk.red(error)); process.exit(1); } // Check if restored model exists and has a valid API key const hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false; if (restoredModel && hasApiKey) { initialModel = restoredModel; if (shouldPrintMessages) { console.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`)); } } else { // Model not found or no API key - fall back to default selection const reason = !restoredModel ? "model no longer exists" : "no API key available"; if (shouldPrintMessages) { console.error( chalk.yellow( `Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}).`, ), ); } // Ensure we have a valid model - use the same fallback logic if (!initialModel) { const { models: availableModels, error: availableError } = await getAvailableModels(); if (availableError) { console.error(chalk.red(availableError)); process.exit(1); } if (availableModels.length > 0) { // Try to find a default model from known providers for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { const defaultModelId = defaultModelPerProvider[provider]; const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId); if (match) { initialModel = match; break; } } // If no default found, use first available if (!initialModel) { initialModel = availableModels[0]; } if (initialModel && shouldPrintMessages) { console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`)); } } else { // No models available at all if (shouldPrintMessages) { console.error(chalk.red("\nNo models available.")); console.error(chalk.yellow("Set an API key environment variable:")); console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); } process.exit(1); } } else if (shouldPrintMessages) { console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`)); } } } } // CLI --thinking flag takes highest priority if (parsed.thinking) { initialThinking = parsed.thinking; } // Determine which tools to use const selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools; // Create agent (initialModel can be null in interactive mode) const agent = new Agent({ initialState: { systemPrompt, model: initialModel as any, // Can be null thinkingLevel: initialThinking, tools: selectedTools, }, queueMode: settingsManager.getQueueMode(), transport: new ProviderTransport({ // Dynamic API key lookup based on current model's provider getApiKey: async () => { const currentModel = agent.state.model; if (!currentModel) { throw new Error("No model selected"); } // Try CLI override first if (parsed.apiKey) { return parsed.apiKey; } // Use model-specific key lookup const key = await getApiKeyForModel(currentModel); if (!key) { throw new Error( `No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ${getModelsPath()}`, ); } return key; }, }), }); // If initial thinking was requested but model doesn't support it, silently reset to off if (initialThinking !== "off" && initialModel && !initialModel.reasoning) { agent.setThinkingLevel("off"); } // Track if we had to fall back from saved model (to show in chat later) let modelFallbackMessage: string | null = null; // Load previous messages if continuing or resuming if (parsed.continue || parsed.resume) { const messages = sessionManager.loadMessages(); if (messages.length > 0) { agent.replaceMessages(messages); } // Load and restore thinking level const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel; if (thinkingLevel) { agent.setThinkingLevel(thinkingLevel); if (shouldPrintMessages) { console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`)); } } // Check if we had to fall back from saved model const savedModel = sessionManager.loadModel(); if (savedModel && initialModel) { const savedMatches = initialModel.provider === savedModel.provider && initialModel.id === savedModel.modelId; if (!savedMatches) { const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId); if (error) { // Config error - already shown above, just use generic message modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}. Using ${initialModel.provider}/${initialModel.id}.`; } else { const reason = !restoredModel ? "model no longer exists" : "no API key available"; modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}). Using ${initialModel.provider}/${initialModel.id}.`; } } } } // Log loaded context files (they're already in the system prompt) if (shouldPrintMessages && !parsed.continue && !parsed.resume) { const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { console.log(chalk.dim("Loaded project context from:")); for (const { path: filePath } of contextFiles) { console.log(chalk.dim(` - ${filePath}`)); } } } // Route to appropriate mode if (mode === "rpc") { // RPC mode - headless operation await runRpcMode(agent, sessionManager, settingsManager); } else if (isInteractive) { // Check for new version (don't block startup if it takes too long) let newVersion: string | null = null; try { newVersion = await Promise.race([ checkForNewVersion(VERSION), new Promise((resolve) => setTimeout(() => resolve(null), 1000)), // 1 second timeout ]); } catch (e) { // Ignore errors } // Check if we should show changelog (only in interactive mode, only for new sessions) let changelogMarkdown: string | null = null; if (!parsed.continue && !parsed.resume) { const lastVersion = settingsManager.getLastChangelogVersion(); // Check if we need to show changelog if (!lastVersion) { // First run - show all entries const changelogPath = getChangelogPath(); const entries = parseChangelog(changelogPath); if (entries.length > 0) { changelogMarkdown = entries.map((e) => e.content).join("\n\n"); settingsManager.setLastChangelogVersion(VERSION); } } else { // Parse current and last versions const changelogPath = getChangelogPath(); const entries = parseChangelog(changelogPath); const newEntries = getNewEntries(entries, lastVersion); if (newEntries.length > 0) { changelogMarkdown = newEntries.map((e) => e.content).join("\n\n"); settingsManager.setLastChangelogVersion(VERSION); } } } // Show model scope if provided if (scopedModels.length > 0) { const modelList = scopedModels .map((sm) => { const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : ""; return `${sm.model.id}${thinkingStr}`; }) .join(", "); console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); } // Ensure fd tool is available for file autocomplete const fdPath = await ensureTool("fd"); // Interactive mode - use TUI (may have initial messages from CLI args) await runInteractiveMode( agent, sessionManager, settingsManager, VERSION, changelogMarkdown, modelFallbackMessage, newVersion, scopedModels, parsed.messages, initialMessage, initialAttachments, fdPath, ); } else { // Non-interactive mode (--print flag or --mode flag) await runSingleShotMode(agent, sessionManager, parsed.messages, mode, initialMessage, initialAttachments); } }