Skip to content
Merged
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
13 changes: 10 additions & 3 deletions apps/ade-cli/src/tuiClient/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,15 +511,22 @@ describe("renderChatLines", () => {
sessionId: "s1",
timestamp: "2026-01-01T12:00:03.000Z",
sequence: 4,
event: { type: "system_notice", noticeKind: "rate_limit", message: "rate limit hit" } as never,
event: { type: "system_notice", noticeKind: "rate_limit", severity: "error", message: "rate limit hit" } as never,
},
{
sessionId: "s1",
timestamp: "2026-01-01T12:00:04.000Z",
sequence: 5,
event: { type: "system_notice", noticeKind: "rate_limit", severity: "info", message: "Claude rate limit allowed" } as never,
},
],
});
expect(lines).toHaveLength(4);
expect(lines).toHaveLength(5);
expect(lines[0]?.tone).toBe("error"); // error noticeKind
expect(lines[1]?.tone).toBe("notice"); // warning is informational
expect(lines[2]?.tone).toBe("notice"); // config is informational
expect(lines[3]?.tone).toBe("error"); // rate_limit is severity-bearing
expect(lines[3]?.tone).toBe("error"); // blocking rate_limit is severity-bearing
expect(lines[4]?.tone).toBe("notice"); // allowed rate_limit is telemetry
});

it("summarizes command pass and fail counts when present", () => {
Expand Down
19 changes: 11 additions & 8 deletions apps/ade-cli/src/tuiClient/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,15 +532,18 @@ export function renderChatLines(args: {
continue;
}
if (event.type === "system_notice") {
// Surface the severity-bearing noticeKinds with an error tone so the TUI
// colorizes them distinctively. Guardian warnings, rate limits, thread
// errors, and provider health issues map to `tone: "error"`; warnings and
// config issues keep the default notice tone.
// Surface severity-bearing notices with an error tone while keeping
// non-blocking telemetry, including allowed Claude rate-limit events,
// in the normal notice channel.
const noticeKind = (event as { noticeKind?: string }).noticeKind;
const tone: "notice" | "error" = noticeKind === "error"
|| noticeKind === "rate_limit"
|| noticeKind === "thread_error"
|| noticeKind === "provider_health"
const severity = (event as { severity?: string }).severity;
const tone: "notice" | "error" = severity === "error"
|| (!severity && (
noticeKind === "error"
|| noticeKind === "thread_error"
|| noticeKind === "provider_health"
|| noticeKind === "rate_limit"
))
? "error"
: "notice";
lines.push({
Expand Down
27 changes: 6 additions & 21 deletions apps/desktop/src/main/packagedRuntimeSmoke.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import { resolveClaudeCodeExecutable } from "./services/ai/claudeCodeExecutable";
import { resolveCodexExecutable } from "./services/ai/codexExecutable";
import {
classifyClaudeStartupFailure,
getClaudeNativeBinaryFileName,
getClaudeNativeBinaryPackageName,
type ClaudeStartupProbeResult,
} from "./packagedRuntimeSmokeShared";

const PTY_PROBE_TIMEOUT_MS = 4_000;
const CLAUDE_PROBE_TIMEOUT_MS = 20_000;
const requireFromHere = createRequire(__filename);

async function probePty(): Promise<{ ok: true; output: string }> {
const pty = await import("node-pty");
Expand Down Expand Up @@ -56,6 +51,7 @@ async function probePty(): Promise<{ ok: true; output: string }> {

async function probeClaudeStartup(): Promise<ClaudeStartupProbeResult> {
const claude = await import("@anthropic-ai/claude-agent-sdk");
const claudeExecutable = resolveClaudeCodeExecutable();
const abortController = new AbortController();
let didTimeout = false;
const timeout = setTimeout(() => {
Expand All @@ -73,6 +69,7 @@ async function probeClaudeStartup(): Promise<ClaudeStartupProbeResult> {
permissionMode: "plan",
tools: [],
abortController,
pathToClaudeCodeExecutable: claudeExecutable.path,
},
});

Expand Down Expand Up @@ -113,23 +110,10 @@ async function probeClaudeStartup(): Promise<ClaudeStartupProbeResult> {
}
}

function resolveClaudeExecutablePath(): string | null {
const packageName = getClaudeNativeBinaryPackageName();
if (!packageName) return null;

try {
const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
const binaryPath = path.join(path.dirname(packageJsonPath), getClaudeNativeBinaryFileName());
return fs.existsSync(binaryPath) ? binaryPath : null;
} catch {
return null;
}
}

async function main(): Promise<void> {
const pty = await import("node-pty");
const claude = await import("@anthropic-ai/claude-agent-sdk");
const claudeExecutablePath = resolveClaudeExecutablePath();
const claudeExecutable = resolveClaudeCodeExecutable();
const codexExecutable = resolveCodexExecutable();
const ptyProbe = await probePty();
const claudeStartup = await probeClaudeStartup();
Expand All @@ -138,7 +122,8 @@ async function main(): Promise<void> {
ok: true,
nodePty: typeof pty.spawn,
claudeQuery: typeof claude.query,
claudeExecutablePath,
claudeExecutablePath: claudeExecutable.path,
claudeExecutableSource: claudeExecutable.source,
claudeStartup,
codexExecutable: typeof resolveCodexExecutable,
codexExecutablePath: codexExecutable.path,
Expand Down
35 changes: 5 additions & 30 deletions apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import type { Logger } from "../logging/logger";
import type { AdeDb } from "../state/kvDb";
import type { createProjectConfigService } from "../config/projectConfigService";
Expand Down Expand Up @@ -75,8 +72,7 @@ import { buildProviderConnections } from "./providerConnectionStatus";
import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth";
import { probeClaudeRuntimeHealth, resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe";
import { runProviderTask } from "./providerTaskRunner";

const requireFromHere = createRequire(import.meta.url);
import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable";

export type AiTaskType =
| "planning"
Expand Down Expand Up @@ -373,32 +369,11 @@ function detectClaudeAuthModeFromConnection(
return "none";
}

function getClaudeNativeBinaryPackageName(): string | null {
const platform = process.platform;
const arch = process.arch;
if (platform === "darwin" && arch === "arm64") return "@anthropic-ai/claude-agent-sdk-darwin-arm64";
if (platform === "darwin" && arch === "x64") return "@anthropic-ai/claude-agent-sdk-darwin-x64";
if (platform === "linux" && arch === "arm64") return "@anthropic-ai/claude-agent-sdk-linux-arm64";
if (platform === "linux" && arch === "x64") return "@anthropic-ai/claude-agent-sdk-linux-x64";
if (platform === "win32" && arch === "x64") return "@anthropic-ai/claude-agent-sdk-win32-x64";
return null;
}

function resolveBundledClaudeBinary(): Pick<AiClaudeAvailability["binary"], "present" | "source" | "path"> {
const packageName = getClaudeNativeBinaryPackageName();
if (!packageName) {
return { present: false, source: "missing", path: null };
}
try {
const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
const binaryPath = path.join(path.dirname(packageJsonPath), process.platform === "win32" ? "claude.exe" : "claude");
if (fs.existsSync(binaryPath)) {
return { present: true, source: "bundled", path: binaryPath };
}
} catch {
// Optional native package was not installed for this platform.
}
return { present: false, source: "missing", path: null };
const resolved = resolveClaudeCodeExecutable({ env: { PATH: "" } });
return resolved.source === "bundled"
? { present: true, source: "bundled", path: resolved.path }
: { present: false, source: "missing", path: null };
}

function buildClaudeAvailabilityFromConnection(
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable";

describe("resolveClaudeCodeExecutable", () => {
Expand Down Expand Up @@ -37,4 +40,45 @@ describe("resolveClaudeCodeExecutable", () => {
source: "auth",
});
});

it("prefers the packaged bundled native binary before detected auth paths", () => {
const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-bundled-"));
const binaryPath = path.join(
resourcesPath,
"app.asar.unpacked",
"node_modules",
"@anthropic-ai",
"claude-agent-sdk-darwin-arm64",
"claude",
);
fs.mkdirSync(path.dirname(binaryPath), { recursive: true });
fs.writeFileSync(binaryPath, "#!/bin/sh\nexit 0\n", "utf8");
fs.chmodSync(binaryPath, 0o755);
try {
expect(
resolveClaudeCodeExecutable({
auth: [
{
type: "cli-subscription",
cli: "claude",
path: "/opt/homebrew/bin/claude",
authenticated: true,
verified: true,
},
],
env: {
PATH: "/usr/bin:/bin",
},
resourcesPath,
platform: "darwin",
arch: "arm64",
}),
).toEqual({
path: binaryPath,
source: "bundled",
});
} finally {
fs.rmSync(resourcesPath, { recursive: true, force: true });
}
});
});
89 changes: 88 additions & 1 deletion apps/desktop/src/main/services/ai/claudeCodeExecutable.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import type { DetectedAuth } from "./authDetector";
import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver";
import {
getClaudeNativeBinaryFileName,
getClaudeNativeBinaryPackageName,
} from "../../packagedRuntimeSmokeShared";

export type ClaudeCodeExecutableResolution = {
path: string;
source: "env" | "auth" | "path" | "common-dir" | "fallback-command";
source: "env" | "bundled" | "auth" | "path" | "common-dir" | "fallback-command";
};

function findClaudeAuthPath(auth?: DetectedAuth[]): string | null {
Expand All @@ -17,16 +24,96 @@ function findClaudeAuthPath(auth?: DetectedAuth[]): string | null {
return null;
}

export function isExecutablePath(candidatePath: string): boolean {
try {
const stat = fs.statSync(candidatePath);
return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0);
Comment on lines +27 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the target platform when checking executability.

resolveBundledClaudeCodeExecutable() accepts platform, but isExecutablePath() still keys off process.platform. That makes cross-platform resolution false-negative for Windows bundles when this code runs on macOS/Linux, so a valid packaged .exe can be skipped and the resolver falls back to auth/PATH instead.

Suggested fix
-export function isExecutablePath(candidatePath: string): boolean {
+export function isExecutablePath(
+  candidatePath: string,
+  platform: NodeJS.Platform = process.platform,
+): boolean {
   try {
     const stat = fs.statSync(candidatePath);
-    return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0);
+    return stat.isFile() && (platform === "win32" || (stat.mode & 0o111) !== 0);
   } catch {
     return false;
   }
 }
@@
   for (const candidate of candidates) {
-    if (isExecutablePath(candidate)) return candidate;
+    if (isExecutablePath(candidate, platform)) return candidate;
   }
@@
-    return isExecutablePath(binaryPath) ? binaryPath : null;
+    return isExecutablePath(binaryPath, platform) ? binaryPath : null;

Also applies to: 80-92

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/services/ai/claudeCodeExecutable.ts` around lines 27 -
30, isExecutablePath currently checks executability using process.platform which
breaks cross-platform resolution in
resolveBundledClaudeCodeExecutable(platform); update isExecutablePath to accept
a platform parameter (e.g., isExecutablePath(candidatePath: string, platform?:
string)) and use that platform value (fallback to process.platform if undefined)
when deciding Windows vs POSIX executable checks, then update calls from
resolveBundledClaudeCodeExecutable and any other callers (including the block
referenced around lines 80-92) to pass the platform argument so .exe files on
Windows bundles are recognized even when running on macOS/Linux.

} catch {
return false;
}
}

function scopedPackagePath(packageName: string): string[] {
return packageName.split("/").filter(Boolean);
}

function resolvePackageFromRuntimeRoots(specifier: string): string {
const roots = new Set([
process.cwd(),
path.resolve(process.cwd(), "apps", "desktop"),
path.resolve(process.cwd(), "..", "desktop"),
path.resolve(process.cwd(), "..", "..", "apps", "desktop"),
]);
let lastError: unknown = null;
for (const root of roots) {
try {
return createRequire(path.join(root, "package.json")).resolve(specifier);
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error(`Unable to resolve ${specifier}`);
}

function resolveBundledClaudeCodeExecutable(args?: {
resourcesPath?: string | null;
platform?: NodeJS.Platform;
arch?: string;
packageResolver?: (specifier: string) => string;
}): string | null {
const platform = args?.platform ?? process.platform;
const arch = args?.arch ?? process.arch;
const packageName = getClaudeNativeBinaryPackageName(platform, arch);
if (!packageName) return null;

const binaryName = getClaudeNativeBinaryFileName(platform);
const resourcesPath = args?.resourcesPath ?? (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath ?? null;
const packageParts = scopedPackagePath(packageName);
const candidates: string[] = [];

if (resourcesPath) {
for (const unpackedName of ["app.asar.unpacked", `app-${arch}.asar.unpacked`]) {
candidates.push(path.join(resourcesPath, unpackedName, "node_modules", ...packageParts, binaryName));
}
}

for (const candidate of candidates) {
if (isExecutablePath(candidate)) return candidate;
}

try {
const resolvePackage = args?.packageResolver ?? resolvePackageFromRuntimeRoots;
const packageJsonPath = resolvePackage(`${packageName}/package.json`);
const normalized = path.normalize(packageJsonPath);
const packagedNodeModule = normalized.includes(`${path.sep}app.asar.unpacked${path.sep}`)
|| normalized.includes(`${path.sep}app-${arch}.asar.unpacked${path.sep}`);
if (!packagedNodeModule) return null;
const binaryPath = path.join(path.dirname(packageJsonPath), binaryName);
return isExecutablePath(binaryPath) ? binaryPath : null;
} catch {
return null;
}
}

export function resolveClaudeCodeExecutable(args?: {
auth?: DetectedAuth[];
env?: NodeJS.ProcessEnv;
resourcesPath?: string | null;
platform?: NodeJS.Platform;
arch?: string;
packageResolver?: (specifier: string) => string;
}): ClaudeCodeExecutableResolution {
const env = args?.env ?? process.env;
const envPath = env.CLAUDE_CODE_EXECUTABLE_PATH?.trim();
if (envPath) {
return { path: envPath, source: "env" };
}

const bundledPath = resolveBundledClaudeCodeExecutable(args);
if (bundledPath) {
return { path: bundledPath, source: "bundled" };
}

const authPath = findClaudeAuthPath(args?.auth);
if (authPath) {
return { path: authPath, source: "auth" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
reportProviderRuntimeFailure: (...args: unknown[]) => mockState.reportProviderRuntimeFailure(...args),
}));

let probeClaudeRuntimeHealth: typeof import("./claudeRuntimeProbe").probeClaudeRuntimeHealth;

Check warning on line 20 in apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
let resetClaudeRuntimeProbeCache: typeof import("./claudeRuntimeProbe").resetClaudeRuntimeProbeCache;

Check warning on line 21 in apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
let isClaudeRuntimeAuthError: typeof import("./claudeRuntimeProbe").isClaudeRuntimeAuthError;

Check warning on line 22 in apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden

function makeStream(messages: unknown[]) {
const close = vi.fn();
Expand Down Expand Up @@ -67,6 +67,7 @@
expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({
options: expect.objectContaining({
tools: [],
pathToClaudeCodeExecutable: expect.any(String),
}),
}));
expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1);
Expand Down
Loading
Loading