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
28 changes: 1 addition & 27 deletions .ade/ade.yaml
Original file line number Diff line number Diff line change
@@ -1,32 +1,6 @@
version: 1
processes:
- id: ad55deza
name: Dev
command:
- npm
- run
- dev
cwd: apps/desktop
processes: []
stackButtons: []
testSuites: []
laneOverlayPolicies: []
automations: []
ai:
features:
narratives: true
conflict_proposals: true
commit_messages: true
pr_descriptions: true
terminal_summaries: true
memory_consolidation: true
mission_planning: true
orchestrator: true
initial_context: true
featureModelOverrides:
commit_messages: openai/gpt-5.3-codex-spark
pr_descriptions: openai/gpt-5.3-codex-spark
terminal_summaries: openai/gpt-5.3-codex-spark
chat:
autoTitleEnabled: true
autoTitleModelId: openai/gpt-5.3-codex-spark
autoTitleRefreshOnComplete: true
17 changes: 10 additions & 7 deletions .ade/cto/identity.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
name: CTO
version: 3
persona: Persistent project CTO with strategic personality.
version: 1
persona: >-
You are the CTO for this project inside ADE.

You are the persistent technical lead who owns architecture, execution
quality, engineering continuity, and team direction.

Use ADE's tools and project context to help the team move forward with clear,
concrete decisions.
personality: strategic
modelPreferences:
provider: claude
Expand All @@ -21,8 +28,4 @@ openclawContextPolicy:
- secret
- token
- system_prompt
onboardingState:
completedSteps:
- identity
completedAt: 2026-03-26T18:45:21.214Z
updatedAt: 2026-03-26T18:45:21.216Z
updatedAt: 1970-01-01T00:00:00.000Z
41 changes: 8 additions & 33 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { app, BrowserWindow, nativeImage, shell } from "electron";
import { execFileSync } from "node:child_process";
import path from "node:path";
type NodePtyType = typeof import("node-pty");
import { registerIpc } from "./services/ipc/registerIpc";
Expand Down Expand Up @@ -29,6 +28,7 @@ import { createGitOperationsService } from "./services/git/gitOperationsService"
import { runGit } from "./services/git/git";
import { createJobEngine } from "./services/jobs/jobEngine";
import { createAiIntegrationService } from "./services/ai/aiIntegrationService";
import { augmentProcessPathWithShellAndKnownCliDirs } from "./services/ai/cliExecutableResolver";
import { createAgentChatService } from "./services/chat/agentChatService";
import { createGithubService } from "./services/github/githubService";
import { createPrService } from "./services/prs/prService";
Expand Down Expand Up @@ -113,38 +113,13 @@ import type { Logger } from "./services/logging/logger";
* the AI SDK can locate the CLI.
*/
function fixElectronShellPath(): void {
if (process.platform !== "darwin" && process.platform !== "linux") return;

const currentPath = process.env.PATH ?? "";
const hasUserLocalBin = currentPath.includes(".local/bin");
const hasCommonCliBin = currentPath.includes("/usr/local/bin") || currentPath.includes("/opt/homebrew/bin");
// Already rich — likely launched from terminal or already fixed.
if (hasUserLocalBin && hasCommonCliBin) return;

try {
const loginShell = process.env.SHELL || "/bin/zsh";
// Use execFileSync so SHELL is treated as a path, not interpolated shell text.
const resolved = execFileSync(loginShell, ["-lc", 'printf "%s" "$PATH"'], {
encoding: "utf-8",
timeout: 5_000,
}).trim();

if (resolved && resolved.length > currentPath.length) {
process.env.PATH = resolved;
}
} catch {
// Shell resolution failed — manually append common paths as fallback.
const extras = [
"/usr/local/bin",
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
`${process.env.HOME}/.local/bin`,
`${process.env.HOME}/.nvm/current/bin`,
].filter((p) => !currentPath.includes(p));

if (extras.length) {
process.env.PATH = `${currentPath}:${extras.join(":")}`;
}
const nextPath = augmentProcessPathWithShellAndKnownCliDirs({
env: process.env,
includeInteractiveShell: true,
timeoutMs: 1_500,
});
if (nextPath) {
process.env.PATH = nextPath;
}
}

Expand Down
33 changes: 26 additions & 7 deletions apps/desktop/src/main/services/ai/aiIntegrationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ beforeEach(() => {

describe("aiIntegrationService", () => {
it("routes executeTask through unified executor", async () => {
const { service, runCalls } = makeService();
const { service, runCalls } = makeService({
aiConfig: { features: { mission_planning: true } },
});

const result = await service.executeTask({
feature: "mission_planning",
Expand All @@ -176,18 +178,25 @@ describe("aiIntegrationService", () => {
expect(usageInsertCalls(runCalls)).toHaveLength(1);
});

it("treats commit_messages as opt-in until explicitly enabled", () => {
it("preserves legacy defaults for missing AI feature toggles", () => {
const { service } = makeService();
const { service: enabledService } = makeService({
aiConfig: {
features: {
commit_messages: true,
terminal_summaries: true,
pr_descriptions: true,
},
},
});

expect(service.getFeatureFlag("commit_messages")).toBe(false);
expect(service.getFeatureFlag("terminal_summaries")).toBe(true);
expect(service.getFeatureFlag("pr_descriptions")).toBe(true);
expect(service.getFeatureFlag("orchestrator")).toBe(true);
expect(enabledService.getFeatureFlag("commit_messages")).toBe(true);
expect(enabledService.getFeatureFlag("terminal_summaries")).toBe(true);
expect(enabledService.getFeatureFlag("pr_descriptions")).toBe(true);
});

it("routes generated commit messages through the commit_messages feature", async () => {
Expand Down Expand Up @@ -248,7 +257,9 @@ describe("aiIntegrationService", () => {
});

it("uses planning tools for mission planning tasks", async () => {
const { service } = makeService();
const { service } = makeService({
aiConfig: { features: { mission_planning: true } },
});

await service.executeTask({
feature: "mission_planning",
Expand All @@ -264,7 +275,9 @@ describe("aiIntegrationService", () => {
});

it("resolves a default task model when model is omitted", async () => {
const { service } = makeService();
const { service } = makeService({
aiConfig: { features: { orchestrator: true } },
});

await service.executeTask({
feature: "orchestrator",
Expand All @@ -280,7 +293,9 @@ describe("aiIntegrationService", () => {
});

it("resolves a default model for memory consolidation tasks when model is omitted", async () => {
const { service } = makeService();
const { service } = makeService({
aiConfig: { features: { memory_consolidation: true } },
});

await service.executeTask({
feature: "memory_consolidation",
Expand All @@ -296,7 +311,9 @@ describe("aiIntegrationService", () => {
});

it("uses planning tools for read-only orchestrator tasks and none for other read-only tasks", async () => {
const { service } = makeService();
const { service } = makeService({
aiConfig: { features: { orchestrator: true, terminal_summaries: true } },
});

await service.executeTask({
feature: "orchestrator",
Expand Down Expand Up @@ -324,7 +341,9 @@ describe("aiIntegrationService", () => {
});

it("forwards memory context and compaction identifiers to the unified executor when provided", async () => {
const { service } = makeService();
const { service } = makeService({
aiConfig: { features: { orchestrator: true } },
});
const memoryService = {
writeMemory: vi.fn(),
} as any;
Expand Down
17 changes: 13 additions & 4 deletions apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ type RuntimeTaskDefaults = {
timeoutMs: number;
};

const DEFAULT_AI_FEATURE_FLAGS: Record<AiFeatureKey, boolean> = {
narratives: true,
conflict_proposals: true,
commit_messages: false,
pr_descriptions: true,
terminal_summaries: true,
memory_consolidation: true,
mission_planning: true,
orchestrator: true,
initial_context: true,
};

const DEFAULT_CLAUDE_TASK_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-4-6";
const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex";

Expand Down Expand Up @@ -461,10 +473,7 @@ export function createAiIntegrationService(args: {
const aiConfig = extractAiConfig(snapshot);
const features = isRecord(aiConfig.features) ? aiConfig.features : {};
const value = features[feature];
if (value == null) {
return feature === "commit_messages" ? false : true;
}
return Boolean(value);
return value == null ? DEFAULT_AI_FEATURE_FLAGS[feature] : Boolean(value);
};

const getDailyBudgetLimit = (feature: AiFeatureKey): number | null => {
Expand Down
104 changes: 104 additions & 0 deletions apps/desktop/src/main/services/ai/authDetector.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const spawnMock = vi.fn();
const execFileSyncMock = vi.fn();
const getAllApiKeysMock = vi.fn();

/** Helper: create a fake ChildProcess that immediately emits close with the given result. */
Expand Down Expand Up @@ -36,6 +40,7 @@ vi.mock("node:child_process", async () => {
return {
...actual,
spawn: (...args: unknown[]) => spawnMock(...args),
execFileSync: (...args: unknown[]) => execFileSyncMock(...args),
};
});

Expand All @@ -47,9 +52,18 @@ vi.mock("./apiKeyStore", () => ({
let detectAllAuth: typeof import("./authDetector").detectAllAuth;
let detectCliAuthStatuses: typeof import("./authDetector").detectCliAuthStatuses;
let verifyProviderApiKey: typeof import("./authDetector").verifyProviderApiKey;
const originalPlatform = process.platform;

function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value,
configurable: true,
});
}

beforeEach(async () => {
vi.resetModules();
setPlatform("darwin");
const mod = await import("./authDetector");
detectAllAuth = mod.detectAllAuth;
detectCliAuthStatuses = mod.detectCliAuthStatuses;
Expand All @@ -58,17 +72,24 @@ beforeEach(async () => {

describe("authDetector", () => {
const originalEnv = { ...process.env };
let tempHomeDir: string | null = null;

beforeEach(() => {
spawnMock.mockReset();
execFileSyncMock.mockReset();
getAllApiKeysMock.mockReset();
vi.unstubAllGlobals();
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = { ...originalEnv };
setPlatform(originalPlatform);
vi.unstubAllGlobals();
if (tempHomeDir) {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
tempHomeDir = null;
}
});

it("reports installed-but-unauthenticated CLI providers", async () => {
Expand Down Expand Up @@ -236,6 +257,89 @@ describe("authDetector", () => {
expect(claude?.authenticated).toBe(true);
});

it("finds codex through an npm-global prefix when PATH lookup fails", async () => {
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-auth-detector-"));
const prefixDir = path.join(tempHomeDir, ".npm-global");
fs.mkdirSync(path.join(prefixDir, "bin"), { recursive: true });
fs.writeFileSync(path.join(tempHomeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8");
fs.writeFileSync(path.join(prefixDir, "bin", "codex"), "#!/bin/sh\nexit 0\n", "utf8");
fs.chmodSync(path.join(prefixDir, "bin", "codex"), 0o755);
process.env.HOME = tempHomeDir;
process.env.PATH = "/usr/bin:/bin";

spawnMock.mockImplementation((command: string, args: string[] = []) => {
if (args[0] === "--version") {
if (command === "codex") return fakeError();
if (command === path.join(prefixDir, "bin", "codex")) return fakeChild({ status: 0, stdout: "0.105.0\n" });
return fakeError();
}
if (command === "which") {
return fakeChild({ status: 1 });
}
if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") {
return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" });
}
return fakeChild({ status: 1 });
});

const statuses = await detectCliAuthStatuses();
const codex = statuses.find((entry) => entry.cli === "codex");

expect(codex).toEqual({
cli: "codex",
installed: true,
path: path.join(prefixDir, "bin", "codex"),
authenticated: true,
verified: true,
});
});

it("repairs PATH from the interactive shell during a forced refresh", async () => {
process.env.PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
process.env.SHELL = "/bin/zsh";

execFileSyncMock.mockImplementation((_command: string, args: string[]) => {
if (args[0] === "-lc") {
return "__ADE_PATH_START__/Users/arul/.local/bin:/usr/local/bin:/usr/bin:/bin__ADE_PATH_END__";
}
if (args[0] === "-ic") {
return "shell noise\n__ADE_PATH_START__/Users/arul/.npm-global/bin:/Users/arul/.local/bin:/usr/local/bin:/usr/bin:/bin__ADE_PATH_END__";
}
throw new Error(`unexpected shell args: ${args.join(" ")}`);
});

spawnMock.mockImplementation((command: string, args: string[] = []) => {
if (args[0] === "--version") {
if (command === "codex" && process.env.PATH?.includes("/Users/arul/.npm-global/bin")) {
return fakeChild({ status: 0, stdout: "codex-cli 0.117.0\n" });
}
return fakeError();
}
if (command === "which") {
if (args[0] === "codex" && process.env.PATH?.includes("/Users/arul/.npm-global/bin")) {
return fakeChild({ status: 0, stdout: "/Users/arul/.npm-global/bin/codex\n" });
}
return fakeChild({ status: 1 });
}
if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") {
return fakeChild({ status: 0, stdout: "Logged in using ChatGPT\n" });
}
return fakeChild({ status: 1 });
});

const statuses = await detectCliAuthStatuses({ force: true });
const codex = statuses.find((entry) => entry.cli === "codex");

expect(process.env.PATH).toContain("/Users/arul/.npm-global/bin");
expect(codex).toEqual({
cli: "codex",
installed: true,
path: "/Users/arul/.npm-global/bin/codex",
authenticated: true,
verified: true,
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("verifies API keys with provider endpoints", async () => {
vi.stubGlobal(
"fetch",
Expand Down
Loading