Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ members = ["src-tauri", "agentmuxsrv-rs", "wsh-rs"]

[profile.release]
strip = true
lto = true
codegen-units = 1
lto = "thin"
codegen-units = 4
opt-level = "s"

# Fast release profile for testing (much faster compile, slightly larger binary)
Expand Down
2 changes: 1 addition & 1 deletion agentmuxsrv-rs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "agentmuxsrv-rs"
version = "0.32.101"
version = "0.32.104"
edition = "2021"
description = "AgentMux Rust backend (drop-in replacement for Go agentmuxsrv)"

Expand Down
26 changes: 26 additions & 0 deletions agentmuxsrv-rs/src/backend/rpc_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ pub const COMMAND_AGENT_STOP: &str = "agentstop";
pub const COMMAND_WRITE_AGENT_CONFIG: &str = "writeagentconfig";
pub const COMMAND_RESOLVE_CLI: &str = "resolvecli";
pub const COMMAND_CHECK_CLI_AUTH: &str = "checkcliauth";
pub const COMMAND_INSTALL_SYSDEP: &str = "installsysdep";

// Block commands
pub const COMMAND_MKDIR: &str = "mkdir";
Expand Down Expand Up @@ -537,6 +538,31 @@ pub struct RunCliLoginResult {
pub raw_output: String,
}

/// Data for InstallSysdepCommand — check and auto-install a system dependency.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandInstallSysdepData {
/// Dependency name: "git", "npm", "gh"
pub dep: String,
/// Block ID for streaming install progress (optional — empty = no streaming)
#[serde(default)]
pub block_id: String,
}

/// Result from InstallSysdepCommand
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallSysdepResult {
/// Whether the dep is usable after this call
pub found: bool,
/// Absolute path to the binary (empty if not found)
pub path: String,
/// Version string (empty if not found)
pub version: String,
/// "present" — already installed | "installed" — we installed it | "not_found" — missing
pub source: String,
/// Manual install instructions shown to user when auto-install is unavailable or fails
pub install_hint: String,
}

/// Matches Go's `FileDataAt`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDataAt {
Expand Down
415 changes: 395 additions & 20 deletions agentmuxsrv-rs/src/server/websocket.rs

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions docs/retro-cli-exe-cmd-regression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Retro: Claude CLI Reverted to npm Install (`.exe` → `.cmd` regression)

**Date:** 2026-03-28
**Commit that introduced it:** `cf1710f` — _fix(windows): prefer .cmd wrapper for Node.js CLI detection_
**Symptom:** Claude CLI shows `(unknown)` version; auth browser opens but CLI invocation broken; full package install ignored in favour of npm

---

## What Happened

### Before `cf1710f`
`cli_bin` (the versioned copy destination) was `<bin_dir>/claude.exe` on Windows.
Fast-path: copies `~/.local/bin/claude.exe` → versioned dir as `.exe`.
`make_cli_cmd("claude.exe")` → runs directly as native PE. Works.

### After `cf1710f`
`cli_bin` was changed to `<bin_dir>/claude.cmd` on Windows (to fix npm-based providers).
Fast-path: now copies `~/.local/bin/claude.exe` → versioned dir as `claude.cmd`.
The file on disk is a PE binary with a `.cmd` filename.
`make_cli_cmd("claude.cmd")` → routes through `cmd.exe /C claude.cmd`.
`cmd.exe` tries to parse a PE binary as a batch script → fails.
`get_cli_version` returns `"unknown"`. CLI is effectively broken.

### Why It Was a Silent Failure
- The versioned dir **file exists** (`claude.cmd`) so Step 1 (already installed check) returns early
- The path is logged as `[local install]` — looks healthy
- Only symptom visible to users: `(unknown)` version and broken CLI invocation

---

## Root Cause

`cf1710f` correctly observed that npm-installed CLIs on Windows are `.cmd` batch wrappers,
and hardcoded the versioned bin path to always use `.cmd`. But this assumption is wrong for
`claude` (Claude Code), which is a native Node.js binary installed by the Claude Code
installer at `~/.local/bin/claude.exe` — **not a `.cmd` batch wrapper**.

The fix for codex/gemini (npm → `.cmd`) conflated **all** Windows CLI targets.

---

## Affected Paths

| Provider | Install method | Real extension | Broken as |
|-----------|-------------------------|---------------|-----------|
| `claude` | Full package installer | `.exe` | `.cmd` |
| `codex` | npm install | `.cmd` | `.cmd` ✓ |
| `gemini` | npm install | `.cmd` | `.cmd` ✓ |

---

## Fix

`cli_bin` must respect the **source extension**, not assume `.cmd`.

Two separate versioned paths on Windows:
- `<bin_dir>/claude.exe` — for native-exe providers (detected via known_paths or `where`)
- `<bin_dir>/claude.cmd` — for npm-installed providers (produced by npm install step)

The fast-path copy should determine the destination filename based on the source extension:

```rust
// Derive destination extension from the source binary
let dest_ext = std::path::Path::new(source)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("exe");
let cli_bin_for_copy = format!("{}/{}.{}", bin_dir, cmd.cli_command, dest_ext);
```

`cli_bin` (the "already installed?" check) needs to check **both** extensions:
```rust
let cli_bin_exe = format!("{}/{}.exe", bin_dir, cmd.cli_command);
let cli_bin_cmd = format!("{}/{}.cmd", bin_dir, cmd.cli_command);
```

`npm_bin` (npm install output) stays as `.cmd` — npm always produces `.cmd` on Windows.

---

## Lesson

When adding support for a new install method (npm → `.cmd`), the existing install method
(full package → `.exe`) must be kept in parallel. A single `cli_bin` variable cannot
serve both; they need separate paths or the copy destination must mirror the source extension.
5 changes: 5 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,11 @@ class RpcApiType {
return client.wshRpcCall("runclilogin", data, opts);
}

// command "installsysdep" [call]
InstallSysdepCommand(client: WshClient, data: CommandInstallSysdepData, opts?: RpcOpts): Promise<InstallSysdepResult> {
return client.wshRpcCall("installsysdep", data, opts);
}

}

export const RpcApi = new RpcApiType();
12 changes: 12 additions & 0 deletions frontend/app/view/agent/agent-view.scss
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@
&:hover { opacity: 0.85; }
}

.agent-auth-code-input {
flex: 1;
padding: 3px 8px;
font-size: 11px;
background: var(--input-bg-color, rgba(0,0,0,0.3));
color: var(--main-text-color, #fff);
border: 1px solid var(--border-color, rgba(255,255,255,0.2));
border-radius: 3px;
outline: none;
&:focus { border-color: var(--accent-color, #4a9eff); }
}

// === Retry Login Bar ===
.agent-retry-bar {
flex-shrink: 0;
Expand Down
98 changes: 94 additions & 4 deletions frontend/app/view/agent/agent-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,83 @@ async function runLaunchFlow(
meta: { cmd: cliResult.cli_path },
});

// Phase 1.5: System Dependency Checks
// Verify (and auto-install where possible) runtime tools the agent needs: git, npm, gh.
// Each dep is checked independently. Fatal deps block launch if install fails.
// Non-fatal deps show a warning but launch continues — tool calls will fail naturally later.
if (provider.requiredSystemDeps && provider.requiredSystemDeps.length > 0) {
log("deps", "checking system dependencies...");

// Subscribe to install progress events for dep installers (same channel as CLI install)
const depInstallScope = WOS.makeORef("block", blockId);
const unsubDepInstall = waveEventSubscribe({
eventType: "install_progress",
scope: depInstallScope,
handler: (event: any) => {
const msg: string = event?.data?.message ?? "";
if (msg) log("deps", ` ${msg}`);
},
});

try {
for (const dep of provider.requiredSystemDeps) {
if (isCancelled()) break;
log(dep.name, `checking ${dep.name}...`);
let result: InstallSysdepResult;
try {
result = await RpcApi.InstallSysdepCommand(TabRpcClient, {
dep: dep.name,
block_id: blockId,
}, { timeout: 360000 }); // 6 min — winget can be slow
} catch (err: any) {
// RPC-level error (backend crashed, timeout) — treat as not found
result = {
found: false,
path: "",
version: "",
source: "not_found",
install_hint: "",
};
log(dep.name, `check failed: ${err?.message ?? String(err)}`, "warn");
}

if (result.found) {
if (result.source === "installed") {
log(dep.name, `installed ${dep.name} ${result.version} at ${result.path}`);
} else {
log(dep.name, `found: ${result.path}${result.version ? ` (${result.version})` : ""}`);
}
} else {
// Dep is missing — compose a verbose, actionable message
const lines: string[] = [
`${dep.name} not found${dep.fatal ? " — this is required" : " — optional but recommended"}`,
`why needed: ${dep.reason}`,
];
if (result.install_hint) {
lines.push(`install: ${result.install_hint}`);
}
if (dep.fatal) {
lines.push("restart AgentMux after installing and try again");
} else {
lines.push("launch will continue, but tool calls requiring this dep will fail");
}

const level = dep.fatal ? "error" : "warn";
for (const line of lines) {
log(dep.name, line, level);
}

if (dep.fatal) {
unsubDepInstall();
return "fatal";
}
}
}
} finally {
unsubDepInstall();
}
}

// Phase 2: Auth Check → auto-login if not authenticated
log("auth", `checking ${provider.cliCommand} authentication...`);
let needsLogin = false;
Expand All @@ -291,14 +368,27 @@ async function runLaunchFlow(
if (needsLogin) {
log("auth", "not authenticated — starting login flow...");
try {
// Run from Tauri host (GUI process) so the browser opens correctly on Windows.
// Returns immediately after spawning — browser opens, frontend polls for completion.
await getApi().runCliLogin(
log("auth", "opening browser for authentication...");
// run_cli_login: spawns `claude auth login`, waits up to 15s for the OAuth URL
// in CLI output, opens the browser from the host process, then returns the URL.
// Returns null if the URL wasn't captured (e.g. CLI exited early).
const loginUrl = await getApi().runCliLogin(
cliResult.cli_path,
provider.authLoginCommand,
authEnv ?? {},
);
log("auth", "a browser window should have opened — complete login there");

if (loginUrl) {
// Show URL in the launch panel with a copy button (setAuthUrl renders the box).
// Also open via openExternal as a redundant second attempt in case the host
// process opener failed (e.g. no default browser registered on this machine).
setAuthUrl(loginUrl);
getApi().openExternal(loginUrl);
log("auth", "browser opened — if it did not appear, use the URL shown below");
} else {
log("auth", "browser should have opened — if it did not, run:", "warn");
log("auth", ` ${provider.cliCommand} ${provider.authLoginCommand.join(" ")}`, "warn");
}

// Poll until authenticated, cancelled, or timed out (5 minutes)
log("auth", "waiting for login to complete...");
Expand Down
60 changes: 46 additions & 14 deletions frontend/app/view/agent/components/AgentDocumentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { createEffect, For, Show, type Accessor, type JSX, onCleanup } from "solid-js";
import { getApi } from "@/app/store/global";
import type { SignalPair } from "../state";
import type { DocumentNode, DocumentState, SubagentLinkNode } from "../types";
import { AgentMessageBlock } from "./AgentMessageBlock";
Expand Down Expand Up @@ -105,21 +106,52 @@ export const AgentDocumentView = ({ documentAtom, documentStateAtom, logLines, a
)}
</For>
<Show when={authUrl?.()}>
{(url) => (
<div class="agent-auth-url-box">
<div class="agent-auth-url-label">Login URL (if browser didn't open):</div>
<div class="agent-auth-url-row">
<span class="agent-auth-url-text">{url()}</span>
<button
class="agent-auth-url-copy"
onClick={() => navigator.clipboard.writeText(url())}
title="Copy URL"
>
Copy
</button>
{(url) => {
let codeInput: HTMLInputElement | undefined;
return (
<div class="agent-auth-url-box">
<div class="agent-auth-url-label">Login URL (if browser didn't open):</div>
<div class="agent-auth-url-row">
<span class="agent-auth-url-text">{url()}</span>
<button
class="agent-auth-url-copy"
onClick={() => navigator.clipboard.writeText(url())}
title="Copy URL"
>
Copy
</button>
</div>
<div class="agent-auth-url-label" style={{ "margin-top": "8px" }}>
Authentication Code (paste here if prompted):
</div>
<div class="agent-auth-url-row">
<input
ref={codeInput}
class="agent-auth-code-input"
type="text"
placeholder="Paste authentication code..."
onKeyDown={(e) => {
if (e.key === "Enter" && codeInput?.value) {
getApi().writeCliLoginStdin(codeInput.value).catch(() => {});
codeInput.value = "";
}
}}
/>
<button
class="agent-auth-url-copy"
onClick={() => {
if (codeInput?.value) {
getApi().writeCliLoginStdin(codeInput.value).catch(() => {});
codeInput.value = "";
}
}}
>
Submit
</button>
</div>
</div>
</div>
)}
);
}}
</Show>
</div>
</Show>
Expand Down
Loading