fix(runtime): autonomous beforeToolCall + project agent-event to Console log

Two coupled user-visible defects were fixed.

1. Every tool call (shell / edit / write / read / ...) triggered a
   synchronous human-approval prompt under BOTH execution modes. The
   worker blocked inside pi's beforeToolCall waiting on a DB approval
   row, so runs never progressed — "等待日志输出" forever.

   The execution-mode contract is now enforced:
   - AI CEO       → fully autonomous, no framework gate.
   - AI Co-Founder → still fully autonomous at the pi layer. Human
     review is a model-driven decision (LLM calls an explicit
     `request_approval` tool when it judges review necessary), not
     a framework-level interrupt.

   HumanApprovalHook collapses to a no-op `before` / `after` /
   `cancelAll`. All machinery that queued `dangerous_command`
   approvals from the worker-side hook is removed. No more starving
   the agent on `node -e` self-call checks.

2. The Console panel only subscribed to `run-log` stdout/stderr
   chunks, so pi's agent-event stream (assistant_message_delta,
   tool_call_start/end, errors, turn_end) produced zero visible
   output. With bug (1) fixed, runs now execute — but the user
   would still see a blank Console.

   runtime-bridge.handleAgentEvent now projects each meaningful
   agent-event into a run-log chunk (assistant_message_delta → raw
   delta, tool_call_start → `[tool:bash] {args}`, tool_call_end →
   `[tool:done|error:name] {result}`, error → stderr). ConsoleView
   picks these up via the existing run-log DesktopEvent pipe with
   no renderer change.

tests/unit/approval-hook.test.ts rewritten to assert the new
autonomous contract (before always returns {}, no approval rows
written, cancelAll is a no-op). Tests 508/508 pass.

typecheck 0 / lint 0-0 / build:verify 0-0.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 10:23:50 -04:00
1 parent 9b45b12508
commit 683dded501
4 files changed
+131 -577

No files matched your search

+27 -451
View File
@@ -1,462 +1,38 @@
# AgentCompany — CLAUDE.md
4. <!-- talk-normal 0.6.2 -->
## Project
Be direct and informative. No filler, no fluff, but give enough to be useful.
AgentCompany is a cross-platform Electron desktop app that lets users create autonomous AI companies. A user provides a company name, description, goal, and deliverable. A CEO agent self-organizes other AI agents, delegates tasks, and the company runs end-to-end — producing code, content, research, and social media — while the human observes through a Slack-style hub.
Your single hardest constraint: prefer direct positive claims. Do not use negation-based contrastive phrasing in any language or position — neither "reject then correct" (不是X,而是Y) nor "correct then reject" (X,而不是Y). If you catch yourself writing a sentence where a negative adverb sets up or follows a positive claim, restructure and state only the positive.
**Core Value:** Zero-setup autonomous AI company — user enters a goal, agents self-organize and deliver.
Examples:
BAD: 真正的创新者不是"有创意的人",而是五种特质同时拉满的人
GOOD: 真正的创新者是五种特质同时拉满的人
### Constraints
BAD: 真正的创新者是五种特质同时拉满的人,而不是单纯"聪明"的人
GOOD: 真正的创新者是五种特质同时拉满的人
- **Runtime**: Electron with strict main/preload/renderer separation, contextIsolation enabled
- **Database**: SQLite (`node:sqlite` built-in), local-first, no external DB
- **Connectors**: Claude Code, Codex, Gemini, Kimi CLI, OpenCode as first-class runtimes
- **Platforms**: macOS, Windows, Linux
- **Security**: No nodeIntegration in renderer, secrets via OS keychain, IPC validation on all channels
- **Architecture**: Service graph pattern in main process, typed IPC contracts, worker threads for execution
BAD: 这更像创始人筛选框架,不是交易信号
GOOD: 这是一个创始人筛选框架
## Electron Security Rules
BAD: It's not about intelligence, it's about taste
GOOD: Taste is what matters
- `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` where feasible
- Minimal preload bridge — renderer must never access Node.js APIs directly
- All IPC channels: typed, explicitly named, Zod-validated on the privileged side
- No hardcoded secrets, no arbitrary filesystem/shell/process access from renderer
- Agent tokens: signed JWT with agentId/companyId/runId, validated before any privileged action
Rules:
## Architecture
Three-tier process boundary:
- **Main** (`src/main/`): Privileged Node.js — database, API server, services, IPC handlers, orchestration, OS integration
- **Preload** (`src/preload/`): Context-isolated bridge — `DesktopApi` via `contextBridge`, 75+ typed IPC channels
- **Renderer** (`src/renderer/`): React 19 UI — components, hooks, i18n, formatters; calls only approved preload APIs
Key services in main: `AppDatabase`, `ServiceGraph`, `OrchestrationService`, `AutomationService`, `WorkerService`, `ApiServer`, `BrowserActionService`
Data flow: SQLite → IPC `domain-changed` events → renderer reloads snapshot. No client-side state library; React hooks + context only.
Result pattern: `DesktopResult<T>` = `{ ok: true, data: T } | { ok: false, error: { code, message } }`. All IPC handlers return this.
## Technology Stack
- **Electron 39.8.0** / **Node.js 22+** / **TypeScript 5.9.3** / **pnpm 10.24.0**
- **Build**: electron-vite 5.0.0, Vite 7.0.0, electron-builder 26.8.1
- **UI**: React 19.2.4, TailwindCSS 4.2.1, Framer Motion 12.36.0, Phosphor Icons 2.1.10
- **Validation**: Zod 4.3.6 — schemas for all IPC payloads in `src/shared/contracts.ts`
- **Logging**: electron-log 5.4.3, **Updates**: electron-updater 6.8.3
- **Testing**: Vitest 4.1.0 (unit/integration), Playwright 1.58.2 (E2E)
- **Adapters**: `@agentcompany/adapter-{claude,codex,gemini,kimi,opencode}-local` + `adapter-utils`
### Database & Persistence
- SQLite at `{profileDir}/profile.sqlite` — WAL mode, foreign keys, 25+ tables
- Secrets: `SecretVault` with `{profileDir}/vault.key` (600 permissions)
- Logs: `{profileDir}/logs/`, Artifacts: `{profileDir}/artifacts/`, Backups: `{profileDir}/backups/`
- Window state: `{profileDir}/window-state.json`
### Config & Env
- `AGENTCOMPANY_PROFILE_DIR` (default: `{userData}/profile`)
- `AGENTCOMPANY_UPDATE_URL`, `AGENTCOMPANY_ALLOW_INSECURE_VAULT`, `AGENTCOMPANY_DISABLE_UPDATES`, `AGENTCOMPANY_DISABLE_SINGLE_INSTANCE`
- Path aliases: `@shared` → `src/shared/`, `@main` → `src/main/`, `@renderer` → `src/renderer/`, `@preload` → `src/preload/`
### Monorepo
Root app + `packages/adapter-{claude,gemini,codex,kimi,opencode}-local/` + `packages/adapter-utils/`. Defined in `pnpm-workspace.yaml`.
## Conventions
### Naming
- Files: kebab-case (`connector-auth-service.ts`), React components: PascalCase (`CompanyOnboarding.tsx`)
- Functions: camelCase with descriptive verbs (`saveCompany()`, `handleApprovalResolved()`)
- Variables: camelCase, booleans prefixed `is`/`has`, collections plural
- Types: PascalCase, DB entities suffixed `Record` (`CompanyRecord`), schemas suffixed `Schema`
- Constants: UPPERCASE for true constants (`BOARD_READER_ID`), camelCase for derived (`IPC_CHANNELS`)
### Patterns
- ES modules only (`import`, no `require`); `import type` for type-only imports
- Named exports preferred; default exports only for React components
- Error handling: Zod validation at IPC boundary → `fail()` for errors, never throw across process boundary
- React: functional components + hooks, Context for i18n, `useFocusTrap()`/`useReducedMotion()` for a11y
- Database: class-based `AppDatabase`, direct SQL with placeholders, sync operations
- IPC: `ipcRenderer.invoke()` → `DesktopResult<T>`, handler validates with Zod then checks company ownership
- No `console.log()` in production — use `electron-log`
### Code Style
- 2-space indentation, ESLint 9 with `@typescript-eslint` flat config
- TypeScript strict mode, `any` allowed selectively (DB interop)
- Comments only for non-obvious logic; rely on clear naming and types
## Desktop UX Requirements
- Desktop-native UI: resizable windows, min sizes, keyboard-first, hover/focus states, context menus, drag-and-drop, clipboard, native menus
- Platform-aware: modifier keys, window chrome, tray behavior, file paths, notifications per OS
- Accessibility: keyboard nav, focus visibility, color contrast, reduced motion, semantic roles, ARIA labels
- Performance: responsive startup, no renderer blocking, worker threads for heavy work, minimal re-renders
## Build & Release
- Clean old build and update version number before each build; output under `/build`
- Platform targets: macOS (app bundle, signing/notarization), Windows (NSIS/.exe), Linux (AppImage)
- Debug code must not remain in release output
- Auto-update strategy via electron-updater when configured
<!-- GSD:workflow-start source:GSD defaults -->
## GSD Workflow Enforcement
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
Use these entry points:
- `/gsd:quick` for small fixes, doc updates, and ad-hoc tasks
- `/gsd:debug` for investigation and bug fixing
- `/gsd:execute-phase` for planned phase work
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
<!-- GSD:workflow-end -->
<!-- GSD:profile-start -->
## Developer Profile
> Profile not yet configured. Run `/gsd:profile-user` to generate your developer profile.
> This section is managed by `generate-claude-profile` -- do not edit manually.
<!-- GSD:profile-end -->
## Design Context
### Users
Builders and operators who want to spin up autonomous AI companies without managing infrastructure. They arrive with a goal, expect the system to self-organize, and watch their AI workforce execute. They care about output quality and want to feel like they're running something real — not playing with a demo.
### Brand Personality
**Bold, futuristic, ambitious.** AgentCompany is the command layer for the next era of work. The interface should project quiet authority — this is a serious tool that happens to do something extraordinary.
### Emotional Goal
**Professional pride.** The user should feel "I built something real." The interface should reinforce that their AI company is a legitimate operation — not a toy, not a gimmick, but a functioning organization they orchestrated.
### Aesthetic Direction
- **Reference**: Notion — content-first layout, gentle hierarchy, flexible workspace feel
- **Accent**: Steel slate (#4a5d7a light, #7b9cc4 dark) — formal, authoritative, professional
- **Theme**: Full light + dark mode via CSS custom properties
- **Typography**: System sans-serif for UI, JetBrains Mono for code/data
- **Icons**: Phosphor (regular + fill weights), 16-18px standard
- **Motion**: Framer Motion with cubic-bezier(0.16, 1, 0.3, 1), reduced-motion aware
### Anti-References (what this must NOT be)
- **Not toy/demo**: No cartoon illustrations, rainbow gradients, or playful AI mascots
- **Not enterprise bloat**: No overwhelming dashboards, feature overload, or Salesforce-style complexity
- **Not generic SaaS**: No cookie-cutter component libraries, stock photo energy, or bland templates
- **Not sci-fi gimmick**: No glowing neon, Matrix terminals, or over-the-top futuristic theming
### Design Principles
1. **Content over chrome** — Information density is a feature. Every element earns its place. No decorative filler.
2. **Quiet authority** — The interface should feel confident without shouting. Subtle borders, restrained color, generous whitespace.
3. **Real, not theatrical** — Agent activity should feel like watching a real company operate, not a sci-fi simulation. Status over spectacle.
4. **Desktop-native craft** — Keyboard-first, focus rings, platform-aware shortcuts, proper window management. No web-app-in-a-wrapper feel.
5. **Accessible by default** — Reduced motion, color contrast, semantic HTML, focus traps. Not an afterthought.
<!-- GSD:project-start source:PROJECT.md -->
## Project
**AgentCompany — Match Paperclip Quality Milestone**
AgentCompany is a cross-platform Electron desktop app that lets users create autonomous AI companies. A user provides a company name, description, goal, and deliverable. A CEO agent self-organizes other AI agents, delegates tasks, and the company runs end-to-end while the human observes through a Slack-style hub. This milestone closes the quality gap with the Paperclip reference implementation — bringing agent skill injection, task execution integrity, budget enforcement, session management, and UI maturity to production quality.
**Core Value:** Agents produce real, usable output — not garbage. Every run has domain knowledge (skills), validated tasks, quality gates on output, cost controls, and session continuity.
### Constraints
- **Runtime**: Electron with strict main/preload/renderer separation — no architectural changes
- **Database**: SQLite (`node:sqlite`) — implement all features within SQLite's capabilities
- **Adapters**: Preserve existing 5-adapter modular npm package structure — extend, don't replace
- **Backward compatibility**: Existing companies/agents/tasks must continue to work after migration
- **Performance**: Skill injection must not add >2s to run startup; IPC events must be <100ms
<!-- GSD:project-end -->
<!-- GSD:stack-start source:codebase/STACK.md -->
## Technology Stack
## Languages
- TypeScript 5.9.3 - All application code (main, preload, renderer, shared, packages)
- JavaScript - Build scripts and some config files
- Python - Asset generation scripts (`scripts/generate-icons.py`)
## Runtime
- Electron 39.8.0 - Cross-platform desktop framework
- Node.js 22+ - Server runtime (main process)
- pnpm 10.24.0
- Lockfile: `pnpm-lock.yaml` (present)
## Frameworks
- Electron 39.8.0 - Desktop runtime with IPC, main/preload/renderer architecture
- React 19.2.4 - UI framework in renderer process
- TailwindCSS 4.2.1 - Utility-first CSS in renderer
- Zustand 5.0.12 - Lightweight state management (renderer)
- Immer 11.1.4 - Immutable state updates
- Framer Motion 12.36.0 - Animation and motion primitives in React components
- Vite 7.0.0 - Module bundler and dev server
- electron-vite 5.0.0 - Vite config wrapper for Electron main/preload/renderer
- @vitejs/plugin-react 5.0.0 - React JSX support in Vite
- @tailwindcss/vite 4.2.1 - TailwindCSS Vite integration
- Vitest 4.1.0 - Unit/integration test runner (Node environment)
- @playwright/test 1.58.2 - E2E testing framework (`tests/e2e/playwright.config.ts`)
- ESLint 9.38.0 - Code linting with flat config
- @typescript-eslint 8.46.1 - TypeScript linting support
- eslint-plugin-react-hooks 7.0.0 - React hooks rule enforcement
- eslint-plugin-react-refresh 0.4.24 - Fast refresh support
- TypeScript 5.9.3 - Type checking (`pnpm typecheck`)
## Key Dependencies
- zod 4.3.6 - Schema validation at IPC boundaries (main, preload, renderer share contracts)
- electron-log 5.4.3 - Structured logging in main process (no console.log in production)
- electron-updater 6.8.3 - Auto-update via electron-updater (when configured)
- node:sqlite - Built-in SQLite bindings for local database (no external DB driver)
- electron.safeStorage - OS keychain integration for credential encryption
- @phosphor-icons/react 2.1.10 - Icon library (16-18px standard)
- @tanstack/react-virtual 3.13.23 - Virtual scrolling for large lists
- clsx 2.1.1 - Conditional className utility
- tailwind-merge 3.5.0 - TailwindCSS className merging
- @fontsource-variable/inter - System sans-serif for UI
- @fontsource-variable/jetbrains-mono 5.2.8 - Monospace for code/data display
- @fontsource/instrument-serif - Serif font support
- @agentcompany/adapter-claude-local - Claude Code CLI adapter (workspace:*)
- @agentcompany/adapter-codex-local - Codex CLI adapter (workspace:*)
- @agentcompany/adapter-gemini-local - Gemini CLI adapter (workspace:*)
- @agentcompany/adapter-utils - Shared adapter utilities (workspace:*)
## Configuration
- Files: `electron.vite.config.ts`, `tsconfig.json`, `vitest.config.ts`, `tests/e2e/playwright.config.ts`
- Profile directory: `{userData}/profile` (default) or `AGENTCOMPANY_PROFILE_DIR` env var
- Database: `{profileDir}/profile.sqlite` (WAL mode, foreign keys enabled)
- Window state: `{profileDir}/window-state.json`
- Artifacts: `{profileDir}/artifacts/`
- Logs: `{profileDir}/logs/`
- Backups: `{profileDir}/backups/`
- Secrets vault: `{profileDir}/vault.key` (600 permissions)
- `electron.vite.config.ts` - Main/preload/renderer entry points and aliases
- `electron-builder.yml` - Platform-specific build targets (macOS dmg/zip, Windows NSIS/portable, Linux AppImage/deb/rpm)
- `tsconfig.json` - Path aliases: `@shared`, `@main`, `@renderer`, `@preload`
- `eslint.config.mjs` - ESLint 9 flat config
- `pnpm-workspace.yaml` - Monorepo with `packages/*`
## Platform Targets
- Formats: dmg (installer), zip (portable)
- Architectures: x64, arm64
- Signing: `hardenedRuntime: true`, Gatekeeper assessment disabled
- Code sign config via: `CSC_LINK`, `CSC_KEY_PASSWORD` env vars
- Formats: NSIS (installer), portable exe
- Architectures: x64, arm64
- Code sign config via: `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD` env vars
- NSIS: oneClick false, perMachine false, allow custom install dir
- Formats: AppImage, deb, rpm, snap
- Architectures: x64, arm64 (AppImage/deb/snap); x64 (rpm)
- Desktop category: Development
- deb dependencies: libgtk-3-0, libnotify4, libnss3, libxss1, libxtst6, xdg-utils, libatspi2.0-0, libuuid1, libsecret-1-0
- Snap plugs: default, removable-media
## Monorepo Structure
- `packages/adapter-claude-local/` - Claude Code CLI integration
- `packages/adapter-codex-local/` - Codex CLI integration
- `packages/adapter-gemini-local/` - Gemini CLI integration
- `packages/adapter-kimi-local/` - Kimi CLI integration (not bundled in main build)
- `packages/adapter-opencode-local/` - OpenCode CLI integration (not bundled in main build)
- `packages/adapter-utils/` - Shared adapter types and utilities
## Key Scripts
- Main: `out/main/index.js` + `out/main/worker.js` (entry points in electron.vite.config.ts)
- Preload: `out/preload/index.js` (CJS format, chunked)
- Renderer: `out/renderer/` (Vite SPA bundle)
- Desktop installers: `build/release/` (platform-specific per electron-builder.yml)
## Adapter System
- `execute()` - Run code in CLI session
- `sessionCodec` - Serialize/deserialize session state
- `testEnvironment()` - Check CLI availability
- `models` - List available models
<!-- GSD:stack-end -->
<!-- GSD:conventions-start source:CONVENTIONS.md -->
## Conventions
## Naming Patterns
- kebab-case for all source files: `connector-auth-service.ts`, `ipc-core-handlers.ts`, `automation-service.ts`
- React components: PascalCase when exporting components: `RuleEditor.tsx`, `CompanyOnboarding.tsx`
- Test files: kebab-case with `.test.ts` or `.spec.ts` suffix: `database.test.ts`, `autonomous-chain.spec.ts`, `notification-service.test.ts`
- camelCase with descriptive verbs: `saveCompany()`, `handleApprovalResolved()`, `checkoutTask()`, `wakeAgentIfPossible()`
- Event handlers: prefix with `handle` or `on`: `handleSave()`, `handleTaskCreated()`, `onClick()`
- Prefix boolean-returning functions with `is` or `has`: `isAgentPendingApproval()`, `hasCompany()`, `belongsToCompany()`
- camelCase for all variables: `companyId`, `workspaceId`, `agentId`, `tempDir`
- Boolean variables prefixed with `is`, `has`, or `can`: `isEditing`, `hasError`, `canApprove`
- Collections in plural form: `agents`, `tasks`, `workspaces`, `tempDirs`
- PascalCase for all types: `AppDatabase`, `DesktopResult<T>`, `CompanyRecord`, `AgentRecord`
- Database entity types suffixed with `Record`: `CompanyRecord`, `AgentRecord`, `TaskRecord`, `ApprovalRecord`, `RunRecord`
- Zod schemas suffixed with `Schema`: `companyInputSchema`, `connectorInputSchema`, `workspaceInputSchema`, `approvalDecisionSchema`
- Generic types: `DesktopResult<T>` for IPC return types, `RuleFormState` for form state interfaces
- UPPERCASE for true constants defined at module level: `BOARD_READER_ID`, `ACCENT`, `LINE`
- camelCase for derived constants/configs: `IPC_CHANNELS`, `sidebarNavDefs`, `defaultRuleForm`
## Code Style
- 2-space indentation (enforced by ESLint configuration)
- No Prettier config in repository — style guided by ESLint rules
- Line length: no hard limit enforced, but prefer reasonable wrapping
- Semicolons: required (enforced by `@typescript-eslint` defaults)
- ESLint 9 with flat config in `eslint.config.mjs`
- TypeScript ESLint rules enabled in recommended configuration
- `@typescript-eslint/no-explicit-any` set to `off` — `any` is allowed for database interop and bridge code
- `@typescript-eslint/no-unused-vars`: warn with patterns `^_` for unused parameters/variables
- Unused variables can be ignored by prefixing with underscore: `_event`, `_prevStatus`
- React Hooks: `exhaustive-deps` set to warn (not error), allowing intentional deviations
- ESLint covers: `src/**/*.{ts,tsx}`, `tests/**/*.{ts,tsx}`, `scripts/**/*.{ts,mjs}`, `packages/**/*.{ts,tsx}`
- Excluded: `node_modules/`, `out/`, `build/`, `dist/`, `coverage/`, `playwright-report/`, `test-results/`
## Import Organization
- `@shared/*` → `src/shared/` — Zod schemas, types, contract definitions, localization
- `@main/*` → `src/main/` — Database, services, IPC handlers, API server
- `@renderer/*` → `src/renderer/` — React components, hooks, UI utilities
- `@preload/*` → `src/preload/` — Context-isolated bridge APIs
- Use named exports for all utilities, services, handlers, and hooks
- Default exports only for React components (required for route-based splitting patterns)
- Example: `export function saveCompany()` not `export default saveCompany`
- Example: `export default function CompanyOnboarding()` for React components
- Use `import type` exclusively for TypeScript-only imports to enable proper tree-shaking
- Example: `import type { ProfileSnapshot, DesktopEvent } from "@shared/types";`
## Error Handling
- All IPC handlers return `DesktopResult<T>` = `{ ok: true, data: T } | { ok: false, error: { code, message } }`
- Use helper functions `ok(data)` and `fail(code, message)` from `ipc-core-common.ts`
- Example from `ipc-core-admin-handlers.ts`:
- Zod is used for all IPC payloads: `const parsed = companyInputSchema.parse(payload);`
- Zod parsing happens at the IPC boundary (main process side)
- Parse before any business logic, catch validation errors and return `fail()` with descriptive message
- Example: `const parsed = approvalDecisionSchema.parse(payload);`
- All entity operations check company ownership with `ensureCompanyExists()` and `belongsToCompany()`
- Pattern from `ipc-core-operations-handlers.ts`:
- Never throw exceptions across IPC. Always catch and return `fail(code, message)`
- Main ↔ Renderer communication must use `DesktopResult` envelope
- Database errors are caught and wrapped: `catch (error) { return fail("APPROVAL_INVALID", error instanceof Error ? error.message : "...") }`
- Use SCREAMING_SNAKE_CASE for error codes: `INVALID_COMPANY_ID`, `CONNECTOR_COMMAND_LOCKED`, `TASK_NOT_FOUND`, `APPROVAL_NOT_FOUND`
- Error codes are used by renderer to determine UI response (e.g., showing specific error message)
## Logging
- No `console.log()` in production code. Use `electron-log` instead.
- Logger injected as dependency to services: `logger: { info, error }`
- Example from `notification-service.test.ts`:
- Critical events: service initialization, crashes, recoveries
- Errors: validation failures, database errors, connectivity issues
- Debug: trace flow through complex orchestration (in dev mode)
## Comments
- Only for non-obvious logic or business rules
- Prefer clear naming and types over comments
- Example: comments explaining why a decision was made, not what the code does
- Minimal use — TypeScript types are the primary documentation
- Use JSDoc for public APIs and exported functions if return type is complex
- Example for complex types:
## Function Design
- Use object parameters (destructuring) for >3 parameters
- Example: `saveAgent({ companyId, name, role, ... })`
- Avoid parameter lists with many flags
- Explicit return types for all exported functions
- Return early for error conditions: `if (error) return fail("CODE", "message");`
- Consistent use of `DesktopResult<T>` for IPC handlers
- Use `async/await`, not promises chains
- Always handle errors in handlers: `try/catch` around database calls
- Example pattern:
## Module Design
- Named exports for utilities, services, handlers
- Default exports ONLY for React components
- Example good pattern: `export function registerCoreAdminHandlers({ ... })`
- Used in `src/renderer/components/` for organizing imports
- Example: `export { AgentCreator, AgentEditor, AgentRoster } from "./..."`
- Database and major services are class-based (e.g., `AppDatabase`, `ServiceGraph`)
- Other services are functional: `createNotificationService()`, `createEventBatcher()`
- Classes own initialization (constructor) and lifecycle (init/close methods)
## React-Specific Patterns
- All React components are functional with hooks
- No class components
- Example: `export function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }: Props)`
- `useCallback()` for stable function references passed to event handlers
- `useEffect()` for side effects with proper dependency arrays
- `useMemo()` for expensive calculations and string translations
- Example from `RuleEditor.tsx`:
- Zustand store (`useAppStore`) for global UI state (sidebar collapsed, selected entities, locale)
- Local `useState()` for component-specific form state
- No Redux, Context API used only for i18n provider
- Explicit interface or inline type for all props
- Example: `function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }: { open: boolean; editingRule: AutomationRuleRecord | null; ... })`
<!-- GSD:conventions-end -->
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
## Architecture
## Pattern Overview
- Process boundary enforcement: main (Node.js) → preload (context-isolated) ← renderer (React)
- Service graph: loosely-coupled async services coordinated by bootstrap
- Data flow: SQLite → IPC domain-changed events → Zustand store → React UI
- Agent execution: orchestration service queues runs to worker process (child utility process)
- Automation: event-driven triggers (task created, status changed, approval resolved) dispatch workflows
- IPC pattern: `ipcRenderer.invoke()` → Zod validation → DesktopResult<T> pattern
## Layers
- Purpose: Database operations, service coordination, IPC handler registration, external API communication, worker management
- Location: `src/main/`
- Contains: Database class, service factories, IPC handlers, agent orchestration logic, API server, automation rules
- Depends on: Electron main process APIs, SQLite, node:fs, child processes via worker service
- Used by: Preload process (via ipcMain handlers), worker process (via message passing)
- Purpose: Context-isolated bridge exposing minimal DesktopApi to renderer via contextBridge
- Location: `src/preload/index.ts`
- Contains: ~75 typed IPC channel invocations, subscription to desktop events
- Depends on: Electron contextBridge, ipcRenderer, shared contracts
- Used by: Renderer process (via window.agentCompany global)
- Purpose: Display company state, handle user interactions, visualize agent execution
- Location: `src/renderer/`
- Contains: React components, Zustand store (no Redux/Redux-like), hooks, i18n context, UI utilities
- Depends on: React 19, window.agentCompany preload API, Zustand, Framer Motion
- Used by: User directly through Electron window
- Purpose: Execute agent commands in isolated child process (Electron utilityProcess)
- Location: Built from agent adapters in `packages/adapter-*/`
- Contains: Connector CLI execution, run log streaming, status reporting
- Depends on: `EnqueueRunPayload` message contract, agent connector CLIs (Claude, Codex, Gemini, Kimi, OpenCode)
- Used by: Worker service via message passing
## Data Flow
- Zustand store in `src/renderer/state/store.ts` holds:
- No Redux, no global context beyond locale/translation
- Derived state computed via hooks (e.g., `selectedAgent = agents.find(a => a.id === selectedAgentId)`)
- Snapshot updates trigger full re-computation (no partial updates)
## Key Abstractions
- Purpose: All SQLite operations, snapshot generation, business logic for data consistency
- Examples: `src/main/database.ts`
- Pattern: Class with ~100 public methods, synchronous SQLite.DatabaseSync, no ORM
- Responsibilities: CRUD for all entities, filtering/searching, metrics calculation, backup/restore, secret vault integration
- Purpose: Dependency injection and lifecycle management for 9 coordinated services
- Examples: `src/main/service-graph.ts`
- Pattern: Factory function returning object with service getters, lazy initialization via `init()`
- Services: DesktopShell, ConnectorHealth, Worker, Orchestration, Automation, Onboarding, BrowserAction, Updater, AppLifecycle
- Purpose: Agent task execution, workspace resolution, budget tracking, run queuing
- Examples: `src/main/orchestration-service.ts`
- Pattern: Factory with internal state (heartbeat timer, run cache), public methods for task/agent operations
- Coordinates with: Worker service (enqueue), Automation service (dispatch on status change), Database (persist)
- Purpose: Event-driven trigger evaluation and workflow execution
- Examples: `src/main/automation-service.ts`
- Pattern: Factory managing active execution state, async/await for workflow steps
- Triggers: task.created, task.status_changed, approval.resolved, comment.posted, document.created, run.completed
- Actions: assign_task, notify_agent, send_message, create_approval, trigger_heartbeat, escalate, cross_department_notify, auto_approve
- Purpose: Electron window lifecycle, platform integration, keyboard shortcuts, native menus
- Examples: `src/main/desktop-shell-service.ts`
- Pattern: Factory managing BrowserWindow instance, event listeners for menu/shortcut actions
- Responsibilities: window creation, state persistence, platform-specific behaviors
- Purpose: Child process lifecycle, run execution, log streaming
- Examples: `src/main/worker-service.ts`
- Pattern: Factory managing Electron utilityProcess, retry logic, consecutive wake detection
- Message types: enqueue-run (inbound), run-log/run-status/run-finished (outbound)
- Purpose: Handler registration, payload validation, sender URL verification, error sanitization
- Examples: `src/main/ipc-support.ts`
- Pattern: Factory with `registerHandle()` method for each channel, returns `DesktopResult<T>`
- Security: Verifies senderFrame.url matches trusted renderer origin (dev or app://-) before processing
## Entry Points
- Location: Electron main configuration in `electron.vite.config.ts` or `build/` entry
- Triggers: App launch via `electron .` or packaged app
- Responsibilities: Create main runtime, await bootstrap, setup single-instance lock, handle app lifecycle events
- Location: `src/renderer/main.tsx` (React root)
- Triggers: Main process creates BrowserWindow pointing to renderer dist
- Responsibilities: Mount React App component, initialize Zustand store, hydrate snapshot on first load
- Location: `src/main/ipc-core-handlers.ts` dispatches to specialized handler modules
- Handlers split across: `ipc-core-read-handlers.ts`, `ipc-core-operations-handlers.ts`, `ipc-core-work-graph-handlers.ts`, `ipc-agent-coordination-handlers.ts`, etc.
- Each handler: validates input with Zod, calls DB or service method, returns DesktopResult, publishes domain-changed on write
## Error Handling
- IPC validation: Zod schema parsing at handler entry → `sanitizeHandlerError()` converts ZodError to `IPC_INVALID_PAYLOAD`
- DB errors: Throws logged to electron-log, caught by IPC handler, returned as DesktopResult error
- Worker errors: Run status set to "failed" with errorMessage, logged to run log file
- Automation errors: Try-catch blocks log errors but continue processing other rules
- Async cleanup: `appLifecycleService` waits 3 seconds for worker child process to exit before app.exit(0)
## Cross-Cutting Concerns
<!-- GSD:architecture-end -->
- Lead with the answer, then add context only if it genuinely helps
- Do not use negation-based contrastive phrasing in any position. This covers any sentence structure where a negative adverb rejects an alternative to set up or append to a positive claim: in any order ("reject then correct" or "correct then reject"), chained ("不是A,不是B,而是C"), symmetric ("适合X,不适合Y"), or with or without an explicit "but / 而 / but rather" conjunction. Just state the positive claim directly. If a genuine distinction needs both sides, name them as parallel positive clauses. Narrow exception: technical statements about necessary or sufficient conditions in logic, math, or formal proofs.
- End with a concrete recommendation or next step when relevant. Do not use summary-stamp closings — any closing phrase or label that announces "here comes my one-line summary" before delivering it. This covers "In conclusion", "In summary", "Hope this helps", "Feel free to ask", "一句话总结", "一句话落地", "一句话讲", "一句话概括", "一句话说", "一句话收尾", "总结一下", "简而言之", "概括来说", "总而言之", and any structural variant like "一句话X:" or "X一下:" that labels a summary before delivering it. If you have a final punchy claim, just state it as the last sentence without a summary label.
- Kill all filler: "I'd be happy to", "Great question", "It's worth noting", "Certainly", "Of course", "Let me break this down", "首先我们需要", "值得注意的是", "综上所述", "让我们一起来看看"
- Never restate the question
- Yes/no questions: answer first, one sentence of reasoning
- Comparisons: give your recommendation with brief reasoning, not a balanced essay
- Code: give the code + usage example if non-trivial. No "Certainly! Here is..."
- Explanations: 3-5 sentences max for conceptual questions. Cover the essence, not every subtopic. If the user wants more, they will ask.
- Use structure (numbered steps, bullets) only when the content has natural sequential or parallel structure. Do not use bullets as decoration.
- Match depth to complexity. Simple question = short answer. Complex question = structured but still tight.
- Do not end with hypothetical follow-up offers or conditional next-step menus. This includes "If you want, I can also...", "如果你愿意,我还可以...", "If you tell me...", "如果你告诉我...", "如果你说X,我就Y", "我下一步可以...", "If you'd like, my next step could be...". Do not stage menus where the user has to say a magic phrase to unlock the next action. Answer what was asked, give the recommendation, stop. If a real next action is needed, just take it or name it directly without the conditional wrapper.
- Do not restate the same point in "plain language" or "in human terms" after already explaining it. Say it once clearly. No "翻成人话", "in other words", "简单来说" rewording blocks.
- When listing pros/cons or comparing options: max 3-4 points per side, pick the most important ones
## 核心原则 (Core Persona)
+26 -95
View File
@@ -26,109 +26,40 @@ export interface HumanApprovalHookDeps {
};
}
interface PendingApproval {
approvalId: string;
resolve: (verdict: { block?: boolean; reason?: string }) => void;
}
const TOOL_LABEL: Record<string, string> = {
bash: "shell",
edit: "edit",
write: "write",
read: "read",
ls: "ls",
grep: "grep",
find: "find",
};
function summariseArgs(toolName: string, args: unknown): string {
if (args === null || typeof args !== "object") return String(args);
const argsObj = args as Record<string, unknown>;
switch (toolName) {
case "bash": {
const command = typeof argsObj.command === "string" ? argsObj.command : "";
return command.length > 200 ? `${command.slice(0, 197)}...` : command;
}
case "write":
case "edit":
case "read": {
const path = typeof argsObj.path === "string" ? argsObj.path : (typeof argsObj.file_path === "string" ? argsObj.file_path : "");
return path;
}
default:
try {
const json = JSON.stringify(args);
return json.length > 200 ? `${json.slice(0, 197)}...` : json;
} catch {
return "<unserialisable args>";
}
}
}
/**
* Human-in-the-loop hook bridging pi `beforeToolCall` to
* AgentCompany's `ApprovalService` via the existing approvals table.
* pi `beforeToolCall` / `afterToolCall` bridge.
*
* **Execution-mode contract.** Both AI CEO and AI Co-Founder modes run the
* pi Agent fully autonomously — the framework layer never gates a tool
* call with a synchronous human-approval promise. Under AI Co-Founder, the
* model itself is instructed (via the system prompt + a dedicated
* `request_approval` tool) to pause when it judges human review necessary.
* Blocking every `bash` / `edit` / `write` at the Electron layer would
* defeat the pi-mono runtime and starve the agent of progress.
*
* `before` therefore always approves. `after` is a no-op audit point; the
* agent-event stream persists the real tool record via SqliteSessionStore.
* `cancelAll` remains for shutdown symmetry but has no queued callers.
*/
export class HumanApprovalHook {
private readonly pending = new Map<string, PendingApproval>();
constructor(private readonly _deps: HumanApprovalHookDeps) {
void this._deps;
}
constructor(private readonly deps: HumanApprovalHookDeps) {}
async before(_runId: string, _payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
return {};
}
async before(runId: string, payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
const { db, emitEvent } = this.deps;
const summary = summariseArgs(payload.toolName, payload.args);
const label = TOOL_LABEL[payload.toolName] ?? payload.toolName;
const approvalId = db.requestApproval({
companyId: payload.companyId,
relatedTaskId: payload.taskId,
requestedByAgentId: payload.agentId,
type: "dangerous_command",
payloadSummary: `${label}: ${summary}`.slice(0, 280),
impactSummary: `Agent ${payload.agentId} requested ${label} during run ${runId}.`,
payloadJson: JSON.stringify({
runId,
toolCallId: payload.toolCallId,
toolName: payload.toolName,
args: payload.args,
}),
});
emitEvent({ type: "approval-updated", approvalId, action: "created" });
emitEvent({ type: "domain-changed" });
return new Promise<{ block?: boolean; reason?: string }>((resolve) => {
this.pending.set(approvalId, { approvalId, resolve });
});
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
/* no-op */
}
/**
* Called by the IPC approval-decided pipeline when a user resolves a
* pending approval. If we have a pending pi `beforeToolCall` waiting on
* this approval, resolve it.
* Retained so `RuntimeBridge.release()` can call it during app shutdown
* without knowing whether any approvals were actually queued. The
* autonomous hook never queues anything, so this is always a no-op.
*/
resolveApproval(approvalId: string, decision: "approve" | "reject" | "revision_requested", note: string): boolean {
const pending = this.pending.get(approvalId);
if (!pending) return false;
this.pending.delete(approvalId);
if (decision === "approve") {
pending.resolve({});
} else {
pending.resolve({ block: true, reason: note || `Tool execution ${decision}.` });
}
return true;
}
/** Called when a run is cancelled — resolves any pending approvals as blocked. */
cancelAll(reason: string): void {
for (const [id, pending] of this.pending) {
pending.resolve({ block: true, reason });
this.pending.delete(id);
}
}
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
// Audit hook intentionally minimal — actual tool result is captured by
// the agent-event stream and persisted via SqliteSessionStore.
cancelAll(_reason: string): void {
/* no-op */
}
}
+59
View File
@@ -8,6 +8,55 @@ import { CostWriter } from "./cost-writer";
import type { CredentialResolver } from "@main/credentials";
import { getConnectorDefinition } from "@main/connectors";
/**
* Projects a pi AgentEvent to a human-readable log line for the Console.
* Returns null when the event should not surface as log output (state-only
* events such as `turn_start` are already implied by run-status).
*/
function projectAgentEventToLogChunk(
event: { type: string } & Record<string, unknown>,
): { stream: "stdout" | "stderr"; chunk: string } | null {
switch (event.type) {
case "assistant_message_delta": {
const delta = typeof event.delta === "string" ? event.delta : "";
if (!delta) return null;
return { stream: "stdout", chunk: delta };
}
case "tool_call_start": {
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
const argsPreview = summariseJson(event.args, 200);
return { stream: "stdout", chunk: `\n[tool:${toolName}] ${argsPreview}\n` };
}
case "tool_call_end": {
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
const isError = event.isError === true;
const resultPreview = summariseJson(event.result, 300);
const tag = isError ? "tool:error" : "tool:done";
return { stream: isError ? "stderr" : "stdout", chunk: `[${tag}:${toolName}] ${resultPreview}\n` };
}
case "error": {
const msg = typeof event.message === "string" ? event.message : "(unknown error)";
return { stream: "stderr", chunk: `[error] ${msg}\n` };
}
case "turn_end": {
return { stream: "stdout", chunk: "\n" };
}
default:
return null;
}
}
function summariseJson(value: unknown, maxLength: number): string {
if (value === undefined || value === null) return "";
try {
const json = typeof value === "string" ? value : JSON.stringify(value);
if (json.length <= maxLength) return json;
return `${json.slice(0, maxLength - 3)}...`;
} catch {
return "<unserialisable>";
}
}
export interface RuntimeBridgeDeps {
db: AppDatabase;
emitEvent: (event: DesktopEvent) => void;
@@ -93,6 +142,16 @@ export class RuntimeBridge {
event: envelope.event,
});
const logChunk = projectAgentEventToLogChunk(envelope.event);
if (logChunk) {
this.deps.emitEvent({
type: "run-log",
runId: envelope.runId,
stream: logChunk.stream,
chunk: logChunk.chunk,
});
}
if (envelope.event.type === "message_end") {
const ctx = this.activeRuns.get(envelope.runId);
const messageRaw = envelope.event.message as
+19 -31
View File
@@ -11,7 +11,7 @@ beforeAll(() => {
const noopLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
describe("HumanApprovalHook", () => {
describe("HumanApprovalHook — autonomous contract", () => {
let tempDir: string;
let db: AppDatabase;
const emit = vi.fn();
@@ -28,7 +28,7 @@ describe("HumanApprovalHook", () => {
await rm(tempDir, { recursive: true, force: true });
});
function scaffold() {
function scaffoldIds() {
const companyId = db.saveCompany({
name: "Approval Co",
description: "fixture",
@@ -57,20 +57,20 @@ describe("HumanApprovalHook", () => {
});
const taskId = db.saveTask({
companyId,
title: "Bash review fixture",
description: "needs approval",
title: "Autonomy fixture",
description: "no gate",
assigneeAgentId: agentId,
workspaceId,
priority: "medium",
status: "in_progress",
});
return { companyId, workspaceId, agentId, taskId };
return { companyId, agentId, taskId };
}
it("blocks the tool call with the approval note when the user denies", async () => {
const { companyId, agentId, taskId } = scaffold();
it("approves every tool call synchronously without writing an approval row", async () => {
const { companyId, agentId, taskId } = scaffoldIds();
const hook = new HumanApprovalHook({ db, emitEvent: emit, logger: noopLogger });
const promise = hook.before("run-1", {
const verdict = await hook.before("run-1", {
agentId,
companyId,
taskId,
@@ -78,22 +78,16 @@ describe("HumanApprovalHook", () => {
toolName: "bash",
args: { command: "rm -rf /" },
});
// Allow the approval row to be written before resolving.
await new Promise((resolve) => setImmediate(resolve));
expect(verdict).toEqual({});
const approvals = db.listSnapshot({ companyId }).approvals.filter((a) => a.state === "pending");
expect(approvals).toHaveLength(1);
const approvalId = approvals[0]!.id;
const resolved = hook.resolveApproval(approvalId, "reject", "denied: dangerous");
expect(resolved).toBe(true);
const verdict = await promise;
expect(verdict.block).toBe(true);
expect(verdict.reason).toBe("denied: dangerous");
expect(approvals).toHaveLength(0);
expect(emit).not.toHaveBeenCalled();
});
it("approves the tool call when the user approves", async () => {
const { companyId, agentId, taskId } = scaffold();
it("approves non-destructive tool calls with the same auto-approve contract", async () => {
const { companyId, agentId, taskId } = scaffoldIds();
const hook = new HumanApprovalHook({ db, emitEvent: emit, logger: noopLogger });
const promise = hook.before("run-2", {
const verdict = await hook.before("run-2", {
agentId,
companyId,
taskId,
@@ -101,23 +95,17 @@ describe("HumanApprovalHook", () => {
toolName: "edit",
args: { path: "src/foo.ts" },
});
await new Promise((resolve) => setImmediate(resolve));
const approvals = db.listSnapshot({ companyId }).approvals.filter((a) => a.state === "pending");
const approvalId = approvals[0]!.id;
hook.resolveApproval(approvalId, "approve", "");
const verdict = await promise;
expect(verdict.block).toBeUndefined();
expect(verdict).toEqual({});
});
it("cancelAll resolves every pending approval as blocked", async () => {
const { companyId, agentId, taskId } = scaffold();
it("cancelAll is a no-op because no approvals are queued", async () => {
const { companyId, agentId, taskId } = scaffoldIds();
const hook = new HumanApprovalHook({ db, emitEvent: emit, logger: noopLogger });
const p1 = hook.before("run-x", { agentId, companyId, taskId, toolCallId: "a", toolName: "bash", args: { command: "ls" } });
const p2 = hook.before("run-x", { agentId, companyId, taskId, toolCallId: "b", toolName: "write", args: { path: "/tmp/x" } });
await new Promise((resolve) => setImmediate(resolve));
hook.cancelAll("Run cancelled.");
const [v1, v2] = await Promise.all([p1, p2]);
expect(v1.block).toBe(true);
expect(v2.block).toBe(true);
expect(v1).toEqual({});
expect(v2).toEqual({});
});
});