From 0b85eeebbd1335664f0dee1ce9e1da9238096165 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 15:44:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(core,desktop):=20M6-rest=20part=204=20?= =?UTF-8?q?=E2=80=94=20typed=20IPC=20protocol=20+=20real=20handlers=20+=20?= =?UTF-8?q?renderer=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the type-safe IPC contract between renderer ↔ main, wires the listed handlers in electron/main.ts (still skipped by default until the electron binary lands), and switches the 4 list-y screens (Sessions / Plugins / MCP / Skills) to call the real IPC with a graceful empty-list fallback when window.deepcode.* isn't present. · packages/core/src/ipc/protocol.ts (NEW, ~160 lines) - IpcRequestMap — exhaustive per-channel { req, res } typing for app:version, creds:load/save, settings:load, sessions:list/resume, plugins:list/install/setEnabled, mcp:list, skills:list/body, agent:start/abort/approve/answer. - IpcEventMap — one-way events (agent:event for streamed turns, updater:update-downloaded). - AgentStreamEvent — union of agent loop event (text_delta / tool_use / tool_result / usage / turn_complete / error) + approval_request + ask_user + turn_done — keyed by turnId so the renderer can route multiple concurrent turns. - newTurnId / newQuestionId helpers. - 4 tests + re-export from core index. · apps/desktop/electron/preload.ts — full typed surface exposed to renderer via contextBridge. window.deepcode now has sessions, plugins, mcp, skills, agent + onUpdateDownloaded. · apps/desktop/src/types/global.d.ts — corresponding renderer-side declarations for window.deepcode. · apps/desktop/electron/main.ts — added handlers: sessions:list, plugins:list, mcp:list, skills:list, skills:body. (Agent loop handlers + install/setEnabled mutators land alongside the live agent streaming wiring in a follow-up; the protocol is pinned now so the renderer code is final.) · apps/desktop/src/screens/{Sessions,Plugins,MCPManager,Skills}.tsx — real IPC fetch with empty-list fallback for current pre-electron state. Tests: core 445 → 449 (+4 ipc); total 508 → 512 passing. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/electron/main.ts | 53 +++++++++ apps/desktop/electron/preload.ts | 88 +++++++++++++- apps/desktop/src/screens/MCPManager.tsx | 10 +- apps/desktop/src/screens/Plugins.tsx | 10 +- apps/desktop/src/screens/Sessions.tsx | 12 +- apps/desktop/src/screens/Skills.tsx | 10 +- apps/desktop/src/types/global.d.ts | 60 ++++++++++ packages/core/src/index.ts | 13 +++ packages/core/src/ipc/protocol.test.ts | 24 ++++ packages/core/src/ipc/protocol.ts | 145 ++++++++++++++++++++++++ 10 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/ipc/protocol.test.ts create mode 100644 packages/core/src/ipc/protocol.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 39f1292..be5e7b4 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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); @@ -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 // ────────────────────────────────────────────────────────────────────────── diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index c985ccd..63c2e8b 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,25 +1,104 @@ // 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 => ipcRenderer.invoke('app:version'), + creds: { load: (): Promise<{ hasKey: boolean; baseURL?: string }> => ipcRenderer.invoke('creds:load'), save: (args: { apiKey: string; baseURL?: string }): Promise => ipcRenderer.invoke('creds:save', args), }, + settings: { load: (): Promise> => 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 => + 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 => 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 => + ipcRenderer.invoke('agent:abort', args), + approve: (args: { turnId: string; toolCallId: string; allow: boolean }): Promise => + ipcRenderer.invoke('agent:approve', args), + answer: (args: { turnId: string; questionId: string; answer: string }): Promise => + 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); }, @@ -27,5 +106,4 @@ const api = { contextBridge.exposeInMainWorld('deepcode', api); -// Type declaration for the renderer (mirrored manually in src/types/global.d.ts) export type DeepCodeRendererAPI = typeof api; diff --git a/apps/desktop/src/screens/MCPManager.tsx b/apps/desktop/src/screens/MCPManager.tsx index 66d0cba..4cb8fdf 100644 --- a/apps/desktop/src/screens/MCPManager.tsx +++ b/apps/desktop/src/screens/MCPManager.tsx @@ -15,8 +15,14 @@ export function MCPManagerScreen(): JSX.Element { const [servers, setServers] = useState(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) { diff --git a/apps/desktop/src/screens/Plugins.tsx b/apps/desktop/src/screens/Plugins.tsx index da80212..8a34b77 100644 --- a/apps/desktop/src/screens/Plugins.tsx +++ b/apps/desktop/src/screens/Plugins.tsx @@ -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 { diff --git a/apps/desktop/src/screens/Sessions.tsx b/apps/desktop/src/screens/Sessions.tsx index 9722b11..d2f5a3d 100644 --- a/apps/desktop/src/screens/Sessions.tsx +++ b/apps/desktop/src/screens/Sessions.tsx @@ -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) { diff --git a/apps/desktop/src/screens/Skills.tsx b/apps/desktop/src/screens/Skills.tsx index 42c2500..d6449b2 100644 --- a/apps/desktop/src/screens/Skills.tsx +++ b/apps/desktop/src/screens/Skills.tsx @@ -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) { diff --git a/apps/desktop/src/types/global.d.ts b/apps/desktop/src/types/global.d.ts index 74d8eca..e13af68 100644 --- a/apps/desktop/src/types/global.d.ts +++ b/apps/desktop/src/types/global.d.ts @@ -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; creds: { @@ -14,6 +45,35 @@ export interface DeepCodeAPI { settings: { load: () => Promise>; }; + sessions: { + list: (args?: { limit?: number }) => Promise; + resume: (args: { id: string }) => Promise<{ history: unknown[]; sessionId: string }>; + }; + plugins: { + list: () => Promise; + install: (args: { spec: string }) => Promise<{ name: string; version: string }>; + setEnabled: (args: { name: string; enabled: boolean }) => Promise; + }; + mcp: { + list: () => Promise; + }; + skills: { + list: () => Promise; + body: (args: { path: string }) => Promise; + }; + agent: { + start: (args: { + sessionId: string; + userMessage: string; + mode?: string; + model?: string; + allowedTools?: string[]; + }) => Promise<{ turnId: string }>; + abort: (args: { turnId: string }) => Promise; + approve: (args: { turnId: string; toolCallId: string; allow: boolean }) => Promise; + answer: (args: { turnId: string; questionId: string; answer: string }) => Promise; + onEvent: (cb: (e: unknown) => void) => () => void; + }; onUpdateDownloaded: (cb: (info: UpdateInfo) => void) => () => void; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 050eee8..511387e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/ipc/protocol.test.ts b/packages/core/src/ipc/protocol.test.ts new file mode 100644 index 0000000..0cef4a1 --- /dev/null +++ b/packages/core/src/ipc/protocol.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { newQuestionId, newTurnId } from './protocol.js'; + +describe('newTurnId', () => { + it('returns turn--', () => { + 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--', () => { + 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); + }); +}); diff --git a/packages/core/src/ipc/protocol.ts b/packages/core/src/ipc/protocol.ts new file mode 100644 index 0000000..c13cff2 --- /dev/null +++ b/packages/core/src/ipc/protocol.ts @@ -0,0 +1,145 @@ +// IPC protocol between the Electron renderer and the main process. +// Spec: docs/DEVELOPMENT_PLAN.md §4 +// +// Goals: +// 1. Type-safe channel names + payload shapes (no string-typed `ipc.invoke`). +// 2. Stream agent events (text_delta / tool_use / tool_result / usage / +// turn_complete / error) one-way from main → renderer. +// 3. Same shape works for the future web SDK if we host the agent loop +// out-of-process (just swap the transport). +// +// Channel naming convention: `:` for request/response invokes +// and `:event` for streamed events. + +import type { + AgentEvent, + Mode, + StoredMessage, +} from '../types.js'; + +// ────────────────────────────────────────────────────────────────────────── +// Request/response channels (renderer → main → reply) +// ────────────────────────────────────────────────────────────────────────── + +export interface IpcRequestMap { + 'app:version': { req: void; res: string }; + 'creds:load': { req: void; res: { hasKey: boolean; baseURL?: string } }; + 'creds:save': { req: { apiKey: string; baseURL?: string }; res: boolean }; + 'settings:load': { req: void; res: Record }; + 'sessions:list': { + req: { limit?: number }; + res: Array<{ id: string; title?: string; cwd: string; updatedAt: string; model?: string }>; + }; + 'sessions:resume': { + req: { id: string }; + res: { history: StoredMessage[]; sessionId: string }; + }; + 'plugins:list': { + req: void; + res: Array<{ + name: string; + version: string; + enabled: boolean; + sourceHash: string; + trustedBy: 'user' | 'marketplace' | 'official'; + contributedHookEvents: string[]; + }>; + }; + 'plugins:install': { req: { spec: string }; res: { name: string; version: string } }; + 'plugins:setEnabled': { req: { name: string; enabled: boolean }; res: boolean }; + 'mcp:list': { + req: void; + res: Array<{ + name: string; + status: 'connected' | 'failed' | 'disabled'; + toolCount?: number; + error?: string; + }>; + }; + 'skills:list': { + req: void; + res: Array<{ + name: string; + description: string; + source: 'builtin' | 'user' | 'project' | 'plugin'; + path: string; + }>; + }; + 'skills:body': { req: { path: string }; res: string }; + /** + * Start an agent turn. Returns a turnId that subsequent events are tagged + * with via the 'agent:event' channel. + */ + 'agent:start': { + req: { + sessionId: string; + userMessage: string; + mode?: Mode; + model?: string; + allowedTools?: string[]; + }; + res: { turnId: string }; + }; + /** Abort an in-flight turn. */ + 'agent:abort': { req: { turnId: string }; res: boolean }; + /** + * Reply to an approval prompt that the agent surfaced via 'agent:event' + * with type 'approval_request'. + */ + 'agent:approve': { + req: { turnId: string; toolCallId: string; allow: boolean }; + res: void; + }; + /** Reply to an AskUserQuestion prompt. */ + 'agent:answer': { + req: { turnId: string; questionId: string; answer: string }; + res: void; + }; +} + +export type IpcChannel = keyof IpcRequestMap; + +// ────────────────────────────────────────────────────────────────────────── +// One-way events (main → renderer) +// ────────────────────────────────────────────────────────────────────────── + +export type AgentStreamEvent = + | ({ kind: 'event' } & AgentEvent & { turnId: string }) + | { kind: 'approval_request'; turnId: string; toolCallId: string; toolName: string; toolInput: Record; reason: string } + | { kind: 'ask_user'; turnId: string; questionId: string; question: string; options: Array<{ label: string; description: string }>; multiSelect?: boolean } + | { kind: 'turn_done'; turnId: string; stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error' }; + +export interface IpcEventMap { + 'agent:event': AgentStreamEvent; + 'updater:update-downloaded': { version: string; releaseNotes?: string }; +} + +export type IpcEventChannel = keyof IpcEventMap; + +// ────────────────────────────────────────────────────────────────────────── +// Helpers for safer channel typing in the renderer/main code +// ────────────────────────────────────────────────────────────────────────── + +/** + * Type-level utility: pull out the request payload type for a channel. + */ +export type IpcRequest = IpcRequestMap[C]['req']; +/** + * Type-level utility: pull out the response type for a channel. + */ +export type IpcResponse = IpcRequestMap[C]['res']; + +/** + * Generate a fresh turn ID — used by the main process when starting a turn. + * Format: `turn--`. + */ +export function newTurnId(): string { + return `turn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Generate a fresh question ID for an AskUserQuestion prompt. + */ +export function newQuestionId(): string { + return `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; +}