MDX Limo
Auctor — Technical Addendum #5

Auctor — Technical Addendum #5

Desktop Agent: Implementation Guide

Status: Pre-build specification Depends on: Addendum #4 (SDK embedding pattern), Phases 1–3 (MCP server, workspace, activation) Scope: Everything required to package Auctor as a desktop application — framework decision, architecture, file-by-file implementation, build tooling, and deployment


1. Framework Decision: Electron

The case that was made for Tauri — and why Electron still wins for Auctor

Conductor (the inspiration project, com.conductor.app) ships on Tauri 2.0 with bundled sidecars: claude, codex, gh, node, and watchexec. This proved the pattern works. Tauri's advantages are real: ~30 MB bundle vs ~180 MB, near-zero idle CPU, capability-based permissions, compiled Rust binary. For a greenfield project, Tauri would be the stronger choice.

Auctor is not greenfield. Three factors tip the decision:

Factor 1: The @anthropic-ai/claude-code SDK is a Node.js package.

The SDK's query() function — the async-generator-based embedding pattern that handles process lifecycle, stream-json parsing, multi-turn conversation, canUseTool callbacks, hook registration, environment assembly, and session reuse — runs in Node.js. In Electron, this runs directly in the main process. In Tauri, you'd need to either:

  • Bundle a Node.js sidecar that hosts the SDK, then build a Rust↔Node IPC layer to bridge query() events to the Tauri core. Every canUseTool callback (which must resolve synchronously from Claude Code's perspective) would cross a process boundary. The requestOperatorApproval() flow — which goes SDK → main process → renderer → user click → renderer → main process → SDK — would add a Rust↔Node hop at each step.
  • Reimplement the SDK's embedding logic in Rust, calling the claude binary directly with Command::new(). This loses the async generator pattern, the canUseTool callback, the hooks integration, accountInfo(), mcpServerStatus(), and session reuse — all of which the SDK provides for free.

Conductor likely takes the second approach (raw sidecar management). Auctor's spec depends on the first (SDK-level embedding). The SDK is the integration surface; Electron is the native host.

Factor 2: The MCP server shares the Node.js runtime.

Auctor's MCP server imports Drizzle ORM, Mastra runtime, DataForSEO adapters, ConsulPublisher, and analytics providers — all TypeScript. In Electron, the MCP server runs in-process in the main thread, sharing the database connection pool with the Next.js dashboard. No sidecar, no IPC, no second Node.js process. In Tauri, the MCP server must run as a Node.js sidecar with its own process, its own DB pool, and an IPC bridge. The "shared runtime" is not elegance — it's a concrete reduction in connection overhead and process management complexity.

Factor 3: The dashboard uses Next.js server components.

The existing dashboard (metrics, pipeline view, agent timeline, memory browser) uses React Server Components for data fetching. In Electron, Next.js runs natively — next start in the main process, BrowserWindow pointed at localhost:3000. In Tauri, you either refactor to a Vite SPA (2–3 weeks of work, loss of RSC patterns) or run Next.js as yet another sidecar. The refactoring cost delays Phase 4 delivery with no functional gain.

The tradeoff accepted: Auctor ships at ~180 MB instead of ~40 MB. It uses ~150 MB idle RAM instead of ~50 MB. Cold start is 2–3 seconds instead of sub-second. These costs are acceptable because Auctor runs on a dedicated machine (not a user's daily driver) and stays open 24/7 (cold start frequency is near-zero). The bundle size difference matters for consumer distribution — Auctor is deployed to one machine per operator.

If conditions change: If the project later needs mobile support, consumer distribution, or multi-operator deployment, the MCP server (Phase 1) and workspace (Phase 2) are framework-agnostic. The migration surface is Layer 4 only — the desktop shell. Phases 1–3 would not change.


2. Architecture: What Gets Built in Phase 4

1desktop/ 2├── package.json # Electron + electron-builder + SDK deps 3├── tsconfig.json # TypeScript config for desktop package 4├── main.ts # Electron entry: orchestrates all subsystems 5├── preload.ts # Context bridge: window.electron API 6├── claude-code-manager.ts # SDK-based session lifecycle manager 7├── mcp-bridge.ts # In-process MCP server initialization 8├── scheduler.ts # node-cron autonomous session scheduler 9├── file-watcher.ts # Workspace change observer → IPC emitter 10├── sidecar.ts # Python/FastAPI child process manager 11├── env-assembly.ts # 6-layer credential assembly 12├── ipc-handlers.ts # All IPC handler registrations 13├── auth-verifier.ts # Pre-session credential + workspace probe 14├── types.ts # Shared TypeScript interfaces 15└── build/ 16 ├── electron-builder.yml # Build + packaging config 17 ├── entitlements.mac.plist # macOS sandbox entitlements 18 └── notarize.js # macOS notarization script

Process architecture at runtime

1┌─────────────────────────────────────────────────────────────────┐ 2│ Electron Main Process (Node.js) │ 3│ │ 4│ ┌───────────────────────────────────────────────────────────┐ │ 5│ │ Subsystems (initialized in main.ts, order matters) │ │ 6│ │ │ │ 7│ │ 1. Environment loader (dotenv → process.env) │ │ 8│ │ 2. MCP server (in-process) (McpServer + domain tools) │ │ 9│ │ 3. Next.js server (next start → :3000) │ │ 10│ │ 4. Python sidecar (FastAPI → :8000) │ │ 11│ │ 5. Claude Code Manager (SDK query() lifecycle) │ │ 12│ │ 6. File watcher (chokidar on workspace) │ │ 13│ │ 7. Scheduler (node-cron → manager) │ │ 14│ │ 8. IPC handlers (bridge to renderer) │ │ 15│ │ 9. BrowserWindow (loads localhost:3000) │ │ 16│ └───────────────────────────────────────────────────────────┘ │ 17│ │ 18│ Child processes managed by main: │ 19│ │ 20│ ┌────────────────────┐ ┌──────────────────────────────────┐ │ 21│ │ Python/FastAPI │ │ Claude Code (via SDK query()) │ │ 22│ │ LangExtract :8000 │ │ One process per active session │ │ 23│ │ Lifecycle: app │ │ Lifecycle: session │ │ 24│ └────────────────────┘ │ Max 5 idle, 30-min timeout │ │ 25│ │ Parent PID watchdog: 2s interval │ │ 26│ └──────────────────────────────────┘ │ 27│ │ 28│ Shared resources: │ 29│ • DB connection pool (Drizzle → Supabase Postgres) │ 30│ • Environment variables (6-layer assembled) │ 31│ • Workspace path (~/.auctor/workspace/) │ 32└─────────────────────────────────────────────────────────────────┘ 33 34┌─────────────────────────────────────────────────────────────────┐ 35│ Electron Renderer Process (Chromium) │ 36│ │ 37│ Next.js app loaded from http://localhost:3000 │ 38│ │ 39│ Routes: │ 40│ / → Dashboard (metrics, pipeline overview) │ 41│ /chat → Agent Chat Panel (stream-json UI) │ 42│ /sessions → Session timeline (scheduled + interactive) │ 43│ /workspace → File browser for ~/.auctor/workspace/ │ 44│ /memory → Memory viewer (observations, decisions, etc.) │ 45│ /calendar → Editorial calendar │ 46│ /settings → Agent config, API keys, schedule │ 47│ │ 48│ Communication: │ 49│ window.electron.invoke() → main process (request/response) │ 50│ window.electron.on() → main process (event stream) │ 51└─────────────────────────────────────────────────────────────────┘

3. File-by-File Implementation

3.1 desktop/package.json

1{ 2 "name": "auctor-desktop", 3 "version": "0.1.0", 4 "description": "Auctor Autonomous Content Operations Agent", 5 "main": "dist/main.js", 6 "scripts": { 7 "dev": "tsc && electron dist/main.js", 8 "dev:watch": "concurrently \"tsc -w\" \"nodemon --watch dist --exec electron dist/main.js\"", 9 "build": "tsc && electron-builder", 10 "build:mac": "tsc && electron-builder --mac", 11 "build:win": "tsc && electron-builder --win", 12 "build:linux": "tsc && electron-builder --linux" 13 }, 14 "dependencies": { 15 "@anthropic-ai/claude-code": "^1.0.0", 16 "chokidar": "^4.0.0", 17 "dotenv": "^16.4.0", 18 "electron-updater": "^6.3.0", 19 "node-cron": "^3.0.3" 20 }, 21 "devDependencies": { 22 "@types/node": "^22.0.0", 23 "@types/node-cron": "^3.0.11", 24 "concurrently": "^9.0.0", 25 "electron": "^40.0.0", 26 "electron-builder": "^25.0.0", 27 "nodemon": "^3.0.0", 28 "typescript": "^5.7.0" 29 }, 30 "build": { 31 "appId": "com.auctor.desktop", 32 "productName": "Auctor", 33 "directories": { 34 "output": "release" 35 }, 36 "files": [ 37 "dist/**/*", 38 "node_modules/**/*", 39 "!node_modules/**/README.md" 40 ], 41 "extraResources": [ 42 { 43 "from": "../frontend/.next/standalone", 44 "to": "next-server", 45 "filter": ["**/*"] 46 } 47 ], 48 "mac": { 49 "category": "public.app-category.productivity", 50 "hardenedRuntime": true, 51 "gatekeeperAssess": false, 52 "entitlements": "build/entitlements.mac.plist", 53 "entitlementsInherit": "build/entitlements.mac.plist", 54 "target": [ 55 { "target": "dmg", "arch": ["universal"] }, 56 { "target": "zip", "arch": ["universal"] } 57 ] 58 }, 59 "win": { 60 "target": [ 61 { "target": "nsis", "arch": ["x64"] } 62 ] 63 }, 64 "linux": { 65 "target": [ 66 { "target": "AppImage", "arch": ["x64"] }, 67 { "target": "deb", "arch": ["x64"] } 68 ] 69 } 70 } 71}

Key decisions in this config:

  • extraResources bundles the Next.js standalone output. At runtime, main.ts spawns node next-server/server.js rather than running next start (which requires the full node_modules tree). The standalone output is self-contained.
  • electron-updater is included for auto-update support (not implemented in Phase 4, but the dependency is present for Phase 5).
  • @anthropic-ai/claude-code is a runtime dependency, not dev-only. It must be in the packaged app.
  • Universal macOS builds ensure Apple Silicon and Intel compatibility.

3.2 desktop/types.ts

All shared interfaces used across the desktop package. Define these first — everything else imports from here.

1// desktop/types.ts 2 3/** Session types map to different Claude Code spawn configurations */ 4export type SessionType = 'interactive' | 'scheduled' | 'event-triggered' 5 6/** Configuration for starting a new Claude Code session */ 7export interface SessionConfig { 8 type: SessionType 9 /** For headless sessions: the single prompt to execute */ 10 prompt?: string 11 /** For resume: reuse an existing session ID */ 12 sessionId?: string 13 /** Model override (default: claude-sonnet-4-20250514) */ 14 model?: string 15 /** Additional system prompt context injected at runtime */ 16 appendSystemPrompt?: string 17 /** Max turns before auto-stop (default: 1000 interactive, 100 scheduled) */ 18 maxTurns?: number 19} 20 21/** Internal session tracking state */ 22export interface Session { 23 id: string 24 type: SessionType 25 /** The SDK query result — async iterable of events */ 26 queryResult: AsyncIterableClaudeResult 27 /** Pending messages for the async generator to yield */ 28 messageQueue: string[] 29 /** Resolver function: when set, the async generator is awaiting a message */ 30 resolveNextMessage: ((msg: string) => void) | null 31 /** Whether Claude Code is currently processing (between user message and result event) */ 32 isProcessing: boolean 33 /** Timestamp of last activity (message sent or event received) */ 34 lastActivityAt: number 35 /** Settings snapshot for change detection */ 36 currentSettings: { model: string } 37} 38 39/** 40 * The return type of the SDK's query() function. 41 * Async iterable of events + control methods. 42 */ 43export interface AsyncIterableClaudeResult extends AsyncIterable<ClaudeEvent> { 44 interrupt(): void 45 accountInfo(): Promise<AccountInfo> 46 supportedCommands(): Promise<string[]> 47 mcpServerStatus(): Promise<McpServerStatus[]> 48} 49 50/** Structured event from Claude Code's stream-json output */ 51export interface ClaudeEvent { 52 type: 53 | 'assistant:text' 54 | 'assistant:tool_use' 55 | 'tool:result' 56 | 'system:error' 57 | 'result' 58 data: unknown 59} 60 61export interface AccountInfo { 62 email: string 63 plan: string 64 capabilities: string[] 65} 66 67export interface McpServerStatus { 68 name: string 69 status: 'connected' | 'error' | 'pending' 70 toolCount?: number 71 error?: string 72} 73 74/** IPC channel definitions — type-safe bridge between main and renderer */ 75export interface IpcChannels { 76 // Renderer → Main (invoke/handle) 77 'claude:start-session': (config: SessionConfig) => Promise<string> 78 'claude:send-message': (args: { sessionId: string; message: string }) => void 79 'claude:stop-session': (args: { sessionId: string }) => void 80 'claude:get-sessions': () => Promise<SessionSummary[]> 81 'claude:verify-auth': () => Promise<AuthResult> 82 'workspace:read-file': (args: { relativePath: string }) => Promise<string> 83 'workspace:list-dir': (args: { relativePath: string }) => Promise<string[]> 84 'settings:get': () => Promise<AppSettings> 85 'settings:set': (settings: Partial<AppSettings>) => void 86 87 // Main → Renderer (send/on) 88 'claude:event': (payload: { sessionId: string; event: ClaudeEvent }) => void 89 'claude:session-ended': (payload: { sessionId: string }) => void 90 'claude:session-error': (payload: { sessionId: string; error: string }) => void 91 'workspace:file-changed': (payload: { path: string; relative: string; timestamp: number }) => void 92} 93 94export interface SessionSummary { 95 id: string 96 type: SessionType 97 isProcessing: boolean 98 lastActivityAt: number 99} 100 101export interface AuthResult { 102 authenticated: boolean 103 email?: string 104 plan?: string 105 error?: string 106} 107 108export interface AppSettings { 109 /** User-configured env vars (KEY=VALUE per line) */ 110 claudeEnvVars: string 111 /** Model selection */ 112 model: string 113 /** Schedule toggles */ 114 schedule: { 115 morningEnabled: boolean 116 morningTime: string // HH:MM 117 middayEnabled: boolean 118 middayTime: string 119 eveningEnabled: boolean 120 eveningTime: string 121 weeklyEnabled: boolean 122 weeklyDay: number // 0=Sun, 1=Mon, ... 123 weeklyTime: string 124 } 125 /** Active site key */ 126 siteKey: string 127} 128 129/** Credential keys stripped from shell environment before assembly */ 130export const STRIPPED_ENV_KEYS = [ 131 'ANTHROPIC_API_KEY', 132 'ANTHROPIC_AUTH_TOKEN', 133 'OPENAI_API_KEY', 134 'CLAUDE_CODE_USE_BEDROCK', 135 'CLAUDE_CODE_USE_VERTEX', 136 'AWS_ACCESS_KEY_ID', 137 'AWS_SECRET_ACCESS_KEY', 138 'GOOGLE_APPLICATION_CREDENTIALS', 139] as const

3.3 desktop/env-assembly.ts

The 6-layer environment assembly extracted into its own module. This is security-critical — it controls what credentials Claude Code can see.

1// desktop/env-assembly.ts 2 3import { STRIPPED_ENV_KEYS } from './types' 4 5/** 6 * Assemble the environment for a Claude Code process. 7 * 8 * Layer priority (highest wins): 9 * 6. GitHub token 10 * 5. User-configured API keys (from app settings UI) 11 * 4. Auctor platform env (DATABASE_URL, Supabase, DataForSEO, etc.) 12 * 3. Application-specific flags 13 * 2. Node process.env (filtered) 14 * 1. Shell environment (with sensitive keys STRIPPED) 15 * 16 * Why strip shell env first: the operator's shell might have ANTHROPIC_API_KEY 17 * set for personal use. Auctor should use the key configured in its settings UI, 18 * not whatever happens to be in .bashrc. Stripping first, then layering user 19 * config on top, ensures Auctor's auth is deterministic. 20 */ 21export function assembleEnvironment(options: { 22 userClaudeEnvVars?: string 23 platformEnv: Record<string, string> 24 ghToken?: string 25}): Record<string, string> { 26 const env: Record<string, string> = {} 27 28 // Layer 1: Shell environment with sensitive keys stripped 29 for (const [key, value] of Object.entries(process.env)) { 30 if (value === undefined) continue 31 if ((STRIPPED_ENV_KEYS as readonly string[]).includes(key)) continue 32 env[key] = value 33 } 34 35 // Layer 2: (merged with Layer 1 above — process.env IS the shell env in Electron) 36 37 // Layer 3: Application-specific flags 38 env['CLAUDE_CODE_ENABLE_TASKS'] = 'true' 39 // Prevent Claude Code from trying to open a browser for OAuth 40 env['CLAUDE_CODE_DISABLE_BROWSER'] = '1' 41 42 // Layer 4: Auctor platform env 43 // These are loaded from .env.local at app startup and include: 44 // DATABASE_URL, DIRECT_URL, SUPABASE_URL, SUPABASE_ANON_KEY, 45 // SUPABASE_SERVICE_ROLE_KEY, DATAFORSEO_LOGIN, DATAFORSEO_PASSWORD, 46 // ANTHROPIC_API_KEY (for Mastra agent LLM calls, NOT for Claude Code auth), 47 // GOOGLE_ANALYTICS_*, CONSUL_CMS_*, etc. 48 for (const [key, value] of Object.entries(options.platformEnv)) { 49 env[key] = value 50 } 51 52 // Layer 5: User-configured API keys (highest priority for auth) 53 // Parsed from KEY=VALUE format stored in app settings 54 if (options.userClaudeEnvVars) { 55 for (const line of options.userClaudeEnvVars.split('\n')) { 56 const trimmed = line.trim() 57 if (!trimmed || trimmed.startsWith('#')) continue 58 const eqIndex = trimmed.indexOf('=') 59 if (eqIndex === -1) continue 60 const key = trimmed.slice(0, eqIndex) 61 const value = trimmed.slice(eqIndex + 1) 62 if (value === '') { 63 delete env[key] // Empty value = unset the key 64 } else { 65 env[key] = value 66 } 67 } 68 } 69 70 // Layer 6: GitHub token (if configured) 71 if (options.ghToken) { 72 env['GH_TOKEN'] = options.ghToken 73 } 74 75 return env 76} 77 78/** 79 * Load Auctor's platform .env file into a plain object. 80 * Called once at app startup. Does NOT mutate process.env. 81 */ 82export function loadPlatformEnv(envPath: string): Record<string, string> { 83 const dotenv = require('dotenv') 84 const result = dotenv.config({ path: envPath }) 85 if (result.error) { 86 console.error(`Failed to load platform env from ${envPath}:`, result.error) 87 return {} 88 } 89 return result.parsed ?? {} 90}

3.4 desktop/claude-code-manager.ts

This is the most complex file. It wraps the @anthropic-ai/claude-code SDK's query() function with Auctor's session management, lifecycle control, and IPC bridge.

1// desktop/claude-code-manager.ts 2 3import { query } from '@anthropic-ai/claude-code' 4import { EventEmitter } from 'events' 5import path from 'path' 6import os from 'os' 7import crypto from 'crypto' 8import { assembleEnvironment } from './env-assembly' 9import type { 10 Session, SessionConfig, SessionSummary, ClaudeEvent, 11 AsyncIterableClaudeResult, AppSettings, 12} from './types' 13 14// ─── Constants ───────────────────────────────────────────────────────── 15 16const WORKSPACE_PATH = path.join(os.homedir(), '.auctor', 'workspace') 17const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes 18const MAX_IDLE_SESSIONS = 5 19const SWEEP_INTERVAL_MS = 60 * 1000 // 1 minute 20 21// ─── System Prompt Template ──────────────────────────────────────────── 22 23function buildSystemPrompt(config: SessionConfig, settings: AppSettings): string { 24 const lines = [ 25 `You are running inside Auctor Desktop v0.1.0.`, 26 `Working directory: ${WORKSPACE_PATH}`, 27 `Active site: ${settings.siteKey}`, 28 `Session type: ${config.type}${config.type === 'scheduled' ? ':' + (config.appendSystemPrompt ?? '') : ''}`, 29 `Timestamp: ${new Date().toISOString()}`, 30 ``, 31 `Your CLAUDE.md in the workspace defines your full identity and mission.`, 32 `Read it if you need to re-orient.`, 33 ] 34 35 if (config.appendSystemPrompt) { 36 lines.push('', '--- Additional Context ---', config.appendSystemPrompt) 37 } 38 39 return lines.join('\n') 40} 41 42// ─── Manager Class ───────────────────────────────────────────────────── 43 44export class ClaudeCodeManager extends EventEmitter { 45 private sessions = new Map<string, Session>() 46 private sweepTimer: ReturnType<typeof setInterval> | null = null 47 private settings: AppSettings 48 private platformEnv: Record<string, string> 49 private claudeExecutablePath: string 50 51 /** 52 * @param claudeExecutablePath - Path to the bundled `claude` binary. 53 * In dev: detected from PATH. 54 * In production: bundled in app resources. 55 * @param platformEnv - Loaded from .env.local at app startup. 56 * @param settings - Current app settings (model, env vars, schedule). 57 */ 58 constructor( 59 claudeExecutablePath: string, 60 platformEnv: Record<string, string>, 61 settings: AppSettings, 62 ) { 63 super() 64 this.claudeExecutablePath = claudeExecutablePath 65 this.platformEnv = platformEnv 66 this.settings = settings 67 this.sweepTimer = setInterval(() => this.sweepIdleSessions(), SWEEP_INTERVAL_MS) 68 } 69 70 /** Update settings (called when operator changes config in UI) */ 71 updateSettings(settings: AppSettings) { 72 this.settings = settings 73 } 74 75 /** Get summary of all active sessions */ 76 getSessions(): SessionSummary[] { 77 return [...this.sessions.values()].map(s => ({ 78 id: s.id, 79 type: s.type, 80 isProcessing: s.isProcessing, 81 lastActivityAt: s.lastActivityAt, 82 })) 83 } 84 85 // ─── Session Lifecycle ────────────────────────────────────────────── 86 87 /** 88 * Start a new Claude Code session. 89 * 90 * For INTERACTIVE sessions: 91 * Creates an async generator that yields user messages on demand. 92 * The generator blocks (awaits a Promise) when no messages are pending. 93 * Call sendMessage() to push messages into the generator. 94 * The SDK keeps the same Claude Code process alive across messages. 95 * 96 * For SCHEDULED/EVENT sessions: 97 * Creates a single-yield generator with the prompt. 98 * Claude Code processes the prompt, calls tools, writes to workspace, and exits. 99 * No interaction possible after spawn. 100 * 101 * Returns the session ID (deterministic UUID or auto-generated). 102 */ 103 async startSession(config: SessionConfig): Promise<string> { 104 const sessionId = config.sessionId ?? crypto.randomUUID() 105 const model = config.model ?? this.settings.model ?? 'claude-sonnet-4-20250514' 106 107 // ── Check for session reuse ───────────────────────────────────── 108 // If an interactive session with this ID already exists and settings 109 // haven't changed, push the new message into the existing process 110 // rather than spawning a new one. 111 const existing = this.sessions.get(sessionId) 112 if (existing && existing.currentSettings.model === model) { 113 if (config.prompt) { 114 this.sendMessage(sessionId, config.prompt) 115 } 116 return sessionId 117 } 118 119 // ── Assemble environment ──────────────────────────────────────── 120 const env = assembleEnvironment({ 121 userClaudeEnvVars: this.settings.claudeEnvVars, 122 platformEnv: this.platformEnv, 123 }) 124 125 // ── Build prompt input (async generator) ──────────────────────── 126 const messageQueue: string[] = [] 127 let resolveNextMessage: ((msg: string) => void) | null = null 128 129 let promptInput: AsyncGenerator<any> 130 131 if (config.type === 'interactive') { 132 // Multi-turn: generator blocks waiting for messages 133 promptInput = (async function* () { 134 // If there's an initial prompt, yield it first 135 if (config.prompt) { 136 yield { 137 type: 'user' as const, 138 message: { role: 'user' as const, content: config.prompt }, 139 } 140 } 141 142 // Then enter the message loop 143 while (true) { 144 const message = messageQueue.length > 0 145 ? messageQueue.shift()! 146 : await new Promise<string>(resolve => { 147 resolveNextMessage = resolve 148 }) 149 resolveNextMessage = null 150 yield { 151 type: 'user' as const, 152 message: { role: 'user' as const, content: message }, 153 } 154 } 155 })() 156 } else { 157 // Headless: single prompt, then generator returns 158 promptInput = (async function* () { 159 yield { 160 type: 'user' as const, 161 message: { role: 'user' as const, content: config.prompt! }, 162 } 163 })() 164 } 165 166 // ── Build SDK options ─────────────────────────────────────────── 167 const sdkOptions = { 168 maxTurns: config.maxTurns ?? (config.type === 'interactive' ? 1000 : 100), 169 model, 170 cwd: WORKSPACE_PATH, 171 pathToClaudeCodeExecutable: this.claudeExecutablePath, 172 systemPrompt: buildSystemPrompt(config, this.settings), 173 settingSources: ['user', 'project', 'local'] as const, 174 env, 175 176 // ── Runtime tool approval ─────────────────────────────────── 177 // This callback fires BEFORE every tool execution inside the 178 // Claude Code process. It's the primary permission gate. 179 canUseTool: async ( 180 toolName: string, 181 toolInput: Record<string, unknown>, 182 ) => { 183 return this.handleToolApproval(sessionId, toolName, toolInput) 184 }, 185 186 // ── Blocked tools ─────────────────────────────────────────── 187 // AskUserQuestion: Auctor provides its own UI for user interaction. 188 // Claude Code's terminal-based question prompts would hang in headless mode. 189 disallowedTools: ['AskUserQuestion'], 190 191 permissionMode: 'default' as const, 192 193 // ── MCP servers ───────────────────────────────────────────── 194 // The auctor MCP server is defined inline. In Electron, it runs 195 // in-process (via mcp-bridge.ts). The SDK connects to it via 196 // the configuration provided here. 197 mcpServers: this.getMcpServerConfig(), 198 199 // ── Hooks ─────────────────────────────────────────────────── 200 hooks: { 201 UserPromptSubmit: [ 202 { 203 type: 'command' as const, 204 command: `cd ${WORKSPACE_PATH} && git add -A && git stash create "checkpoint-$(date +%s)" || true`, 205 }, 206 ], 207 Stop: [ 208 { 209 type: 'command' as const, 210 command: `cd ${WORKSPACE_PATH} && git add -A && git stash create "checkpoint-$(date +%s)" || true`, 211 }, 212 { 213 type: 'command' as const, 214 command: `cd ${WORKSPACE_PATH} && git add -A && git commit -m "session: auto-commit on stop" --allow-empty || true`, 215 }, 216 ], 217 PostToolUse: [ 218 { 219 type: 'command' as const, 220 command: `echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $TOOL_NAME" >> ${path.join(os.homedir(), '.auctor', 'logs', 'tool-audit.log')}`, 221 }, 222 ], 223 }, 224 } 225 226 // ── Execute query ─────────────────────────────────────────────── 227 const queryResult = query({ 228 prompt: promptInput, 229 options: sdkOptions, 230 }) as AsyncIterableClaudeResult 231 232 // ── Register session ──────────────────────────────────────────── 233 const session: Session = { 234 id: sessionId, 235 type: config.type, 236 queryResult, 237 messageQueue, 238 resolveNextMessage: null, 239 isProcessing: true, 240 lastActivityAt: Date.now(), 241 currentSettings: { model }, 242 } 243 244 // The resolveNextMessage pointer is managed by the async generator 245 // closure. We need to keep a live reference on the session object 246 // so sendMessage() can resolve it. 247 Object.defineProperty(session, 'resolveNextMessage', { 248 get: () => resolveNextMessage, 249 set: (v: ((msg: string) => void) | null) => { resolveNextMessage = v }, 250 enumerable: true, 251 configurable: true, 252 }) 253 254 this.sessions.set(sessionId, session) 255 256 // ── Consume events in background ──────────────────────────────── 257 this.consumeEvents(sessionId, queryResult) 258 259 return sessionId 260 } 261 262 /** 263 * Send a message to an active interactive session. 264 * 265 * If the async generator is currently awaiting (resolveNextMessage is set), 266 * the message is delivered immediately by resolving the Promise. 267 * Otherwise, it's queued — the generator will pick it up on next yield. 268 * 269 * This is how multi-turn conversation works without spawning a new process. 270 */ 271 sendMessage(sessionId: string, message: string): void { 272 const session = this.sessions.get(sessionId) 273 if (!session) { 274 throw new Error(`No active session: ${sessionId}`) 275 } 276 277 if (session.resolveNextMessage) { 278 // Generator is awaiting — deliver immediately 279 session.resolveNextMessage(message) 280 } else { 281 // Generator is busy processing — queue for next iteration 282 session.messageQueue.push(message) 283 } 284 285 session.lastActivityAt = Date.now() 286 session.isProcessing = true 287 } 288 289 /** 290 * Gracefully stop a session. 291 * The SDK's interrupt() sends SIGTERM with a 5-second grace period, 292 * then SIGKILL if the process hasn't exited. 293 */ 294 stopSession(sessionId: string): void { 295 const session = this.sessions.get(sessionId) 296 if (session) { 297 session.queryResult.interrupt() 298 // consumeEvents() will handle cleanup when the iterator completes 299 } 300 } 301 302 /** 303 * Stop all sessions. Called on app quit. 304 */ 305 stopAll(): void { 306 for (const [id] of this.sessions) { 307 this.stopSession(id) 308 } 309 if (this.sweepTimer) { 310 clearInterval(this.sweepTimer) 311 } 312 } 313 314 // ─── Event Consumption ────────────────────────────────────────────── 315 316 /** 317 * Consume the async iterable of events from a query result. 318 * 319 * This runs as a background async loop for the lifetime of the session. 320 * Each event is forwarded to the Electron renderer via EventEmitter. 321 * When the iterable completes (session ends), cleanup is performed. 322 */ 323 private async consumeEvents( 324 sessionId: string, 325 queryResult: AsyncIterable<ClaudeEvent>, 326 ): Promise<void> { 327 try { 328 for await (const event of queryResult) { 329 const session = this.sessions.get(sessionId) 330 if (session) { 331 session.lastActivityAt = Date.now() 332 } 333 334 // Forward to renderer 335 this.emit('session-event', { sessionId, event }) 336 337 // Track processing state 338 if (event.type === 'result') { 339 const session = this.sessions.get(sessionId) 340 if (session) { 341 session.isProcessing = false 342 } 343 } 344 } 345 } catch (err: unknown) { 346 const message = err instanceof Error ? err.message : String(err) 347 this.emit('session-error', { sessionId, error: message }) 348 } finally { 349 this.sessions.delete(sessionId) 350 this.emit('session-ended', { sessionId }) 351 } 352 } 353 354 // ─── Tool Approval ────────────────────────────────────────────────── 355 356 /** 357 * Handle runtime tool approval. 358 * 359 * This is the canUseTool callback passed to the SDK. It fires synchronously 360 * from Claude Code's perspective — the process blocks until this resolves. 361 * 362 * For most tools: auto-approve (fast path). 363 * For publish tools: emit an event to the renderer, await operator decision. 364 * For file writes: validate path is within workspace. 365 */ 366 private async handleToolApproval( 367 sessionId: string, 368 toolName: string, 369 toolInput: Record<string, unknown>, 370 ): Promise<{ allowed: boolean; reason?: string }> { 371 372 // ── Publish gate ──────────────────────────────────────────────── 373 // upload-to-cms is the most dangerous operation. It pushes content 374 // to the live CMS. Require explicit operator approval every time. 375 if (toolName === 'mcp__auctor__upload-to-cms') { 376 return this.requestOperatorApproval(sessionId, toolName, toolInput) 377 } 378 379 // ── File path validation ──────────────────────────────────────── 380 // Prevent Claude Code from writing outside the workspace. 381 // This catches path traversal (../../etc/passwd) and absolute paths. 382 if (['Edit', 'Write', 'MultiEdit'].includes(toolName)) { 383 const filePath = toolInput.file_path as string 384 if (filePath) { 385 const resolved = path.resolve(WORKSPACE_PATH, filePath) 386 if (!resolved.startsWith(WORKSPACE_PATH)) { 387 return { 388 allowed: false, 389 reason: `Path '${filePath}' resolves outside workspace`, 390 } 391 } 392 } 393 } 394 395 // ── All other tools: auto-approve ─────────────────────────────── 396 return { allowed: true } 397 } 398 399 /** 400 * Request operator approval for a dangerous tool call. 401 * 402 * Emits an event to the renderer, which shows an approval card in the 403 * chat panel. Returns a Promise that resolves when the operator clicks 404 * Approve or Reject. 405 * 406 * IMPORTANT: This blocks the Claude Code process. The SDK will not 407 * proceed with the tool call until this Promise resolves. Keep the 408 * timeout reasonable (5 minutes) — if the operator doesn't respond, 409 * auto-reject. 410 */ 411 private requestOperatorApproval( 412 sessionId: string, 413 toolName: string, 414 toolInput: Record<string, unknown>, 415 ): Promise<{ allowed: boolean; reason?: string }> { 416 const approvalId = crypto.randomUUID() 417 418 return new Promise((resolve) => { 419 // Emit approval request to renderer 420 this.emit('approval-requested', { 421 approvalId, 422 sessionId, 423 toolName, 424 toolInput, 425 }) 426 427 // Listen for operator decision 428 const handler = (decision: { 429 approvalId: string 430 approved: boolean 431 reason?: string 432 }) => { 433 if (decision.approvalId === approvalId) { 434 this.removeListener('approval-decision', handler) 435 clearTimeout(timeout) 436 resolve({ 437 allowed: decision.approved, 438 reason: decision.reason ?? (decision.approved ? undefined : 'Rejected by operator'), 439 }) 440 } 441 } 442 this.on('approval-decision', handler) 443 444 // Auto-reject after 5 minutes of no response 445 const timeout = setTimeout(() => { 446 this.removeListener('approval-decision', handler) 447 resolve({ allowed: false, reason: 'Approval timed out (5 minutes)' }) 448 }, 5 * 60 * 1000) 449 }) 450 } 451 452 // ─── Idle Session Management ──────────────────────────────────────── 453 454 /** 455 * Sweep idle sessions. 456 * Runs every 60 seconds via setInterval. 457 * 458 * 1. Kill sessions that have been idle > 30 minutes 459 * 2. If more than 5 idle sessions remain, kill oldest first 460 */ 461 private sweepIdleSessions(): void { 462 const now = Date.now() 463 464 // Find idle sessions (not processing, past timeout) 465 const idle = [...this.sessions.entries()] 466 .filter(([, s]) => !s.isProcessing && (now - s.lastActivityAt) > IDLE_TIMEOUT_MS) 467 .sort((a, b) => a[1].lastActivityAt - b[1].lastActivityAt) 468 469 for (const [id] of idle) { 470 this.stopSession(id) 471 } 472 473 // Enforce max idle count 474 const remainingIdle = [...this.sessions.entries()] 475 .filter(([, s]) => !s.isProcessing) 476 .sort((a, b) => a[1].lastActivityAt - b[1].lastActivityAt) 477 478 while (remainingIdle.length > MAX_IDLE_SESSIONS) { 479 const [id] = remainingIdle.shift()! 480 this.stopSession(id) 481 } 482 } 483 484 // ─── MCP Server Config ────────────────────────────────────────────── 485 486 /** 487 * Build the MCP server configuration passed to the SDK. 488 * 489 * In Electron, the auctor MCP server runs in-process via mcp-bridge.ts. 490 * The SDK connects to it via stdio transport. 491 * 492 * In dev mode (no Electron), this points to the same .mcp.json that 493 * Claude Code reads from the workspace. 494 */ 495 private getMcpServerConfig(): Record<string, unknown> { 496 // The MCP server is started in-process by mcp-bridge.ts. 497 // Here we configure the SDK to connect to it via stdio. 498 return { 499 auctor: { 500 type: 'stdio', 501 command: 'npx', 502 args: [ 503 'tsx', 504 '--import', './mcp-server/register.mjs', 505 './mcp-server/index.ts', 506 ], 507 cwd: path.resolve(__dirname, '..', 'frontend'), 508 env: { 509 DOTENV_CONFIG_PATH: path.resolve(__dirname, '..', '.env.local'), 510 }, 511 }, 512 } 513 } 514 515 // ─── Helper: Platform Env ─────────────────────────────────────────── 516 517 /** Exposed for env-assembly to access platform env */ 518 getPlatformEnv(): Record<string, string> { 519 return { ...this.platformEnv } 520 } 521}

Implementation notes for the builder:

  1. The Object.defineProperty trick for resolveNextMessage is necessary because the async generator closure captures resolveNextMessage by reference in its own scope. The getter/setter on the session object bridges the gap — when sendMessage() reads session.resolveNextMessage, it actually reads the variable from the generator's closure. Without this, the session object and the generator would have independent copies of the variable.

  2. The canUseTool callback is async but blocks Claude Code — the SDK awaits its resolution before allowing the tool to execute. This is by design and documented in the SDK. Do not add artificial delays.

  3. Hook commands use || true at the end because git operations (stash, commit) will fail if there are no changes. Without || true, the hook failure would propagate to Claude Code as an error event.

  4. The MCP server config in getMcpServerConfig() launches the MCP server as a stdio child of Claude Code. In the Electron production build, the path resolution (__dirname) will point to the dist/ directory. The cwd must resolve to the frontend/ directory where the MCP server code lives. If the frontend is bundled as extraResources, adjust the path accordingly. During development, __dirname resolves correctly because tsc outputs to desktop/dist/ and the frontend is at ../frontend/.

3.5 desktop/mcp-bridge.ts

Initializes the MCP server in-process so it shares Electron's Node.js runtime and database pool.

1// desktop/mcp-bridge.ts 2 3import path from 'path' 4 5/** 6 * Initialize the Auctor MCP server in the Electron main process. 7 * 8 * This is the "shared runtime" advantage of Electron: the MCP server's 9 * Drizzle ORM, Mastra runtime, and database pool live in the same 10 * Node.js process as the main thread. No sidecar, no IPC, no second 11 * connection pool. 12 * 13 * IMPORTANT: This must be called BEFORE ClaudeCodeManager.startSession() 14 * so the MCP server is ready when Claude Code connects to it. 15 * 16 * The MCP server is registered as a stdio transport in Claude Code's 17 * config. When Claude Code calls mcp__auctor__*, the request goes 18 * through stdin/stdout pipes to THIS process. 19 * 20 * In practice for Phase 4 initial build: 21 * The MCP server still runs as a spawned process (via the .mcp.json 22 * config). The in-process optimization is a Phase 5 enhancement. 23 * This module exists as a placeholder and initialization checkpoint. 24 */ 25 26interface McpBridgeOptions { 27 /** Path to .env.local for the MCP server */ 28 envPath: string 29 /** Path to the frontend directory (where mcp-server/ lives) */ 30 frontendPath: string 31} 32 33export async function initMcpBridge(options: McpBridgeOptions): Promise<void> { 34 // Phase 4: Verify the MCP server can start 35 // The MCP server runs as a stdio child of Claude Code (spawned by the SDK). 36 // Here we validate that the required files and env exist. 37 38 const fs = await import('fs') 39 40 const serverEntry = path.join(options.frontendPath, 'mcp-server', 'index.ts') 41 if (!fs.existsSync(serverEntry)) { 42 throw new Error( 43 `MCP server entry not found: ${serverEntry}\n` + 44 `Ensure Phase 1 is complete: the MCP server must exist at frontend/mcp-server/index.ts` 45 ) 46 } 47 48 if (!fs.existsSync(options.envPath)) { 49 throw new Error( 50 `Environment file not found: ${options.envPath}\n` + 51 `Auctor requires a .env.local with DATABASE_URL, Supabase keys, and API credentials.` 52 ) 53 } 54 55 console.log('[mcp-bridge] MCP server validated:', serverEntry) 56 console.log('[mcp-bridge] Environment file:', options.envPath) 57 58 // Phase 5 enhancement: import the MCP server directly and run it in-process 59 // using the McpServer's request() method instead of stdio transport. 60 // This eliminates the child process and shares the DB pool. 61}

3.6 desktop/scheduler.ts

1// desktop/scheduler.ts 2 3import cron from 'node-cron' 4import fs from 'fs' 5import path from 'path' 6import os from 'os' 7import type { ClaudeCodeManager } from './claude-code-manager' 8import type { AppSettings, ClaudeEvent } from './types' 9 10const LOGS_DIR = path.join(os.homedir(), '.auctor', 'logs') 11 12// ─── Schedule Prompts ────────────────────────────────────────────────── 13// Each prompt is a complete instruction for a headless Claude Code session. 14// The agent reads its workspace CLAUDE.md (auto-loaded), then follows 15// these cycle-specific instructions. 16 17const PROMPTS = { 18 morning: [ 19 'Run your morning cycle.', 20 '1. Read memory/current-focus.md for today\'s priorities.', 21 '2. Check intelligence/*/_last-sync.txt timestamps.', 22 '3. If any data is older than 24 hours, sync it using the appropriate MCP tools.', 23 '4. Review intelligence/analytics/content-performance.json for overnight changes.', 24 '5. Check content/drafts/_queue.json for items needing attention.', 25 '6. Write your observations to memory/observations.md.', 26 '7. Update memory/current-focus.md with today\'s plan.', 27 '8. Commit all changes.', 28 ].join('\n'), 29 30 midday: [ 31 'Quick midday check.', 32 '1. Read memory/current-focus.md — are you on track?', 33 '2. Check content/drafts/_queue.json — any drafts needing review?', 34 '3. Look for performance anomalies in intelligence/analytics/.', 35 '4. If anything urgent, append to memory/observations.md.', 36 '5. Commit any changes.', 37 ].join('\n'), 38 39 evening: [ 40 'End of day review.', 41 '1. Summarize what was accomplished today in memory/observations.md.', 42 '2. Log any decisions made to memory/decisions.md with reasoning.', 43 '3. If you learned something generalizable, add it to memory/learnings.md.', 44 '4. Update memory/current-focus.md with tomorrow\'s priorities.', 45 '5. Commit all changes with a summary message.', 46 ].join('\n'), 47 48 weekly: [ 49 'Weekly deep analysis.', 50 '1. Sync ALL data sources (analytics, search, competitors, content).', 51 '2. Compare this week\'s intelligence/analytics/ with last week\'s.', 52 '3. Review memory/learnings.md — are the patterns still holding?', 53 '4. Review memory/mistakes.md — are we repeating errors?', 54 '5. Analyze content/published/ performance trends.', 55 '6. Check intelligence/competitors/ for new competitive moves.', 56 '7. Update the editorial calendar based on what\'s working.', 57 '8. Write a weekly summary in memory/observations.md.', 58 '9. Distill any new insights to memory/learnings.md.', 59 '10. Commit all changes.', 60 ].join('\n'), 61} 62 63// ─── Scheduler ───────────────────────────────────────────────────────── 64 65export function initScheduler( 66 manager: ClaudeCodeManager, 67 settings: AppSettings, 68): { updateSchedule: (settings: AppSettings) => void; stop: () => void } { 69 const tasks: cron.ScheduledTask[] = [] 70 71 function clearTasks() { 72 for (const task of tasks) { 73 task.stop() 74 } 75 tasks.length = 0 76 } 77 78 function buildSchedule(settings: AppSettings) { 79 clearTasks() 80 81 // Ensure logs directory exists 82 if (!fs.existsSync(LOGS_DIR)) { 83 fs.mkdirSync(LOGS_DIR, { recursive: true }) 84 } 85 86 const { schedule } = settings 87 88 if (schedule.morningEnabled) { 89 const [h, m] = schedule.morningTime.split(':') 90 tasks.push(cron.schedule(`${m} ${h} * * *`, () => { 91 runScheduledSession(manager, PROMPTS.morning, 'morning') 92 })) 93 } 94 95 if (schedule.middayEnabled) { 96 const [h, m] = schedule.middayTime.split(':') 97 tasks.push(cron.schedule(`${m} ${h} * * *`, () => { 98 runScheduledSession(manager, PROMPTS.midday, 'midday') 99 })) 100 } 101 102 if (schedule.eveningEnabled) { 103 const [h, m] = schedule.eveningTime.split(':') 104 tasks.push(cron.schedule(`${m} ${h} * * *`, () => { 105 runScheduledSession(manager, PROMPTS.evening, 'evening') 106 })) 107 } 108 109 if (schedule.weeklyEnabled) { 110 const [h, m] = schedule.weeklyTime.split(':') 111 tasks.push(cron.schedule(`${m} ${h} * * ${schedule.weeklyDay}`, () => { 112 runScheduledSession(manager, PROMPTS.weekly, 'weekly') 113 })) 114 } 115 116 console.log(`[scheduler] ${tasks.length} scheduled tasks configured`) 117 } 118 119 // Initial build 120 buildSchedule(settings) 121 122 return { 123 updateSchedule: (newSettings: AppSettings) => buildSchedule(newSettings), 124 stop: () => clearTasks(), 125 } 126} 127 128// ─── Session Runner ──────────────────────────────────────────────────── 129 130async function runScheduledSession( 131 manager: ClaudeCodeManager, 132 prompt: string, 133 logSuffix: string, 134): Promise<void> { 135 const date = new Date().toISOString().split('T')[0] 136 const logFile = path.join(LOGS_DIR, `${date}-${logSuffix}.json`) 137 const events: string[] = [] 138 139 console.log(`[scheduler] Starting ${logSuffix} session`) 140 141 try { 142 const sessionId = await manager.startSession({ 143 type: 'scheduled', 144 prompt, 145 appendSystemPrompt: `scheduled:${logSuffix}`, 146 maxTurns: 100, 147 }) 148 149 // Collect events for log file 150 const onEvent = ({ sessionId: sid, event }: { sessionId: string; event: ClaudeEvent }) => { 151 if (sid === sessionId) { 152 events.push(JSON.stringify({ 153 timestamp: new Date().toISOString(), 154 ...event, 155 })) 156 } 157 } 158 159 const onEnded = ({ sessionId: sid }: { sessionId: string }) => { 160 if (sid === sessionId) { 161 manager.removeListener('session-event', onEvent) 162 manager.removeListener('session-ended', onEnded) 163 164 // Write log file 165 try { 166 fs.writeFileSync(logFile, events.join('\n')) 167 console.log(`[scheduler] ${logSuffix} session complete. Log: ${logFile}`) 168 } catch (err) { 169 console.error(`[scheduler] Failed to write log: ${err}`) 170 } 171 } 172 } 173 174 manager.on('session-event', onEvent) 175 manager.on('session-ended', onEnded) 176 } catch (err) { 177 console.error(`[scheduler] Failed to start ${logSuffix} session:`, err) 178 } 179}

3.7 desktop/file-watcher.ts

1// desktop/file-watcher.ts 2 3import chokidar from 'chokidar' 4import path from 'path' 5import os from 'os' 6import type { BrowserWindow } from 'electron' 7 8const WORKSPACE_PATH = path.join(os.homedir(), '.auctor', 'workspace') 9 10/** 11 * Watch the Auctor workspace for file changes. 12 * 13 * When Claude Code writes to memory/observations.md, edits a draft, 14 * or updates intelligence data, the file watcher detects the change 15 * and notifies the renderer. Dashboard components can then refetch 16 * to show the latest state. 17 * 18 * Debouncing: chokidar fires on write close, not on every byte. 19 * For large files (analytics JSON), this means one event per write, 20 * not thousands. No additional debouncing needed. 21 */ 22export function initFileWatcher(mainWindow: BrowserWindow): { 23 close: () => Promise<void> 24} { 25 const watcher = chokidar.watch(WORKSPACE_PATH, { 26 ignored: [ 27 /(^|[\/\\])\../, // dotfiles (.git, .claude, etc.) 28 /node_modules/, 29 /\.git\//, 30 ], 31 persistent: true, 32 ignoreInitial: true, // Don't fire for existing files on startup 33 awaitWriteFinish: { // Wait for writes to complete 34 stabilityThreshold: 300, 35 pollInterval: 100, 36 }, 37 }) 38 39 watcher.on('change', (filePath: string) => { 40 if (mainWindow.isDestroyed()) return 41 42 const relative = path.relative(WORKSPACE_PATH, filePath) 43 mainWindow.webContents.send('workspace:file-changed', { 44 path: filePath, 45 relative, 46 timestamp: Date.now(), 47 }) 48 }) 49 50 watcher.on('add', (filePath: string) => { 51 if (mainWindow.isDestroyed()) return 52 53 const relative = path.relative(WORKSPACE_PATH, filePath) 54 mainWindow.webContents.send('workspace:file-changed', { 55 path: filePath, 56 relative, 57 timestamp: Date.now(), 58 }) 59 }) 60 61 watcher.on('error', (error: Error) => { 62 console.error('[file-watcher] Error:', error) 63 }) 64 65 console.log(`[file-watcher] Watching: ${WORKSPACE_PATH}`) 66 67 return { 68 close: () => watcher.close(), 69 } 70}

3.8 desktop/sidecar.ts

1// desktop/sidecar.ts 2 3import { spawn, ChildProcess } from 'child_process' 4import path from 'path' 5 6/** 7 * Manage the Python/FastAPI sidecar for LangExtract. 8 * 9 * LangExtract provides content extraction capabilities (HTML → structured data) 10 * that the Mastra agents use during content analysis workflows. 11 * 12 * Lifecycle: starts with the app, stops on app quit. 13 * Health check: GET http://localhost:8000/health every 30 seconds. 14 * Restart: auto-restart on crash, max 3 retries then give up. 15 */ 16export class SidecarManager { 17 private process: ChildProcess | null = null 18 private restartCount = 0 19 private maxRestarts = 3 20 private healthCheckTimer: ReturnType<typeof setInterval> | null = null 21 private pythonPath: string 22 private scriptPath: string 23 24 constructor(options: { 25 /** Path to python3 or the bundled Python executable */ 26 pythonPath: string 27 /** Path to the FastAPI entry script (backend/main.py) */ 28 scriptPath: string 29 }) { 30 this.pythonPath = options.pythonPath 31 this.scriptPath = options.scriptPath 32 } 33 34 start(): void { 35 if (this.process) return 36 37 console.log(`[sidecar] Starting FastAPI: ${this.pythonPath} ${this.scriptPath}`) 38 39 this.process = spawn(this.pythonPath, [this.scriptPath], { 40 stdio: ['ignore', 'pipe', 'pipe'], 41 env: { 42 ...process.env, 43 PORT: '8000', 44 HOST: '127.0.0.1', 45 }, 46 }) 47 48 this.process.stdout?.on('data', (data: Buffer) => { 49 console.log(`[sidecar:stdout] ${data.toString().trim()}`) 50 }) 51 52 this.process.stderr?.on('data', (data: Buffer) => { 53 console.error(`[sidecar:stderr] ${data.toString().trim()}`) 54 }) 55 56 this.process.on('exit', (code, signal) => { 57 console.log(`[sidecar] Exited with code ${code}, signal ${signal}`) 58 this.process = null 59 60 if (this.restartCount < this.maxRestarts) { 61 this.restartCount++ 62 console.log(`[sidecar] Restarting (attempt ${this.restartCount}/${this.maxRestarts})`) 63 setTimeout(() => this.start(), 2000) 64 } else { 65 console.error('[sidecar] Max restarts exceeded. LangExtract is down.') 66 } 67 }) 68 69 // Health check every 30 seconds 70 this.healthCheckTimer = setInterval(async () => { 71 try { 72 const response = await fetch('http://127.0.0.1:8000/health') 73 if (!response.ok) throw new Error(`Status ${response.status}`) 74 } catch { 75 console.warn('[sidecar] Health check failed') 76 } 77 }, 30_000) 78 } 79 80 stop(): void { 81 if (this.healthCheckTimer) { 82 clearInterval(this.healthCheckTimer) 83 this.healthCheckTimer = null 84 } 85 if (this.process) { 86 this.process.kill('SIGTERM') 87 // Force kill after 5 seconds 88 setTimeout(() => { 89 if (this.process) { 90 this.process.kill('SIGKILL') 91 this.process = null 92 } 93 }, 5000) 94 } 95 } 96}

3.9 desktop/preload.ts

1// desktop/preload.ts 2 3import { contextBridge, ipcRenderer } from 'electron' 4 5/** 6 * Electron preload script. 7 * 8 * Exposes a safe, typed API on window.electron that the renderer 9 * (Next.js React components) can use to communicate with the main process. 10 * 11 * Security: contextBridge ensures the renderer cannot access Node.js 12 * directly. All communication goes through these explicitly defined channels. 13 */ 14contextBridge.exposeInMainWorld('electron', { 15 // ── Request/Response (invoke → handle) ───────────────────────────── 16 17 invoke: (channel: string, args?: unknown) => { 18 const validChannels = [ 19 'claude:start-session', 20 'claude:send-message', 21 'claude:stop-session', 22 'claude:get-sessions', 23 'claude:verify-auth', 24 'claude:approve-tool', 25 'workspace:read-file', 26 'workspace:list-dir', 27 'settings:get', 28 'settings:set', 29 ] 30 if (validChannels.includes(channel)) { 31 return ipcRenderer.invoke(channel, args) 32 } 33 throw new Error(`Invalid IPC channel: ${channel}`) 34 }, 35 36 // ── Event Stream (send → on) ─────────────────────────────────────── 37 38 on: (channel: string, callback: (...args: unknown[]) => void) => { 39 const validChannels = [ 40 'claude:event', 41 'claude:session-ended', 42 'claude:session-error', 43 'claude:approval-requested', 44 'workspace:file-changed', 45 ] 46 if (validChannels.includes(channel)) { 47 // Wrap to strip the IpcRendererEvent — renderer doesn't need it 48 const wrappedCallback = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => { 49 callback(...args) 50 } 51 ipcRenderer.on(channel, wrappedCallback) 52 53 // Return cleanup function 54 return () => { 55 ipcRenderer.removeListener(channel, wrappedCallback) 56 } 57 } 58 throw new Error(`Invalid IPC channel: ${channel}`) 59 }, 60 61 // ── One-time Event ───────────────────────────────────────────────── 62 63 once: (channel: string, callback: (...args: unknown[]) => void) => { 64 const validChannels = [ 65 'claude:session-ended', 66 ] 67 if (validChannels.includes(channel)) { 68 ipcRenderer.once(channel, (_event, ...args) => callback(...args)) 69 } 70 }, 71}) 72 73// TypeScript declaration for the renderer 74declare global { 75 interface Window { 76 electron: { 77 invoke: (channel: string, args?: unknown) => Promise<unknown> 78 on: (channel: string, callback: (...args: unknown[]) => void) => () => void 79 once: (channel: string, callback: (...args: unknown[]) => void) => void 80 } 81 } 82}

3.10 desktop/ipc-handlers.ts

All IPC handler registrations in one file. Imported by main.ts.

1// desktop/ipc-handlers.ts 2 3import { ipcMain, BrowserWindow } from 'electron' 4import fs from 'fs/promises' 5import path from 'path' 6import os from 'os' 7import type { ClaudeCodeManager } from './claude-code-manager' 8import type { AppSettings } from './types' 9 10const WORKSPACE_PATH = path.join(os.homedir(), '.auctor', 'workspace') 11const SETTINGS_PATH = path.join(os.homedir(), '.auctor', 'state', 'settings.json') 12 13/** 14 * Register all IPC handlers. 15 * 16 * This bridges the renderer (React) ↔ main process (Claude Code Manager). 17 * Each handler maps to a window.electron.invoke() call in the renderer. 18 */ 19export function registerIpcHandlers( 20 mainWindow: BrowserWindow, 21 manager: ClaudeCodeManager, 22 onSettingsChanged: (settings: AppSettings) => void, 23): void { 24 25 // ── Claude Code Session Management ───────────────────────────────── 26 27 ipcMain.handle('claude:start-session', async (_event, config) => { 28 return manager.startSession(config) 29 }) 30 31 ipcMain.handle('claude:send-message', async (_event, { sessionId, message }) => { 32 manager.sendMessage(sessionId, message) 33 }) 34 35 ipcMain.handle('claude:stop-session', async (_event, { sessionId }) => { 36 manager.stopSession(sessionId) 37 }) 38 39 ipcMain.handle('claude:get-sessions', async () => { 40 return manager.getSessions() 41 }) 42 43 // ── Tool Approval ────────────────────────────────────────────────── 44 // When canUseTool() in the manager emits an approval request, 45 // we forward it to the renderer. When the operator clicks Approve/Reject, 46 // the renderer calls this handler, which resolves the canUseTool() Promise. 47 48 ipcMain.handle('claude:approve-tool', async (_event, decision) => { 49 manager.emit('approval-decision', decision) 50 }) 51 52 // Forward approval requests from manager → renderer 53 manager.on('approval-requested', (payload) => { 54 if (!mainWindow.isDestroyed()) { 55 mainWindow.webContents.send('claude:approval-requested', payload) 56 } 57 }) 58 59 // Forward Claude Code events from manager → renderer 60 manager.on('session-event', (payload) => { 61 if (!mainWindow.isDestroyed()) { 62 mainWindow.webContents.send('claude:event', payload) 63 } 64 }) 65 66 manager.on('session-ended', (payload) => { 67 if (!mainWindow.isDestroyed()) { 68 mainWindow.webContents.send('claude:session-ended', payload) 69 } 70 }) 71 72 manager.on('session-error', (payload) => { 73 if (!mainWindow.isDestroyed()) { 74 mainWindow.webContents.send('claude:session-error', payload) 75 } 76 }) 77 78 // ── Auth Verification ────────────────────────────────────────────── 79 80 ipcMain.handle('claude:verify-auth', async () => { 81 try { 82 // Spawn a throwaway Claude Code process just to check credentials 83 const sessionId = await manager.startSession({ 84 type: 'scheduled', 85 prompt: 'Echo "auth-check-ok"', 86 maxTurns: 1, 87 }) 88 89 // Wait for it to complete (or timeout) 90 return new Promise((resolve) => { 91 const timeout = setTimeout(() => { 92 manager.stopSession(sessionId) 93 resolve({ authenticated: false, error: 'Auth check timed out' }) 94 }, 15_000) 95 96 const onEnd = ({ sessionId: sid }: { sessionId: string }) => { 97 if (sid === sessionId) { 98 clearTimeout(timeout) 99 manager.removeListener('session-ended', onEnd) 100 resolve({ authenticated: true }) 101 } 102 } 103 manager.on('session-ended', onEnd) 104 105 const onError = ({ sessionId: sid, error }: { sessionId: string; error: string }) => { 106 if (sid === sessionId) { 107 clearTimeout(timeout) 108 manager.removeListener('session-error', onError) 109 resolve({ authenticated: false, error }) 110 } 111 } 112 manager.on('session-error', onError) 113 }) 114 } catch (err: unknown) { 115 return { authenticated: false, error: err instanceof Error ? err.message : String(err) } 116 } 117 }) 118 119 // ── Workspace File Access ────────────────────────────────────────── 120 121 ipcMain.handle('workspace:read-file', async (_event, { relativePath }) => { 122 const fullPath = path.join(WORKSPACE_PATH, relativePath) 123 // Security: prevent path traversal 124 if (!fullPath.startsWith(WORKSPACE_PATH)) { 125 throw new Error('Path traversal detected') 126 } 127 return fs.readFile(fullPath, 'utf-8') 128 }) 129 130 ipcMain.handle('workspace:list-dir', async (_event, { relativePath }) => { 131 const fullPath = path.join(WORKSPACE_PATH, relativePath) 132 if (!fullPath.startsWith(WORKSPACE_PATH)) { 133 throw new Error('Path traversal detected') 134 } 135 const entries = await fs.readdir(fullPath, { withFileTypes: true }) 136 return entries.map(e => ({ 137 name: e.name, 138 isDirectory: e.isDirectory(), 139 })) 140 }) 141 142 // ── App Settings ─────────────────────────────────────────────────── 143 144 ipcMain.handle('settings:get', async () => { 145 try { 146 const raw = await fs.readFile(SETTINGS_PATH, 'utf-8') 147 return JSON.parse(raw) 148 } catch { 149 return getDefaultSettings() 150 } 151 }) 152 153 ipcMain.handle('settings:set', async (_event, partialSettings) => { 154 let current: AppSettings 155 try { 156 const raw = await fs.readFile(SETTINGS_PATH, 'utf-8') 157 current = JSON.parse(raw) 158 } catch { 159 current = getDefaultSettings() 160 } 161 162 const updated = { ...current, ...partialSettings } 163 await fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true }) 164 await fs.writeFile(SETTINGS_PATH, JSON.stringify(updated, null, 2)) 165 166 // Notify manager and scheduler of settings change 167 manager.updateSettings(updated) 168 onSettingsChanged(updated) 169 }) 170} 171 172function getDefaultSettings(): AppSettings { 173 return { 174 claudeEnvVars: '', 175 model: 'claude-sonnet-4-20250514', 176 schedule: { 177 morningEnabled: true, 178 morningTime: '07:00', 179 middayEnabled: true, 180 middayTime: '12:00', 181 eveningEnabled: true, 182 eveningTime: '18:00', 183 weeklyEnabled: true, 184 weeklyDay: 1, 185 weeklyTime: '08:00', 186 }, 187 siteKey: 'consul', 188 } 189}

3.11 desktop/auth-verifier.ts

1// desktop/auth-verifier.ts 2 3import { query } from '@anthropic-ai/claude-code' 4import os from 'os' 5import path from 'path' 6import { assembleEnvironment } from './env-assembly' 7 8/** 9 * Pre-session verification probes. 10 * 11 * Before the first interactive session, Auctor runs two lightweight 12 * "probe" queries to verify: 13 * 1. API credentials are valid 14 * 2. MCP servers are accessible 15 * 3. Workspace exists and is readable 16 * 17 * These are ephemeral processes — spawned, queried for metadata, killed. 18 * No actual prompts are sent to the LLM. 19 */ 20export async function verifyAuth(options: { 21 claudeExecutablePath: string 22 platformEnv: Record<string, string> 23 userClaudeEnvVars?: string 24}): Promise<{ 25 authenticated: boolean 26 email?: string 27 plan?: string 28 mcpServers?: { name: string; status: string }[] 29 error?: string 30}> { 31 const env = assembleEnvironment({ 32 userClaudeEnvVars: options.userClaudeEnvVars, 33 platformEnv: options.platformEnv, 34 }) 35 36 const workspacePath = path.join(os.homedir(), '.auctor', 'workspace') 37 38 try { 39 // Empty generator — no prompts 40 const emptyPrompt = (async function* () { 41 // yield nothing — we only want metadata 42 })() 43 44 const probeResult = query({ 45 prompt: emptyPrompt, 46 options: { 47 maxTurns: 0, 48 model: 'claude-sonnet-4-20250514', 49 cwd: workspacePath, 50 pathToClaudeCodeExecutable: options.claudeExecutablePath, 51 systemPrompt: 'Auth verification probe. No action needed.', 52 env, 53 permissionMode: 'default', 54 }, 55 }) as any // SDK types may not expose all methods 56 57 // Query for account info 58 const accountInfo = await Promise.race([ 59 probeResult.accountInfo(), 60 new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 10_000)), 61 ]) as { email: string; plan: string } 62 63 // Query for MCP server status 64 let mcpServers: { name: string; status: string }[] = [] 65 try { 66 mcpServers = await Promise.race([ 67 probeResult.mcpServerStatus(), 68 new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 10_000)), 69 ]) as any[] 70 } catch { 71 // MCP status is nice-to-have, not critical 72 } 73 74 // Kill the probe process 75 probeResult.interrupt() 76 77 return { 78 authenticated: true, 79 email: accountInfo.email, 80 plan: accountInfo.plan, 81 mcpServers, 82 } 83 } catch (err: unknown) { 84 return { 85 authenticated: false, 86 error: err instanceof Error ? err.message : String(err), 87 } 88 } 89}

3.12 desktop/main.ts

The main process — Electron's entry point. Orchestrates all subsystems in order.

1// desktop/main.ts 2// Electron main process entry point 3 4import { app, BrowserWindow, shell } from 'electron' 5import path from 'path' 6import os from 'os' 7import fs from 'fs' 8import { loadPlatformEnv } from './env-assembly' 9import { ClaudeCodeManager } from './claude-code-manager' 10import { initMcpBridge } from './mcp-bridge' 11import { initScheduler } from './scheduler' 12import { initFileWatcher } from './file-watcher' 13import { SidecarManager } from './sidecar' 14import { registerIpcHandlers } from './ipc-handlers' 15import type { AppSettings } from './types' 16 17// ─── Paths ───────────────────────────────────────────────────────────── 18 19const IS_DEV = process.env.NODE_ENV === 'development' 20 21const WORKSPACE_PATH = path.join(os.homedir(), '.auctor', 'workspace') 22const LOGS_PATH = path.join(os.homedir(), '.auctor', 'logs') 23const STATE_PATH = path.join(os.homedir(), '.auctor', 'state') 24 25// In production, resources are at process.resourcesPath 26// In dev, they're relative to the project root 27const RESOURCES = IS_DEV 28 ? path.resolve(__dirname, '..') 29 : process.resourcesPath! 30 31const FRONTEND_PATH = IS_DEV 32 ? path.resolve(__dirname, '..', 'frontend') 33 : path.join(RESOURCES, 'next-server') 34 35const ENV_PATH = IS_DEV 36 ? path.resolve(__dirname, '..', '.env.local') 37 : path.join(RESOURCES, '.env.local') 38 39// Claude Code executable: use system PATH in dev, bundled in production 40const CLAUDE_EXECUTABLE = IS_DEV 41 ? 'claude' 42 : path.join(RESOURCES, 'bin', 'claude') 43 44const PYTHON_PATH = IS_DEV 45 ? 'python3' 46 : path.join(RESOURCES, 'bin', 'python3') 47 48const FASTAPI_SCRIPT = IS_DEV 49 ? path.resolve(__dirname, '..', 'backend', 'main.py') 50 : path.join(RESOURCES, 'backend', 'main.py') 51 52// ─── Global references ───────────────────────────────────────────────── 53 54let mainWindow: BrowserWindow | null = null 55let claudeManager: ClaudeCodeManager | null = null 56let scheduler: ReturnType<typeof initScheduler> | null = null 57let fileWatcher: { close: () => Promise<void> } | null = null 58let sidecar: SidecarManager | null = null 59let nextServerProcess: ReturnType<typeof import('child_process').spawn> | null = null 60 61// ─── App Lifecycle ───────────────────────────────────────────────────── 62 63app.whenReady().then(async () => { 64 try { 65 await startup() 66 } catch (err) { 67 console.error('[main] Startup failed:', err) 68 app.quit() 69 } 70}) 71 72app.on('window-all-closed', () => { 73 shutdown() 74 app.quit() 75}) 76 77app.on('before-quit', () => { 78 shutdown() 79}) 80 81// ─── Startup Sequence ────────────────────────────────────────────────── 82// Order matters. Each step depends on the previous. 83 84async function startup(): Promise<void> { 85 console.log('[main] Starting Auctor Desktop...') 86 console.log(`[main] Mode: ${IS_DEV ? 'development' : 'production'}`) 87 console.log(`[main] Workspace: ${WORKSPACE_PATH}`) 88 89 // ── Step 0: Ensure directories exist ───────────────────────────── 90 for (const dir of [WORKSPACE_PATH, LOGS_PATH, STATE_PATH]) { 91 if (!fs.existsSync(dir)) { 92 fs.mkdirSync(dir, { recursive: true }) 93 } 94 } 95 96 // ── Step 1: Load environment ────────────────────────────────────── 97 const platformEnv = loadPlatformEnv(ENV_PATH) 98 console.log(`[main] Platform env loaded: ${Object.keys(platformEnv).length} vars`) 99 100 // ── Step 2: Validate MCP server ────────────────────────────────── 101 await initMcpBridge({ 102 envPath: ENV_PATH, 103 frontendPath: FRONTEND_PATH, 104 }) 105 106 // ── Step 3: Start Next.js server ───────────────────────────────── 107 const nextPort = 3000 108 if (IS_DEV) { 109 console.log(`[main] Dev mode: expecting Next.js at http://localhost:${nextPort}`) 110 console.log(`[main] Run 'pnpm dev' in frontend/ if not already running`) 111 } else { 112 const { spawn } = await import('child_process') 113 nextServerProcess = spawn( 114 'node', 115 [path.join(FRONTEND_PATH, 'server.js')], 116 { 117 env: { 118 ...process.env, 119 ...platformEnv, 120 PORT: String(nextPort), 121 HOSTNAME: '127.0.0.1', 122 }, 123 stdio: ['ignore', 'pipe', 'pipe'], 124 }, 125 ) 126 nextServerProcess.stdout?.on('data', (d: Buffer) => 127 console.log(`[next] ${d.toString().trim()}`)) 128 nextServerProcess.stderr?.on('data', (d: Buffer) => 129 console.error(`[next:err] ${d.toString().trim()}`)) 130 131 // Wait for Next.js to be ready 132 await waitForServer(`http://127.0.0.1:${nextPort}`, 30_000) 133 console.log(`[main] Next.js ready at :${nextPort}`) 134 } 135 136 // ── Step 4: Start Python sidecar ───────────────────────────────── 137 sidecar = new SidecarManager({ 138 pythonPath: PYTHON_PATH, 139 scriptPath: FASTAPI_SCRIPT, 140 }) 141 sidecar.start() 142 143 // ── Step 5: Load settings ──────────────────────────────────────── 144 let settings: AppSettings 145 try { 146 const raw = fs.readFileSync(path.join(STATE_PATH, 'settings.json'), 'utf-8') 147 settings = JSON.parse(raw) 148 } catch { 149 settings = getDefaultSettings() 150 } 151 152 // ── Step 6: Initialize Claude Code Manager ─────────────────────── 153 claudeManager = new ClaudeCodeManager( 154 CLAUDE_EXECUTABLE, 155 platformEnv, 156 settings, 157 ) 158 console.log('[main] Claude Code Manager initialized') 159 160 // ── Step 7: Create window ──────────────────────────────────────── 161 mainWindow = new BrowserWindow({ 162 width: 1440, 163 height: 900, 164 minWidth: 1024, 165 minHeight: 600, 166 title: 'Auctor', 167 webPreferences: { 168 preload: path.join(__dirname, 'preload.js'), 169 contextIsolation: true, 170 nodeIntegration: false, 171 sandbox: false, // Required for preload contextBridge 172 }, 173 // macOS 174 titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : undefined, 175 trafficLightPosition: { x: 16, y: 16 }, 176 }) 177 178 // Open external links in system browser 179 mainWindow.webContents.setWindowOpenHandler(({ url }) => { 180 shell.openExternal(url) 181 return { action: 'deny' } 182 }) 183 184 mainWindow.loadURL(`http://127.0.0.1:${nextPort}`) 185 186 if (IS_DEV) { 187 mainWindow.webContents.openDevTools({ mode: 'detach' }) 188 } 189 190 // ── Step 8: Start file watcher ─────────────────────────────────── 191 fileWatcher = initFileWatcher(mainWindow) 192 193 // ── Step 9: Start scheduler ────────────────────────────────────── 194 scheduler = initScheduler(claudeManager, settings) 195 console.log('[main] Scheduler started') 196 197 // ── Step 10: Register IPC handlers ─────────────────────────────── 198 registerIpcHandlers(mainWindow, claudeManager, (newSettings) => { 199 scheduler?.updateSchedule(newSettings) 200 claudeManager?.updateSettings(newSettings) 201 }) 202 203 console.log('[main] Auctor Desktop ready') 204} 205 206// ─── Shutdown Sequence ───────────────────────────────────────────────── 207 208function shutdown(): void { 209 console.log('[main] Shutting down...') 210 211 scheduler?.stop() 212 claudeManager?.stopAll() 213 fileWatcher?.close() 214 sidecar?.stop() 215 216 if (nextServerProcess) { 217 nextServerProcess.kill('SIGTERM') 218 nextServerProcess = null 219 } 220} 221 222// ─── Helpers ─────────────────────────────────────────────────────────── 223 224async function waitForServer(url: string, timeoutMs: number): Promise<void> { 225 const start = Date.now() 226 while (Date.now() - start < timeoutMs) { 227 try { 228 const res = await fetch(url) 229 if (res.ok) return 230 } catch { 231 // Server not ready yet 232 } 233 await new Promise(r => setTimeout(r, 500)) 234 } 235 throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`) 236} 237 238function getDefaultSettings(): AppSettings { 239 return { 240 claudeEnvVars: '', 241 model: 'claude-sonnet-4-20250514', 242 schedule: { 243 morningEnabled: true, 244 morningTime: '07:00', 245 middayEnabled: true, 246 middayTime: '12:00', 247 eveningEnabled: true, 248 eveningTime: '18:00', 249 weeklyEnabled: true, 250 weeklyDay: 1, 251 weeklyTime: '08:00', 252 }, 253 siteKey: 'consul', 254 } 255}

4. Renderer Components (Chat Panel + Dashboard Routes)

These live in the existing Next.js frontend/ codebase, not in desktop/. They render in Electron's BrowserWindow.

4.1 Agent Chat Panel

The chat panel is the primary interaction surface. It renders Claude Code's stream-json events as structured UI, not terminal text.

File: frontend/src/components/agent-chat/agent-chat-panel.tsx

1'use client' 2 3import { useState, useEffect, useRef, useCallback } from 'react' 4import { AgentMessage } from './agent-message' 5import { ToolCallCard } from './tool-call-card' 6import { ApprovalCard } from './approval-card' 7import { ChatInput } from './chat-input' 8 9interface StreamEvent { 10 type: string 11 data: any 12} 13 14interface ChatEntry { 15 id: string 16 type: 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'error' | 'approval' 17 content: string 18 metadata?: Record<string, unknown> 19 timestamp: number 20} 21 22export function AgentChatPanel() { 23 const [entries, setEntries] = useState<ChatEntry[]>([]) 24 const [sessionId, setSessionId] = useState<string | null>(null) 25 const [isProcessing, setIsProcessing] = useState(false) 26 const [streamingText, setStreamingText] = useState('') 27 const scrollRef = useRef<HTMLDivElement>(null) 28 29 // ── Event listener ──────────────────────────────────────────────── 30 useEffect(() => { 31 if (typeof window === 'undefined' || !window.electron) return 32 33 const cleanupEvent = window.electron.on('claude:event', (payload: any) => { 34 const { event } = payload as { sessionId: string; event: StreamEvent } 35 36 switch (event.type) { 37 case 'assistant:text': { 38 // Stream text into the current message bubble 39 setStreamingText(prev => prev + (event.data?.text ?? '')) 40 break 41 } 42 43 case 'assistant:tool_use': { 44 // Flush any accumulated streaming text as a message 45 flushStreamingText() 46 47 setEntries(prev => [...prev, { 48 id: crypto.randomUUID(), 49 type: 'tool-call', 50 content: event.data?.name ?? 'Unknown tool', 51 metadata: { 52 toolName: event.data?.name, 53 input: event.data?.input, 54 status: 'running', 55 }, 56 timestamp: Date.now(), 57 }]) 58 break 59 } 60 61 case 'tool:result': { 62 // Update the most recent tool-call entry with the result 63 setEntries(prev => { 64 const updated = [...prev] 65 const lastToolCall = [...updated].reverse().find(e => e.type === 'tool-call') 66 if (lastToolCall) { 67 lastToolCall.metadata = { 68 ...lastToolCall.metadata, 69 result: event.data?.result, 70 status: 'complete', 71 } 72 } 73 return updated 74 }) 75 break 76 } 77 78 case 'result': { 79 // Turn complete — flush text, mark not processing 80 flushStreamingText() 81 setIsProcessing(false) 82 break 83 } 84 85 case 'system:error': { 86 setEntries(prev => [...prev, { 87 id: crypto.randomUUID(), 88 type: 'error', 89 content: event.data?.message ?? 'Unknown error', 90 timestamp: Date.now(), 91 }]) 92 setIsProcessing(false) 93 break 94 } 95 } 96 }) 97 98 const cleanupApproval = window.electron.on('claude:approval-requested', (payload: any) => { 99 setEntries(prev => [...prev, { 100 id: payload.approvalId, 101 type: 'approval', 102 content: `${payload.toolName} requires approval`, 103 metadata: { 104 approvalId: payload.approvalId, 105 toolName: payload.toolName, 106 toolInput: payload.toolInput, 107 }, 108 timestamp: Date.now(), 109 }]) 110 }) 111 112 const cleanupEnded = window.electron.on('claude:session-ended', () => { 113 flushStreamingText() 114 setIsProcessing(false) 115 setSessionId(null) 116 }) 117 118 return () => { 119 cleanupEvent?.() 120 cleanupApproval?.() 121 cleanupEnded?.() 122 } 123 }, []) 124 125 // ── Auto-scroll ─────────────────────────────────────────────────── 126 useEffect(() => { 127 scrollRef.current?.scrollTo({ 128 top: scrollRef.current.scrollHeight, 129 behavior: 'smooth', 130 }) 131 }, [entries, streamingText]) 132 133 // ── Flush accumulated streaming text into a message entry ───────── 134 const flushStreamingText = useCallback(() => { 135 setStreamingText(prev => { 136 if (prev.trim()) { 137 setEntries(entries => [...entries, { 138 id: crypto.randomUUID(), 139 type: 'assistant', 140 content: prev, 141 timestamp: Date.now(), 142 }]) 143 } 144 return '' 145 }) 146 }, []) 147 148 // ── Send message ────────────────────────────────────────────────── 149 const handleSend = useCallback(async (text: string) => { 150 // Add user message to entries 151 setEntries(prev => [...prev, { 152 id: crypto.randomUUID(), 153 type: 'user', 154 content: text, 155 timestamp: Date.now(), 156 }]) 157 setIsProcessing(true) 158 159 let sid = sessionId 160 if (!sid) { 161 // First message: start a new interactive session 162 sid = await window.electron.invoke('claude:start-session', { 163 type: 'interactive', 164 prompt: text, 165 }) as string 166 setSessionId(sid) 167 } else { 168 // Subsequent messages: push into existing session 169 await window.electron.invoke('claude:send-message', { 170 sessionId: sid, 171 message: text, 172 }) 173 } 174 }, [sessionId]) 175 176 // ── Approval handler ────────────────────────────────────────────── 177 const handleApproval = useCallback(async (approvalId: string, approved: boolean) => { 178 await window.electron.invoke('claude:approve-tool', { 179 approvalId, 180 approved, 181 reason: approved ? undefined : 'Rejected by operator', 182 }) 183 184 // Update the approval card to show the decision 185 setEntries(prev => prev.map(e => 186 e.id === approvalId 187 ? { ...e, metadata: { ...e.metadata, decided: true, approved } } 188 : e 189 )) 190 }, []) 191 192 // ── Render ──────────────────────────────────────────────────────── 193 return ( 194 <div className="flex flex-col h-full bg-background"> 195 {/* Scrollable message area */} 196 <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3"> 197 {entries.map(entry => { 198 switch (entry.type) { 199 case 'user': 200 return <AgentMessage key={entry.id} role="user" content={entry.content} /> 201 case 'assistant': 202 return <AgentMessage key={entry.id} role="assistant" content={entry.content} /> 203 case 'tool-call': 204 return <ToolCallCard key={entry.id} entry={entry} /> 205 case 'approval': 206 return ( 207 <ApprovalCard 208 key={entry.id} 209 entry={entry} 210 onApprove={() => handleApproval(entry.id, true)} 211 onReject={() => handleApproval(entry.id, false)} 212 /> 213 ) 214 case 'error': 215 return ( 216 <div key={entry.id} className="bg-destructive/10 text-destructive text-sm p-3 rounded-lg"> 217 {entry.content} 218 </div> 219 ) 220 } 221 })} 222 223 {/* Currently streaming text */} 224 {streamingText && ( 225 <AgentMessage role="assistant" content={streamingText} isStreaming /> 226 )} 227 228 {/* Processing indicator */} 229 {isProcessing && !streamingText && ( 230 <div className="flex items-center gap-2 text-muted-foreground text-sm"> 231 <span className="animate-pulse"></span> Agent is thinking... 232 </div> 233 )} 234 </div> 235 236 {/* Input area */} 237 <ChatInput onSend={handleSend} disabled={isProcessing} /> 238 </div> 239 ) 240}

4.2 Supporting Components (Stubs)

These are referenced by the chat panel. Implement during build:

agent-message.tsx — Renders a message bubble. User messages right-aligned, assistant messages left-aligned. Assistant content rendered through a markdown renderer (use react-markdown + rehype-highlight for code blocks).

tool-call-card.tsx — Collapsible card showing tool name, input arguments (JSON, syntax-highlighted), and result (when available). Shows a loading spinner while status === 'running'. Color-coded by tool category: blue for data queries, yellow for mutations, red for publish operations.

approval-card.tsx — Full-width card with the tool name, input summary, and two buttons: Approve (green) and Reject (red). Disabled after decision. Shows the decision result inline.

chat-input.tsx — Text input with send button. Supports Shift+Enter for newlines, Enter to send. Disabled prop greys out during processing.


5. Dashboard Route Additions

Three new routes for Claude Code observability. These are Next.js pages in the existing frontend.

/sessions — Session Timeline

Reads ~/.auctor/logs/ directory. Each .json file is a session log (array of stream-json events). Renders a timeline with session cards showing: timestamp, type (morning/midday/evening/weekly/interactive), duration, tool call count, key observations.

Data source: File system via workspace:list-dir + workspace:read-file IPC calls.

/workspace — Workspace File Browser

Tree view of ~/.auctor/workspace/. Clicking a file shows its content (markdown rendered, JSON syntax-highlighted). Real-time updates via workspace:file-changed events — when Claude Code writes to a file, the browser highlights the change.

Data source: workspace:list-dir for tree, workspace:read-file for content.

/memory — Memory Timeline

Parses memory/observations.md, memory/decisions.md, memory/learnings.md, and memory/mistakes.md into a unified timeline. Each entry shows the date, source file, and content. The timeline is reverse-chronological.

This is the operator's primary window into the agent's thinking. They can see what the agent noticed today, what decisions it made and why, and what strategic insights it has accumulated.

Data source: workspace:read-file for each memory file, parsed by date headers.


6. Build and Packaging

Development Workflow

1# Terminal 1: Next.js dashboard (hot reload) 2cd frontend && pnpm dev 3 4# Terminal 2: Python sidecar 5cd backend && python main.py 6 7# Terminal 3: Electron app (watches for TS changes) 8cd desktop && pnpm dev:watch

The Electron app in dev mode expects Next.js at :3000 and FastAPI at :8000. It does NOT start them — that's the operator's job in dev mode. In production, main.ts starts both.

Production Build

1# 1. Build Next.js standalone output 2cd frontend && pnpm build 3# This produces .next/standalone/ — a self-contained Node.js server 4 5# 2. Build desktop package 6cd desktop && pnpm build 7# This: 8# a. Compiles TypeScript to dist/ 9# b. Copies .next/standalone to extraResources/next-server 10# c. Packages with electron-builder into release/

Bundling Claude Code

The claude binary must be available at runtime. Two approaches:

Approach A: Require system installation (Phase 4)

Auctor checks PATH for claude on startup. If not found, shows a setup screen instructing the operator to install Claude Code globally (npm i -g @anthropic-ai/claude-code). This is the simplest approach and avoids bundling license-sensitive binaries.

Approach B: Bundle as extraResource (Phase 5)

Extract the claude binary from node_modules/@anthropic-ai/claude-code/ during build and include it in extraResources/bin/claude. Update CLAUDE_EXECUTABLE path accordingly. This makes the app fully self-contained.

Recommendation: Start with Approach A. The operator running Auctor on a dedicated machine can easily install Claude Code globally. Move to Approach B when preparing for distribution.

macOS Entitlements

1<!-- desktop/build/entitlements.mac.plist --> 2<?xml version="1.0" encoding="UTF-8"?> 3<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 4<plist version="1.0"> 5<dict> 6 <!-- Required for network access (Supabase, DataForSEO, GSC, etc.) --> 7 <key>com.apple.security.network.client</key> 8 <true/> 9 <!-- Required for spawning child processes (Claude Code, Python) --> 10 <key>com.apple.security.cs.allow-unsigned-executable-memory</key> 11 <true/> 12 <!-- Required for hardened runtime + spawning binaries --> 13 <key>com.apple.security.cs.disable-library-validation</key> 14 <true/> 15</dict> 16</plist>

7. Verification Checklist

Run these checks after Phase 4 build is complete. Each maps to a spec requirement.

Core Functionality

#TestExpectedVerified
1pnpm dev:watch in desktop/Electron window opens, loads Next.js dashboard
2Type a message in chat panelClaude Code spawns (check Activity Monitor/Task Manager for claude process), streams response
3Send second message in same sessionNo new process spawned. Same session continues. Check session-event events in DevTools console.
4Claude Code calls mcp__auctor__read-domain-summaryTool call card appears in chat UI with tool name, arguments, and result
5Claude Code attempts mcp__auctor__upload-to-cmsApproval card appears. Clicking Reject blocks the publish. Clicking Approve allows it.
6Close and reopen Electron appPrevious session ID is recoverable. --resume works.
7Idle for 30+ minutesSession auto-terminates (check session-ended event).

Scheduled Sessions

#TestExpectedVerified
8Wait for morning schedule (or manually trigger)New claude process spawns with scheduled prompt.
9Check ~/.auctor/logs/ after scheduled sessionJSON log file exists with session events.
10Check memory/observations.md after morning sessionNew dated entry appended by agent.
11Check git log in workspaceAuto-commit with session: prefix message.

File Watcher

#TestExpectedVerified
12Manually edit memory/observations.mdworkspace:file-changed event fires in renderer.
13Navigate to /workspace routeFile tree renders. Clicking a file shows its content.
14Agent writes to workspace during sessionDashboard updates within 1–2 seconds.

Security

#TestExpectedVerified
15Agent attempts Write(brand/voice.md)Blocked by .claude/settings.json deny rule.
16Agent attempts to write outside workspacecanUseTool returns { allowed: false }. Agent sees error.
17Check ~/.auctor/logs/tool-audit.logEvery tool call logged with timestamp and tool name.

8. Known Risks and Mitigations

Risk: SDK API surface changes

The @anthropic-ai/claude-code SDK is pre-1.0. The query() function signature, event types, and metadata methods (accountInfo, mcpServerStatus) may change between releases.

Mitigation: Pin the SDK version in package.json (exact version, not range). Test against each new release before upgrading. The types.ts file acts as an adapter layer — if SDK types change, update the adapter, not every consumer.

Risk: MCP server process lifecycle

Claude Code spawns the MCP server as a child process. If the MCP server crashes mid-session, Claude Code's tool calls fail silently or with opaque errors.

Mitigation: Phase 5 moves the MCP server in-process (shared runtime). For Phase 4, add health-check logic: if mcp__auctor__* tools fail 3 times consecutively, the chat panel shows a "MCP server may be down" warning and offers a restart button.

Risk: Git conflicts from concurrent sessions

If a scheduled session and an interactive session both write to memory/observations.md simultaneously, git may encounter merge conflicts on the next commit.

Mitigation: The scheduler should check for active interactive sessions before spawning. If an interactive session is active, defer the scheduled session by 15 minutes. Add this check to scheduler.ts:

1// Before spawning scheduled session 2if (manager.getSessions().some(s => s.type === 'interactive' && s.isProcessing)) { 3 console.log(`[scheduler] Deferring ${logSuffix} — interactive session active`) 4 setTimeout(() => runScheduledSession(manager, prompt, logSuffix), 15 * 60 * 1000) 5 return 6}

Risk: Electron memory pressure on dedicated machine

Electron + Next.js + Claude Code + Python sidecar can consume 400–800 MB combined during active sessions. On a dedicated machine with 8 GB RAM, this leaves headroom. On a shared machine, it may compete with other processes.

Mitigation: Document minimum system requirements: 8 GB RAM, 4 cores, 20 GB disk. The 30-minute idle timeout and max-5-sessions limit prevent unbounded memory growth.


9. What's Deferred to Phase 5

These are documented but intentionally not built in Phase 4:

  1. In-process MCP server — Currently runs as a stdio child of Claude Code. Phase 5 moves it in-process for shared DB pool and lower latency.
  2. Auto-updaterelectron-updater dependency is included but not wired. Phase 5 adds GitHub Releases or custom update server.
  3. Bundled Claude Code — Phase 4 requires system claude installation. Phase 5 bundles it.
  4. Session resume across app restarts--session-id and --resume are supported by the SDK but the UI for "resume previous session?" is Phase 5.
  5. Event-triggered sessions — Webhook receiver for competitor alerts, analytics anomalies. Phase 5.
  6. Multi-site support — Current spec hardcodes siteKey: 'consul'. Phase 5 adds site switching.

Appendix A: Workspace Initialization Script

Run this once to set up a fresh workspace for a new Auctor installation:

1#!/bin/bash 2# scripts/init-workspace.sh 3 4WORKSPACE="$HOME/.auctor/workspace" 5 6echo "Initializing Auctor workspace at $WORKSPACE" 7 8# Create directory structure 9mkdir -p "$WORKSPACE"/{brand,content/{published,drafts,ideas},intelligence/{analytics,search,competitors,market},memory,calendar,operations/{sync,generate},config,.claude} 10 11# Create log and state directories 12mkdir -p "$HOME/.auctor"/{logs,state} 13 14# Initialize git repo 15cd "$WORKSPACE" 16git init 17echo "logs/" > .gitignore 18echo "*.tmp" >> .gitignore 19 20# Copy template files (from the Auctor repo) 21# These would be populated from the project's templates/ directory 22touch brand/voice.md brand/audience.md brand/style-guide.md 23touch memory/observations.md memory/decisions.md memory/learnings.md memory/mistakes.md memory/current-focus.md 24touch calendar/editorial-calendar.md calendar/deadlines.json 25touch content/drafts/_queue.json content/ideas/backlog.md 26touch config/endpoints.json config/thresholds.json 27 28# Create .claude/settings.json with permission rules 29cat > .claude/settings.json << 'EOF' 30{ 31 "permissions": { 32 "allow": [ 33 "Bash(operations/*)", 34 "Write(content/*)", 35 "Write(memory/*)", 36 "Edit(content/*)", 37 "Edit(memory/*)", 38 "Write(calendar/*)", 39 "Edit(calendar/*)" 40 ], 41 "deny": [ 42 "Write(operations/*)", 43 "Write(config/*)", 44 "Write(brand/*)", 45 "Write(CLAUDE.md)" 46 ] 47 } 48} 49EOF 50 51# Initial commit 52git add -A 53git commit -m "init: workspace scaffolding" 54 55echo "Workspace initialized. Copy CLAUDE.md from the Auctor repo to $WORKSPACE/CLAUDE.md"

Appendix B: Dependency Matrix

PackageVersionUsed InPurpose
electron^40.0.0desktop/Desktop shell, BrowserWindow, IPC
electron-builder^25.0.0desktop/ buildPackaging (.dmg, .exe, .AppImage)
@anthropic-ai/claude-code^1.0.0desktop/ runtimeSDK query() for embedding Claude Code
node-cron^3.0.3desktop/ runtimeScheduled session triggers
chokidar^4.0.0desktop/ runtimeWorkspace file watching
dotenv^16.4.0desktop/ runtime.env.local loading
electron-updater^6.3.0desktop/ runtimeAuto-update (Phase 5)
concurrently^9.0.0desktop/ devRun tsc + electron in parallel
nodemon^3.0.0desktop/ devRestart electron on file changes
typescript^5.7.0desktop/ devType checking

End of Addendum #5. This document provides the complete implementation specification for Auctor's Phase 4 desktop shell. All code in this document is implementation-ready — copy into the project and adjust paths as needed for your repository structure.

Auctor — Technical Addendum #5 | MDX Limo