From 2e63f048fd03c292316cf92645e7cbe0f58e3541 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 3 Dec 2025 10:00:33 -0600 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20first-class=20?= =?UTF-8?q?`mux=20run`=20CLI=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the internal agentSessionCli.ts with a user-facing `mux run` command: - Add `mux run` subcommand with Commander.js for proper --help - Smart defaults: local runtime, medium thinking, auto-generated workspace ID - Human-friendly timeout parsing (5m, 300s, etc.) - Clean flag names (--json, --thinking, --dir) - Full documentation in docs/cli.md Update terminal-bench to use the new entry point: - benchmarks/terminal_bench/mux-run.sh now invokes src/cli/run.ts - Simpler invocation with fewer verbose flags Usage: mux run "Fix the tests" mux run --dir /project --runtime "ssh user@host" "Deploy" echo "Add logging" | mux run --json _Generated with `mux`_ --- benchmarks/terminal_bench/mux-run.sh | 17 +- benchmarks/terminal_bench/mux_agent.py | 1 + docs/SUMMARY.md | 1 + docs/benchmarking.md | 3 +- docs/cli.md | 97 +++++ scripts/check-bench-agent.sh | 2 +- src/cli/index.ts | 33 ++ src/cli/{debug/agentSessionCli.ts => run.ts} | 425 +++++++++---------- src/node/services/agentSession.ts | 11 +- 9 files changed, 362 insertions(+), 228 deletions(-) create mode 100644 docs/cli.md rename src/cli/{debug/agentSessionCli.ts => run.ts} (54%) diff --git a/benchmarks/terminal_bench/mux-run.sh b/benchmarks/terminal_bench/mux-run.sh index 52cc14ab4..583228548 100644 --- a/benchmarks/terminal_bench/mux-run.sh +++ b/benchmarks/terminal_bench/mux-run.sh @@ -29,6 +29,7 @@ MUX_TRUNK="${MUX_TRUNK:-main}" MUX_WORKSPACE_ID="${MUX_WORKSPACE_ID:-mux-bench}" MUX_THINKING_LEVEL="${MUX_THINKING_LEVEL:-high}" MUX_MODE="${MUX_MODE:-exec}" +MUX_RUNTIME="${MUX_RUNTIME:-}" resolve_project_path() { if [[ -n "${MUX_PROJECT_PATH}" ]]; then @@ -77,21 +78,21 @@ ensure_git_repo "${project_path}" log "starting mux agent session for ${project_path}" cd "${MUX_APP_ROOT}" -cmd=(bun src/cli/debug/agentSessionCli.ts - --config-root "${MUX_CONFIG_ROOT}" - --project-path "${project_path}" - --workspace-path "${project_path}" - --workspace-id "${MUX_WORKSPACE_ID}" +cmd=(bun src/cli/run.ts + --dir "${project_path}" --model "${MUX_MODEL}" --mode "${MUX_MODE}" - --json-streaming) + --thinking "${MUX_THINKING_LEVEL}" + --config-root "${MUX_CONFIG_ROOT}" + --workspace-id "${MUX_WORKSPACE_ID}" + --json) if [[ -n "${MUX_TIMEOUT_MS}" ]]; then cmd+=(--timeout "${MUX_TIMEOUT_MS}") fi -if [[ -n "${MUX_THINKING_LEVEL}" ]]; then - cmd+=(--thinking-level "${MUX_THINKING_LEVEL}") +if [[ -n "${MUX_RUNTIME}" ]]; then + cmd+=(--runtime "${MUX_RUNTIME}") fi # Terminal-bench enforces timeouts via --global-agent-timeout-sec diff --git a/benchmarks/terminal_bench/mux_agent.py b/benchmarks/terminal_bench/mux_agent.py index ff9bf71e4..bf946c4c3 100644 --- a/benchmarks/terminal_bench/mux_agent.py +++ b/benchmarks/terminal_bench/mux_agent.py @@ -62,6 +62,7 @@ class MuxAgent(AbstractInstalledAgent): "MUX_APP_ROOT", "MUX_WORKSPACE_ID", "MUX_MODE", + "MUX_RUNTIME", ) def __init__( diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e7166f0ea..4e9b659ec 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -4,6 +4,7 @@ - [Introduction](./intro.md) - [Install](./install.md) +- [CLI](./cli.md) - [Why Parallelize?](./why-parallelize.md) # Features diff --git a/docs/benchmarking.md b/docs/benchmarking.md index d35ac0f67..c479d6472 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -18,6 +18,7 @@ Optional environment overrides: | `MUX_MODEL` | Preferred model (supports `provider/model` syntax) | `anthropic/claude-sonnet-4-5` | | `MUX_THINKING_LEVEL` | Optional reasoning level (`off`, `low`, `medium`, `high`) | `high` | | `MUX_MODE` | Starting mode (`plan` or `exec`) | `exec` | +| `MUX_RUNTIME` | Runtime type (`local`, `worktree`, or `ssh `) | `worktree` | | `MUX_TIMEOUT_MS` | Optional stream timeout in milliseconds | no timeout | | `MUX_CONFIG_ROOT` | Location for mux session data inside the container | `/root/.mux` | | `MUX_APP_ROOT` | Path where the mux sources are staged | `/opt/mux-app` | @@ -65,7 +66,7 @@ The adapter lives in `benchmarks/terminal_bench/mux_agent.py`. For each task it: 1. Copies the mux repository (package manifests + `src/`) into `/tmp/mux-app` inside the container. 2. Ensures Bun exists, then runs `bun install --frozen-lockfile`. -3. Launches `src/cli/debug/agentSessionCli.ts` to prepare workspace metadata and stream the instruction, storing state under `MUX_CONFIG_ROOT` (default `/root/.mux`). +3. Launches `mux run` (`src/cli/run.ts`) to prepare workspace metadata and stream the instruction, storing state under `MUX_CONFIG_ROOT` (default `/root/.mux`). `MUX_MODEL` accepts either the mux colon form (`anthropic:claude-sonnet-4-5`) or the Terminal-Bench slash form (`anthropic/claude-sonnet-4-5`); the adapter normalises whichever you provide. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 000000000..8103d31d9 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,97 @@ +# Command Line Interface + +Mux provides a CLI for running agent sessions without opening the desktop app. + +## `mux run` + +Run an agent session in any directory: + +```bash +# Basic usage - run in current directory +mux run "Fix the failing tests" + +# Specify a directory +mux run --dir /path/to/project "Add authentication" + +# Use SSH runtime +mux run --runtime "ssh user@myserver" "Deploy changes" + +# Plan mode (proposes a plan, then auto-executes) +mux run --mode plan "Refactor the auth module" + +# Pipe instructions via stdin +echo "Add logging to all API endpoints" | mux run + +# JSON output for scripts +mux run --json "List all TypeScript files" | jq '.type' +``` + +### Options + +| Option | Short | Description | Default | +| ---------------------- | ----- | -------------------------------------------------- | ----------------- | +| `--dir ` | `-d` | Project directory | Current directory | +| `--model ` | `-m` | Model to use (e.g., `anthropic:claude-sonnet-4-5`) | Default model | +| `--runtime ` | `-r` | Runtime: `local`, `worktree`, or `ssh ` | `local` | +| `--mode ` | | Agent mode: `plan` or `exec` | `exec` | +| `--thinking ` | `-t` | Thinking level: `off`, `low`, `medium`, `high` | `medium` | +| `--timeout ` | | Timeout (e.g., `5m`, `300s`, `300000`) | No timeout | +| `--json` | | Output NDJSON for programmatic use | Off | +| `--quiet` | `-q` | Only output final result | Off | +| `--workspace-id ` | | Explicit workspace ID | Auto-generated | +| `--config-root ` | | Mux config directory | `~/.mux` | + +### Runtimes + +- **`local`** (default): Runs directly in the specified directory. Best for one-off tasks. +- **`worktree`**: Creates an isolated git worktree under `~/.mux/src`. Useful for parallel work. +- **`ssh `**: Runs on a remote machine via SSH. Example: `--runtime "ssh user@myserver.com"` + +### Output Modes + +- **Default (TTY)**: Human-readable streaming with tool call formatting +- **`--json`**: NDJSON streaming - each line is a JSON object with event data +- **`--quiet`**: Suppresses streaming output, only shows final assistant response + +### Examples + +```bash +# Quick fix in current directory +mux run "Fix the TypeScript errors" + +# Use a specific model with extended thinking +mux run -m anthropic:claude-sonnet-4-5 -t high "Optimize database queries" + +# Run on remote server +mux run -r "ssh dev@staging.example.com" -d /app "Update dependencies" + +# Scripted usage with timeout +mux run --json --timeout 5m "Generate API documentation" > output.jsonl + +# Plan first, then execute +mux run --mode plan "Migrate from REST to GraphQL" +``` + +## `mux server` + +Start the HTTP/WebSocket server for remote access (e.g., from mobile devices): + +```bash +mux server --port 3000 --host 0.0.0.0 +``` + +Options: + +- `--host ` - Host to bind to (default: `localhost`) +- `--port ` - Port to bind to (default: `3000`) +- `--auth-token ` - Optional bearer token for authentication +- `--add-project ` - Add and open project at the specified path + +## `mux version` + +Print the version and git commit: + +```bash +mux version +# mux v0.8.4 (abc123) +``` diff --git a/scripts/check-bench-agent.sh b/scripts/check-bench-agent.sh index 09b693ad0..965c38644 100755 --- a/scripts/check-bench-agent.sh +++ b/scripts/check-bench-agent.sh @@ -15,7 +15,7 @@ if [[ ! -f "$MUX_RUN_SH" ]]; then fi # Extract the agent CLI path from mux-run.sh -# Looks for line like: cmd=(bun src/cli/debug/agentSessionCli.ts +# Looks for line like: cmd=(bun src/cli/run.ts CLI_PATH_MATCH=$(grep -o "bun src/.*\.ts" "$MUX_RUN_SH" | head -1 | cut -d' ' -f2) if [[ -z "$CLI_PATH_MATCH" ]]; then diff --git a/src/cli/index.ts b/src/cli/index.ts index 91da07889..291771bb9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import { VERSION } from "../version"; +<<<<<<< HEAD const program = new Command(); program @@ -44,6 +45,38 @@ program // Default action: launch desktop app when no subcommand given program.action(() => { +||||||| parent of 0f258d5fc (🤖 feat: add first-class `mux run` CLI command) +if (subcommand === "server") { + // Remove 'server' from args since main-server doesn't expect it as a positional argument. + process.argv.splice(2, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./server"); +} else if (subcommand === "version") { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { VERSION } = require("../version") as { + VERSION: { git_describe: string; git_commit: string }; + }; + console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); +} else { +======= +if (subcommand === "server") { + // Remove 'server' from args since main-server doesn't expect it as a positional argument. + process.argv.splice(2, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./server"); +} else if (subcommand === "run") { + // Remove 'run' from args since run.ts uses Commander which handles its own parsing + process.argv.splice(2, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./run"); +} else if (subcommand === "version") { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { VERSION } = require("../version") as { + VERSION: { git_describe: string; git_commit: string }; + }; + console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); +} else { +>>>>>>> 0f258d5fc (🤖 feat: add first-class `mux run` CLI command) // eslint-disable-next-line @typescript-eslint/no-require-imports require("../desktop/main"); }); diff --git a/src/cli/debug/agentSessionCli.ts b/src/cli/run.ts similarity index 54% rename from src/cli/debug/agentSessionCli.ts rename to src/cli/run.ts index d58dac054..ecbf643a3 100644 --- a/src/cli/debug/agentSessionCli.ts +++ b/src/cli/run.ts @@ -1,10 +1,16 @@ #!/usr/bin/env bun - -import assert from "@/common/utils/assert"; -import * as fs from "fs/promises"; +/** + * `mux run` - First-class CLI for running agent sessions + * + * Usage: + * mux run "Fix the failing tests" + * mux run --dir /path/to/project "Add authentication" + * mux run --runtime "ssh user@host" "Deploy changes" + */ + +import { Command } from "commander"; import * as path from "path"; -import { PlatformPaths } from "@/common/utils/paths"; -import { parseArgs } from "util"; +import * as fs from "fs/promises"; import { Config } from "@/node/config"; import { HistoryService } from "@/node/services/historyService"; import { PartialService } from "@/node/services/partialService"; @@ -27,66 +33,62 @@ import { import { defaultModel } from "@/common/utils/ai/models"; import { ensureProvidersConfig } from "@/common/utils/providers/ensureProvidersConfig"; import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; -import { - extractAssistantText, - extractReasoning, - extractToolCalls, -} from "@/cli/debug/chatExtractors"; import type { ThinkingLevel } from "@/common/types/thinking"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { parseRuntimeModeAndHost, RUNTIME_MODE } from "@/common/types/runtime"; +import assert from "@/common/utils/assert"; -interface CliResult { - success: boolean; - error?: string; - data?: Record; -} +type CLIMode = "plan" | "exec"; -async function ensureDirectory(pathToCheck: string): Promise { - const stats = await fs.stat(pathToCheck); - if (!stats.isDirectory()) { - throw new Error(`"${pathToCheck}" is not a directory`); +function parseRuntimeConfig(value: string | undefined, srcBaseDir: string): RuntimeConfig { + if (!value) { + // Default to local for `mux run` (no worktree isolation needed for one-off) + return { type: "local" }; } -} -async function gatherMessageFromStdin(): Promise { - if (process.stdin.isTTY) { - return ""; - } + const { mode, host } = parseRuntimeModeAndHost(value); - const chunks: Uint8Array[] = []; - for await (const chunk of process.stdin) { - if (Buffer.isBuffer(chunk)) { - chunks.push(chunk); - continue; - } - if (typeof chunk === "string") { - chunks.push(Buffer.from(chunk)); - continue; - } - if (chunk instanceof Uint8Array) { - chunks.push(chunk); - continue; - } - throw new Error(`Unsupported stdin chunk type: ${typeof chunk}`); + switch (mode) { + case RUNTIME_MODE.LOCAL: + return { type: "local" }; + case RUNTIME_MODE.WORKTREE: + return { type: "worktree", srcBaseDir }; + case RUNTIME_MODE.SSH: + if (!host.trim()) { + throw new Error("SSH runtime requires a host (e.g., --runtime 'ssh user@host')"); + } + return { type: "ssh", host: host.trim(), srcBaseDir }; + default: + return { type: "local" }; } - return Buffer.concat(chunks).toString("utf-8"); } -function parseTimeout(timeoutRaw: string | undefined): number | undefined { - if (!timeoutRaw) { - return undefined; - } +function parseTimeout(value: string | undefined): number | undefined { + if (!value) return undefined; - const parsed = Number.parseInt(timeoutRaw, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`Invalid timeout value "${timeoutRaw}"`); + const trimmed = value.trim().toLowerCase(); + + // Parse human-friendly formats: 5m, 300s, 5min, 5minutes, etc. + const regex = /^(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|ms)?$/i; + const match = regex.exec(trimmed); + if (!match) { + throw new Error( + `Invalid timeout format "${value}". Use: 300s, 5m, 5min, or milliseconds (e.g., 300000)` + ); } - return parsed; + + const num = parseFloat(match[1]); + const unit = (match[2] || "ms").toLowerCase(); + + if (unit === "ms") return Math.round(num); + if (unit.startsWith("s")) return Math.round(num * 1000); + if (unit.startsWith("m")) return Math.round(num * 60 * 1000); + + return Math.round(num); } function parseThinkingLevel(value: string | undefined): ThinkingLevel | undefined { - if (!value) { - return undefined; - } + if (!value) return "medium"; // Default for mux run const normalized = value.trim().toLowerCase(); if ( @@ -97,31 +99,52 @@ function parseThinkingLevel(value: string | undefined): ThinkingLevel | undefine ) { return normalized; } - throw new Error(`Invalid thinking level "${value}". Expected one of: off, low, medium, high.`); + throw new Error(`Invalid thinking level "${value}". Expected: off, low, medium, high`); } -type CLIMode = "plan" | "exec"; +function parseMode(value: string | undefined): CLIMode { + if (!value) return "exec"; -function parseMode(raw: string | undefined): CLIMode { - if (!raw) { - return "exec"; - } + const normalized = value.trim().toLowerCase(); + if (normalized === "plan") return "plan"; + if (normalized === "exec" || normalized === "execute") return "exec"; - const normalized = raw.trim().toLowerCase(); - if (normalized === "plan") { - return "plan"; + throw new Error(`Invalid mode "${value}". Expected: plan, exec`); +} + +function generateWorkspaceId(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `run-${timestamp}-${random}`; +} + +async function ensureDirectory(dirPath: string): Promise { + const stats = await fs.stat(dirPath); + if (!stats.isDirectory()) { + throw new Error(`"${dirPath}" is not a directory`); } - if (normalized === "exec" || normalized === "execute") { - return "exec"; +} + +async function gatherMessageFromStdin(): Promise { + if (process.stdin.isTTY) { + return ""; } - throw new Error('Invalid mode "' + raw + '". Expected "plan" or "exec" (or "execute").'); + const chunks: Uint8Array[] = []; + for await (const chunk of process.stdin) { + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else if (typeof chunk === "string") { + chunks.push(Buffer.from(chunk)); + } else if (chunk instanceof Uint8Array) { + chunks.push(chunk); + } + } + return Buffer.concat(chunks).toString("utf-8"); } function renderUnknown(value: unknown): string { - if (typeof value === "string") { - return value; - } + if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch { @@ -129,94 +152,101 @@ function renderUnknown(value: unknown): string { } } -function writeJson(result: CliResult): void { - process.stdout.write(`${JSON.stringify(result)}\n`); +const program = new Command(); + +program + .name("mux run") + .description("Run an agent session in the current directory") + .argument("[message]", "instruction for the agent (can also be piped via stdin)") + .option("-d, --dir ", "project directory", process.cwd()) + .option("-m, --model ", "model to use", defaultModel) + .option("-r, --runtime ", "runtime type: local, worktree, or 'ssh '", "local") + .option("--mode ", "agent mode: plan or exec", "exec") + .option("-t, --thinking ", "thinking level: off, low, medium, high", "medium") + .option("--timeout ", "timeout (e.g., 5m, 300s, 300000)") + .option("--json", "output NDJSON for programmatic consumption") + .option("-q, --quiet", "only output final result") + .option("--workspace-id ", "explicit workspace ID (auto-generated if not provided)") + .option("--config-root ", "mux config directory") + .addHelpText( + "after", + ` +Examples: + $ mux run "Fix the failing tests" + $ mux run --dir /path/to/project "Add authentication" + $ mux run --runtime "ssh user@host" "Deploy changes" + $ mux run --mode plan "Refactor the auth module" + $ echo "Add logging" | mux run + $ mux run --json "List all files" | jq '.type' +` + ); + +program.parse(process.argv); + +interface CLIOptions { + dir: string; + model: string; + runtime: string; + mode: string; + thinking: string; + timeout?: string; + json?: boolean; + quiet?: boolean; + workspaceId?: string; + configRoot?: string; } -async function main(): Promise { - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - "workspace-path": { type: "string" }, - "workspace-id": { type: "string" }, - "project-path": { type: "string" }, - "config-root": { type: "string" }, - message: { type: "string" }, - model: { type: "string" }, - "thinking-level": { type: "string" }, - mode: { type: "string" }, - timeout: { type: "string" }, - json: { type: "boolean" }, - "json-streaming": { type: "boolean" }, - }, - allowPositionals: false, - }); - - const workspacePathRaw = values["workspace-path"]; - if (typeof workspacePathRaw !== "string" || workspacePathRaw.trim().length === 0) { - throw new Error("--workspace-path is required"); - } - const workspacePath = path.resolve(workspacePathRaw.trim()); - await ensureDirectory(workspacePath); - - const configRootRaw = values["config-root"]; - const configRoot = - configRootRaw && configRootRaw.trim().length > 0 ? configRootRaw.trim() : undefined; - const config = new Config(configRoot); +const opts = program.opts(); +const messageArg = program.args[0]; - const workspaceIdRaw = values["workspace-id"]; - if (typeof workspaceIdRaw !== "string" || workspaceIdRaw.trim().length === 0) { - throw new Error("--workspace-id is required"); - } - const workspaceId = workspaceIdRaw.trim(); - - const projectPathRaw = values["project-path"]; - const projectName = - typeof projectPathRaw === "string" && projectPathRaw.trim().length > 0 - ? PlatformPaths.basename(path.resolve(projectPathRaw.trim())) - : PlatformPaths.basename(path.dirname(workspacePath)) || "unknown"; - - const messageArg = - values.message && values.message.trim().length > 0 ? values.message : undefined; - const messageText = messageArg ?? (await gatherMessageFromStdin()); - if (messageText?.trim().length === 0) { - throw new Error("Message must be provided via --message or stdin"); +async function main(): Promise { + // Resolve directory + const projectDir = path.resolve(opts.dir); + await ensureDirectory(projectDir); + + // Get message from arg or stdin + const stdinMessage = await gatherMessageFromStdin(); + const message = messageArg?.trim() ?? stdinMessage.trim(); + + if (!message) { + console.error("Error: No message provided. Pass as argument or pipe via stdin."); + console.error('Usage: mux run "Your instruction here"'); + process.exit(1); } - const model = values.model && values.model.trim().length > 0 ? values.model.trim() : defaultModel; - const timeoutMs = parseTimeout(values.timeout); - const thinkingLevel = parseThinkingLevel(values["thinking-level"]); - const initialMode = parseMode(values.mode); - const emitFinalJson = values.json === true; - const emitJsonStreaming = values["json-streaming"] === true; + // Setup config + const config = new Config(opts.configRoot); + const workspaceId = opts.workspaceId ?? generateWorkspaceId(); + const model: string = opts.model; + const runtimeConfig = parseRuntimeConfig(opts.runtime, config.srcDir); + const thinkingLevel = parseThinkingLevel(opts.thinking); + const initialMode = parseMode(opts.mode); + const timeoutMs = parseTimeout(opts.timeout); + const emitJson = opts.json === true; + const quiet = opts.quiet === true; - const suppressHumanOutput = emitJsonStreaming || emitFinalJson; - - // Log model selection for terminal-bench verification - if (!suppressHumanOutput) { - console.error(`[mux-cli] Using model: ${model}`); - } + const suppressHumanOutput = emitJson || quiet; - const humanStream = process.stdout; const writeHuman = (text: string) => { - if (suppressHumanOutput) { - return; - } - humanStream.write(text); + if (!suppressHumanOutput) process.stdout.write(text); }; const writeHumanLine = (text = "") => { - if (suppressHumanOutput) { - return; - } - humanStream.write(`${text}\n`); + if (!suppressHumanOutput) process.stdout.write(`${text}\n`); }; const emitJsonLine = (payload: unknown) => { - if (!emitJsonStreaming) { - return; - } - process.stdout.write(`${JSON.stringify(payload)}\n`); + if (emitJson) process.stdout.write(`${JSON.stringify(payload)}\n`); }; + if (!suppressHumanOutput) { + console.error(`[mux run] Directory: ${projectDir}`); + console.error(`[mux run] Model: ${model}`); + console.error( + `[mux run] Runtime: ${runtimeConfig.type}${runtimeConfig.type === "ssh" ? ` (${runtimeConfig.host})` : ""}` + ); + console.error(`[mux run] Mode: ${initialMode}`); + } + + // Initialize services const historyService = new HistoryService(config); const partialService = new PartialService(config, historyService); const initStateManager = new InitStateManager(config); @@ -233,15 +263,16 @@ async function main(): Promise { }); await session.ensureMetadata({ - workspacePath, - projectName, + workspacePath: projectDir, + projectName: path.basename(projectDir), + runtimeConfig, }); - const buildSendOptions = (mode: CLIMode): SendMessageOptions => ({ + const buildSendOptions = (cliMode: CLIMode): SendMessageOptions => ({ model, thinkingLevel, - toolPolicy: modeToToolPolicy(mode), - additionalSystemInstructions: mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined, + toolPolicy: modeToToolPolicy(cliMode), + additionalSystemInstructions: cliMode === "plan" ? PLAN_MODE_INSTRUCTION : undefined, }); const liveEvents: WorkspaceChatMessage[] = []; @@ -277,9 +308,7 @@ async function main(): Promise { }), ]); } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } + if (timeoutHandle) clearTimeout(timeoutHandle); } } else { await completionPromise; @@ -290,9 +319,9 @@ async function main(): Promise { } }; - const sendAndAwait = async (message: string, options: SendMessageOptions): Promise => { + const sendAndAwait = async (msg: string, options: SendMessageOptions): Promise => { completionPromise = createCompletionPromise(); - const sendResult = await session.sendMessage(message, options); + const sendResult = await session.sendMessage(msg, options); if (!sendResult.success) { const errorValue = sendResult.error; let formattedError = "unknown error"; @@ -308,14 +337,11 @@ async function main(): Promise { } throw new Error(`Failed to send message: ${formattedError}`); } - await waitForCompletion(); }; const handleToolStart = (payload: WorkspaceChatMessage): boolean => { - if (!isToolCallStart(payload)) { - return false; - } + if (!isToolCallStart(payload)) return false; writeHumanLine("\n========== TOOL CALL START =========="); writeHumanLine(`Tool: ${payload.toolName}`); writeHumanLine(`Call ID: ${payload.toolCallId}`); @@ -326,9 +352,7 @@ async function main(): Promise { }; const handleToolDelta = (payload: WorkspaceChatMessage): boolean => { - if (!isToolCallDelta(payload)) { - return false; - } + if (!isToolCallDelta(payload)) return false; writeHumanLine("\n----------- TOOL OUTPUT -------------"); writeHumanLine(renderUnknown(payload.delta)); writeHumanLine("-------------------------------------"); @@ -336,9 +360,7 @@ async function main(): Promise { }; const handleToolEnd = (payload: WorkspaceChatMessage): boolean => { - if (!isToolCallEnd(payload)) { - return false; - } + if (!isToolCallEnd(payload)) return false; writeHumanLine("\n=========== TOOL CALL END ==========="); writeHumanLine(`Tool: ${payload.toolName}`); writeHumanLine(`Call ID: ${payload.toolCallId}`); @@ -441,7 +463,7 @@ async function main(): Promise { const unsubscribe = await session.subscribeChat(chatListener); try { - await sendAndAwait(messageText, buildSendOptions(initialMode)); + await sendAndAwait(message, buildSendOptions(initialMode)); const planWasProposed = planProposed; planProposed = false; @@ -453,38 +475,24 @@ async function main(): Promise { await sendAndAwait("Plan approved. Execute it.", buildSendOptions("exec")); } - let finalEvent: WorkspaceChatMessage | undefined; - for (let i = liveEvents.length - 1; i >= 0; i -= 1) { - const candidate = liveEvents[i]; - if (isStreamEnd(candidate)) { - finalEvent = candidate; - break; + // Output final result for --quiet mode + if (quiet) { + let finalEvent: WorkspaceChatMessage | undefined; + for (let i = liveEvents.length - 1; i >= 0; i--) { + if (isStreamEnd(liveEvents[i])) { + finalEvent = liveEvents[i]; + break; + } + } + if (finalEvent && isStreamEnd(finalEvent)) { + const parts = (finalEvent as unknown as { parts?: unknown[] }).parts ?? []; + for (const part of parts) { + if (part && typeof part === "object" && "type" in part && part.type === "text") { + const text = (part as { text?: string }).text; + if (text) console.log(text); + } + } } - } - - if (!finalEvent || !isStreamEnd(finalEvent)) { - throw new Error("Stream ended without receiving stream-end event"); - } - - const parts = (finalEvent as unknown as { parts?: unknown }).parts ?? []; - const text = extractAssistantText(parts); - const reasoning = extractReasoning(parts); - const toolCalls = extractToolCalls(parts); - - if (emitFinalJson) { - writeJson({ - success: true, - data: { - messageId: finalEvent.messageId, - model: finalEvent.metadata?.model ?? null, - text, - reasoning, - toolCalls, - metadata: finalEvent.metadata ?? null, - parts, - events: liveEvents, - }, - }); } } finally { unsubscribe(); @@ -492,31 +500,18 @@ async function main(): Promise { } } -// Keep process alive explicitly - Bun may exit when stdin closes even if async work is pending +// Keep process alive - Bun may exit when stdin closes even if async work is pending const keepAliveInterval = setInterval(() => { // No-op to keep event loop alive }, 1000000); -(async () => { - try { - await main(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const wantsJsonStreaming = - process.argv.includes("--json-streaming") || process.argv.includes("--json-streaming=true"); - const wantsJson = process.argv.includes("--json") || process.argv.includes("--json=true"); - - if (wantsJsonStreaming) { - process.stdout.write(`${JSON.stringify({ type: "error", error: message })}\n`); - } - - if (wantsJson) { - writeJson({ success: false, error: message }); - } else { - process.stderr.write(`Error: ${message}\n`); - } - process.exitCode = 1; - } finally { +main() + .then(() => { clearInterval(keepAliveInterval); - } -})(); + process.exit(0); + }) + .catch((error) => { + clearInterval(keepAliveInterval); + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index d4cd20293..40567af4b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9,6 +9,7 @@ import type { HistoryService } from "@/node/services/historyService"; import type { PartialService } from "@/node/services/partialService"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { RuntimeConfig } from "@/common/types/runtime"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import type { WorkspaceChatMessage, @@ -200,10 +201,14 @@ export class AgentSession { }); } - async ensureMetadata(args: { workspacePath: string; projectName?: string }): Promise { + async ensureMetadata(args: { + workspacePath: string; + projectName?: string; + runtimeConfig?: RuntimeConfig; + }): Promise { this.assertNotDisposed("ensureMetadata"); assert(args, "ensureMetadata requires arguments"); - const { workspacePath, projectName } = args; + const { workspacePath, projectName, runtimeConfig } = args; assert(typeof workspacePath === "string", "workspacePath must be a string"); const trimmedWorkspacePath = workspacePath.trim(); @@ -269,7 +274,7 @@ export class AgentSession { projectName: derivedProjectName, projectPath: derivedProjectPath, namedWorkspacePath: normalizedWorkspacePath, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, + runtimeConfig: runtimeConfig ?? DEFAULT_RUNTIME_CONFIG, }; // Write metadata directly to config.json (single source of truth) From c496929ddefa5a73f7a1b983cb79f5d7ca3256ca Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 3 Dec 2025 10:11:22 -0600 Subject: [PATCH 2/5] refactor: use parse-duration library for timeout parsing Replace the janky hand-rolled duration regex with the well-tested parse-duration library. This supports more formats including: - Compound expressions: '1h30m', '1hr 20mins' - All common units: ms, s, m, h, d, w - Plain numbers as milliseconds _Generated with `mux`_ --- bun.lock | 3 +++ package.json | 1 + src/cli/run.ts | 27 +++++++++++++-------------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index dc43b2f05..3d38dc3a1 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "motion": "^12.23.24", "ollama-ai-provider-v2": "^1.5.4", "openai": "^6.9.1", + "parse-duration": "^2.1.4", "rehype-harden": "^1.1.5", "shescape": "^2.1.6", "source-map-support": "^0.5.21", @@ -2937,6 +2938,8 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-duration": ["parse-duration@2.1.4", "", {}, "sha512-b98m6MsCh+akxfyoz9w9dt0AlH2dfYLOBss5SdDsr9pkhKNvkWBXU/r8A4ahmIGByBOLV2+4YwfCuFxbDDaGyg=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], diff --git a/package.json b/package.json index 694bac513..764bbaca0 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "motion": "^12.23.24", "ollama-ai-provider-v2": "^1.5.4", "openai": "^6.9.1", + "parse-duration": "^2.1.4", "rehype-harden": "^1.1.5", "shescape": "^2.1.6", "source-map-support": "^0.5.21", diff --git a/src/cli/run.ts b/src/cli/run.ts index ecbf643a3..ac9888b83 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -37,6 +37,7 @@ import type { ThinkingLevel } from "@/common/types/thinking"; import type { RuntimeConfig } from "@/common/types/runtime"; import { parseRuntimeModeAndHost, RUNTIME_MODE } from "@/common/types/runtime"; import assert from "@/common/utils/assert"; +import parseDuration from "parse-duration"; type CLIMode = "plan" | "exec"; @@ -66,25 +67,23 @@ function parseRuntimeConfig(value: string | undefined, srcBaseDir: string): Runt function parseTimeout(value: string | undefined): number | undefined { if (!value) return undefined; - const trimmed = value.trim().toLowerCase(); + const trimmed = value.trim(); - // Parse human-friendly formats: 5m, 300s, 5min, 5minutes, etc. - const regex = /^(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|ms)?$/i; - const match = regex.exec(trimmed); - if (!match) { + // Try parsing as plain number (milliseconds) + const asNumber = Number(trimmed); + if (!Number.isNaN(asNumber) && asNumber > 0) { + return Math.round(asNumber); + } + + // Use parse-duration for human-friendly formats (5m, 300s, 1h30m, etc.) + const ms = parseDuration(trimmed); + if (ms === null || ms <= 0) { throw new Error( - `Invalid timeout format "${value}". Use: 300s, 5m, 5min, or milliseconds (e.g., 300000)` + `Invalid timeout format "${value}". Use: 5m, 300s, 1h30m, or milliseconds (e.g., 300000)` ); } - const num = parseFloat(match[1]); - const unit = (match[2] || "ms").toLowerCase(); - - if (unit === "ms") return Math.round(num); - if (unit.startsWith("s")) return Math.round(num * 1000); - if (unit.startsWith("m")) return Math.round(num * 60 * 1000); - - return Math.round(num); + return Math.round(ms); } function parseThinkingLevel(value: string | undefined): ThinkingLevel | undefined { From eb6255e1e08304819a5d1c0eac8035f88bcab2f4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 3 Dec 2025 10:20:49 -0600 Subject: [PATCH 3/5] refactor: centralize default model to Opus 4.5 - Change default model from Sonnet to Opus 4.5 - Remove isDefault boolean from model definitions (no static guarantee) - Add DEFAULT_MODEL_KEY const that directly references a KnownModelKey - Update all hardcoded model strings to use the centralized DEFAULT_MODEL - Fix WORKSPACE_DEFAULTS to import from knownModels.ts The default model is now defined in one place: src/common/constants/knownModels.ts -> DEFAULT_MODEL_KEY = "OPUS" _Generated with `mux`_ --- src/browser/stories/mockFactory.ts | 3 ++- src/browser/stories/storyHelpers.ts | 3 ++- src/common/constants/knownModels.ts | 20 +++++++++----------- src/constants/workspaceDefaults.test.ts | 3 ++- src/constants/workspaceDefaults.ts | 5 +++-- src/node/services/workspaceService.ts | 5 +++-- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/browser/stories/mockFactory.ts b/src/browser/stories/mockFactory.ts index 443c77ad3..99c101d76 100644 --- a/src/browser/stories/mockFactory.ts +++ b/src/browser/stories/mockFactory.ts @@ -16,6 +16,7 @@ import type { MuxImagePart, MuxToolPart, } from "@/common/types/message"; +import { DEFAULT_MODEL } from "@/common/constants/knownModels"; /** Part type for message construction */ type MuxPart = MuxTextPart | MuxReasoningPart | MuxImagePart | MuxToolPart; @@ -196,7 +197,7 @@ export function createAssistantMessage( metadata: { historySequence: opts.historySequence, timestamp: opts.timestamp ?? STABLE_TIMESTAMP, - model: opts.model ?? "anthropic:claude-sonnet-4-5", + model: opts.model ?? DEFAULT_MODEL, usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, duration: 1000, }, diff --git a/src/browser/stories/storyHelpers.ts b/src/browser/stories/storyHelpers.ts index 09832b394..19161f6e9 100644 --- a/src/browser/stories/storyHelpers.ts +++ b/src/browser/stories/storyHelpers.ts @@ -14,6 +14,7 @@ import { getInputKey, getModelKey, } from "@/common/constants/storage"; +import { DEFAULT_MODEL } from "@/common/constants/knownModels"; import { createWorkspace, groupWorkspacesByProject, @@ -178,7 +179,7 @@ export function setupStreamingChatStory(opts: StreamingChatSetupOptions): APICli createStreamingChatHandler({ messages: opts.messages, streamingMessageId: opts.streamingMessageId, - model: opts.model ?? "anthropic:claude-sonnet-4-5", + model: opts.model ?? DEFAULT_MODEL, historySequence: opts.historySequence, streamText: opts.streamText, pendingTool: opts.pendingTool, diff --git a/src/common/constants/knownModels.ts b/src/common/constants/knownModels.ts index ce405f079..77caa2adf 100644 --- a/src/common/constants/knownModels.ts +++ b/src/common/constants/knownModels.ts @@ -15,8 +15,6 @@ interface KnownModelDefinition { aliases?: string[]; /** Preload tokenizer encodings at startup */ warm?: boolean; - /** Use as global default model */ - isDefault?: boolean; /** Optional tokenizer override for ai-tokenizer */ tokenizerOverride?: string; } @@ -29,12 +27,17 @@ interface KnownModel extends KnownModelDefinition { // Model definitions. Note we avoid listing legacy models here. These represent the focal models // of the community. const MODEL_DEFINITIONS = { + OPUS: { + provider: "anthropic", + providerModelId: "claude-opus-4-5", + aliases: ["opus"], + warm: true, + }, SONNET: { provider: "anthropic", providerModelId: "claude-sonnet-4-5", aliases: ["sonnet"], warm: true, - isDefault: true, tokenizerOverride: "anthropic/claude-sonnet-4.5", }, HAIKU: { @@ -43,11 +46,6 @@ const MODEL_DEFINITIONS = { aliases: ["haiku"], tokenizerOverride: "anthropic/claude-3.5-haiku", }, - OPUS: { - provider: "anthropic", - providerModelId: "claude-opus-4-5", - aliases: ["opus"], - }, GPT: { provider: "openai", providerModelId: "gpt-5.1", @@ -119,10 +117,10 @@ export function getKnownModel(key: KnownModelKey): KnownModel { // Derived collections // ------------------------------------------------------------------------------------ -const DEFAULT_MODEL_ENTRY = - Object.values(KNOWN_MODELS).find((model) => model.isDefault) ?? KNOWN_MODELS.SONNET; +/** The default model key - change this single line to update the global default */ +export const DEFAULT_MODEL_KEY: KnownModelKey = "OPUS"; -export const DEFAULT_MODEL = DEFAULT_MODEL_ENTRY.id; +export const DEFAULT_MODEL = KNOWN_MODELS[DEFAULT_MODEL_KEY].id; export const DEFAULT_WARM_MODELS = Object.values(KNOWN_MODELS) .filter((model) => model.warm) diff --git a/src/constants/workspaceDefaults.test.ts b/src/constants/workspaceDefaults.test.ts index 3f3fcf599..b4e19aed3 100644 --- a/src/constants/workspaceDefaults.test.ts +++ b/src/constants/workspaceDefaults.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; import { WORKSPACE_DEFAULTS } from "./workspaceDefaults"; +import { DEFAULT_MODEL } from "@/common/constants/knownModels"; type Mutable = { -readonly [P in keyof T]: T[P] }; @@ -15,7 +16,7 @@ describe("WORKSPACE_DEFAULTS", () => { test("should have correct default values", () => { expect(WORKSPACE_DEFAULTS.mode).toBe("exec"); expect(WORKSPACE_DEFAULTS.thinkingLevel).toBe("off"); - expect(WORKSPACE_DEFAULTS.model).toBe("anthropic:claude-sonnet-4-5"); + expect(WORKSPACE_DEFAULTS.model).toBe(DEFAULT_MODEL); expect(WORKSPACE_DEFAULTS.autoRetry).toBe(true); expect(WORKSPACE_DEFAULTS.input).toBe(""); }); diff --git a/src/constants/workspaceDefaults.ts b/src/constants/workspaceDefaults.ts index 3c33ed934..3be7a88d0 100644 --- a/src/constants/workspaceDefaults.ts +++ b/src/constants/workspaceDefaults.ts @@ -22,6 +22,7 @@ import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; +import { DEFAULT_MODEL } from "@/common/constants/knownModels"; /** * Hard-coded default values for workspace settings. @@ -36,9 +37,9 @@ export const WORKSPACE_DEFAULTS = { /** * Default AI model for new workspaces. - * This is the TRUE default - not dependent on user's LRU cache. + * Uses the centralized default from knownModels.ts. */ - model: "anthropic:claude-sonnet-4-5" as string, + model: DEFAULT_MODEL as string, /** Default auto-retry preference for new workspaces */ autoRetry: true as boolean, diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 48616be6c..ad62c2f74 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -31,6 +31,7 @@ import type { import type { MuxMessage } from "@/common/types/message"; import type { RuntimeConfig } from "@/common/types/runtime"; import { hasSrcBaseDir, getSrcBaseDir } from "@/common/types/runtime"; +import { defaultModel } from "@/common/utils/ai/models"; import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; import type { TerminalService } from "@/node/services/terminalService"; @@ -660,7 +661,7 @@ export class WorkspaceService extends EventEmitter { if (messageText.trim()) { const branchNameResult = await generateWorkspaceName( messageText, - "anthropic:claude-sonnet-4-5", + defaultModel, this.aiService ); @@ -880,7 +881,7 @@ export class WorkspaceService extends EventEmitter { projectPath?: string; trunkBranch?: string; }) - | undefined = { model: "claude-sonnet-4-5-latest" } + | undefined = { model: defaultModel } ): Promise< | Result | { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata } From ae243a3c8cab4ebaa6428169aa34021f1a0b4115 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 3 Dec 2025 10:27:28 -0600 Subject: [PATCH 4/5] refactor: use Commander.js for top-level CLI routing - Replace manual argv parsing with proper Commander.js subcommands - Add --version flag with proper version info - Subcommands (run, server) now properly routed via executableFile - Default action launches desktop app when no subcommand given - Update docs to reflect --version flag instead of subcommand --- docs/cli.md | 26 +-- src/cli/index.ts | 118 +++++------ src/cli/run.test.ts | 185 ++++++++++++++++++ src/cli/server.ts | 2 +- .../services/mock/scenarios/slashCommands.ts | 8 +- tests/e2e/scenarios/slashCommands.spec.ts | 15 +- 6 files changed, 260 insertions(+), 94 deletions(-) create mode 100644 src/cli/run.test.ts diff --git a/docs/cli.md b/docs/cli.md index 8103d31d9..c86187f96 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,10 +1,10 @@ # Command Line Interface -Mux provides a CLI for running agent sessions without opening the desktop app. +Mux provides a CLI for running one-off agent tasks without the desktop app. Unlike the interactive desktop experience, `mux run` executes a single request to completion and exits. ## `mux run` -Run an agent session in any directory: +Execute a one-off agent task: ```bash # Basic usage - run in current directory @@ -16,9 +16,6 @@ mux run --dir /path/to/project "Add authentication" # Use SSH runtime mux run --runtime "ssh user@myserver" "Deploy changes" -# Plan mode (proposes a plan, then auto-executes) -mux run --mode plan "Refactor the auth module" - # Pipe instructions via stdin echo "Add logging to all API endpoints" | mux run @@ -67,9 +64,6 @@ mux run -r "ssh dev@staging.example.com" -d /app "Update dependencies" # Scripted usage with timeout mux run --json --timeout 5m "Generate API documentation" > output.jsonl - -# Plan first, then execute -mux run --mode plan "Migrate from REST to GraphQL" ``` ## `mux server` @@ -87,11 +81,21 @@ Options: - `--auth-token ` - Optional bearer token for authentication - `--add-project ` - Add and open project at the specified path -## `mux version` +## `mux desktop` + +Launch the desktop app. This is automatically invoked when running the packaged app or via `electron .`: + +```bash +mux desktop +``` + +Note: Requires Electron. When running `mux` with no arguments under Electron, the desktop app launches automatically. + +## `mux --version` Print the version and git commit: ```bash -mux version -# mux v0.8.4 (abc123) +mux --version +# v0.8.4 (abc123) ``` diff --git a/src/cli/index.ts b/src/cli/index.ts index 291771bb9..4a6954490 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,84 +1,60 @@ #!/usr/bin/env node - +/** + * Mux CLI entry point. + * + * LAZY LOADING REQUIREMENT: + * We manually route subcommands before calling program.parse() to avoid + * eagerly importing heavy modules. The desktop app imports Electron, which + * fails when running CLI commands in non-GUI environments. Subcommands like + * `run` and `server` import the AI SDK which has significant startup cost. + * + * By checking argv[2] first, we only load the code path actually needed. + * + * ELECTRON DETECTION: + * When run via `electron .` or as a packaged app, Electron sets process.versions.electron. + * In that case, we launch the desktop app automatically. When run via `bun` or `node`, + * we show CLI help instead. + */ import { Command } from "commander"; import { VERSION } from "../version"; -<<<<<<< HEAD -const program = new Command(); - -program - .name("mux") - .description("mux - coder multiplexer") - .version(`mux ${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version"); - -// Subcommands with their own CLI parsers - disable help interception so --help passes through -program - .command("server") - .description("Start the HTTP/WebSocket oRPC server") - .helpOption(false) - .allowUnknownOption() - .allowExcessArguments() - .action(() => { - process.argv.splice(2, 1); - // eslint-disable-next-line @typescript-eslint/no-require-imports - require("./server"); - }); - -program - .command("api") - .description("Interact with the mux API via a running server") - .helpOption(false) - .allowUnknownOption() - .allowExcessArguments() - .action(() => { - process.argv.splice(2, 1); - // eslint-disable-next-line @typescript-eslint/no-require-imports - require("./api"); - }); +const subcommand = process.argv[2]; +const isElectron = "electron" in process.versions; -program - .command("version") - .description("Show version information") - .action(() => { - console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); - }); - -// Default action: launch desktop app when no subcommand given -program.action(() => { -||||||| parent of 0f258d5fc (🤖 feat: add first-class `mux run` CLI command) -if (subcommand === "server") { - // Remove 'server' from args since main-server doesn't expect it as a positional argument. - process.argv.splice(2, 1); +function launchDesktop(): void { // eslint-disable-next-line @typescript-eslint/no-require-imports - require("./server"); -} else if (subcommand === "version") { + require("../desktop/main"); +} + +// Route known subcommands to their dedicated entry points (each has its own Commander instance) +if (subcommand === "run") { + process.argv.splice(2, 1); // Remove "run" since run.ts defines .name("mux run") // eslint-disable-next-line @typescript-eslint/no-require-imports - const { VERSION } = require("../version") as { - VERSION: { git_describe: string; git_commit: string }; - }; - console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); -} else { -======= -if (subcommand === "server") { - // Remove 'server' from args since main-server doesn't expect it as a positional argument. + require("./run"); +} else if (subcommand === "server") { process.argv.splice(2, 1); // eslint-disable-next-line @typescript-eslint/no-require-imports require("./server"); -} else if (subcommand === "run") { - // Remove 'run' from args since run.ts uses Commander which handles its own parsing +} else if (subcommand === "api") { process.argv.splice(2, 1); // eslint-disable-next-line @typescript-eslint/no-require-imports - require("./run"); -} else if (subcommand === "version") { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { VERSION } = require("../version") as { - VERSION: { git_describe: string; git_commit: string }; - }; - console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); + require("./api"); +} else if (subcommand === "desktop" || (subcommand === undefined && isElectron)) { + // Explicit `mux desktop` or no args when running under Electron + launchDesktop(); } else { ->>>>>>> 0f258d5fc (🤖 feat: add first-class `mux run` CLI command) - // eslint-disable-next-line @typescript-eslint/no-require-imports - require("../desktop/main"); -}); - -program.parse(); + // No subcommand (non-Electron), flags (--help, --version), or unknown commands + const program = new Command(); + program + .name("mux") + .description("Mux - AI agent orchestration") + .version(`${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version"); + + // Register subcommand stubs for help display (actual implementations are above) + program.command("run").description("Run a one-off agent task"); + program.command("server").description("Start the HTTP/WebSocket ORPC server"); + program.command("api").description("Interact with the mux API via a running server"); + program.command("desktop").description("Launch the desktop app (requires Electron)"); + + program.parse(); +} diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts new file mode 100644 index 000000000..1772c3e3c --- /dev/null +++ b/src/cli/run.test.ts @@ -0,0 +1,185 @@ +/** + * Integration tests for `mux run` CLI command. + * + * These tests verify the CLI interface without actually running agent sessions. + * They test argument parsing, help output, and error handling. + */ +import { describe, test, expect, beforeAll } from "bun:test"; +import { spawn } from "child_process"; +import * as path from "path"; + +const CLI_PATH = path.resolve(__dirname, "index.ts"); +const RUN_PATH = path.resolve(__dirname, "run.ts"); + +interface ExecResult { + stdout: string; + stderr: string; + output: string; // combined stdout + stderr + exitCode: number; +} + +async function runCli(args: string[], timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const proc = spawn("bun", [CLI_PATH, ...args], { + timeout: timeoutMs, + env: { ...process.env, NO_COLOR: "1" }, + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + resolve({ stdout, stderr, output: stdout + stderr, exitCode: code ?? 1 }); + }); + + proc.on("error", () => { + resolve({ stdout, stderr, output: stdout + stderr, exitCode: 1 }); + }); + }); +} + +/** + * Run run.ts directly with stdin closed to avoid hanging. + * Passes empty stdin to simulate non-TTY invocation without input. + */ +async function runRunDirect(args: string[], timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const proc = spawn("bun", [RUN_PATH, ...args], { + timeout: timeoutMs, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["pipe", "pipe", "pipe"], // stdin, stdout, stderr + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + // Close stdin immediately to prevent hanging on stdin.read() + proc.stdin?.end(); + + proc.on("close", (code) => { + resolve({ stdout, stderr, output: stdout + stderr, exitCode: code ?? 1 }); + }); + + proc.on("error", () => { + resolve({ stdout, stderr, output: stdout + stderr, exitCode: 1 }); + }); + }); +} + +describe("mux CLI", () => { + beforeAll(() => { + // Verify CLI files exist + expect(Bun.file(CLI_PATH).size).toBeGreaterThan(0); + expect(Bun.file(RUN_PATH).size).toBeGreaterThan(0); + }); + + describe("top-level", () => { + test("--help shows usage", async () => { + const result = await runCli(["--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage: mux"); + expect(result.stdout).toContain("Mux - AI agent orchestration"); + expect(result.stdout).toContain("run"); + expect(result.stdout).toContain("server"); + }); + + test("--version shows version info", async () => { + const result = await runCli(["--version"]); + expect(result.exitCode).toBe(0); + // Version format: vX.Y.Z-N-gHASH (HASH) + expect(result.stdout).toMatch(/v\d+\.\d+\.\d+/); + }); + + test("unknown command shows error", async () => { + const result = await runCli(["nonexistent"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown command"); + }); + }); + + describe("mux run", () => { + test("--help shows all options", async () => { + const result = await runCli(["run", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage: mux run"); + expect(result.stdout).toContain("--dir"); + expect(result.stdout).toContain("--model"); + expect(result.stdout).toContain("--runtime"); + expect(result.stdout).toContain("--mode"); + expect(result.stdout).toContain("--thinking"); + expect(result.stdout).toContain("--timeout"); + expect(result.stdout).toContain("--json"); + expect(result.stdout).toContain("--quiet"); + expect(result.stdout).toContain("--workspace-id"); + expect(result.stdout).toContain("--config-root"); + }); + + test("shows default model as opus", async () => { + const result = await runCli(["run", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("anthropic:claude-opus-4-5"); + }); + + test("no message shows error", async () => { + const result = await runRunDirect([]); + expect(result.exitCode).toBe(1); + expect(result.output).toContain("No message provided"); + }); + + test("invalid thinking level shows error", async () => { + const result = await runRunDirect(["--thinking", "extreme", "test message"]); + expect(result.exitCode).toBe(1); + expect(result.output).toContain("Invalid thinking level"); + }); + + test("invalid mode shows error", async () => { + const result = await runRunDirect(["--mode", "chaos", "test message"]); + expect(result.exitCode).toBe(1); + expect(result.output).toContain("Invalid mode"); + }); + + test("invalid timeout shows error", async () => { + const result = await runRunDirect(["--timeout", "abc", "test message"]); + expect(result.exitCode).toBe(1); + expect(result.output).toContain("Invalid timeout"); + }); + + test("nonexistent directory shows error", async () => { + const result = await runRunDirect([ + "--dir", + "/nonexistent/path/that/does/not/exist", + "test message", + ]); + expect(result.exitCode).toBe(1); + expect(result.output.length).toBeGreaterThan(0); + }); + }); + + describe("mux server", () => { + test("--help shows all options", async () => { + const result = await runCli(["server", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage: mux server"); + expect(result.stdout).toContain("--host"); + expect(result.stdout).toContain("--port"); + expect(result.stdout).toContain("--auth-token"); + expect(result.stdout).toContain("--add-project"); + }); + }); +}); diff --git a/src/cli/server.ts b/src/cli/server.ts index 743ef1fc0..89f98d150 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -13,7 +13,7 @@ import type { ORPCContext } from "@/node/orpc/context"; const program = new Command(); program - .name("mux-server") + .name("mux server") .description("HTTP/WebSocket ORPC server for mux") .option("-h, --host ", "bind to specific host", "localhost") .option("-p, --port ", "bind to specific port", "3000") diff --git a/src/node/services/mock/scenarios/slashCommands.ts b/src/node/services/mock/scenarios/slashCommands.ts index 0b197a801..0defa15d8 100644 --- a/src/node/services/mock/scenarios/slashCommands.ts +++ b/src/node/services/mock/scenarios/slashCommands.ts @@ -65,18 +65,18 @@ const modelStatusTurn: ScenarioTurn = { kind: "stream-start", delay: 0, messageId: "msg-slash-model-status", - model: "anthropic:claude-opus-4-5", + model: "anthropic:claude-sonnet-4-5", }, { kind: "stream-delta", delay: STREAM_BASE_DELAY, - text: "Claude Opus 4.5 is now responding with enhanced reasoning capacity.", + text: "Claude Sonnet 4.5 is now responding with standard reasoning capacity.", }, { kind: "stream-end", delay: STREAM_BASE_DELAY * 2, metadata: { - model: "anthropic:claude-opus-4-5", + model: "anthropic:claude-sonnet-4-5", inputTokens: 70, outputTokens: 54, systemMessageTokens: 12, @@ -84,7 +84,7 @@ const modelStatusTurn: ScenarioTurn = { parts: [ { type: "text", - text: "I'm responding as Claude Opus 4.5, which you selected via /model opus. Let me know how to proceed.", + text: "I'm responding as Claude Sonnet 4.5, which you selected via /model sonnet. Let me know how to proceed.", }, ], }, diff --git a/tests/e2e/scenarios/slashCommands.spec.ts b/tests/e2e/scenarios/slashCommands.spec.ts index 0bb8a71f0..25ddccbf4 100644 --- a/tests/e2e/scenarios/slashCommands.spec.ts +++ b/tests/e2e/scenarios/slashCommands.spec.ts @@ -99,26 +99,27 @@ test.describe("slash command flows", () => { await expect(transcript).not.toContainText("Directory listing:"); }); - test("slash command /model opus switches models for subsequent turns", async ({ ui, page }) => { + test("slash command /model sonnet switches models for subsequent turns", async ({ ui, page }) => { await ui.projects.openFirstWorkspace(); const modeToggles = page.locator('[data-component="ChatModeToggles"]'); + // Default model is now Opus + await expect(modeToggles.getByText("anthropic:claude-opus-4-5", { exact: true })).toBeVisible(); + + await ui.chat.sendMessage("/model sonnet"); + await ui.chat.expectStatusMessageContains("Model changed to anthropic:claude-sonnet-4-5"); await expect( modeToggles.getByText("anthropic:claude-sonnet-4-5", { exact: true }) ).toBeVisible(); - await ui.chat.sendMessage("/model opus"); - await ui.chat.expectStatusMessageContains("Model changed to anthropic:claude-opus-4-5"); - await expect(modeToggles.getByText("anthropic:claude-opus-4-5", { exact: true })).toBeVisible(); - const timeline = await ui.chat.captureStreamTimeline(async () => { await ui.chat.sendMessage(SLASH_COMMAND_PROMPTS.MODEL_STATUS); }); const streamStart = timeline.events.find((event) => event.type === "stream-start"); - expect(streamStart?.model).toBe("anthropic:claude-opus-4-5"); + expect(streamStart?.model).toBe("anthropic:claude-sonnet-4-5"); await ui.chat.expectTranscriptContains( - "Claude Opus 4.5 is now responding with enhanced reasoning capacity." + "Claude Sonnet 4.5 is now responding with standard reasoning capacity." ); }); From ce920e4a73525bfe1d0b6bff835978a62c52b348 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 3 Dec 2025 11:36:47 -0600 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20unify=20logging?= =?UTF-8?q?=20with=20configurable=20log=20levels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add log levels: error, warn, info, debug (hierarchical) - Add log.warn() method and log.setLevel()/getLevel() for programmatic control - CLI defaults to 'error' level (quiet), Desktop defaults to 'info' - Add --verbose/-v flag to mux run to enable info-level logs - Add --log-level flag for explicit level control - Migrate all console.error/warn calls in src/node/ to log.* - Migrate ORPC error handler to use log.error - Fix circular dependency: log.ts no longer imports defaultConfig Environment variable MUX_LOG_LEVEL=error|warn|info|debug overrides defaults. MUX_DEBUG=1 enables debug level as before. _Generated with mux_ --- src/cli/orpcServer.ts | 3 +- src/cli/run.ts | 34 +++- src/node/config.ts | 17 +- src/node/services/ExtensionMetadataService.ts | 7 +- src/node/services/compactionHandler.ts | 3 +- src/node/services/historyService.ts | 4 +- src/node/services/log.ts | 161 ++++++++++++++---- src/node/services/mock/mockScenarioPlayer.ts | 6 +- src/node/services/partialService.ts | 3 +- src/node/services/providerService.ts | 3 +- src/node/services/streamManager.ts | 8 +- src/node/services/tempDir.ts | 3 +- src/node/services/tools/testHelpers.ts | 3 +- src/node/services/workspaceService.ts | 2 +- src/node/utils/extensionMetadata.ts | 5 +- src/node/utils/main/tokenizer.ts | 5 +- src/node/utils/main/workerPool.ts | 7 +- src/node/utils/sessionFile.ts | 3 +- 18 files changed, 200 insertions(+), 77 deletions(-) diff --git a/src/cli/orpcServer.ts b/src/cli/orpcServer.ts index be2689022..10a3258ae 100644 --- a/src/cli/orpcServer.ts +++ b/src/cli/orpcServer.ts @@ -17,6 +17,7 @@ import { router } from "@/node/orpc/router"; import type { ORPCContext } from "@/node/orpc/context"; import { extractWsHeaders } from "@/node/orpc/authMiddleware"; import { VERSION } from "@/version"; +import { log } from "@/node/services/log"; // --- Types --- @@ -71,7 +72,7 @@ export async function createOrpcServer({ context, serveStatic = false, staticDir = path.join(__dirname, ".."), - onOrpcError = (error) => console.error("ORPC Error:", error), + onOrpcError = (error) => log.error("ORPC Error:", error), }: OrpcServerOptions): Promise { // Express app setup const app = express(); diff --git a/src/cli/run.ts b/src/cli/run.ts index ac9888b83..b14d20d7c 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -38,6 +38,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { parseRuntimeModeAndHost, RUNTIME_MODE } from "@/common/types/runtime"; import assert from "@/common/utils/assert"; import parseDuration from "parse-duration"; +import { log, type LogLevel } from "@/node/services/log"; type CLIMode = "plan" | "exec"; @@ -163,6 +164,8 @@ program .option("--mode ", "agent mode: plan or exec", "exec") .option("-t, --thinking ", "thinking level: off, low, medium, high", "medium") .option("--timeout ", "timeout (e.g., 5m, 300s, 300000)") + .option("-v, --verbose", "show info-level logs (default: errors only)") + .option("--log-level ", "set log level: error, warn, info, debug") .option("--json", "output NDJSON for programmatic consumption") .option("-q, --quiet", "only output final result") .option("--workspace-id ", "explicit workspace ID (auto-generated if not provided)") @@ -189,6 +192,8 @@ interface CLIOptions { mode: string; thinking: string; timeout?: string; + verbose?: boolean; + logLevel?: string; json?: boolean; quiet?: boolean; workspaceId?: string; @@ -199,6 +204,20 @@ const opts = program.opts(); const messageArg = program.args[0]; async function main(): Promise { + // Configure log level early (before any logging happens) + if (opts.logLevel) { + const level = opts.logLevel.toLowerCase(); + if (level === "error" || level === "warn" || level === "info" || level === "debug") { + log.setLevel(level as LogLevel); + } else { + console.error(`Invalid log level "${opts.logLevel}". Expected: error, warn, info, debug`); + process.exit(1); + } + } else if (opts.verbose) { + log.setLevel("info"); + } + // Default is already "warn" for CLI mode (set in log.ts) + // Resolve directory const projectDir = path.resolve(opts.dir); await ensureDirectory(projectDir); @@ -236,14 +255,13 @@ async function main(): Promise { if (emitJson) process.stdout.write(`${JSON.stringify(payload)}\n`); }; - if (!suppressHumanOutput) { - console.error(`[mux run] Directory: ${projectDir}`); - console.error(`[mux run] Model: ${model}`); - console.error( - `[mux run] Runtime: ${runtimeConfig.type}${runtimeConfig.type === "ssh" ? ` (${runtimeConfig.host})` : ""}` - ); - console.error(`[mux run] Mode: ${initialMode}`); - } + // Log startup info (shown at info+ level, i.e., with --verbose) + log.info(`Directory: ${projectDir}`); + log.info(`Model: ${model}`); + log.info( + `Runtime: ${runtimeConfig.type}${runtimeConfig.type === "ssh" ? ` (${runtimeConfig.host})` : ""}` + ); + log.info(`Mode: ${initialMode}`); // Initialize services const historyService = new HistoryService(config); diff --git a/src/node/config.ts b/src/node/config.ts index 56b01cf8c..eaec6422a 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -3,6 +3,7 @@ import * as path from "path"; import * as crypto from "crypto"; import * as jsonc from "jsonc-parser"; import writeFileAtomic from "write-file-atomic"; +import { log } from "@/node/services/log"; import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { Secret, SecretsConfig } from "@/common/types/secrets"; import type { Workspace, ProjectConfig, ProjectsConfig } from "@/common/types/project"; @@ -64,7 +65,7 @@ export class Config { } } } catch (error) { - console.error("Error loading config:", error); + log.error("Error loading config:", error); } // Return default config @@ -85,7 +86,7 @@ export class Config { await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { - console.error("Error saving config:", error); + log.error("Error saving config:", error); } } @@ -344,7 +345,7 @@ export class Config { workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); } } catch (error) { - console.error(`Failed to load/migrate workspace metadata:`, error); + log.error(`Failed to load/migrate workspace metadata:`, error); // Fallback to basic metadata if migration fails const legacyId = this.generateLegacyId(projectPath, workspace.path); const metadata: WorkspaceMetadata = { @@ -431,7 +432,7 @@ export class Config { } if (!workspaceFound) { - console.warn(`Workspace ${workspaceId} not found in config during removal`); + log.warn(`Workspace ${workspaceId} not found in config during removal`); } return config; @@ -469,7 +470,7 @@ export class Config { return jsonc.parse(data) as ProvidersConfig; } } catch (error) { - console.error("Error loading providers config:", error); + log.error("Error loading providers config:", error); } return null; @@ -510,7 +511,7 @@ ${jsonString}`; fs.writeFileSync(this.providersFile, contentWithComments); } catch (error) { - console.error("Error saving providers config:", error); + log.error("Error saving providers config:", error); throw error; // Re-throw to let caller handle } } @@ -526,7 +527,7 @@ ${jsonString}`; return JSON.parse(data) as SecretsConfig; } } catch (error) { - console.error("Error loading secrets config:", error); + log.error("Error loading secrets config:", error); } return {}; @@ -544,7 +545,7 @@ ${jsonString}`; await writeFileAtomic(this.secretsFile, JSON.stringify(config, null, 2), "utf-8"); } catch (error) { - console.error("Error saving secrets config:", error); + log.error("Error saving secrets config:", error); throw error; } } diff --git a/src/node/services/ExtensionMetadataService.ts b/src/node/services/ExtensionMetadataService.ts index bc51e99e6..39b872b07 100644 --- a/src/node/services/ExtensionMetadataService.ts +++ b/src/node/services/ExtensionMetadataService.ts @@ -8,6 +8,7 @@ import { getExtensionMetadataPath, } from "@/node/utils/extensionMetadata"; import type { WorkspaceActivitySnapshot } from "@/common/types/workspace"; +import { log } from "@/node/services/log"; /** * Stateless service for managing workspace metadata used by VS Code extension integration. @@ -74,13 +75,13 @@ export class ExtensionMetadataService { // Validate structure if (typeof parsed !== "object" || parsed.version !== 1) { - console.error("[ExtensionMetadataService] Invalid metadata file, resetting"); + log.error("Invalid metadata file, resetting"); return { version: 1, workspaces: {} }; } return parsed; } catch (error) { - console.error("[ExtensionMetadataService] Failed to load metadata:", error); + log.error("Failed to load metadata:", error); return { version: 1, workspaces: {} }; } } @@ -90,7 +91,7 @@ export class ExtensionMetadataService { const content = JSON.stringify(data, null, 2); await writeFileAtomic(this.filePath, content, "utf-8"); } catch (error) { - console.error("[ExtensionMetadataService] Failed to save metadata:", error); + log.error("Failed to save metadata:", error); } } diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index df218cf15..c24c20ea1 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -8,6 +8,7 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { collectUsageHistory } from "@/common/utils/tokens/displayUsage"; import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { log } from "@/node/services/log"; interface CompactionHandlerOptions { workspaceId: string; @@ -71,7 +72,7 @@ export class CompactionHandler { const result = await this.performCompaction(summary, messages, event.metadata); if (!result.success) { - console.error("[CompactionHandler] Compaction failed:", result.error); + log.error("Compaction failed:", result.error); return false; } diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 708cd4bcd..fbb86a6b8 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -54,8 +54,8 @@ export class HistoryService { messages.push(normalizeLegacyMuxMetadata(message)); } catch (parseError) { // Skip malformed lines but log error for debugging - console.error( - `[HistoryService] Skipping malformed JSON at line ${i + 1} in ${workspaceId}/chat.jsonl:`, + log.warn( + `Skipping malformed JSON at line ${i + 1} in ${workspaceId}/chat.jsonl:`, parseError instanceof Error ? parseError.message : String(parseError), "\nLine content:", lines[i].substring(0, 100) + (lines[i].length > 100 ? "..." : "") diff --git a/src/node/services/log.ts b/src/node/services/log.ts index 41839f083..03d02ac04 100644 --- a/src/node/services/log.ts +++ b/src/node/services/log.ts @@ -1,26 +1,79 @@ /** - * Pipe-safe logging utilities for mux + * Unified logging for mux (backend + CLI) * - * These functions wrap console.log/error with EPIPE protection to prevent - * crashes when stdout/stderr pipes are closed (e.g., when piping to head/tail). + * Features: + * - Log levels: error, warn, info, debug (hierarchical) + * - EPIPE protection for piped output + * - Caller file:line prefix for debugging + * - Colored output in TTY * - * They also prefix log messages with the caller's file path and line number - * for easier debugging. + * Log level selection (in priority order): + * 1. MUX_LOG_LEVEL env var (error|warn|info|debug) + * 2. MUX_DEBUG=1 → debug level + * 3. CLI mode (no Electron) → error level (quiet by default) + * 4. Desktop mode → info level + * + * Use log.setLevel() to override programmatically (e.g., --verbose flag). */ import * as fs from "fs"; import * as path from "path"; import chalk from "chalk"; -import { defaultConfig } from "@/node/config"; import { parseBoolEnv } from "@/common/utils/env"; +import { getMuxHome } from "@/common/constants/paths"; + +// Lazy-initialized to avoid circular dependency with config.ts +let _debugObjDir: string | null = null; +function getDebugObjDir(): string { + _debugObjDir ??= path.join(getMuxHome(), "debug_obj"); + return _debugObjDir; +} + +/** Log levels in order of verbosity (lower = less verbose) */ +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const LOG_LEVEL_PRIORITY: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +/** + * Determine the default log level based on environment + */ +function getDefaultLogLevel(): LogLevel { + // Explicit env var takes priority + const envLevel = process.env.MUX_LOG_LEVEL?.toLowerCase(); + if (envLevel && envLevel in LOG_LEVEL_PRIORITY) { + return envLevel as LogLevel; + } + + // MUX_DEBUG=1 enables debug level + if (parseBoolEnv(process.env.MUX_DEBUG)) { + return "debug"; + } + + // CLI mode (no Electron) defaults to error (quiet) + // Desktop mode defaults to info + const isElectron = "electron" in process.versions; + return isElectron ? "info" : "error"; +} -const DEBUG_OBJ_DIR = path.join(defaultConfig.rootDir, "debug_obj"); +let currentLogLevel: LogLevel = getDefaultLogLevel(); /** - * Check if debug mode is enabled + * Check if a message at the given level should be logged + */ +function shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[currentLogLevel]; +} + +/** + * Check if debug mode is enabled (for backwards compatibility) */ function isDebugMode(): boolean { - return parseBoolEnv(process.env.MUX_DEBUG); + return currentLogLevel === "debug"; } /** @@ -48,6 +101,10 @@ const chalkRed = typeof (chalk as { red?: (text: string) => string }).red === "function" ? (chalk as { red: (text: string) => string }).red : (text: string) => text; +const chalkYellow = + typeof (chalk as { yellow?: (text: string) => string }).yellow === "function" + ? (chalk as { yellow: (text: string) => string }).yellow + : (text: string) => text; /** * Get kitchen time timestamp for logs (12-hour format with milliseconds) @@ -104,10 +161,15 @@ function getCallerLocation(): string { /** * Pipe-safe logging function with styled timestamp and caller location * Format: 8:23.456PM src/main.ts:23 - * @param level - "info", "error", or "debug" + * @param level - Log level * @param args - Arguments to log */ -function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): void { +function safePipeLog(level: LogLevel, ...args: unknown[]): void { + // Check if this level should be logged + if (!shouldLog(level)) { + return; + } + const timestamp = getTimestamp(); const location = getCallerLocation(); const useColor = supportsColor(); @@ -120,6 +182,8 @@ function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): voi if (level === "error") { prefix = `${coloredTimestamp} ${coloredLocation}`; + } else if (level === "warn") { + prefix = `${coloredTimestamp} ${coloredLocation}`; } else if (level === "debug") { prefix = `${coloredTimestamp} ${chalkGray(location)}`; } else { @@ -142,12 +206,18 @@ function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): voi } else { console.error(prefix, ...args); } - } else if (level === "debug") { - // Only log debug messages if MUX_DEBUG is set - if (isDebugMode()) { - console.log(prefix, ...args); + } else if (level === "warn") { + // Color the entire warning message yellow if supported + if (useColor) { + console.error( + prefix, + ...args.map((arg) => (typeof arg === "string" ? chalkYellow(arg) : arg)) + ); + } else { + console.error(prefix, ...args); } } else { + // info and debug go to stdout console.log(prefix, ...args); } } catch (error) { @@ -161,7 +231,7 @@ function safePipeLog(level: "info" | "error" | "debug", ...args: unknown[]): voi if (errorCode !== "EPIPE") { try { - const stream = level === "error" ? process.stderr : process.stdout; + const stream = level === "error" || level === "warn" ? process.stderr : process.stdout; stream.write(`${timestamp} ${location} Console error: ${errorMessage}\n`); } catch { // Even the fallback might fail, just ignore @@ -182,9 +252,10 @@ function debugObject(filename: string, obj: unknown): void { try { // Ensure debug_obj directory exists - fs.mkdirSync(DEBUG_OBJ_DIR, { recursive: true }); + const debugObjDir = getDebugObjDir(); + fs.mkdirSync(debugObjDir, { recursive: true }); - const filePath = path.join(DEBUG_OBJ_DIR, filename); + const filePath = path.join(debugObjDir, filename); const dirPath = path.dirname(filePath); // Ensure subdirectories exist @@ -202,48 +273,70 @@ function debugObject(filename: string, obj: unknown): void { } /** - * Logging utilities with EPIPE protection and caller location prefixes + * Unified logging interface for mux + * + * Log levels (hierarchical - each includes all levels above it): + * - error: Critical failures only + * - warn: Warnings + errors + * - info: Informational + warnings + errors + * - debug: Everything (verbose) + * + * Default levels: + * - CLI mode: error (quiet by default) + * - Desktop mode: info + * - MUX_DEBUG=1: debug + * - MUX_LOG_LEVEL=: explicit override */ export const log = { /** - * Log an informational message to stdout - * Prefixes output with caller's file path and line number + * Log an informational message to stdout (shown at info+ level) */ info: (...args: unknown[]): void => { safePipeLog("info", ...args); }, /** - * Log an error message to stderr - * Prefixes output with caller's file path and line number + * Log a warning message to stderr (shown at warn+ level) + */ + warn: (...args: unknown[]): void => { + safePipeLog("warn", ...args); + }, + + /** + * Log an error message to stderr (always shown) */ error: (...args: unknown[]): void => { safePipeLog("error", ...args); }, /** - * Log a debug message to stdout (only when MUX_DEBUG is set) - * Prefixes output with caller's file path and line number + * Log a debug message to stdout (shown at debug level only) */ debug: (...args: unknown[]): void => { safePipeLog("debug", ...args); }, /** - * Dump an object to a JSON file for debugging (only when MUX_DEBUG is set) + * Dump an object to a JSON file for debugging (only at debug level) * Files are written to ~/.mux/debug_obj/ - * - * @param filename - Name of the file (e.g., "model_messages.json" or "workspace/data.json") - * @param obj - Object to serialize and dump - * - * @example - * log.debug_obj("transformed_messages.json", messages); - * log.debug_obj(`${workspaceId}/model_messages.json`, modelMessages); */ debug_obj: debugObject, /** - * Check if debug mode is enabled + * Set the current log level programmatically + * @example log.setLevel("info") // Enable verbose output + */ + setLevel: (level: LogLevel): void => { + currentLogLevel = level; + }, + + /** + * Get the current log level + */ + getLevel: (): LogLevel => currentLogLevel, + + /** + * Check if debug mode is enabled (backwards compatibility) */ isDebugMode, }; diff --git a/src/node/services/mock/mockScenarioPlayer.ts b/src/node/services/mock/mockScenarioPlayer.ts index 930257da7..606e38b27 100644 --- a/src/node/services/mock/mockScenarioPlayer.ts +++ b/src/node/services/mock/mockScenarioPlayer.ts @@ -251,7 +251,7 @@ export class MockScenarioPlayer { try { await handler(); } catch (error) { - console.error(`[MockScenarioPlayer] Event handler error for ${workspaceId}:`, error); + log.error(`Event handler error for ${workspaceId}:`, error); } } @@ -326,7 +326,7 @@ export class MockScenarioPlayer { try { tokens = await tokenizeWithMockModel(event.text, "stream-delta text"); } catch (error) { - console.error("[MockScenarioPlayer] tokenize failed for stream-delta", error); + log.error("tokenize failed for stream-delta", error); throw error; } const payload: StreamDeltaEvent = { @@ -386,7 +386,7 @@ export class MockScenarioPlayer { ); if (!updateResult.success) { - console.error(`Failed to update history for ${messageId}: ${updateResult.error}`); + log.error(`Failed to update history for ${messageId}: ${updateResult.error}`); } } } diff --git a/src/node/services/partialService.ts b/src/node/services/partialService.ts index 6bb20c3cb..0f3677ed9 100644 --- a/src/node/services/partialService.ts +++ b/src/node/services/partialService.ts @@ -8,6 +8,7 @@ import type { Config } from "@/node/config"; import type { HistoryService } from "./historyService"; import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; import { normalizeLegacyMuxMetadata } from "@/node/utils/messages/legacy"; +import { log } from "@/node/services/log"; /** * PartialService - Manages partial message persistence for interrupted streams @@ -57,7 +58,7 @@ export class PartialService { return null; // No partial exists } // Log other errors but don't fail - console.error("Error reading partial:", error); + log.error("Error reading partial:", error); return null; } } diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index 457b3d474..c98420971 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -7,6 +7,7 @@ import type { ProviderConfigInfo, ProvidersConfigMap, } from "@/common/orpc/types"; +import { log } from "@/node/services/log"; // Re-export types for backward compatibility export type { AWSCredentialStatus, ProviderConfigInfo, ProvidersConfigMap }; @@ -33,7 +34,7 @@ export class ProviderService { try { return [...SUPPORTED_PROVIDERS]; } catch (error) { - console.error("Failed to list providers:", error); + log.error("Failed to list providers:", error); return []; } } diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 61f80eb41..05eadbac7 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -530,7 +530,7 @@ export class StreamManager extends EventEmitter { // Clean up immediately this.workspaceStreams.delete(workspaceId); } catch (error) { - console.error("Error during stream cancellation:", error); + log.error("Error during stream cancellation:", error); // Force cleanup even if cancellation fails this.workspaceStreams.delete(workspaceId); } @@ -558,7 +558,7 @@ export class StreamManager extends EventEmitter { : false; void this.cleanupStream(workspaceId, streamInfo, abandonPartial); } catch (error) { - console.error("Error during stream cancellation:", error); + log.error("Error during stream cancellation:", error); // Force cleanup even if cancellation fails this.workspaceStreams.delete(workspaceId); } @@ -1129,7 +1129,7 @@ export class StreamManager extends EventEmitter { streamInfo.state = StreamState.ERROR; // Log the actual error for debugging - console.error("Stream processing error:", error); + log.error("Stream processing error:", error); // Check if this is a lost previousResponseId error and record it // Frontend will automatically retry, and buildProviderOptions will filter it out @@ -1436,7 +1436,7 @@ export class StreamManager extends EventEmitter { streamInfo, historySequence ).catch((error) => { - console.error("Unexpected error in stream processing:", error); + log.error("Unexpected error in stream processing:", error); }); return Ok(streamToken); diff --git a/src/node/services/tempDir.ts b/src/node/services/tempDir.ts index 074915edc..72114da84 100644 --- a/src/node/services/tempDir.ts +++ b/src/node/services/tempDir.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { log } from "@/node/services/log"; /** * Disposable temporary directory that auto-cleans when disposed @@ -22,7 +23,7 @@ export class DisposableTempDir implements Disposable { try { fs.rmSync(this.path, { recursive: true, force: true }); } catch (error) { - console.error(`Failed to cleanup temp dir ${this.path}:`, error); + log.warn(`Failed to cleanup temp dir ${this.path}:`, error); // Don't throw - cleanup is best-effort } } diff --git a/src/node/services/tools/testHelpers.ts b/src/node/services/tools/testHelpers.ts index 77beb749e..c7831afbe 100644 --- a/src/node/services/tools/testHelpers.ts +++ b/src/node/services/tools/testHelpers.ts @@ -5,6 +5,7 @@ import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { InitStateManager } from "@/node/services/initStateManager"; import { Config } from "@/node/config"; import type { ToolConfiguration } from "@/common/utils/tools/tools"; +import { log } from "@/node/services/log"; /** * Disposable test temp directory that auto-cleans when disposed @@ -24,7 +25,7 @@ export class TestTempDir implements Disposable { try { fs.rmSync(this.path, { recursive: true, force: true }); } catch (error) { - console.error(`Failed to cleanup test temp dir ${this.path}:`, error); + log.warn(`Failed to cleanup test temp dir ${this.path}:`, error); } } } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index ad62c2f74..3c5633e64 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -634,7 +634,7 @@ export class WorkspaceService extends EventEmitter { try { return await this.config.getAllWorkspaceMetadata(); } catch (error) { - console.error("Failed to list workspaces:", error); + log.error("Failed to list workspaces:", error); return []; } } diff --git a/src/node/utils/extensionMetadata.ts b/src/node/utils/extensionMetadata.ts index e90025518..24f73b7fd 100644 --- a/src/node/utils/extensionMetadata.ts +++ b/src/node/utils/extensionMetadata.ts @@ -1,5 +1,6 @@ import { readFileSync, existsSync } from "fs"; import { getMuxExtensionMetadataPath } from "@/common/constants/paths"; +import { log } from "@/node/services/log"; /** * Extension metadata for a single workspace. @@ -45,7 +46,7 @@ export function readExtensionMetadata(): Map { // Validate structure if (typeof data !== "object" || data.version !== 1) { - console.error("[extensionMetadata] Invalid metadata file format"); + log.error("Invalid metadata file format"); return new Map(); } @@ -56,7 +57,7 @@ export function readExtensionMetadata(): Map { return map; } catch (error) { - console.error("[extensionMetadata] Failed to read metadata:", error); + log.error("Failed to read metadata:", error); return new Map(); } } diff --git a/src/node/utils/main/tokenizer.ts b/src/node/utils/main/tokenizer.ts index a30245c9a..38bdc8c56 100644 --- a/src/node/utils/main/tokenizer.ts +++ b/src/node/utils/main/tokenizer.ts @@ -7,6 +7,7 @@ import { models, type ModelName } from "ai-tokenizer"; import { run } from "./workerPool"; import { TOKENIZER_MODEL_OVERRIDES, DEFAULT_WARM_MODELS } from "@/common/constants/knownModels"; import { normalizeGatewayModel } from "@/common/utils/ai/models"; +import { log } from "@/node/services/log"; /** * Public tokenizer interface exposed to callers. @@ -64,8 +65,8 @@ function resolveModelName(modelString: string): ModelName { // Only warn once per unknown model to avoid log spam if (!warnedModels.has(modelString)) { warnedModels.add(modelString); - console.warn( - `[tokenizer] Unknown model '${modelString}', using ${fallbackModel} tokenizer for approximate token counting` + log.warn( + `Unknown model '${modelString}', using ${fallbackModel} tokenizer for approximate token counting` ); } diff --git a/src/node/utils/main/workerPool.ts b/src/node/utils/main/workerPool.ts index 40968276c..8e8381b01 100644 --- a/src/node/utils/main/workerPool.ts +++ b/src/node/utils/main/workerPool.ts @@ -1,5 +1,6 @@ import { Worker } from "node:worker_threads"; import { join, dirname, sep, extname } from "node:path"; +import { log } from "@/node/services/log"; interface WorkerRequest { messageId: number; @@ -62,7 +63,7 @@ const worker = new Worker(workerPath); worker.on("message", (response: WorkerResponse) => { const pending = pendingPromises.get(response.messageId); if (!pending) { - console.error(`[workerPool] No pending promise for messageId ${response.messageId}`); + log.error(`No pending promise for messageId ${response.messageId}`); return; } @@ -79,7 +80,7 @@ worker.on("message", (response: WorkerResponse) => { // Handle worker errors worker.on("error", (error) => { - console.error("[workerPool] Worker error:", error); + log.error("Worker error:", error); // Reject all pending promises for (const pending of pendingPromises.values()) { pending.reject(error); @@ -90,7 +91,7 @@ worker.on("error", (error) => { // Handle worker exit worker.on("exit", (code) => { if (code !== 0) { - console.error(`[workerPool] Worker stopped with exit code ${code}`); + log.error(`Worker stopped with exit code ${code}`); const error = new Error(`Worker stopped with exit code ${code}`); for (const pending of pendingPromises.values()) { pending.reject(error); diff --git a/src/node/utils/sessionFile.ts b/src/node/utils/sessionFile.ts index e1b690127..ecc5388ac 100644 --- a/src/node/utils/sessionFile.ts +++ b/src/node/utils/sessionFile.ts @@ -5,6 +5,7 @@ import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { Config } from "@/node/config"; import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; +import { log } from "@/node/services/log"; /** * Shared utility for managing JSON files in workspace session directories. @@ -41,7 +42,7 @@ export class SessionFileManager { return null; // File doesn't exist } // Log other errors but don't fail - console.error(`Error reading ${this.fileName}:`, error); + log.error(`Error reading ${this.fileName}:`, error); return null; } }