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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -108,7 +108,7 @@ jobs:
apps/mcp-server/node_modules
apps/web/node_modules
key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
- run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/3
- run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/5

test-mcp:
needs: install
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
import { createEpisodicSummaryService } from "./services/memory/episodicSummaryService";
import { createHumanWorkDigestService } from "./services/memory/humanWorkDigestService";
import { createProceduralLearningService } from "./services/memory/proceduralLearningService";
import { createMemoryRepairService } from "./services/memory/memoryRepairService";
import { createSkillRegistryService } from "./services/memory/skillRegistryService";
import { createKnowledgeCaptureService } from "./services/memory/knowledgeCaptureService";
import { createCtoStateService } from "./services/cto/ctoStateService";
Expand Down Expand Up @@ -494,7 +495,7 @@
const loadPty = () => {
// node-pty is a native dependency; keep the require inside the main process runtime.
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("node-pty") as typeof import("node-pty");

Check warning on line 498 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
};

const normalizeProjectRoot = (projectRoot: string) => path.resolve(projectRoot);
Expand Down Expand Up @@ -667,7 +668,7 @@
logger,
broadcastEvent: (ev) => emitProjectEvent(projectRoot, IPC.lanesPortEvent, ev),
persistLeases: (leases) => db.setJson("port_leases", leases),
loadLeases: () => db.getJson<import("../shared/types").PortLease[]>("port_leases") ?? [],

Check warning on line 671 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
});
portAllocationService.restore();

Expand Down Expand Up @@ -954,6 +955,8 @@
logger,
cacheDir: path.join(app.getPath("userData"), "transformers-cache"),
});
// Auto-detect previously downloaded embedding model at startup
void embeddingService.probeCache().catch(() => { /* best-effort */ });
const hybridSearchService = createHybridSearchService({
db,
embeddingService,
Expand Down Expand Up @@ -995,6 +998,11 @@
projectId,
onStatus: (event) => emitProjectEvent(projectRoot, IPC.memorySweepStatus, event)
});
const memoryRepairService = createMemoryRepairService({
db,
projectId,
logger,
});
const embeddingWorkerService = createEmbeddingWorkerService({
db,
logger,
Expand Down Expand Up @@ -1107,6 +1115,15 @@
error: error instanceof Error ? error.message : String(error),
});
}

try {
memoryRepairService.runRepair();
} catch (error) {
logger.warn("memory.repair.failed", {
projectRoot,
error: error instanceof Error ? error.message : String(error),
});
}
});

const workerRevisionService = createWorkerRevisionService({
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable";

describe("resolveClaudeCodeExecutable", () => {
it("prefers the explicit env override", () => {
expect(
resolveClaudeCodeExecutable({
env: {
CLAUDE_CODE_EXECUTABLE_PATH: "/custom/bin/claude",
PATH: "/usr/bin:/bin",
},
}),
).toEqual({
path: "/custom/bin/claude",
source: "env",
});
});

it("uses the detected Claude auth path before falling back to PATH lookup", () => {
expect(
resolveClaudeCodeExecutable({
auth: [
{
type: "cli-subscription",
cli: "claude",
path: "/opt/homebrew/bin/claude",
authenticated: true,
verified: true,
},
],
env: {
PATH: "/usr/bin:/bin",
},
}),
).toEqual({
path: "/opt/homebrew/bin/claude",
source: "auth",
});
});
});
84 changes: 84 additions & 0 deletions apps/desktop/src/main/services/ai/claudeCodeExecutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { DetectedAuth } from "./authDetector";

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

const HOME_DIR = os.homedir();
const COMMON_BIN_DIRS = [
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/usr/bin",
"/bin",
`${HOME_DIR}/.local/bin`,
`${HOME_DIR}/.nvm/current/bin`,
].filter(Boolean);

function isExecutableFile(candidatePath: string): boolean {
try {
const stat = fs.statSync(candidatePath);
return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0);
} catch {
return false;
}
}

function resolveFromPathEntries(command: string, pathValue: string | undefined): string | null {
if (!pathValue) return null;
for (const entry of pathValue.split(path.delimiter)) {
const trimmed = entry.trim();
if (!trimmed) continue;
const candidatePath = path.join(trimmed, command);
if (isExecutableFile(candidatePath)) {
return candidatePath;
}
}
return null;
}
Comment on lines +32 to +43
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Node.js/Electron on Windows, when manually resolving an executable from PATH, do you need to account for PATHEXT extensions like .exe/.cmdand thePATH/Path environment-variable casing?

💡 Result:

Yes, when manually resolving an executable from PATH in Node.js/Electron on Windows, you need to account for both PATHEXT extensions (like .exe, .cmd) and PATH/Path environment variable casing. Node.js child_process.spawn and spawnSync do not automatically handle PATHEXT; they only find .exe files by default and ignore PATHEXT for non-.exe files like .bat/.cmd. For manual resolution matching Windows shell behavior, implement PATH splitting, PATHEXT appending, and file existence checks. The PATH environment variable in process.env may appear as 'PATH' or 'Path' due to Windows case-insensitivity; check both or use case-insensitive lookup (e.g., find key where key.toUpperCase === 'PATH'). Use libraries like 'which' npm package, which properly handles PATH/PATHEXT on Windows. Electron uses the same Node.js child_process, so same rules apply.

Citations:


🏁 Script executed:

# First, let's look at the file structure and locate the target file
find apps -name "claudeCodeExecutable.ts" -type f

Repository: arul28/ADE

Length of output: 111


🏁 Script executed:

# Read the file to understand the full context
cat -n apps/desktop/src/main/services/ai/claudeCodeExecutable.ts

Repository: arul28/ADE

Length of output: 2929


🏁 Script executed:

# Let's also check if the function is called elsewhere and how it's used
rg "resolveFromPathEntries" apps/desktop/

Repository: arul28/ADE

Length of output: 333


🏁 Script executed:

# Check what the isExecutableFile function does
rg -A 5 "function isExecutableFile|const isExecutableFile" apps/desktop/src/main/services/ai/

Repository: arul28/ADE

Length of output: 633


Windows executables require extension handling and case-insensitive PATH lookup.

On Windows, the claude executable is typically installed as claude.exe or claude.cmd. The current code only checks for the literal name claude without extensions, which will fail to find these installations on Windows. Additionally, the code only accesses env.PATH, but on Windows this environment variable may be cased as Path due to case-insensitivity.

This affects both the PATH probe (line 67) and the COMMON_BIN_DIRS probe (lines 72-76), causing the code to fall through to the fallback "claude" which relies on the shell to resolve the executable.

Suggested fix
+function resolvePathValue(env: NodeJS.ProcessEnv): string | undefined {
+  return env.PATH ?? env.Path;
+}
+
+function executableNames(command: string, env: NodeJS.ProcessEnv): string[] {
+  if (process.platform !== "win32") return [command];
+  const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
+    .split(";")
+    .map((ext) => ext.toLowerCase())
+    .filter(Boolean);
+  return [...new Set([command, ...pathExt.map((ext) => `${command}${ext}`)])];
+}
+
 function resolveFromPathEntries(command: string, pathValue: string | undefined): string | null {
   if (!pathValue) return null;
   for (const entry of pathValue.split(path.delimiter)) {
     const trimmed = entry.trim();
     if (!trimmed) continue;
-    const candidatePath = path.join(trimmed, command);
-    if (isExecutableFile(candidatePath)) {
-      return candidatePath;
+    for (const executableName of executableNames(command, env)) {
+      const candidatePath = path.join(trimmed, executableName);
+      if (isExecutableFile(candidatePath)) {
+        return candidatePath;
+      }
     }
   }
   return null;
 }

-  const pathResolved = resolveFromPathEntries("claude", env.PATH);
+  const pathResolved = resolveFromPathEntries("claude", resolvePathValue(env));

   for (const binDir of COMMON_BIN_DIRS) {
-    const candidatePath = path.join(binDir, "claude");
-    if (isExecutableFile(candidatePath)) {
-      return { path: candidatePath, source: "common-dir" };
+    for (const executableName of executableNames("claude", env)) {
+      const candidatePath = path.join(binDir, executableName);
+      if (isExecutableFile(candidatePath)) {
+        return { path: candidatePath, source: "common-dir" };
+      }
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/claudeCodeExecutable.ts` around lines 32 -
43, The PATH probe fails on Windows because resolveFromPathEntries only checks
the literal command string and reads env.PATH with a fixed case; update
resolution to (1) read PATH case-insensitively (check process.env.PATH and
process.env.Path) before calling resolveFromPathEntries, and (2) modify
resolveFromPathEntries to, when on Windows (process.platform === "win32"), try
the command with common executable extensions (e.g., .exe, .cmd, .bat) as well
as the literal name by attempting candidatePath for each extension and calling
isExecutableFile(candidatePath); also apply the same extension-aware check to
the COMMON_BIN_DIRS probe code that looks for "claude" so Windows finds
"claude.exe"/"claude.cmd". Ensure you reference the existing functions/variables
resolveFromPathEntries, isExecutableFile, and COMMON_BIN_DIRS when making
changes.


function findClaudeAuthPath(auth?: DetectedAuth[]): string | null {
for (const entry of auth ?? []) {
if (entry.type !== "cli-subscription" || entry.cli !== "claude") continue;
const candidate = entry.path.trim();
if (candidate) {
return candidate;
}
}
return null;
}

export function resolveClaudeCodeExecutable(args?: {
auth?: DetectedAuth[];
env?: NodeJS.ProcessEnv;
}): ClaudeCodeExecutableResolution {
const env = args?.env ?? process.env;
const envPath = env.CLAUDE_CODE_EXECUTABLE_PATH?.trim();
if (envPath) {
return { path: envPath, source: "env" };
}

const authPath = findClaudeAuthPath(args?.auth);
if (authPath) {
return { path: authPath, source: "auth" };
}

const pathResolved = resolveFromPathEntries("claude", env.PATH);
if (pathResolved) {
return { path: pathResolved, source: "path" };
}

for (const binDir of COMMON_BIN_DIRS) {
const candidatePath = path.join(binDir, "claude");
if (isExecutableFile(candidatePath)) {
return { path: candidatePath, source: "common-dir" };
}
}

return { path: "claude", source: "fallback-command" };
}
38 changes: 38 additions & 0 deletions apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
reportProviderRuntimeReady: vi.fn(),
reportProviderRuntimeAuthFailure: vi.fn(),
reportProviderRuntimeFailure: vi.fn(),
resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })),
normalizeCliMcpServers: vi.fn(() => ({
ade: {
type: "stdio",
command: "node",
args: ["probe.js"],
env: { ADE_PROJECT_ROOT: "/tmp/project" },
},
})),
resolveAdeMcpServerLaunch: vi.fn(() => ({
command: "node",
cmdArgs: ["probe.js"],
env: { ADE_PROJECT_ROOT: "/tmp/project" },
})),
}));

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
Expand All @@ -17,8 +31,20 @@
reportProviderRuntimeFailure: (...args: unknown[]) => mockState.reportProviderRuntimeFailure(...args),
}));

vi.mock("./claudeCodeExecutable", () => ({
resolveClaudeCodeExecutable: mockState.resolveClaudeCodeExecutable,
}));

vi.mock("./providerResolver", () => ({
normalizeCliMcpServers: mockState.normalizeCliMcpServers,
}));

vi.mock("../orchestrator/unifiedOrchestratorAdapter", () => ({
resolveAdeMcpServerLaunch: mockState.resolveAdeMcpServerLaunch,
}));

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

Check warning on line 46 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 47 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;

function makeStream(messages: unknown[]) {
Expand All @@ -40,6 +66,9 @@
mockState.reportProviderRuntimeReady.mockReset();
mockState.reportProviderRuntimeAuthFailure.mockReset();
mockState.reportProviderRuntimeFailure.mockReset();
mockState.resolveClaudeCodeExecutable.mockClear();
mockState.normalizeCliMcpServers.mockClear();
mockState.resolveAdeMcpServerLaunch.mockClear();
const mod = await import("./claudeRuntimeProbe");
probeClaudeRuntimeHealth = mod.probeClaudeRuntimeHealth;
resetClaudeRuntimeProbeCache = mod.resetClaudeRuntimeProbeCache;
Expand All @@ -66,6 +95,15 @@
expect(query.close).toHaveBeenCalledTimes(1);
expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1);
expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled();
expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({
options: expect.objectContaining({
cwd: "/tmp/project",
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
mcpServers: expect.objectContaining({
ade: expect.any(Object),
}),
}),
}));
});

it("treats Anthropic 401 invalid credentials responses as auth failures", async () => {
Expand Down
87 changes: 69 additions & 18 deletions apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import fs from "node:fs";
import path from "node:path";
import { query as claudeQuery, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../logging/logger";
import { getErrorMessage } from "../shared/utils";
import { resolveAdeMcpServerLaunch } from "../orchestrator/unifiedOrchestratorAdapter";
import {
reportProviderRuntimeAuthFailure,
reportProviderRuntimeFailure,
reportProviderRuntimeReady,
} from "./providerRuntimeHealth";
import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable";
import { normalizeCliMcpServers } from "./providerResolver";

const PROBE_TIMEOUT_MS = 20_000;
const PROBE_CACHE_TTL_MS = 30_000;
Expand All @@ -22,6 +27,7 @@ type ClaudeRuntimeProbeResult =
/** Cache and in-flight probe keyed by projectRoot to avoid cross-project contamination. */
const probeCache = new Map<string, { checkedAtMs: number; result: ClaudeRuntimeProbeResult }>();
const inFlightProbes = new Map<string, Promise<ClaudeRuntimeProbeResult>>();
let runtimeRootCache: string | null = null;

function normalizeErrorMessage(error: unknown): string {
const text = getErrorMessage(error).trim();
Expand Down Expand Up @@ -84,16 +90,52 @@ function cacheResult(projectRoot: string, result: ClaudeRuntimeProbeResult): Cla
return result;
}

function publishResult(result: ClaudeRuntimeProbeResult): void {
if (result.state === "ready") {
reportProviderRuntimeReady("claude");
return;
function resolveProbeRuntimeRoot(): string {
if (runtimeRootCache !== null) return runtimeRootCache;
const startPoints = [process.cwd(), __dirname];
for (const start of startPoints) {
let dir = path.resolve(start);
for (let i = 0; i < 12; i += 1) {
if (fs.existsSync(path.join(dir, "apps", "mcp-server", "package.json"))) {
runtimeRootCache = dir;
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
if (result.state === "auth-failed") {
reportProviderRuntimeAuthFailure("claude", result.message);
return;
runtimeRootCache = process.cwd();
return runtimeRootCache;
}

function resolveProbeMcpServers(projectRoot: string): Record<string, Record<string, unknown>> | undefined {
const launch = resolveAdeMcpServerLaunch({
workspaceRoot: projectRoot,
runtimeRoot: resolveProbeRuntimeRoot(),
defaultRole: "agent",
});
return normalizeCliMcpServers("claude", {
ade: {
command: launch.command,
args: launch.cmdArgs,
env: launch.env,
},
});
}

function publishResult(result: ClaudeRuntimeProbeResult): void {
switch (result.state) {
case "ready":
reportProviderRuntimeReady("claude");
break;
case "auth-failed":
reportProviderRuntimeAuthFailure("claude", result.message);
break;
case "runtime-failed":
reportProviderRuntimeFailure("claude", result.message);
break;
}
reportProviderRuntimeFailure("claude", result.message);
}

export function resetClaudeRuntimeProbeCache(): void {
Expand All @@ -119,20 +161,28 @@ export async function probeClaudeRuntimeHealth(args: {
return;
}

let claudeExecutablePath: string | null = null;

const probe = (async (): Promise<ClaudeRuntimeProbeResult> => {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), PROBE_TIMEOUT_MS);
const stream = claudeQuery({
prompt: "System initialization check. Respond with only the word READY.",
options: {
cwd: projectRoot,
permissionMode: "plan",
tools: [],
abortController,
},
});
let stream: ReturnType<typeof claudeQuery> | null = null;

try {
const claudeExecutable = resolveClaudeCodeExecutable();
claudeExecutablePath = claudeExecutable.path;
stream = claudeQuery({
prompt: "System initialization check. Respond with only the word READY.",
options: {
cwd: projectRoot,
permissionMode: "plan",
tools: [],
pathToClaudeCodeExecutable: claudeExecutable.path,
mcpServers: resolveProbeMcpServers(projectRoot) as any,
abortController,
},
});

for await (const message of stream) {
const result = resultFromSdkMessage(message);
if (result) {
Expand All @@ -154,7 +204,7 @@ export async function probeClaudeRuntimeHealth(args: {
} finally {
clearTimeout(timeout);
try {
stream.close();
stream?.close();
} catch {
// Best effort cleanup — avoid leaving the probe subprocess running.
}
Expand All @@ -172,6 +222,7 @@ export async function probeClaudeRuntimeHealth(args: {
projectRoot,
state: result.state,
message: result.message,
claudeExecutablePath,
});
}
} finally {
Expand Down
Loading
Loading