diff options
| author | Clawd <ai@clawd.bot> | 2026-02-28 07:27:49 -0800 |
|---|---|---|
| committer | Clawd <ai@clawd.bot> | 2026-02-28 07:27:49 -0800 |
| commit | 66f66d1c17213f55aa56d69c0cccc309b16f3362 (patch) | |
| tree | 5a6464c7f36e7c731f6d07995856df00bce257a4 /src/main | |
| parent | 332e5cec2992fefb302251962a3ceca38437a110 (diff) | |
Phase 3: IPC layer
- Implement src/main/preload.ts with typed API bridge
- Projects, sessions, messages CRUD
- Chat send/interrupt
- Workflow review/advance/permissions
- Artifact read/write
- Directory picker dialog
- Claude message event subscription
- Implement src/main/ipc/handlers.ts
- All IPC handlers with proper error handling
- Message forwarding to renderer
- Assistant message storage
- Update src/main/index.ts
- Initialize database on startup
- Register IPC handlers
- Clean database close on exit
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/index.ts | 90 | ||||
| -rw-r--r-- | src/main/ipc/handlers.ts | 117 | ||||
| -rw-r--r-- | src/main/preload.ts | 115 |
3 files changed, 275 insertions, 47 deletions
diff --git a/src/main/index.ts b/src/main/index.ts index b164f15..f0b23f7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts | |||
| @@ -1,63 +1,61 @@ | |||
| 1 | import Database from 'better-sqlite3' | 1 | import { app, BrowserWindow } from "electron"; |
| 2 | import { app, BrowserWindow } from 'electron' | 2 | import path from "node:path"; |
| 3 | import fs from 'node:fs' | 3 | import { getDb, closeDb } from "./db"; |
| 4 | import path from 'node:path' | 4 | import { registerIpcHandlers } from "./ipc/handlers"; |
| 5 | 5 | ||
| 6 | const isDev = !app.isPackaged // reliable dev/prod switch. [oai_citation:2‡Electron](https://electronjs.org/docs/latest/api/app?utm_source=chatgpt.com) | 6 | const isDev = !app.isPackaged; |
| 7 | let mainWindow: BrowserWindow | null = null; | ||
| 7 | 8 | ||
| 8 | function createWindow() { | 9 | function createWindow() { |
| 9 | const win = new BrowserWindow({ | 10 | mainWindow = new BrowserWindow({ |
| 10 | width: 800, | 11 | width: 1400, |
| 11 | height: 600, | 12 | height: 900, |
| 13 | minWidth: 1000, | ||
| 14 | minHeight: 600, | ||
| 12 | show: false, | 15 | show: false, |
| 16 | titleBarStyle: "hiddenInset", | ||
| 13 | webPreferences: { | 17 | webPreferences: { |
| 14 | contextIsolation: true, | 18 | contextIsolation: true, |
| 15 | nodeIntegration: false, | 19 | nodeIntegration: false, |
| 16 | preload: path.join(__dirname, 'preload.js'), | 20 | preload: path.join(__dirname, "preload.js"), |
| 17 | }, | 21 | }, |
| 18 | }) | 22 | }); |
| 23 | |||
| 24 | registerIpcHandlers(mainWindow); | ||
| 19 | 25 | ||
| 20 | if (isDev) { | 26 | if (isDev) { |
| 21 | const url = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5173' | 27 | const url = process.env.VITE_DEV_SERVER_URL ?? "http://localhost:5173"; |
| 22 | win.loadURL(url).finally(() => { | 28 | mainWindow.loadURL(url).finally(() => { |
| 23 | win.show() | 29 | mainWindow!.show(); |
| 24 | win.webContents.openDevTools({ mode: 'detach' }) | 30 | mainWindow!.webContents.openDevTools({ mode: "detach" }); |
| 25 | }) | 31 | }); |
| 26 | } else { | 32 | } else { |
| 27 | const indexHtml = path.join( | 33 | const indexHtml = path.join( |
| 28 | app.getAppPath(), | 34 | app.getAppPath(), |
| 29 | 'renderer', | 35 | "renderer", |
| 30 | 'dist', | 36 | "dist", |
| 31 | 'index.html' | 37 | "index.html" |
| 32 | ) | 38 | ); |
| 33 | win.loadFile(indexHtml).finally(() => win.show()) | 39 | mainWindow.loadFile(indexHtml).finally(() => mainWindow!.show()); |
| 34 | } | 40 | } |
| 35 | } | 41 | } |
| 36 | 42 | ||
| 37 | function initDb() { | ||
| 38 | const dbDir = app.getPath('userData') | ||
| 39 | if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }) | ||
| 40 | const dbPath = path.join(dbDir, 'app.db') | ||
| 41 | |||
| 42 | const db = new Database(dbPath) | ||
| 43 | db.pragma('journal_mode = WAL') | ||
| 44 | db.prepare('CREATE TABLE IF NOT EXISTS messages (text TEXT)').run() | ||
| 45 | db.prepare('INSERT INTO messages (text) VALUES (?)').run( | ||
| 46 | 'hello from better-sqlite3' | ||
| 47 | ) | ||
| 48 | const row = db.prepare('SELECT text FROM messages LIMIT 1').get() | ||
| 49 | console.log('Selected row:', row) | ||
| 50 | db.close() | ||
| 51 | } | ||
| 52 | |||
| 53 | app.whenReady().then(() => { | 43 | app.whenReady().then(() => { |
| 54 | initDb() | 44 | // Initialize database |
| 55 | createWindow() | 45 | getDb(); |
| 56 | app.on( | 46 | |
| 57 | 'activate', | 47 | createWindow(); |
| 58 | () => BrowserWindow.getAllWindows().length === 0 && createWindow() | 48 | |
| 59 | ) | 49 | app.on("activate", () => { |
| 60 | }) | 50 | if (BrowserWindow.getAllWindows().length === 0) { |
| 61 | app.on('window-all-closed', () => { | 51 | createWindow(); |
| 62 | if (process.platform !== 'darwin') app.quit() | 52 | } |
| 63 | }) | 53 | }); |
| 54 | }); | ||
| 55 | |||
| 56 | app.on("window-all-closed", () => { | ||
| 57 | closeDb(); | ||
| 58 | if (process.platform !== "darwin") { | ||
| 59 | app.quit(); | ||
| 60 | } | ||
| 61 | }); | ||
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts new file mode 100644 index 0000000..ce95f4c --- /dev/null +++ b/src/main/ipc/handlers.ts | |||
| @@ -0,0 +1,117 @@ | |||
| 1 | import { ipcMain, dialog, type BrowserWindow } from "electron"; | ||
| 2 | import * as projects from "../db/projects"; | ||
| 3 | import * as sessions from "../db/sessions"; | ||
| 4 | import * as claude from "../claude"; | ||
| 5 | import type { UserPermissionMode } from "../claude/phases"; | ||
| 6 | |||
| 7 | export function registerIpcHandlers(mainWindow: BrowserWindow): void { | ||
| 8 | // Projects | ||
| 9 | ipcMain.handle("projects:list", () => projects.listProjects()); | ||
| 10 | ipcMain.handle("projects:create", (_, name: string, path: string) => | ||
| 11 | projects.createProject(name, path) | ||
| 12 | ); | ||
| 13 | ipcMain.handle("projects:delete", (_, id: string) => | ||
| 14 | projects.deleteProject(id) | ||
| 15 | ); | ||
| 16 | |||
| 17 | // Sessions | ||
| 18 | ipcMain.handle("sessions:list", (_, projectId: string) => | ||
| 19 | sessions.listSessions(projectId) | ||
| 20 | ); | ||
| 21 | ipcMain.handle("sessions:create", (_, projectId: string, name: string) => | ||
| 22 | sessions.createSession(projectId, name) | ||
| 23 | ); | ||
| 24 | ipcMain.handle("sessions:delete", (_, id: string) => | ||
| 25 | sessions.deleteSession(id) | ||
| 26 | ); | ||
| 27 | ipcMain.handle("sessions:get", (_, id: string) => sessions.getSession(id)); | ||
| 28 | |||
| 29 | // Messages | ||
| 30 | ipcMain.handle("messages:list", (_, sessionId: string) => | ||
| 31 | sessions.listMessages(sessionId) | ||
| 32 | ); | ||
| 33 | |||
| 34 | // Chat | ||
| 35 | ipcMain.handle( | ||
| 36 | "chat:send", | ||
| 37 | async (_, sessionId: string, message: string) => { | ||
| 38 | const session = sessions.getSession(sessionId); | ||
| 39 | if (!session) throw new Error("Session not found"); | ||
| 40 | |||
| 41 | // Store user message | ||
| 42 | sessions.addMessage(sessionId, "user", message); | ||
| 43 | |||
| 44 | await claude.sendMessage({ | ||
| 45 | session, | ||
| 46 | message, | ||
| 47 | onMessage: (msg) => { | ||
| 48 | // Forward all messages to renderer | ||
| 49 | mainWindow.webContents.send("claude:message", sessionId, msg); | ||
| 50 | |||
| 51 | // Store assistant text messages | ||
| 52 | if (msg.type === "assistant") { | ||
| 53 | const content = (msg.message.content as Array<{ type: string; text?: string }>) | ||
| 54 | .filter((c) => c.type === "text" && c.text) | ||
| 55 | .map((c) => c.text!) | ||
| 56 | .join("\n"); | ||
| 57 | if (content) { | ||
| 58 | sessions.addMessage(sessionId, "assistant", content); | ||
| 59 | } | ||
| 60 | } | ||
| 61 | }, | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | ); | ||
| 65 | |||
| 66 | ipcMain.handle("chat:interrupt", (_, sessionId: string) => { | ||
| 67 | claude.interruptSession(sessionId); | ||
| 68 | }); | ||
| 69 | |||
| 70 | // Workflow | ||
| 71 | ipcMain.handle("workflow:review", async (_, sessionId: string) => { | ||
| 72 | const session = sessions.getSession(sessionId); | ||
| 73 | if (!session) throw new Error("Session not found"); | ||
| 74 | |||
| 75 | await claude.triggerReview(session, (msg) => { | ||
| 76 | mainWindow.webContents.send("claude:message", sessionId, msg); | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | ipcMain.handle("workflow:advance", (_, sessionId: string) => { | ||
| 81 | const session = sessions.getSession(sessionId); | ||
| 82 | if (!session) throw new Error("Session not found"); | ||
| 83 | return claude.advancePhase(session); | ||
| 84 | }); | ||
| 85 | |||
| 86 | ipcMain.handle( | ||
| 87 | "workflow:setPermissionMode", | ||
| 88 | (_, sessionId: string, mode: string) => { | ||
| 89 | sessions.updateSession(sessionId, { | ||
| 90 | permission_mode: mode as UserPermissionMode, | ||
| 91 | }); | ||
| 92 | } | ||
| 93 | ); | ||
| 94 | |||
| 95 | // Artifacts | ||
| 96 | ipcMain.handle( | ||
| 97 | "artifact:read", | ||
| 98 | (_, projectPath: string, filename: string) => { | ||
| 99 | return claude.readArtifact(projectPath, filename); | ||
| 100 | } | ||
| 101 | ); | ||
| 102 | |||
| 103 | ipcMain.handle( | ||
| 104 | "artifact:write", | ||
| 105 | (_, projectPath: string, filename: string, content: string) => { | ||
| 106 | claude.writeArtifact(projectPath, filename, content); | ||
| 107 | } | ||
| 108 | ); | ||
| 109 | |||
| 110 | // Dialogs | ||
| 111 | ipcMain.handle("dialog:selectDirectory", async () => { | ||
| 112 | const result = await dialog.showOpenDialog(mainWindow, { | ||
| 113 | properties: ["openDirectory"], | ||
| 114 | }); | ||
| 115 | return result.canceled ? null : result.filePaths[0]; | ||
| 116 | }); | ||
| 117 | } | ||
diff --git a/src/main/preload.ts b/src/main/preload.ts index 0b39d91..b3e3f8b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts | |||
| @@ -1 +1,114 @@ | |||
| 1 | // Expose nothing for now; keep the door open for future IPC-safe APIs. | 1 | import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; |
| 2 | import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; | ||
| 3 | import type { Project } from "./db/projects"; | ||
| 4 | import type { Session, Message } from "./db/sessions"; | ||
| 5 | import type { Phase, UserPermissionMode } from "./claude/phases"; | ||
| 6 | |||
| 7 | export interface ClaudeFlowAPI { | ||
| 8 | // Projects | ||
| 9 | listProjects: () => Promise<Project[]>; | ||
| 10 | createProject: (name: string, path: string) => Promise<Project>; | ||
| 11 | deleteProject: (id: string) => Promise<void>; | ||
| 12 | |||
| 13 | // Sessions | ||
| 14 | listSessions: (projectId: string) => Promise<Session[]>; | ||
| 15 | createSession: (projectId: string, name: string) => Promise<Session>; | ||
| 16 | deleteSession: (id: string) => Promise<void>; | ||
| 17 | getSession: (id: string) => Promise<Session | undefined>; | ||
| 18 | |||
| 19 | // Messages | ||
| 20 | listMessages: (sessionId: string) => Promise<Message[]>; | ||
| 21 | |||
| 22 | // Chat | ||
| 23 | sendMessage: (sessionId: string, message: string) => Promise<void>; | ||
| 24 | interruptSession: (sessionId: string) => Promise<void>; | ||
| 25 | |||
| 26 | // Workflow | ||
| 27 | triggerReview: (sessionId: string) => Promise<void>; | ||
| 28 | advancePhase: (sessionId: string) => Promise<Phase | null>; | ||
| 29 | setPermissionMode: ( | ||
| 30 | sessionId: string, | ||
| 31 | mode: UserPermissionMode | ||
| 32 | ) => Promise<void>; | ||
| 33 | |||
| 34 | // Artifacts | ||
| 35 | readArtifact: ( | ||
| 36 | projectPath: string, | ||
| 37 | filename: string | ||
| 38 | ) => Promise<string | null>; | ||
| 39 | writeArtifact: ( | ||
| 40 | projectPath: string, | ||
| 41 | filename: string, | ||
| 42 | content: string | ||
| 43 | ) => Promise<void>; | ||
| 44 | |||
| 45 | // Events | ||
| 46 | onClaudeMessage: ( | ||
| 47 | callback: (sessionId: string, message: SDKMessage) => void | ||
| 48 | ) => () => void; | ||
| 49 | |||
| 50 | // Dialogs | ||
| 51 | selectDirectory: () => Promise<string | null>; | ||
| 52 | } | ||
| 53 | |||
| 54 | const api: ClaudeFlowAPI = { | ||
| 55 | // Projects | ||
| 56 | listProjects: () => ipcRenderer.invoke("projects:list"), | ||
| 57 | createProject: (name, path) => | ||
| 58 | ipcRenderer.invoke("projects:create", name, path), | ||
| 59 | deleteProject: (id) => ipcRenderer.invoke("projects:delete", id), | ||
| 60 | |||
| 61 | // Sessions | ||
| 62 | listSessions: (projectId) => ipcRenderer.invoke("sessions:list", projectId), | ||
| 63 | createSession: (projectId, name) => | ||
| 64 | ipcRenderer.invoke("sessions:create", projectId, name), | ||
| 65 | deleteSession: (id) => ipcRenderer.invoke("sessions:delete", id), | ||
| 66 | getSession: (id) => ipcRenderer.invoke("sessions:get", id), | ||
| 67 | |||
| 68 | // Messages | ||
| 69 | listMessages: (sessionId) => ipcRenderer.invoke("messages:list", sessionId), | ||
| 70 | |||
| 71 | // Chat | ||
| 72 | sendMessage: (sessionId, message) => | ||
| 73 | ipcRenderer.invoke("chat:send", sessionId, message), | ||
| 74 | interruptSession: (sessionId) => | ||
| 75 | ipcRenderer.invoke("chat:interrupt", sessionId), | ||
| 76 | |||
| 77 | // Workflow | ||
| 78 | triggerReview: (sessionId) => ipcRenderer.invoke("workflow:review", sessionId), | ||
| 79 | advancePhase: (sessionId) => ipcRenderer.invoke("workflow:advance", sessionId), | ||
| 80 | setPermissionMode: (sessionId, mode) => | ||
| 81 | ipcRenderer.invoke("workflow:setPermissionMode", sessionId, mode), | ||
| 82 | |||
| 83 | // Artifacts | ||
| 84 | readArtifact: (projectPath, filename) => | ||
| 85 | ipcRenderer.invoke("artifact:read", projectPath, filename), | ||
| 86 | writeArtifact: (projectPath, filename, content) => | ||
| 87 | ipcRenderer.invoke("artifact:write", projectPath, filename, content), | ||
| 88 | |||
| 89 | // Events | ||
| 90 | onClaudeMessage: (callback) => { | ||
| 91 | const handler = ( | ||
| 92 | _: IpcRendererEvent, | ||
| 93 | sessionId: string, | ||
| 94 | message: SDKMessage | ||
| 95 | ) => callback(sessionId, message); | ||
| 96 | ipcRenderer.on("claude:message", handler); | ||
| 97 | return () => ipcRenderer.removeListener("claude:message", handler); | ||
| 98 | }, | ||
| 99 | |||
| 100 | // Dialogs | ||
| 101 | selectDirectory: async () => { | ||
| 102 | const result = await ipcRenderer.invoke("dialog:selectDirectory"); | ||
| 103 | return result; | ||
| 104 | }, | ||
| 105 | }; | ||
| 106 | |||
| 107 | contextBridge.exposeInMainWorld("api", api); | ||
| 108 | |||
| 109 | // Type declaration for renderer | ||
| 110 | declare global { | ||
| 111 | interface Window { | ||
| 112 | api: ClaudeFlowAPI; | ||
| 113 | } | ||
| 114 | } | ||
