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
409 changes: 244 additions & 165 deletions electron/main/ipc/provider.ts

Large diffs are not rendered by default.

44 changes: 28 additions & 16 deletions electron/main/ipc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export const ProviderIdSchema = z.union([
z.literal("codex"),
]);

export const McpDiscoveryArgsSchema = z
.object({ cwd: z.string().max(4096).optional() })
.strict();

export const SuggestTaskNameArgsSchema = z
.object({
prompt: z.string().max(2000),
Expand Down Expand Up @@ -823,22 +827,30 @@ export const CodexThreadRollbackArgsSchema = z
.strict();

export const CodexReviewTargetSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("uncommittedChanges"),
}).strict(),
z.object({
type: z.literal("baseBranch"),
baseBranch: z.string().min(1).max(200),
}).strict(),
z.object({
type: z.literal("commit"),
sha: z.string().min(1).max(200),
title: z.string().max(200).optional(),
}).strict(),
z.object({
type: z.literal("custom"),
instructions: z.string().min(1).max(20_000),
}).strict(),
z
.object({
type: z.literal("uncommittedChanges"),
})
.strict(),
z
.object({
type: z.literal("baseBranch"),
baseBranch: z.string().min(1).max(200),
})
.strict(),
z
.object({
type: z.literal("commit"),
sha: z.string().min(1).max(200),
title: z.string().max(200).optional(),
})
.strict(),
z
.object({
type: z.literal("custom"),
instructions: z.string().min(1).max(20_000),
})
.strict(),
]);

export const CodexReviewStartArgsSchema = z
Expand Down
125 changes: 125 additions & 0 deletions electron/main/mcp-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
McpDiscoveryResponse,
McpDiscoveredServer,
} from "../../src/lib/providers/provider.types";

type Source = McpDiscoveredServer["sources"][number];

async function readOptionalJson(filePath: string) {
try {
return JSON.parse(await readFile(filePath, "utf8")) as Record<
string,
unknown
>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
return { __error: error instanceof Error ? error.message : "Invalid JSON" };
}
}

function asRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}

export function discoverClaudeEntries(args: {
document: Record<string, unknown>;
source: Source;
}) {
const servers =
asRecord(args.document.mcpServers) ?? asRecord(args.document.mcp_servers);
if (!servers) return [];
return Object.entries(servers).map(([name, config]) => {
const value = asRecord(config) ?? {};
return {
name,
sources: [args.source],
claude: { configured: true },
codex: { configured: false },
transport: typeof value.url === "string" ? "http" : "stdio",
} satisfies McpDiscoveredServer;
});
}

export function discoverCodexEntries(toml: string) {
const servers: McpDiscoveredServer[] = [];
const pattern = /^\s*\[mcp_servers\.(["']?)([^\]"']+)\1\]\s*$/gm;
let match: RegExpExecArray | null;
while ((match = pattern.exec(toml))) {
const next = toml.slice(match.index + match[0].length).search(/^\s*\[/m);
const section = toml.slice(
match.index,
next < 0 ? undefined : match.index + match[0].length + next,
);
servers.push({
name: match[2].trim(),
sources: ["codex-user"],
claude: { configured: false },
codex: { configured: true },
transport: /^\s*url\s*=/m.test(section) ? "http" : "stdio",
});
}
return servers;
}

function mergeServers(servers: McpDiscoveredServer[]) {
const merged = new Map<string, McpDiscoveredServer>();
for (const server of servers) {
const key = server.name.toLowerCase();
const existing = merged.get(key);
if (!existing) {
merged.set(key, server);
continue;
}
existing.sources.push(...server.sources);
existing.claude.configured ||= server.claude.configured;
existing.codex.configured ||= server.codex.configured;
if (existing.transport !== server.transport) existing.transport = "unknown";
}
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
}

export async function discoverMcpServers(args: {
cwd?: string;
}): Promise<McpDiscoveryResponse> {
const cwd = args.cwd && path.isAbsolute(args.cwd) ? args.cwd : process.cwd();
const home = homedir();
const claudeFiles: Array<[string, Source]> = [
[path.join(home, ".claude", "settings.json"), "claude-user"],
[path.join(cwd, ".claude", "settings.json"), "claude-project"],
[path.join(cwd, ".mcp.json"), "claude-project"],
];
const discovered: McpDiscoveredServer[] = [];
const errors: string[] = [];
for (const [filePath, source] of claudeFiles) {
const document = await readOptionalJson(filePath);
if (!document) continue;
if (typeof document.__error === "string") {
errors.push(`${path.basename(filePath)}: ${document.__error}`);
continue;
}
discovered.push(...discoverClaudeEntries({ document, source }));
}
try {
discovered.push(
...discoverCodexEntries(
await readFile(path.join(home, ".codex", "config.toml"), "utf8"),
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
errors.push(
`config.toml: ${error instanceof Error ? error.message : "Unreadable"}`,
);
}
return {
ok: errors.length === 0,
servers: mergeServers(discovered),
errors,
discoveredAt: Date.now(),
};
}
50 changes: 36 additions & 14 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
CanonicalConversationRequest,
ClaudeContextUsageResponse,
CodexMcpStatusResponse,
McpDiscoveryResponse,
ClaudePluginReloadResponse,
CodexMutationResponse,
CodexPluginDetailResponse,
Expand Down Expand Up @@ -570,6 +571,11 @@ contextBridge.exposeInMainWorld("api", {
"provider:get-codex-mcp-status",
args,
) as Promise<CodexMcpStatusResponse>,
discoverMcpServers: (args: { cwd?: string }) =>
ipcRenderer.invoke(
"provider:discover-mcp-servers",
args,
) as Promise<McpDiscoveryResponse>,
getCodexModelCatalog: (args: {
cwd?: string;
runtimeOptions?: StreamTurnArgs["runtimeOptions"];
Expand Down Expand Up @@ -1205,12 +1211,20 @@ contextBridge.exposeInMainWorld("api", {
ipcRenderer.invoke("scm:discard-file", args),
getDiff: (args: { path: string; cwd?: string }) =>
ipcRenderer.invoke("scm:diff", args),
getGraph: (args: { cwd?: string; limit?: number; skip?: number; scope?: string }) =>
ipcRenderer.invoke("scm:graph", args),
getGraph: (args: {
cwd?: string;
limit?: number;
skip?: number;
scope?: string;
}) => ipcRenderer.invoke("scm:graph", args),
getCommitFiles: (args: { hash: string; cwd?: string }) =>
ipcRenderer.invoke("scm:commit-files", args),
getCommitDiff: (args: { hash: string; path: string; oldPath?: string; cwd?: string }) =>
ipcRenderer.invoke("scm:commit-diff", args),
getCommitDiff: (args: {
hash: string;
path: string;
oldPath?: string;
cwd?: string;
}) => ipcRenderer.invoke("scm:commit-diff", args),
getHistory: (args: { cwd?: string; limit?: number }) =>
ipcRenderer.invoke("scm:history", args),
listBranches: (args: { cwd?: string; refreshRemote?: boolean }) =>
Expand All @@ -1231,18 +1245,29 @@ contextBridge.exposeInMainWorld("api", {
ipcRenderer.invoke("scm:cherry-pick", args),
revert: (args: { commit: string; cwd?: string }) =>
ipcRenderer.invoke("scm:revert", args),
reset: (args: { commit: string; mode: "soft" | "mixed" | "hard"; cwd?: string }) =>
ipcRenderer.invoke("scm:reset", args),
createTag: (args: { name: string; commit?: string; message?: string; cwd?: string }) =>
ipcRenderer.invoke("scm:create-tag", args),
reset: (args: {
commit: string;
mode: "soft" | "mixed" | "hard";
cwd?: string;
}) => ipcRenderer.invoke("scm:reset", args),
createTag: (args: {
name: string;
commit?: string;
message?: string;
cwd?: string;
}) => ipcRenderer.invoke("scm:create-tag", args),
deleteTag: (args: { name: string; cwd?: string }) =>
ipcRenderer.invoke("scm:delete-tag", args),
renameBranch: (args: { from: string; to: string; cwd?: string }) =>
ipcRenderer.invoke("scm:rename-branch", args),
deleteBranch: (args: { name: string; force?: boolean; cwd?: string }) =>
ipcRenderer.invoke("scm:delete-branch", args),
push: (args: { branch?: string; remote?: string; force?: boolean; cwd?: string }) =>
ipcRenderer.invoke("scm:push", args),
push: (args: {
branch?: string;
remote?: string;
force?: boolean;
cwd?: string;
}) => ipcRenderer.invoke("scm:push", args),
createPR: (args: {
title: string;
body?: string;
Expand Down Expand Up @@ -1617,10 +1642,7 @@ contextBridge.exposeInMainWorld("api", {
annotations?: LensAnnotation[];
message?: string;
}>,
removeAnnotation: (args: {
workspaceId: string;
annotationId: string;
}) =>
removeAnnotation: (args: { workspaceId: string; annotationId: string }) =>
ipcRenderer.invoke("lens:remove-annotation", args) as Promise<{
ok: boolean;
message?: string;
Expand Down
52 changes: 47 additions & 5 deletions electron/providers/claude-sdk-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ import {
createBoundedBridgeEventCollector,
measureBridgeEventBytes,
} from "./provider-buffering";
import {
getClaudeMcpConfigPaths,
McpConfigRefreshTracker,
} from "./mcp-config-refresh";

/**
* Cache boundary marker for the claude-agent-sdk systemPrompt string[] API.
Expand Down Expand Up @@ -2578,7 +2582,10 @@ export function mapClaudeMessageToEvents(args: {
}

const sessionIdByTask = new Map<string, string>();
const sessionMcpScopeByTask = new Map<string, string>();
const activeRunByTask = new Map<string, Promise<void>>();
const claudeMcpConfigRefreshTracker = new McpConfigRefreshTracker();
const freshClaudeSessionScopes = new Set<string>();

export async function getClaudeCommandCatalog(args: {
cwd?: string;
Expand Down Expand Up @@ -2750,6 +2757,7 @@ export async function reloadClaudePlugins(args: {

export function cleanupClaudeTask(taskId: string) {
sessionIdByTask.delete(taskId);
sessionMcpScopeByTask.delete(taskId);
activeRunByTask.delete(taskId);
}

Expand All @@ -2761,13 +2769,20 @@ function resolveSessionId(args: {
return sessionIdByTask.get(taskKey) ?? args.fallbackSessionId?.trim();
}

function rememberSessionId(args: { taskId?: string; sessionId?: string }) {
function rememberSessionId(args: {
taskId?: string;
sessionId?: string;
mcpScopeKey?: string;
}) {
const nextSessionId = args.sessionId?.trim();
if (!nextSessionId) {
return;
}
const taskKey = args.taskId ?? "default";
sessionIdByTask.set(taskKey, nextSessionId);
if (args.mcpScopeKey) {
sessionMcpScopeByTask.set(taskKey, args.mcpScopeKey);
}
}

/**
Expand Down Expand Up @@ -2951,12 +2966,38 @@ export async function streamClaudeWithSdk(
return { ok: true };
});

const claudeRuntimeEnv = buildClaudeEnv({
executablePath: claudeExecutablePath,
});
const claudeMcpConfigPaths = getClaudeMcpConfigPaths({
cwd: runtimeCwd,
claudeConfigDir: claudeRuntimeEnv.CLAUDE_CONFIG_DIR,
});
const claudeMcpScopeKey = `claude:${claudeRuntimeEnv.CLAUDE_CONFIG_DIR ?? "default"}:${runtimeCwd}`;
const claudeMcpRefresh = await claudeMcpConfigRefreshTracker.check({
scopeKey: claudeMcpScopeKey,
paths: claudeMcpConfigPaths,
});
if (claudeMcpRefresh.changed) {
// Claude resumes preserve the native session's MCP catalog. Begin fresh
// sessions after configuration changes; Stave still provides transcript
// context through the rendered provider prompt.
for (const [taskKey, scopeKey] of sessionMcpScopeByTask) {
if (scopeKey === claudeMcpScopeKey) {
sessionIdByTask.delete(taskKey);
}
}
freshClaudeSessionScopes.add(claudeMcpScopeKey);
}

const existingSessionId = resolveSessionId({
taskId: args.taskId,
fallbackSessionId: resolveProviderResumeSessionId({
conversation: args.conversation,
fallbackResumeId: args.runtimeOptions?.claudeResumeSessionId,
}),
fallbackSessionId: freshClaudeSessionScopes.has(claudeMcpScopeKey)
? undefined
: resolveProviderResumeSessionId({
conversation: args.conversation,
fallbackResumeId: args.runtimeOptions?.claudeResumeSessionId,
}),
});
const claudeSystemPrompt = buildClaudeSystemPrompt({
cwd: runtimeCwd,
Expand Down Expand Up @@ -3411,6 +3452,7 @@ export async function streamClaudeWithSdk(
rememberSessionId({
taskId: args.taskId,
sessionId: (message as SDKSystemMessage).session_id,
mcpScopeKey: claudeMcpScopeKey,
});
}
if (
Expand Down
Loading
Loading