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
53 changes: 53 additions & 0 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import {
CredentialsStore,
SessionManager,
discoverPlugins,
loadSettings,
loadSkills,
resolveCredentials,
VERSION,
} from '@deepcode/core';
import { promises as fs } from 'node:fs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -82,6 +86,55 @@ ipcMain.handle('settings:load', async () => {
return merged;
});

// ──────────────────────────────────────────────────────────────────────────
// M6-rest IPC handlers — list sessions / plugins / skills / mcp
// ──────────────────────────────────────────────────────────────────────────

ipcMain.handle('sessions:list', async (_event, args: { limit?: number } = {}) => {
const sm = new SessionManager();
const all = await sm.list();
return all.slice(0, args.limit ?? 50);
});

ipcMain.handle('plugins:list', async () => {
const { plugins, hashMismatches } = await discoverPlugins({ home: homedir() });
return plugins.map((p) => ({
name: p.manifest.name,
version: p.manifest.version,
enabled: p.enabled,
sourceHash: p.sourceHash,
trustedBy: 'user', // proper trust map lookup belongs here once exposed
contributedHookEvents: Object.keys(p.manifest.contributes?.hooks ?? {}),
warning: hashMismatches.find((m) => m.startsWith(p.manifest.name)),
}));
});

ipcMain.handle('mcp:list', async () => {
// The actual MCP connect happens once the agent loop boots; here we surface
// the configured servers from settings as 'disabled' until then.
const { merged } = await loadSettings({ cwd: process.cwd(), home: homedir() });
const servers = merged.mcpServers ?? {};
return Object.keys(servers).map((name) => ({ name, status: 'disabled' as const }));
});

ipcMain.handle('skills:list', async () => {
const skills = await loadSkills({ cwd: process.cwd(), home: homedir() });
return skills.map((s) => ({
name: s.name,
description: s.description,
source: s.source,
path: s.path,
}));
});

ipcMain.handle('skills:body', async (_event, args: { path: string }) => {
try {
return await fs.readFile(args.path, 'utf8');
} catch (err) {
return `(error reading skill body: ${(err as Error).message})`;
}
});

// ──────────────────────────────────────────────────────────────────────────
// electron-updater — lazy import so the skeleton works without the dep
// ──────────────────────────────────────────────────────────────────────────
Expand Down
88 changes: 83 additions & 5 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,109 @@
// Electron preload — bridges renderer to the trusted main process via
// contextBridge. The renderer can ONLY call these exposed APIs; raw `require`
// and Node globals are disabled.
// Spec: docs/DEVELOPMENT_PLAN.md §4
// Milestone: M6
// Spec: docs/DEVELOPMENT_PLAN.md §4 + packages/core/src/ipc/protocol.ts
// Milestone: M6 + M6-rest

import { contextBridge, ipcRenderer } from 'electron';

const api = {
version: (): Promise<string> => ipcRenderer.invoke('app:version'),

creds: {
load: (): Promise<{ hasKey: boolean; baseURL?: string }> =>
ipcRenderer.invoke('creds:load'),
save: (args: { apiKey: string; baseURL?: string }): Promise<boolean> =>
ipcRenderer.invoke('creds:save', args),
},

settings: {
load: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('settings:load'),
},

sessions: {
list: (
args: { limit?: number } = {},
): Promise<
Array<{ id: string; title?: string; cwd: string; updatedAt: string; model?: string }>
> => ipcRenderer.invoke('sessions:list', args),
resume: (
args: { id: string },
): Promise<{ history: unknown[]; sessionId: string }> =>
ipcRenderer.invoke('sessions:resume', args),
},

plugins: {
list: (): Promise<
Array<{
name: string;
version: string;
enabled: boolean;
sourceHash: string;
trustedBy: 'user' | 'marketplace' | 'official';
contributedHookEvents: string[];
}>
> => ipcRenderer.invoke('plugins:list'),
install: (args: { spec: string }): Promise<{ name: string; version: string }> =>
ipcRenderer.invoke('plugins:install', args),
setEnabled: (args: { name: string; enabled: boolean }): Promise<boolean> =>
ipcRenderer.invoke('plugins:setEnabled', args),
},

mcp: {
list: (): Promise<
Array<{
name: string;
status: 'connected' | 'failed' | 'disabled';
toolCount?: number;
error?: string;
}>
> => ipcRenderer.invoke('mcp:list'),
},

skills: {
list: (): Promise<
Array<{
name: string;
description: string;
source: 'builtin' | 'user' | 'project' | 'plugin';
path: string;
}>
> => ipcRenderer.invoke('skills:list'),
body: (args: { path: string }): Promise<string> => ipcRenderer.invoke('skills:body', args),
},

agent: {
start: (args: {
sessionId: string;
userMessage: string;
mode?: string;
model?: string;
allowedTools?: string[];
}): Promise<{ turnId: string }> => ipcRenderer.invoke('agent:start', args),
abort: (args: { turnId: string }): Promise<boolean> =>
ipcRenderer.invoke('agent:abort', args),
approve: (args: { turnId: string; toolCallId: string; allow: boolean }): Promise<void> =>
ipcRenderer.invoke('agent:approve', args),
answer: (args: { turnId: string; questionId: string; answer: string }): Promise<void> =>
ipcRenderer.invoke('agent:answer', args),
onEvent: (cb: (e: unknown) => void): (() => void) => {
const listener = (_event: unknown, payload: unknown) => cb(payload);
ipcRenderer.on('agent:event', listener);
return () => ipcRenderer.removeListener('agent:event', listener);
},
},

/** Subscribe to "update downloaded" events from the auto-updater. */
onUpdateDownloaded: (cb: (info: { version: string; releaseNotes?: string }) => void): (() => void) => {
const listener = (_e: unknown, info: { version: string; releaseNotes?: string }) => cb(info);
onUpdateDownloaded: (
cb: (info: { version: string; releaseNotes?: string }) => void,
): (() => void) => {
const listener = (_e: unknown, info: { version: string; releaseNotes?: string }) =>
cb(info);
ipcRenderer.on('updater:update-downloaded', listener);
return () => ipcRenderer.removeListener('updater:update-downloaded', listener);
},
};

contextBridge.exposeInMainWorld('deepcode', api);

// Type declaration for the renderer (mirrored manually in src/types/global.d.ts)
export type DeepCodeRendererAPI = typeof api;
10 changes: 8 additions & 2 deletions apps/desktop/src/screens/MCPManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ export function MCPManagerScreen(): JSX.Element {
const [servers, setServers] = useState<McpServerStatus[] | null>(null);

useEffect(() => {
// Real impl: window.deepcode.mcp.list() — wired in M6-rest IPC PR.
setServers([]);
if (window.deepcode?.mcp?.list) {
void window.deepcode.mcp
.list()
.then((rows) => setServers(rows as McpServerStatus[]))
.catch(() => setServers([]));
} else {
setServers([]);
}
}, []);

if (servers === null) {
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/screens/Plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ export function PluginsScreen(): JSX.Element {
const [installing, setInstalling] = useState(false);

useEffect(() => {
// Real impl: window.deepcode.plugins.list() — wired in IPC PR.
setPlugins([]);
if (window.deepcode?.plugins?.list) {
void window.deepcode.plugins
.list()
.then((rows) => setPlugins(rows as PluginRow[]))
.catch(() => setPlugins([]));
} else {
setPlugins([]);
}
}, []);

async function handleInstall(): Promise<void> {
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/screens/Sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ export function SessionsScreen({ onPick, onNew }: SessionsProps): JSX.Element {
const [filter, setFilter] = useState('');

useEffect(() => {
// Real impl wires through window.deepcode.sessions.list — added in
// M6-rest IPC PR. For now, render an empty state.
setSessions([]);
// IPC call; fall back to empty list when main hasn't implemented yet.
if (window.deepcode?.sessions?.list) {
void window.deepcode.sessions
.list()
.then((rows) => setSessions(rows as SessionMeta[]))
.catch(() => setSessions([]));
} else {
setSessions([]);
}
}, []);

if (sessions === null) {
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/screens/Skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ export function SkillsScreen(): JSX.Element {
const [filter, setFilter] = useState('');

useEffect(() => {
// Real impl: window.deepcode.skills.list() — wired in IPC PR.
setSkills([]);
if (window.deepcode?.skills?.list) {
void window.deepcode.skills
.list()
.then((rows) => setSkills(rows as SkillRow[]))
.catch(() => setSkills([]));
} else {
setSkills([]);
}
}, []);

if (skills === null) {
Expand Down
60 changes: 60 additions & 0 deletions apps/desktop/src/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@ export interface UpdateInfo {
releaseNotes?: string;
}

export interface SessionListEntry {
id: string;
title?: string;
cwd: string;
updatedAt: string;
model?: string;
}

export interface PluginRow {
name: string;
version: string;
enabled: boolean;
sourceHash: string;
trustedBy: 'user' | 'marketplace' | 'official';
contributedHookEvents: string[];
}

export interface McpServerRow {
name: string;
status: 'connected' | 'failed' | 'disabled';
toolCount?: number;
error?: string;
}

export interface SkillRow {
name: string;
description: string;
source: 'builtin' | 'user' | 'project' | 'plugin';
path: string;
}

export interface DeepCodeAPI {
version: () => Promise<string>;
creds: {
Expand All @@ -14,6 +45,35 @@ export interface DeepCodeAPI {
settings: {
load: () => Promise<Record<string, unknown>>;
};
sessions: {
list: (args?: { limit?: number }) => Promise<SessionListEntry[]>;
resume: (args: { id: string }) => Promise<{ history: unknown[]; sessionId: string }>;
};
plugins: {
list: () => Promise<PluginRow[]>;
install: (args: { spec: string }) => Promise<{ name: string; version: string }>;
setEnabled: (args: { name: string; enabled: boolean }) => Promise<boolean>;
};
mcp: {
list: () => Promise<McpServerRow[]>;
};
skills: {
list: () => Promise<SkillRow[]>;
body: (args: { path: string }) => Promise<string>;
};
agent: {
start: (args: {
sessionId: string;
userMessage: string;
mode?: string;
model?: string;
allowedTools?: string[];
}) => Promise<{ turnId: string }>;
abort: (args: { turnId: string }) => Promise<boolean>;
approve: (args: { turnId: string; toolCallId: string; allow: boolean }) => Promise<void>;
answer: (args: { turnId: string; questionId: string; answer: string }) => Promise<void>;
onEvent: (cb: (e: unknown) => void) => () => void;
};
onUpdateDownloaded: (cb: (info: UpdateInfo) => void) => () => void;
}

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,19 @@ export {
type MarketplaceConfig,
} from './plugins/index.js';

// IPC protocol (M6-rest — renderer ↔ main process type-safe channels)
export {
newTurnId,
newQuestionId,
type IpcChannel,
type IpcEventChannel,
type IpcRequest,
type IpcResponse,
type IpcRequestMap,
type IpcEventMap,
type AgentStreamEvent,
} from './ipc/protocol.js';

// Voice input (M8 — whisper.cpp wrapper + stub provider)
export {
WhisperCppProvider,
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/ipc/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { newQuestionId, newTurnId } from './protocol.js';

describe('newTurnId', () => {
it('returns turn-<base36 ts>-<random>', () => {
const id = newTurnId();
expect(id).toMatch(/^turn-[0-9a-z]+-[0-9a-z]+$/);
});
it('produces unique ids across rapid calls', () => {
const set = new Set(Array.from({ length: 50 }, newTurnId));
expect(set.size).toBe(50);
});
});

describe('newQuestionId', () => {
it('returns q-<base36 ts>-<random>', () => {
const id = newQuestionId();
expect(id).toMatch(/^q-[0-9a-z]+-[0-9a-z]+$/);
});
it('produces unique ids', () => {
const set = new Set(Array.from({ length: 50 }, newQuestionId));
expect(set.size).toBe(50);
});
});
Loading
Loading