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. EverycanUseToolcallback (which must resolve synchronously from Claude Code's perspective) would cross a process boundary. TherequestOperatorApproval()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
claudebinary directly withCommand::new(). This loses the async generator pattern, thecanUseToolcallback, thehooksintegration,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 scriptProcess 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:
extraResourcesbundles the Next.js standalone output. At runtime, main.ts spawnsnode next-server/server.jsrather than runningnext start(which requires the fullnode_modulestree). The standalone output is self-contained.electron-updateris included for auto-update support (not implemented in Phase 4, but the dependency is present for Phase 5).@anthropic-ai/claude-codeis 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 const3.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:
-
The
Object.definePropertytrick forresolveNextMessageis necessary because the async generator closure capturesresolveNextMessageby reference in its own scope. The getter/setter on the session object bridges the gap — whensendMessage()readssession.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. -
The
canUseToolcallback 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. -
Hook commands use
|| trueat 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. -
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 thedist/directory. Thecwdmust resolve to thefrontend/directory where the MCP server code lives. If the frontend is bundled asextraResources, adjust the path accordingly. During development,__dirnameresolves correctly becausetscoutputs todesktop/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:watchThe 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
| # | Test | Expected | Verified |
|---|---|---|---|
| 1 | pnpm dev:watch in desktop/ | Electron window opens, loads Next.js dashboard | ☐ |
| 2 | Type a message in chat panel | Claude Code spawns (check Activity Monitor/Task Manager for claude process), streams response | ☐ |
| 3 | Send second message in same session | No new process spawned. Same session continues. Check session-event events in DevTools console. | ☐ |
| 4 | Claude Code calls mcp__auctor__read-domain-summary | Tool call card appears in chat UI with tool name, arguments, and result | ☐ |
| 5 | Claude Code attempts mcp__auctor__upload-to-cms | Approval card appears. Clicking Reject blocks the publish. Clicking Approve allows it. | ☐ |
| 6 | Close and reopen Electron app | Previous session ID is recoverable. --resume works. | ☐ |
| 7 | Idle for 30+ minutes | Session auto-terminates (check session-ended event). | ☐ |
Scheduled Sessions
| # | Test | Expected | Verified |
|---|---|---|---|
| 8 | Wait for morning schedule (or manually trigger) | New claude process spawns with scheduled prompt. | ☐ |
| 9 | Check ~/.auctor/logs/ after scheduled session | JSON log file exists with session events. | ☐ |
| 10 | Check memory/observations.md after morning session | New dated entry appended by agent. | ☐ |
| 11 | Check git log in workspace | Auto-commit with session: prefix message. | ☐ |
File Watcher
| # | Test | Expected | Verified |
|---|---|---|---|
| 12 | Manually edit memory/observations.md | workspace:file-changed event fires in renderer. | ☐ |
| 13 | Navigate to /workspace route | File tree renders. Clicking a file shows its content. | ☐ |
| 14 | Agent writes to workspace during session | Dashboard updates within 1–2 seconds. | ☐ |
Security
| # | Test | Expected | Verified |
|---|---|---|---|
| 15 | Agent attempts Write(brand/voice.md) | Blocked by .claude/settings.json deny rule. | ☐ |
| 16 | Agent attempts to write outside workspace | canUseTool returns { allowed: false }. Agent sees error. | ☐ |
| 17 | Check ~/.auctor/logs/tool-audit.log | Every 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:
- 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.
- Auto-updater —
electron-updaterdependency is included but not wired. Phase 5 adds GitHub Releases or custom update server. - Bundled Claude Code — Phase 4 requires system
claudeinstallation. Phase 5 bundles it. - Session resume across app restarts —
--session-idand--resumeare supported by the SDK but the UI for "resume previous session?" is Phase 5. - Event-triggered sessions — Webhook receiver for competitor alerts, analytics anomalies. Phase 5.
- 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
| Package | Version | Used In | Purpose |
|---|---|---|---|
electron | ^40.0.0 | desktop/ | Desktop shell, BrowserWindow, IPC |
electron-builder | ^25.0.0 | desktop/ build | Packaging (.dmg, .exe, .AppImage) |
@anthropic-ai/claude-code | ^1.0.0 | desktop/ runtime | SDK query() for embedding Claude Code |
node-cron | ^3.0.3 | desktop/ runtime | Scheduled session triggers |
chokidar | ^4.0.0 | desktop/ runtime | Workspace file watching |
dotenv | ^16.4.0 | desktop/ runtime | .env.local loading |
electron-updater | ^6.3.0 | desktop/ runtime | Auto-update (Phase 5) |
concurrently | ^9.0.0 | desktop/ dev | Run tsc + electron in parallel |
nodemon | ^3.0.0 | desktop/ dev | Restart electron on file changes |
typescript | ^5.7.0 | desktop/ dev | Type 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.