Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2ce5c2e
Add PR convergence runtime and inventory tools
arul28 Apr 1, 2026
0d46e19
Refactor CTO convergence runtime patching and launch context
arul28 Apr 1, 2026
d7aaa4c
Make Path to Merge tab permanent and simplify convergence wiring
arul28 Apr 1, 2026
a5c9db3
Add PR issue tool guidance to agent system prompts
arul28 Apr 1, 2026
398542f
Use ADE MCP PR runtime, tool names, and timeouts
arul28 Apr 1, 2026
71954a2
Add ADE MCP namespacing for PR issue tool names
arul28 Apr 1, 2026
63dcddf
Add Codex MCP override mapping for ADE server settings
arul28 Apr 1, 2026
9d4d13a
Refactor MCP socket handling to reusable JSON-RPC server
arul28 Apr 1, 2026
3c71147
Pass projectRoot when resolving Codex MCP launches
arul28 Apr 1, 2026
49e7176
Treat aborts as interruptions and preserve chat state
arul28 Apr 1, 2026
2e12590
Ignore textual artifact URLs and require PR branch push
arul28 Apr 1, 2026
dd1ec57
Address all 12 PR review comments + fix MCP tools in unified sessions…
arul28 Apr 1, 2026
5eed09e
Resolve chat provider and model per model descriptor
arul28 Apr 1, 2026
9b3e645
Support headless MCP by guarding electron safeStorage
arul28 Apr 1, 2026
2d822a4
Harden MCP config and enforce ADE query controls
arul28 Apr 1, 2026
716bbe1
Resolve rebase route selection using active rebase needs
arul28 Apr 1, 2026
c8897e7
Wire MCP tools into unified chat sessions + show all models in picker
arul28 Apr 1, 2026
ffaf1a3
Fix storeConvergenceState guard and add MemoryRouter to issueResolver…
Copilot Apr 1, 2026
f54dcbc
Fix CI test failures: remove process.env leak from MCP launch + add M…
arul28 Apr 1, 2026
3c23bff
final changes
arul28 Apr 1, 2026
ee1389c
Fix convergence test failures and update Mintlify docs for PR converg…
arul28 Apr 1, 2026
88496e5
Fix PR review findings: guards, validation, a11y, error handling acro…
arul28 Apr 1, 2026
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
Binary file added .ade/ade.db.pre-crsqlite-w1.bak
Binary file not shown.
106 changes: 73 additions & 33 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, BrowserWindow, nativeImage, protocol, shell } from "electron";
import path from "node:path";
type NodePtyType = typeof import("node-pty");

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

View workflow job for this annotation

GitHub Actions / lint-desktop

`import()` type annotations are forbidden
import { registerIpc } from "./services/ipc/registerIpc";
import { createFileLogger } from "./services/logging/logger";
import { openKvDb } from "./services/state/kvDb";
Expand Down Expand Up @@ -46,6 +46,8 @@
import net from "node:net";
import { createMcpRequestHandler } from "../../../mcp-server/src/mcpServer";
import { createEventBuffer, type AdeMcpRuntime, type AdeMcpPaths } from "../../../mcp-server/src/bootstrap";
import { startJsonRpcServer } from "../../../mcp-server/src/jsonrpc";
import type { JsonRpcTransport } from "../../../mcp-server/src/transport";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { createKeybindingsService } from "./services/keybindings/keybindingsService";
import { createAgentToolsService } from "./services/agentTools/agentToolsService";
import { createDevToolsService } from "./services/devTools/devToolsService";
Expand Down Expand Up @@ -601,6 +603,7 @@
const normalizeProjectRoot = (projectRoot: string) => path.resolve(projectRoot);
const projectContexts = new Map<string, AppContext>();
const closeContextPromises = new Map<string, Promise<void>>();
const mcpSocketCleanupByRoot = new Map<string, () => void>();
let activeProjectRoot: string | null = null;
let dormantContext!: AppContext;

Expand Down Expand Up @@ -1019,6 +1022,16 @@
const onTrackedSessionEnded = ({ laneId, sessionId, exitCode }: { laneId: string; sessionId: string; exitCode: number | null }) => {
jobEngine?.onSessionEnded({ laneId, sessionId });
automationService?.onSessionEnded({ laneId, sessionId });
try {
issueInventoryService.reconcileConvergenceSessionExit(sessionId, { exitCode });
} catch (error) {
logger.warn("main.convergence_session_reconcile_failed", {
laneId,
sessionId,
exitCode,
error: error instanceof Error ? error.message : String(error),
});
}
void linearSyncServiceRef?.processActiveRunsNow().catch(() => {});
if (orchestratorServiceRef) {
void orchestratorServiceRef
Expand Down Expand Up @@ -1362,6 +1375,7 @@
linearClient,
linearCredentials: linearCredentialService,
prService,
issueInventoryService,
processService,
getTestService: () => testServiceRef,
ptyService,
Expand Down Expand Up @@ -2239,47 +2253,64 @@
computerUseArtifactBrokerService,
orchestratorService,
aiOrchestratorService,
issueInventoryService,
eventBuffer: mcpEventBuffer,
dispose: () => {} // desktop manages service lifecycle
};

const mcpSocketPath = adePaths.socketPath;
const activeMcpConnections = new Set<net.Socket>();

const destroyActiveMcpConnections = (): void => {
for (const conn of activeMcpConnections) {
activeMcpConnections.delete(conn);
try {
conn.destroy();
} catch {
// ignore
}
}
};
mcpSocketCleanupByRoot.set(normalizeProjectRoot(projectRoot), destroyActiveMcpConnections);

// Clean stale socket from prior crash
try { fs.unlinkSync(mcpSocketPath); } catch {}

const mcpSocketServer = net.createServer((conn) => {
activeMcpConnections.add(conn);
let stopped = false;
const transport: JsonRpcTransport = {
onData(callback) {
conn.on("data", callback);
},
write(data) {
conn.write(data);
},
close() {
if (!conn.destroyed) conn.destroy();
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
let stop: ReturnType<typeof startJsonRpcServer> | null = null;
const mcpHandler = createMcpRequestHandler({
runtime: mcpRuntime,
serverVersion: app.getVersion(),
onToolsListChanged: () => {
conn.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed", params: {} })}\n`);
stop?.notify("notifications/tools/list_changed", {});
},
});
let buf = "";
stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true });
const removeConnection = (): void => {
activeMcpConnections.delete(conn);
};
conn.once("close", removeConnection);
conn.once("end", removeConnection);
conn.once("error", removeConnection);
conn.on("close", () => {
mcpHandler.dispose();
});
conn.on("data", (chunk) => {
buf += chunk.toString();
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let parsed: any;
try { parsed = JSON.parse(line); } catch { continue; }
const id = parsed.id ?? null;
void mcpHandler(parsed).then((result) => {
if (id !== null && id !== undefined) {
conn.write(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }) + "\n");
}
}).catch((err: any) => {
if (id !== null && id !== undefined) {
conn.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32603, message: err?.message ?? String(err) } }) + "\n");
}
});
if (!stopped) {
stopped = true;
stop?.();
}
mcpHandler.dispose();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
conn.on("error", () => {}); // ignore connection errors
});
Expand Down Expand Up @@ -2464,6 +2495,25 @@
};

const disposeContextResources = async (ctx: AppContext): Promise<void> => {
const normalizedRoot = typeof ctx.project?.rootPath === "string" && ctx.project.rootPath.trim().length > 0
? normalizeProjectRoot(ctx.project.rootPath)
: null;
// Tear down MCP socket BEFORE any service disposal so in-flight MCP requests
// do not race with services that are being shut down.
try {
if (normalizedRoot) {
mcpSocketCleanupByRoot.get(normalizedRoot)?.();
mcpSocketCleanupByRoot.delete(normalizedRoot);
}
ctx.mcpSocketServer?.close();
} catch {
// ignore
}
try {
if (ctx.mcpSocketPath) fs.unlinkSync(ctx.mcpSocketPath);
} catch {
// ignore
}
// Flush DB before disposing services so that any pending writes are persisted.
// Services may write during disposal, so we flush again at the end as a safety net.
try {
Expand Down Expand Up @@ -2586,16 +2636,6 @@
} catch {
// ignore
}
try {
ctx.mcpSocketServer?.close();
} catch {
// ignore
}
try {
if (ctx.mcpSocketPath) fs.unlinkSync(ctx.mcpSocketPath);
} catch {
// ignore
}
try {
ctx.db.flushNow();
ctx.db.close();
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
getModelById,
getAvailableModels,
listModelDescriptorsForProvider,
MODEL_REGISTRY,

Check warning on line 13 in apps/desktop/src/main/services/ai/aiIntegrationService.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'MODEL_REGISTRY' is defined but never used. Allowed unused vars must match /^_/u
resolveModelAlias,
enrichModelRegistry,
} from "../../../shared/modelRegistry";
Expand Down Expand Up @@ -829,7 +829,14 @@

const auth = await detectAuth();
const available = await getResolvedAvailableModels(auth);
const family = provider === "codex" ? "openai" : provider === "cursor" ? "cursor" : "anthropic";
let family: string;
if (provider === "codex") {
family = "openai";
} else if (provider === "cursor") {
family = "cursor";
} else {
family = "anthropic";
}
const models = available
.filter((descriptor) => descriptor.family === family)
.map((descriptor) => ({
Expand Down
22 changes: 19 additions & 3 deletions apps/desktop/src/main/services/ai/apiKeyStore.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import fs from "node:fs";
import path from "node:path";
import { safeStorage } from "electron";
import type { SafeStorage } from "electron";
import { resolveAdeLayout } from "../../../shared/adeLayout";

// electron.safeStorage is only available inside an Electron main process.
// When this module is bundled into the headless MCP server (spawned by
// Claude Agent SDK / Codex App Server as a plain Node process), `electron`
// is not present. Gracefully degrade so the MCP server can start.
let safeStorage: SafeStorage | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
safeStorage = require("electron").safeStorage;
} catch (err) {
// Not running inside Electron — secure storage unavailable.
// Log at debug level so silent failures don't hide useful diagnostics.
if (typeof process !== "undefined" && process.env.DEBUG) {
console.debug("[apiKeyStore] electron.safeStorage unavailable:", err);
}
}

type StoredKeys = Record<string, string>;

export type ApiKeyStoreStatus = {
Expand Down Expand Up @@ -64,7 +80,7 @@ function ensureStore(): StoredKeys {

try {
const raw = fs.readFileSync(storePath);
const decrypted = safeStorage.decryptString(raw);
const decrypted = safeStorage!.decryptString(raw);
cache = normalizeStoredKeys(JSON.parse(decrypted));
decryptionFailed = false;
return cache;
Expand All @@ -81,7 +97,7 @@ function persist(): void {
throw new Error("OS secure storage is unavailable. Cannot persist API keys.");
}
fs.mkdirSync(path.dirname(storePath), { recursive: true });
const encrypted = safeStorage.encryptString(JSON.stringify(cache));
const encrypted = safeStorage!.encryptString(JSON.stringify(cache));
fs.writeFileSync(storePath, encrypted);
try {
fs.chmodSync(storePath, 0o600);
Expand Down
62 changes: 19 additions & 43 deletions apps/desktop/src/main/services/ai/authDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
/run .*login/i,
];

const UNAUTH_INDICATORS = [...STRONG_UNAUTH_INDICATORS, ...WEAK_UNAUTH_INDICATORS];

Check warning on line 101 in apps/desktop/src/main/services/ai/authDetector.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'UNAUTH_INDICATORS' is assigned a value but never used. Allowed unused vars must match /^_/u

const UNSUPPORTED_INDICATORS = [
/unknown command/i,
Expand Down Expand Up @@ -186,15 +186,21 @@
/** JSON fields that indicate a positive login state across CLI versions. */
const JSON_AUTH_FIELDS = ["loggedIn", "logged_in", "authenticated", "signedIn", "signed_in", "active"] as const;

function parseJsonAuthStatus(stdout: string): { authenticated: boolean; verified: true } | null {
type ParsedJsonAuthStatus = {
authenticated: boolean;
verified: true;
json: Record<string, unknown>;
} | null;

function parseJsonAuthStatus(stdout: string): ParsedJsonAuthStatus {
try {
const json = JSON.parse(stdout.trim() || "");
if (typeof json !== "object" || json === null) return null;

// Check well-known boolean fields
for (const field of JSON_AUTH_FIELDS) {
if (field in json) {
return { authenticated: Boolean(json[field]), verified: true };
return { authenticated: Boolean(json[field]), verified: true, json: json as Record<string, unknown> };
}
}

Expand All @@ -204,7 +210,7 @@
(typeof json.email === "string" && json.email.trim().length > 0)
|| (typeof json.account === "string" && json.account.trim().length > 0)
) {
return { authenticated: true, verified: true };
return { authenticated: true, verified: true, json: json as Record<string, unknown> };
}
} catch {
// Not JSON — fall through to regex matching.
Expand Down Expand Up @@ -307,64 +313,34 @@
for (const args of probes) {
try {
const result = await spawnAsync(command, args, { timeout: 8_000 });
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
const normalized = output.toLowerCase();
const stdout = result.stdout ?? "";
const normalized = `${stdout}\n${result.stderr ?? ""}`.trim().toLowerCase();

try {
const json = JSON.parse(result.stdout?.trim() || "") as Record<string, unknown>;
if (json && typeof json === "object") {
const jsonAuth = parseJsonAuthStatus(result.stdout ?? "");
if (jsonAuth) {
return {
authenticated: jsonAuth.authenticated,
verified: true,
paidPlan: inferCursorPaidPlanFromJson(json),
};
}
}
} catch {
// not JSON
}

const jsonResult = parseJsonAuthStatus(result.stdout ?? "");
if (jsonResult) {
let paidPlan = true;
try {
const parsed = JSON.parse(result.stdout?.trim() || "") as Record<string, unknown>;
if (parsed && typeof parsed === "object") {
paidPlan = inferCursorPaidPlanFromJson(parsed);
}
} catch {
// Not JSON — fall back to paidPlan = true
}
// Try structured JSON auth first
const jsonAuth = parseJsonAuthStatus(stdout);
if (jsonAuth) {
const paidPlan = inferCursorPaidPlanFromJson(jsonAuth.json);
return {
authenticated: jsonResult.authenticated,
authenticated: jsonAuth.authenticated,
verified: true,
paidPlan,
};
}

const matchesStrongUnauth = hasPattern(normalized, STRONG_UNAUTH_INDICATORS);
if (matchesStrongUnauth) {
if (hasPattern(normalized, STRONG_UNAUTH_INDICATORS)) {
return { authenticated: false, verified: true, paidPlan: false };
}

const matchesAuth = hasPattern(normalized, AUTH_INDICATORS);
const matchesWeakUnauth = hasPattern(normalized, WEAK_UNAUTH_INDICATORS);
if (matchesAuth) {
if (hasPattern(normalized, AUTH_INDICATORS)) {
return { authenticated: true, verified: true, paidPlan: true };
}
if (matchesWeakUnauth) {
if (hasPattern(normalized, WEAK_UNAUTH_INDICATORS)) {
return { authenticated: false, verified: true, paidPlan: false };
}

if (result.status === 0 && normalized.length === 0) {
return { authenticated: true, verified: true, paidPlan: true };
}

// If exit 0 with non-empty output reached here, all regex branches above
// already returned, so a separate `normalized.length > 0` check is unreachable.

if (hasPattern(normalized, UNSUPPORTED_INDICATORS)) {
sawUnsupported = true;
}
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { buildCodexAppServerMcpConfigOverrides } from "./codexAppServerConfig";

describe("buildCodexAppServerMcpConfigOverrides", () => {
it("maps ADE stdio MCP server settings into Codex app-server config overrides", () => {
const result = buildCodexAppServerMcpConfigOverrides({
ade: {
transport: "stdio",
command: "node",
args: ["/tmp/mcp-server.js"],
env: { ADE_RUN_ID: "run-1" },
required: true,
startup_timeout_sec: 30,
tool_timeout_sec: 120,
},
});

expect(result).toEqual({
"mcp_servers.ade.required": true,
"mcp_servers.ade.startup_timeout_sec": 30,
"mcp_servers.ade.tool_timeout_sec": 120,
"mcp_servers.ade.command": "node",
"mcp_servers.ade.args": ["/tmp/mcp-server.js"],
"mcp_servers.ade.env": { ADE_RUN_ID: "run-1" },
});
});

it("supports camelCase timeout keys and HTTP MCP servers", () => {
const result = buildCodexAppServerMcpConfigOverrides({
docs: {
transport: "http",
url: "https://mcp.example.com",
startupTimeoutSec: 15,
toolTimeoutSec: 45,
httpHeaders: { "x-tenant": "acme" },
envHttpHeaders: { Authorization: "MCP_AUTH" },
},
});

expect(result).toEqual({
"mcp_servers.docs.startup_timeout_sec": 15,
"mcp_servers.docs.tool_timeout_sec": 45,
"mcp_servers.docs.url": "https://mcp.example.com",
"mcp_servers.docs.http_headers": { "x-tenant": "acme" },
"mcp_servers.docs.env_http_headers": { Authorization: "MCP_AUTH" },
});
});

it("returns undefined when no MCP servers are configured", () => {
expect(buildCodexAppServerMcpConfigOverrides()).toBeUndefined();
});
});
Loading
Loading