From 1ba3147ed261ea3b093f5b664572d7227988d7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 18 Mar 2026 01:15:58 -0400 Subject: [PATCH] update --- .gitignore | 1 + README.md | 7 +- docs/2026-03-17-architecture-freeze.md | 264 ++ docs/2026-03-17-audit-and-rebuild-plan.md | 307 ++ docs/2026-03-17-runtime-security-tranche.md | 31 + docs/README.md | 13 + electron-builder.yml | 4 +- electron.vite.config.ts | 1 + eslint.config.mjs | 1 + package.json | 19 +- .../src/server/execute.ts | 2 +- .../adapter-claude-local/src/server/test.ts | 8 +- .../adapter-codex-local/src/server/execute.ts | 2 +- .../adapter-codex-local/src/server/test.ts | 11 +- .../src/server/execute.ts | 2 +- .../adapter-gemini-local/src/server/test.ts | 14 +- packages/adapter-kimi-local/package.json | 50 + .../src/cli/format-event.ts | 9 + packages/adapter-kimi-local/src/cli/index.ts | 1 + packages/adapter-kimi-local/src/index.ts | 33 + .../adapter-kimi-local/src/server/execute.ts | 73 + .../adapter-kimi-local/src/server/index.ts | 2 + .../adapter-kimi-local/src/server/test.ts | 62 + .../adapter-kimi-local/src/ui/build-config.ts | 72 + packages/adapter-kimi-local/src/ui/index.ts | 2 + .../adapter-kimi-local/src/ui/parse-stdout.ts | 7 + .../tests/parse-stdout.test.ts | 15 + packages/adapter-kimi-local/tsconfig.json | 8 + .../src/server/execute.ts | 2 +- .../src/server/models.ts | 2 +- .../adapter-opencode-local/src/server/test.ts | 12 +- packages/adapter-utils/src/server-utils.ts | 2 +- pnpm-lock.yaml | 27 +- scripts/build-desktop.mjs | 62 +- scripts/postinstall.mjs | 33 + src/main/agent-auth.ts | 38 +- src/main/agent-permissions.ts | 71 + src/main/api-server.ts | 158 +- src/main/app-lifecycle-service.ts | 62 + src/main/automation-service.ts | 1020 +++++ src/main/bootstrap-application.ts | 208 + src/main/browser-action-service.ts | 114 + src/main/browser-manager.ts | 150 +- src/main/connector-auth-service.ts | 115 + src/main/connector-health-service.ts | 167 + src/main/connectors.ts | 154 +- src/main/database.ts | 213 +- src/main/default-automation-rules.ts | 165 + src/main/desktop-shell-service.ts | 326 ++ src/main/host-environment-service.ts | 58 + src/main/index.ts | 3574 +---------------- src/main/ipc-agent-coordination-handlers.ts | 114 + .../ipc-automation-operations-handlers.ts | 100 + src/main/ipc-browser-handlers.ts | 117 + src/main/ipc-content-operations-handlers.ts | 192 + src/main/ipc-core-admin-handlers.ts | 131 + src/main/ipc-core-common.ts | 41 + src/main/ipc-core-handlers.ts | 12 + src/main/ipc-core-operations-handlers.ts | 208 + src/main/ipc-core-read-handlers.ts | 146 + src/main/ipc-core-work-graph-handlers.ts | 229 ++ src/main/ipc-domain-common.ts | 49 + src/main/ipc-domain-handlers.ts | 14 + src/main/ipc-onboarding-handlers.ts | 22 + src/main/ipc-support.ts | 120 + src/main/main-process-setup.ts | 58 + src/main/main-runtime-support.ts | 178 + src/main/main-runtime.ts | 94 + src/main/onboarding-service.ts | 230 ++ src/main/orchestration-service.ts | 683 ++++ src/main/orchestrator/worker.ts | 88 +- src/main/prompt-builder.ts | 29 +- src/main/runtime-env.ts | 107 + src/main/service-graph.ts | 243 ++ src/main/templates.ts | 1568 -------- src/main/updater-service.ts | 61 + src/main/window-state.ts | 82 + src/main/worker-service.ts | 337 ++ src/preload/index.ts | 7 +- src/renderer/App.tsx | 439 +- src/renderer/components/CompanyOnboarding.tsx | 910 ++--- src/renderer/components/communication.tsx | 188 +- src/renderer/components/connectors.tsx | 232 +- src/renderer/components/dashboard.tsx | 6 +- src/renderer/components/execution.tsx | 117 +- src/renderer/components/inbox.tsx | 2 +- src/renderer/components/runs.tsx | 504 +++ src/renderer/components/social.tsx | 93 +- src/renderer/components/ui.tsx | 4 +- src/shared/contracts.ts | 62 +- src/shared/types.ts | 57 +- tests/e2e/desktop.smoke.spec.ts | 26 +- tests/e2e/e2e-global-setup.ts | 3 +- tests/e2e/launch.ts | 5 +- tests/integration/autonomous-chain.spec.ts | 3 +- tests/unit/connectors.spec.ts | 4 +- 96 files changed, 9400 insertions(+), 6269 deletions(-) create mode 100644 docs/2026-03-17-architecture-freeze.md create mode 100644 docs/2026-03-17-audit-and-rebuild-plan.md create mode 100644 docs/2026-03-17-runtime-security-tranche.md create mode 100644 docs/README.md create mode 100644 packages/adapter-kimi-local/package.json create mode 100644 packages/adapter-kimi-local/src/cli/format-event.ts create mode 100644 packages/adapter-kimi-local/src/cli/index.ts create mode 100644 packages/adapter-kimi-local/src/index.ts create mode 100644 packages/adapter-kimi-local/src/server/execute.ts create mode 100644 packages/adapter-kimi-local/src/server/index.ts create mode 100644 packages/adapter-kimi-local/src/server/test.ts create mode 100644 packages/adapter-kimi-local/src/ui/build-config.ts create mode 100644 packages/adapter-kimi-local/src/ui/index.ts create mode 100644 packages/adapter-kimi-local/src/ui/parse-stdout.ts create mode 100644 packages/adapter-kimi-local/tests/parse-stdout.test.ts create mode 100644 packages/adapter-kimi-local/tsconfig.json create mode 100644 scripts/postinstall.mjs create mode 100644 src/main/agent-permissions.ts create mode 100644 src/main/app-lifecycle-service.ts create mode 100644 src/main/automation-service.ts create mode 100644 src/main/bootstrap-application.ts create mode 100644 src/main/browser-action-service.ts create mode 100644 src/main/connector-auth-service.ts create mode 100644 src/main/connector-health-service.ts create mode 100644 src/main/default-automation-rules.ts create mode 100644 src/main/desktop-shell-service.ts create mode 100644 src/main/host-environment-service.ts create mode 100644 src/main/ipc-agent-coordination-handlers.ts create mode 100644 src/main/ipc-automation-operations-handlers.ts create mode 100644 src/main/ipc-browser-handlers.ts create mode 100644 src/main/ipc-content-operations-handlers.ts create mode 100644 src/main/ipc-core-admin-handlers.ts create mode 100644 src/main/ipc-core-common.ts create mode 100644 src/main/ipc-core-handlers.ts create mode 100644 src/main/ipc-core-operations-handlers.ts create mode 100644 src/main/ipc-core-read-handlers.ts create mode 100644 src/main/ipc-core-work-graph-handlers.ts create mode 100644 src/main/ipc-domain-common.ts create mode 100644 src/main/ipc-domain-handlers.ts create mode 100644 src/main/ipc-onboarding-handlers.ts create mode 100644 src/main/ipc-support.ts create mode 100644 src/main/main-process-setup.ts create mode 100644 src/main/main-runtime-support.ts create mode 100644 src/main/main-runtime.ts create mode 100644 src/main/onboarding-service.ts create mode 100644 src/main/orchestration-service.ts create mode 100644 src/main/runtime-env.ts create mode 100644 src/main/service-graph.ts delete mode 100644 src/main/templates.ts create mode 100644 src/main/updater-service.ts create mode 100644 src/main/window-state.ts create mode 100644 src/main/worker-service.ts create mode 100644 src/renderer/components/runs.tsx diff --git a/.gitignore b/.gitignore index 044bf898..ea58afa1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ oldreference/ # Markdown docs *.md !/README.md +!/docs/*.md # Local design scratch files homepagedesign.html diff --git a/README.md b/README.md index 9428f19e..73a0344d 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,21 @@ pnpm typecheck pnpm lint pnpm test:unit pnpm test:integration -pnpm build +pnpm test:packages +pnpm test:e2e +pnpm build:verify ``` ## Packaging ```sh -pnpm build # Compile + package (DMG/NSIS/AppImage) +pnpm build # Compile + package the current host platform into /build/release pnpm build:dir # Compile + unpackaged directory ``` - Supported targets: macOS, Windows, Linux - Packaging uses `electron-builder` +- Local packaging cleans `/build` and injects a non-mutating build version - Local builds use ad-hoc signing (`CSC_IDENTITY_AUTO_DISCOVERY=false`) - Defaults to `--publish never`; override with `AGENTCOMPANY_ELECTRON_PUBLISH` env var diff --git a/docs/2026-03-17-architecture-freeze.md b/docs/2026-03-17-architecture-freeze.md new file mode 100644 index 00000000..ed76709a --- /dev/null +++ b/docs/2026-03-17-architecture-freeze.md @@ -0,0 +1,264 @@ +# 2026-03-17 Architecture Freeze + +## Supported Platform Target + +The product target remains cross-platform: + +- macOS +- Windows +- Linux + +The current codebase can build on the local host, but release readiness is not equivalent across targets yet. + +### Platform-specific constraints to preserve + +- macOS + - hidden inset title bar + - signing and notarization required for public release + - `safeStorage` may block on keychain access +- Windows + - App User Model ID is required for correct notifications and taskbar integration + - NSIS is the release target + - code signing required for updater trust +- Linux + - safe storage backend can fall back to weaker semantics when no secret store exists + - Wayland window behavior is more limited than X11 + - AppImage is the primary packaged target today + +## External Research Decisions + +### Electron security + +Based on the current Electron security checklist: + +- keep `nodeIntegration: false` +- keep `contextIsolation: true` +- keep `sandbox: true` +- keep the custom `app://` protocol instead of `file://` +- validate sender URLs on every IPC entry point +- do not expose raw IPC objects in preload +- define a restrictive CSP in local renderer content + +### Utility process + +The current choice of `utilityProcess.fork()` is still the right worker primitive. + +Reasoning: + +- it keeps heavy connector execution off the renderer +- it is more aligned with Electron security guidance than broad use of `ELECTRON_RUN_AS_NODE` +- it is compatible with future fuse hardening around `runAsNode` + +### Secret storage + +`safeStorage` remains the primary storage contract. + +Fallback rules: + +- use OS-backed `safeStorage` when real encryption is available +- reject insecure runtime environments by default +- allow AES vault fallback only in explicitly allowed test/insecure modes + +### Updater + +`electron-updater` remains acceptable, but only if release lanes are made real. + +Required implications: + +- real publish endpoint +- signed macOS app +- Windows signing +- explicit metadata handling for the generic provider +- download/apply UX in-app, not just “check available” + +## Process Boundary Freeze + +### Main process + +Owns: + +- app lifecycle +- BrowserWindow creation +- custom protocol registration +- native menus / tray / dialogs / notifications +- database initialization +- worker process lifecycle +- updater lifecycle +- browser automation windows +- privileged IPC handlers + +Must not: + +- become the place where all query, mutation, aggregation, and orchestration code accumulates + +### Preload + +Owns: + +- typed, minimal bridge from renderer to approved use-cases + +Must not: + +- expose raw `ipcRenderer` +- expose generic file-system or shell primitives +- become a mirror of every DB method + +### Renderer + +Owns: + +- operator workflows +- presentation logic +- optimistic local UI state +- command palette / shell navigation +- empty / loading / error / review states + +Must not: + +- talk in raw CRUD terms when the user is performing operator flows +- own privileged state +- load whole-world snapshots forever as the only data pattern + +### Utility process + +Owns: + +- connector execution +- per-run stream forwarding +- cancellation +- run queue scheduling local to worker + +Must not: + +- become the persistence layer +- own security policy decisions that belong in main + +## State Ownership Model + +### Renderer-local state + +- selected entities +- open panels / modals +- search queries +- console UI state +- transient filter/sort state + +### Persisted domain state + +- companies +- connectors +- workspaces +- agents +- goals / projects / tasks +- approvals +- runs +- cost entries +- activity +- secrets +- automation rules +- workflows +- browser actions + +### Runtime execution state + +- active worker jobs +- agent concurrency map +- workspace lock ownership +- pending wakes +- update progress +- browser action queue + +### Derived read models + +These should be separated from write repositories: + +- overview metrics +- inbox +- standup +- run queue / attention views +- workload and review summaries + +## Storage Decision + +### Current decision + +Keep SQLite + WAL for this tranche. + +### Constraints + +- single-writer semantics remain acceptable if heavy aggregations are not kept in the same giant repository +- crash recovery must preserve unfinished run semantics +- backup / restore must remain profile-local and reversible + +### Required follow-up + +- split read models from write repositories +- add offset-based log readers +- keep artifacts and logs on disk, indexed in SQLite + +## IPC Contract Strategy + +### Contract rules + +- every mutating IPC call must stay company-scoped when relevant +- large payloads should use typed request objects, not positional args +- contract names stay explicit and descriptive +- result envelopes stay structured + +### Next contract change + +The next contract slice will add: + +- dedicated run listing / filtering reads +- chunked log reads +- connector selection in onboarding + +## Renderer Information Architecture Freeze + +Primary desktop sections: + +1. Overview +2. Runs +3. Tasks +4. Approvals +5. Messages +6. Agents +7. Workspaces +8. Connectors +9. Costs +10. Activity +11. Settings + +Secondary knowledge/operations surfaces can remain, but they should not dominate the shell. + +## Milestone Mapping + +### Architecture Freeze + +- this document accepted + +### Interactive Prototype + +- shell reordered around operator workflows +- run console and runs list coherent + +### Internal Alpha + +- onboarding, connector health, task run, approval, message routing, and workspace inspection work together + +### Cross-Platform Validation + +- menu, tray, notification, and package behavior documented per OS + +### Signed Beta + +- updater feed real +- signing path documented and testable + +### Release Candidate + +- all repo quality gates green + +### Production Ready + +- security audit, release hardening, and operator workflow polish complete diff --git a/docs/2026-03-17-audit-and-rebuild-plan.md b/docs/2026-03-17-audit-and-rebuild-plan.md new file mode 100644 index 00000000..5c35722b --- /dev/null +++ b/docs/2026-03-17-audit-and-rebuild-plan.md @@ -0,0 +1,307 @@ +# 2026-03-17 Audit And Rebuild Plan + +## Execution Order + +This document is the Phase 1-4 artifact required by `AGENTS.md`. + +1. ProjectManager: read the full PRD and define milestone gates. +2. ResearchAnalyst: verify Electron runtime, security, packaging, updater, and OS constraints. +3. TechnicalImplementationResearcher: freeze main/preload/renderer/storage/runtime boundaries. +4. EnterpriseArchitect: validate dependency direction, module seams, IPC contracts, and naming rules. + +Implementation work starts only after the decisions below are recorded. + +## Scope + +The audit covers: + +- PRD alignment +- Electron main / preload / renderer boundaries +- IPC contracts, sender validation, and company scoping +- connector runtime execution, orchestration, and background work +- local persistence, backup / restore, workspace locking, and crash recovery +- renderer information architecture, onboarding, and desktop UX +- packaging, updater, release, and verification posture +- adapter package quality and test coverage + +## Baseline Evidence + +### Repository state + +- The working tree is already in a large, incomplete refactor state. +- The repo is no longer a prototype shell; it already contains a local data model, automation engine, workflow engine, browser automation surface, and an Electron shell. +- The implementation breadth is now large enough that coherence, not feature count, is the main problem. + +### Verification results on 2026-03-17 + +- `pnpm typecheck`: pass +- `pnpm test:unit`: pass +- `pnpm test:integration`: pass +- `pnpm test:packages`: pass +- `pnpm test:e2e`: pass +- `pnpm build:verify`: pass +- `pnpm build:dir`: pass, producing `build/release/AgentCompany-0.4.26-arm64-mac.zip` +- `pnpm lint`: fail + +### Current build posture + +- Main bundle: `out/main/index.js` about `592 kB` +- Preload bundle: `out/preload/index.js` about `151 kB` +- Renderer bundle: `out/renderer/assets/index-*.js` about `1.69 MB` +- Packaging currently succeeds only on the host platform tested locally. + +## Research Notes + +The following external references were re-checked during this audit: + +- Electron security checklist: https://www.electronjs.org/docs/latest/tutorial/security +- BrowserWindow API and platform notices: https://www.electronjs.org/docs/latest/api/browser-window +- Utility process API: https://www.electronjs.org/docs/latest/api/utility-process +- safeStorage API: https://www.electronjs.org/docs/latest/api/safe-storage +- Electron fuses: https://www.electronjs.org/docs/latest/tutorial/fuses +- electron-builder auto-update guide: https://www.electron.build/auto-update +- electron-builder publish guide: https://www.electron.build/publish + +These references matter because the PRD explicitly requires secure preload exposure, custom protocol usage, sandboxing, OS-backed secret storage, release signing, and update integrity. + +## Current State Summary + +The application already has the following real subsystems: + +- Electron shell with `contextIsolation`, `nodeIntegration: false`, `sandbox: true`, and a custom `app://` protocol +- local `DatabaseSync` SQLite store with WAL mode +- local API server for agent-driven actions +- utility-process worker for connector execution +- run orchestration, automation rules, workflow pipelines, messages, approvals, documents, meetings, sprints, and browser actions + +The main issue is not “missing a single feature”. + +The issue is that the system surface is broader than the stability and architecture boundaries that currently support it. + +## Findings + +### High severity + +1. Main-process data access is concentrated in a single synchronous repository that is too large to remain reliable. + - Evidence: `src/main/database.ts` is `3292` lines and owns schema setup, migrations, snapshots, orchestration state, cost accounting, approvals, messaging, workflow persistence, backup/restore, workspace locking, and dashboard aggregation. + - Impact: + - blocks maintainability + - encourages cross-domain coupling + - increases renderer-visible latency because many IPC calls read the same monolith + +2. The renderer shell is still a god component and does not match the desktop-native operating model in the PRD. + - Evidence: `src/renderer/App.tsx` is `1520` lines and owns navigation, data loading, company switching, command palette, modal orchestration, run log buffering, and page composition for every domain. + - Impact: + - feature interaction is hard to reason about + - state ownership is blurred + - operator workflows such as run monitoring are buried behind a console drawer instead of having first-class surfaces + +3. The verification gate defined by the project itself is currently broken because lint does not run. + - Evidence: `pnpm lint` fails with `ENOENT ... electron.vite.config..mjs`. + - Impact: + - the release checklist in `AGENTS.md` is not met + - CI or local quality enforcement is unreliable + +4. Update and release readiness are not production-grade. + - Evidence: + - `electron-builder.yml` still defaults to a generic placeholder publish URL + - `src/main/updater-service.ts` only checks availability; it does not implement a complete download/apply flow or pre-update backup + - local mac packaging falls back to ad-hoc signing and skips notarization + - Impact: + - no shippable trust chain for public release + - updater behavior is not aligned with PRD requirements + +5. Automatic internal coordination is still mostly emergent behavior from prompts and heuristics, not a hardened orchestration contract. + - Evidence: + - `src/main/prompt-builder.ts` contains the majority of “real company behavior” instructions + - `src/main/automation-service.ts` uses rule-triggered wakeups and message dispatch to simulate organization flow + - no dedicated coordination state model exists for “conversation pending response”, “delegation awaiting acceptance”, or “review SLA breached” + - Impact: + - the system can appear intelligent in happy paths while remaining hard to debug in stalled paths + - message flow, approvals, and delegation are not deterministic enough for a production control plane + +### Medium severity + +1. Snapshot loading is coarse-grained and expensive. + - Evidence: + - `db.listSnapshot()` reads nearly every table every time + - `db.listRendererSnapshot()` still calls `listSnapshot()` first and only then filters by company + - `App.refresh()` reloads the entire current company snapshot on most domain events + - Impact: + - unnecessary main-process work + - unnecessary renderer re-renders + - future scale problems once run/message/activity volume increases + +2. Run log access is full-file based, not incremental. + - Evidence: + - `src/main/database.ts#readRunLog` returns the entire log file + - renderer keeps in-memory log buffers in `App.tsx` + - PRD mismatch: + - PRD requires offset-based incremental log reading + - Impact: + - degraded performance on large logs + - poor long-running run observability + +3. The preload bridge is typed but too broad for the current architecture maturity. + - Evidence: `src/preload/index.ts` exposes dozens of direct CRUD and orchestration methods. + - Impact: + - large privileged surface area + - difficult contract governance + - weak module boundaries between renderer and domain actions + +4. Workflow and automation configuration crosses boundaries as opaque JSON strings. + - Evidence: + - `conditionsJson`, `actionConfigJson`, `stepsJson`, `triggerConfigJson`, `metadataJson`, `attachmentsJson` are passed across IPC and persistence as raw strings + - Impact: + - weak compile-time guarantees + - more runtime parsing failures + - harder migration and refactoring path + +5. Onboarding still optimizes for spectacle more than operator clarity. + - Evidence: + - `src/renderer/components/CompanyOnboarding.tsx` strongly promises “autonomous company” behavior + - connector choice and capability differences are not explicit enough + - initial run review is not operator-first + - Impact: + - weak first-use trust + - harder diagnosis when the selected connector is degraded or capability-limited + +6. Browser/social automation is fragile and outside the core PRD center of gravity. + - Evidence: + - `src/main/browser-manager.ts` relies on direct DOM selectors, timed delays, and third-party site structure + - Impact: + - brittle surface + - expands support burden + - competes with higher-priority core orchestration work + +7. The app supports many domains, but there is no dedicated Runs page. + - Evidence: + - run monitoring exists only through dashboard widgets and the bottom console drawer + - PRD mismatch: + - PRD requires live runs and log console as first-class operating surfaces + +### Low severity + +1. Renderer design language is coherent but still too web-dashboard-like for a desktop control plane. + - Evidence: + - system font stack in `src/renderer/styles.css` + - single-column content area on many dense surfaces + - heavy modal use for editing + +2. Package-level runtime verification is narrow. + - Evidence: + - `test:packages` covers only `adapter-opencode-local` and `adapter-kimi-local` + - `codex`, `claude`, and `gemini` packages do not have equivalent package test commands here + +## Detailed Gap List By Domain + +### Main / preload / IPC + +- Sender validation exists and the custom protocol is correct, which is a good baseline. +- Contracts are typed, but not versioned and not yet narrowed by capability groups. +- `ipc-core-common.ts` and `ipc-domain-common.ts` still use `any`-heavy dependency wiring. +- The privileged bridge still mirrors domain CRUD too directly instead of exposing operator use-cases. + +### Data / orchestration + +- Workspace lock handling exists, but it is still embedded inside the database class. +- Queued-run recovery exists, but recovery semantics are still best-effort. +- Workflow execution is inline, stateful, and sync-heavy in the main process service layer. +- Aggregation views such as inbox, standup, and metrics are built inside the same DB class. + +### Connectors + +- Codex / Claude / Gemini / OpenCode have deeper execution paths than Kimi. +- Kimi remains generic CLI mode with explicit capability downgrade. +- Connector health checks use command probes and status translation, but operator feedback is still too shallow. +- Connector discovery and operator selection are still secondary in first-launch UX. + +### Renderer + +- The UI covers a large domain surface, but the operating hierarchy is inverted: + - overview first + - CRUD pages second + - runs/logs/workspaces hidden as secondary tools +- The app does not yet “scream” workspace, run, and coordination as the primary desktop tasks. + +### Testing / release + +- Current automated coverage is better than zero, but still not close to the PRD completion criteria. +- E2E currently proves only first-launch onboarding and console toggle. +- No E2E covers: + - task run lifecycle + - approval resolution + - message routing + - workflow execution + - connector health state changes + - backup/restore + - workspace locking / recovery +- Release docs do not yet define signed beta, RC, and production lanes. + +## Rebuild Direction + +### Tranche A: operator shell and observability + +- add first-class Runs navigation and run triage workflow +- move run logs to chunked readers instead of whole-file loads +- make onboarding explicitly choose connector and workspace intent +- fix lint so the repo’s minimum quality gate works again + +### Tranche B: boundary extraction + +- split `AppDatabase` into read models, command repositories, and orchestration state store +- reduce preload surface to bounded capability groups +- split renderer shell from feature pages and detail inspectors + +### Tranche C: release hardening + +- define real update lanes and signing rules +- add updater backup/install flow +- document mac notarization, Windows signing, Linux packaging behavior +- flip relevant Electron fuses during packaging + +## Milestone Gates + +### Architecture Freeze + +- audit findings accepted +- platform target confirmed +- process boundaries frozen +- core run / workspace / connector model frozen + +### Interactive Prototype + +- onboarding, connectors, agents, tasks, runs, console, workspaces all coherent + +### Internal Alpha + +- connector lifecycle, approvals, message routing, and crash recovery usable end-to-end + +### Cross-Platform Validation + +- macOS, Windows, Linux behavior documented and smoke-validated + +### Signed Beta Build + +- signing path defined +- updater feed real +- rollback / backup strategy documented + +### Release Candidate + +- lint, typecheck, unit, integration, E2E, build, and packaging gates all green + +### Production Readiness + +- security audit complete +- no placeholder release config remains +- operator workflows are deterministic and observable + +## Next Implementation Slice + +The next code tranche will target: + +1. repair the lint gate +2. add first-class Runs navigation and incremental log loading +3. make onboarding force an explicit connector choice +4. keep build packaging green while making these changes diff --git a/docs/2026-03-17-runtime-security-tranche.md b/docs/2026-03-17-runtime-security-tranche.md new file mode 100644 index 00000000..6c49e09f --- /dev/null +++ b/docs/2026-03-17-runtime-security-tranche.md @@ -0,0 +1,31 @@ +# 2026-03-17 Runtime/Security Tranche + +This tranche locks the first production-boundary fixes before larger module extraction. + +## Scope + +- tighten agent run-token authority in the local API server +- stop default connector execution from bypassing runtime permission/sandbox flags +- harden renderer IPC sender validation +- prevent secret bindings from overriding process-critical environment keys +- make browser-action cancellation affect real execution, not only database state + +## Why this tranche comes first + +These issues sit underneath every feature: + +1. A compromised or overpowered run token could mutate company structure, approvals, and browser automation. +2. A globally bypassed connector runtime defeats approval and sandbox posture for all task runs. +3. Loose origin matching weakens the only boundary between renderer and privileged main-process handlers. +4. Fake cancellation creates visible state drift between the UI and external side effects. +5. Unrestricted env injection allows privilege escalation through runtime process configuration. + +## Intended outcome + +After this tranche: + +- agent runs can no longer approve approvals or request arbitrary organization changes without role-based checks +- connector child processes run without implicit dangerous bypass flags +- only exact trusted renderer origins can invoke privileged IPC handlers +- reserved env keys are blocked from connector secret bindings +- cancelling a browser action cancels queued or active execution, not just the row in SQLite diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..f9117136 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# Docs Index + +Active documents only. + +- [2026-03-17-audit-and-rebuild-plan.md](./2026-03-17-audit-and-rebuild-plan.md) +- [2026-03-17-architecture-freeze.md](./2026-03-17-architecture-freeze.md) +- [2026-03-17-runtime-security-tranche.md](./2026-03-17-runtime-security-tranche.md) + +Removed: + +- duplicate audit snapshots +- duplicate rebuild plans +- stale one-off implementation deltas that no longer matched the working tree diff --git a/electron-builder.yml b/electron-builder.yml index 4359106e..0b5bd139 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -14,9 +14,7 @@ mac: target: - dmg - zip - # hardenedRuntime requires a real code signing identity. - # For local/unsigned builds, keep it off. Enable when signing is configured. - hardenedRuntime: false + hardenedRuntime: true win: icon: assets/icon.ico target: diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 77d3baef..380b6b51 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,6 +7,7 @@ const bundledMainDependencies = [ "@agentcompany/adapter-claude-local", "@agentcompany/adapter-codex-local", "@agentcompany/adapter-gemini-local", + "@agentcompany/adapter-kimi-local", "@agentcompany/adapter-opencode-local", "@agentcompany/adapter-utils", ]; diff --git a/eslint.config.mjs b/eslint.config.mjs index 5d418a95..d4a25da9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ export default tseslint.config( "test-results/**", "playwright-report/**", "coverage/**", + "electron.vite.config.*.mjs", ], }, js.configs.recommended, diff --git a/package.json b/package.json index 2b89e558..2ccd573f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.16", + "version": "0.4.25", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", @@ -9,22 +9,28 @@ "scripts": { "assets:generate": "python3 scripts/generate-icons.py", "dev": "electron-vite dev", + "build:verify": "electron-vite build", "build": "electron-vite build && node scripts/build-desktop.mjs", "build:dir": "electron-vite build && node scripts/build-desktop.mjs --dir", "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "tsc --noEmit", + "typecheck:all": "pnpm -r typecheck", "test:unit": "vitest run --config vitest.config.ts tests/unit", "test:integration": "vitest run --config vitest.config.ts tests/integration", + "test:packages": "pnpm --filter @agentcompany/adapter-opencode-local exec vitest run && pnpm --filter @agentcompany/adapter-kimi-local exec vitest run", "test:e2e": "playwright test -c tests/e2e/playwright.config.ts", - "test:run": "pnpm test:unit && pnpm test:integration && pnpm test:e2e", - "postinstall": "bash scripts/patch-electron-name.sh || true" + "test:run": "pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e", + "verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e", + "verify:release": "pnpm verify && pnpm build:dir", + "postinstall": "node scripts/postinstall.mjs" }, "dependencies": { "@fontsource-variable/jetbrains-mono": "5.2.8", "@agentcompany/adapter-claude-local": "workspace:*", "@agentcompany/adapter-codex-local": "workspace:*", "@agentcompany/adapter-gemini-local": "workspace:*", + "@agentcompany/adapter-kimi-local": "workspace:*", "@agentcompany/adapter-opencode-local": "workspace:*", "@agentcompany/adapter-utils": "workspace:*", "@phosphor-icons/react": "2.1.10", @@ -57,5 +63,10 @@ "typescript-eslint": "8.46.1", "vite": "^7.0.0", "vitest": "4.1.0" - } + }, + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + }, + "packageManager": "pnpm@10.24.0" } diff --git a/packages/adapter-claude-local/src/server/execute.ts b/packages/adapter-claude-local/src/server/execute.ts index 057ca651..dfd6fa69 100644 --- a/packages/adapter-claude-local/src/server/execute.ts +++ b/packages/adapter-claude-local/src/server/execute.ts @@ -236,7 +236,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, config, context, onLog } = ctx; + const command = asString(config.command, "kimi"); + const cwd = asString(config.cwd, process.cwd()); + const model = asString(config.model, DEFAULT_KIMI_LOCAL_MODEL); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = asStringArray(config.extraArgs); + + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const prompt = typeof context.taskTitle === "string" && context.taskTitle.length > 0 + ? [ + context.taskTitle, + typeof context.taskDescription === "string" && context.taskDescription.length > 0 + ? context.taskDescription + : "", + ].filter(Boolean).join("\n\n") + : [ + `You are agent ${ctx.agent.name}.`, + "Respond with a concise execution summary when complete.", + ].join("\n\n"); + + const env = normalizeEnv(ensurePathInEnv(normalizeEnv(config.env))); + await ensureCommandResolvable(command, cwd, env); + + const result = await runChildProcess(runId, command, extraArgs, { + cwd, + env, + timeoutSec, + graceSec, + stdin: prompt, + onLog, + }); + + const summary = + result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-8) + .join("\n") || null; + + return { + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + summary, + model, + billingType: "unknown", + }; +} diff --git a/packages/adapter-kimi-local/src/server/index.ts b/packages/adapter-kimi-local/src/server/index.ts new file mode 100644 index 00000000..04036438 --- /dev/null +++ b/packages/adapter-kimi-local/src/server/index.ts @@ -0,0 +1,2 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; diff --git a/packages/adapter-kimi-local/src/server/test.ts b/packages/adapter-kimi-local/src/server/test.ts new file mode 100644 index 00000000..7d47964a --- /dev/null +++ b/packages/adapter-kimi-local/src/server/test.ts @@ -0,0 +1,62 @@ +import type { + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@agentcompany/adapter-utils"; +import { + asString, + ensureCommandResolvable, + ensurePathInEnv, +} from "@agentcompany/adapter-utils/server-utils"; + +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const command = asString(ctx.config.command, "kimi"); + const cwd = asString(ctx.config.cwd, process.cwd()); + const env = normalizeEnv(ensurePathInEnv(normalizeEnv(ctx.config.env))); + + try { + await ensureCommandResolvable(command, cwd, env); + return { + adapterType: "kimi_local", + status: "warn", + checks: [ + { + code: "kimi_command_detected", + level: "info", + message: `Detected Kimi Code command "${command}".`, + }, + { + code: "kimi_runtime_generic_mode", + level: "warn", + message: + "Kimi integration is running in generic CLI mode. Session resume, model discovery, and structured transcript are not guaranteed.", + }, + ], + testedAt: new Date().toISOString(), + }; + } catch (error) { + return { + adapterType: "kimi_local", + status: "fail", + checks: [ + { + code: "kimi_command_missing", + level: "error", + message: error instanceof Error ? error.message : "Kimi command is not available.", + hint: "Install Kimi Code with `curl -L code.kimi.com/install.sh | bash` and ensure the CLI is available on PATH.", + }, + ], + testedAt: new Date().toISOString(), + }; + } +} diff --git a/packages/adapter-kimi-local/src/ui/build-config.ts b/packages/adapter-kimi-local/src/ui/build-config.ts new file mode 100644 index 00000000..fc083809 --- /dev/null +++ b/packages/adapter-kimi-local/src/ui/build-config.ts @@ -0,0 +1,72 @@ +import type { CreateConfigValues } from "@agentcompany/adapter-utils"; +import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildKimiLocalConfig(v: CreateConfigValues): Record { + const config: Record = {}; + if (v.cwd) config.cwd = v.cwd; + if (v.promptTemplate) config.promptTemplate = v.promptTemplate; + config.model = v.model || DEFAULT_KIMI_LOCAL_MODEL; + config.timeoutSec = 0; + config.graceSec = 20; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) config.env = env; + if (v.command) config.command = v.command; + if (v.extraArgs) config.extraArgs = parseCommaArgs(v.extraArgs); + return config; +} diff --git a/packages/adapter-kimi-local/src/ui/index.ts b/packages/adapter-kimi-local/src/ui/index.ts new file mode 100644 index 00000000..87794b87 --- /dev/null +++ b/packages/adapter-kimi-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { buildKimiLocalConfig } from "./build-config.js"; +export { parseKimiStdoutLine } from "./parse-stdout.js"; diff --git a/packages/adapter-kimi-local/src/ui/parse-stdout.ts b/packages/adapter-kimi-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..68f38f79 --- /dev/null +++ b/packages/adapter-kimi-local/src/ui/parse-stdout.ts @@ -0,0 +1,7 @@ +import type { TranscriptEntry } from "@agentcompany/adapter-utils"; + +export function parseKimiStdoutLine(line: string, ts: string): TranscriptEntry[] { + const text = line.trim(); + if (!text) return []; + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapter-kimi-local/tests/parse-stdout.test.ts b/packages/adapter-kimi-local/tests/parse-stdout.test.ts new file mode 100644 index 00000000..aa8cd5fe --- /dev/null +++ b/packages/adapter-kimi-local/tests/parse-stdout.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { parseKimiStdoutLine } from "../src/ui/parse-stdout.js"; + +describe("parseKimiStdoutLine", () => { + it("returns a stdout entry for non-empty lines", () => { + const entries = parseKimiStdoutLine("hello", "2026-03-17T00:00:00.000Z"); + expect(entries).toEqual([ + { kind: "stdout", ts: "2026-03-17T00:00:00.000Z", text: "hello" }, + ]); + }); + + it("drops blank lines", () => { + expect(parseKimiStdoutLine(" ", "2026-03-17T00:00:00.000Z")).toEqual([]); + }); +}); diff --git a/packages/adapter-kimi-local/tsconfig.json b/packages/adapter-kimi-local/tsconfig.json new file mode 100644 index 00000000..5a24989c --- /dev/null +++ b/packages/adapter-kimi-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapter-opencode-local/src/server/execute.ts b/packages/adapter-opencode-local/src/server/execute.ts index 6914b13b..8668f8b2 100644 --- a/packages/adapter-opencode-local/src/server/execute.ts +++ b/packages/adapter-opencode-local/src/server/execute.ts @@ -159,7 +159,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); diff --git a/packages/adapter-opencode-local/src/server/models.ts b/packages/adapter-opencode-local/src/server/models.ts index 9cee489f..b10e759f 100644 --- a/packages/adapter-opencode-local/src/server/models.ts +++ b/packages/adapter-opencode-local/src/server/models.ts @@ -106,7 +106,7 @@ export async function discoverOpenCodeModels(input: { const command = resolveOpenCodeCommand(input.command); const cwd = asString(input.cwd, process.cwd()); const env = normalizeEnv(input.env); - const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); + const runtimeEnv = normalizeEnv(ensurePathInEnv(env)); const result = await runChildProcess( `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, diff --git a/packages/adapter-opencode-local/src/server/test.ts b/packages/adapter-opencode-local/src/server/test.ts index deb6ea30..523afcc1 100644 --- a/packages/adapter-opencode-local/src/server/test.ts +++ b/packages/adapter-opencode-local/src/server/test.ts @@ -90,7 +90,7 @@ export async function testEnvironment( }); } - const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); + const runtimeEnv = normalizeEnv(ensurePathInEnv(env)); const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); if (cwdInvalid) { @@ -191,9 +191,13 @@ export async function testEnvironment( } } - const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); - if (!configuredModel && !modelUnavailable) { - // No model configured – skip model requirement if no model-related checks exist + if (!configuredModel) { + checks.push({ + code: "opencode_model_missing", + level: "warn", + message: "OpenCode requires a configured provider/model ID before runs can start.", + hint: "Run `opencode models`, choose a provider/model, and save it in the connector settings.", + }); } else if (configuredModel && canRunProbe) { try { await ensureOpenCodeModelConfiguredAndAvailable({ diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 9e4e7ceb..2269430e 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -272,7 +272,7 @@ export async function runChildProcess( const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); return new Promise((resolve, reject) => { - const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; + const rawMerged: NodeJS.ProcessEnv = { ...opts.env }; // Strip Claude Code nesting-guard env vars so spawned `claude` processes // don't refuse to start with "cannot be launched inside another session". diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72c0f5f9..5430464f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@agentcompany/adapter-gemini-local': specifier: workspace:* version: link:packages/adapter-gemini-local + '@agentcompany/adapter-kimi-local': + specifier: workspace:* + version: link:packages/adapter-kimi-local '@agentcompany/adapter-opencode-local': specifier: workspace:* version: link:packages/adapter-opencode-local @@ -26,9 +29,6 @@ importers: '@fontsource-variable/jetbrains-mono': specifier: 5.2.8 version: 5.2.8 - '@fontsource-variable/outfit': - specifier: 5.2.8 - version: 5.2.8 '@phosphor-icons/react': specifier: 2.1.10 version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -163,6 +163,22 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapter-kimi-local: + dependencies: + '@agentcompany/adapter-utils': + specifier: workspace:* + version: link:../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapter-opencode-local: dependencies: '@agentcompany/adapter-utils': @@ -683,9 +699,6 @@ packages: '@fontsource-variable/jetbrains-mono@5.2.8': resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} - '@fontsource-variable/outfit@5.2.8': - resolution: {integrity: sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==} - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -3343,8 +3356,6 @@ snapshots: '@fontsource-variable/jetbrains-mono@5.2.8': {} - '@fontsource-variable/outfit@5.2.8': {} - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs index bdb2897d..1a913d29 100644 --- a/scripts/build-desktop.mjs +++ b/scripts/build-desktop.mjs @@ -1,14 +1,21 @@ import { spawn } from "node:child_process"; -import { readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, "..", "package.json"); -const releaseDir = join(__dirname, "..", "build", "release"); +const buildDir = join(__dirname, "..", "build"); +const releaseDir = join(buildDir, "release"); +const runtimeConfigPath = join(buildDir, "electron-builder.runtime.json"); const extraArgs = process.argv.slice(2); const env = { ...process.env }; +const hasMacSigningMaterial = Boolean( + env.CSC_LINK?.trim() || + env.CSC_NAME?.trim() || + env.APPLE_API_KEY?.trim(), +); function bumpPatchVersion(currentVersion) { const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion); @@ -19,21 +26,53 @@ function bumpPatchVersion(currentVersion) { return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; } -async function prepareBuildVersion() { +async function resolveBuildVersion() { const raw = await readFile(packageJsonPath, "utf8"); const pkg = JSON.parse(raw); const nextVersion = bumpPatchVersion(pkg.version); - pkg.version = nextVersion; - await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); return nextVersion; } +async function writeRuntimeConfig(buildVersion) { + const runtimeConfig = { + extends: join(__dirname, "..", "electron-builder.yml"), + directories: { + output: releaseDir, + }, + extraMetadata: { + version: buildVersion, + }, + }; + if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true") { + runtimeConfig.mac = { + target: ["zip"], + hardenedRuntime: false, + }; + } else if (process.platform === "darwin") { + runtimeConfig.mac = { + hardenedRuntime: true, + }; + } + await writeFile(runtimeConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8"); +} + if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) { env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; } const allowedPublishModes = new Set(["always", "never", "onTag", "onTagOrDraft"]); const explicitPublishArg = extraArgs.some((arg) => arg === "--publish" || arg.startsWith("--publish=")); +const explicitPlatformArg = extraArgs.some((arg) => + arg === "--mac" || + arg === "--win" || + arg === "--linux" || + arg === "-m" || + arg === "-w" || + arg === "-l" || + arg.startsWith("--mac=") || + arg.startsWith("--win=") || + arg.startsWith("--linux="), +); const requestedPublishMode = env.AGENTCOMPANY_ELECTRON_PUBLISH?.trim(); let publishMode = "never"; @@ -48,17 +87,22 @@ if (requestedPublishMode) { } } -const builderArgs = ["exec", "electron-builder", "--config", "electron-builder.yml"]; +const builderArgs = ["exec", "electron-builder", "--config", runtimeConfigPath]; if (!explicitPublishArg) { builderArgs.push("--publish", publishMode); } +if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" && !explicitPlatformArg) { + builderArgs.push("--mac", "zip"); +} builderArgs.push(...extraArgs); -await rm(releaseDir, { recursive: true, force: true }); -const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await prepareBuildVersion(); +await rm(buildDir, { recursive: true, force: true }); +await mkdir(buildDir, { recursive: true }); +const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await resolveBuildVersion(); +await writeRuntimeConfig(buildVersion); env.AGENTCOMPANY_BUILD_VERSION = buildVersion; env.npm_package_version = buildVersion; -console.log(`[build-desktop] Cleaned build output and bumped package version to ${buildVersion}.`); +console.log(`[build-desktop] Cleaned ${buildDir} and staged non-mutating build version ${buildVersion}.`); const child = spawn("pnpm", builderArgs, { stdio: "inherit", diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 00000000..627adfef --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,33 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const patchScriptPath = join(__dirname, "patch-electron-name.sh"); + +if (process.platform !== "darwin") { + console.log("[postinstall] Skipping macOS Electron plist patch on non-darwin host."); + process.exit(0); +} + +if (!existsSync(patchScriptPath)) { + console.log("[postinstall] No Electron name patch script found."); + process.exit(0); +} + +const result = spawnSync("bash", [patchScriptPath], { + cwd: dirname(__dirname), + stdio: "inherit", +}); + +if (result.error) { + console.warn(`[postinstall] Electron name patch failed: ${result.error.message}`); + process.exit(0); +} + +if ((result.status ?? 0) !== 0) { + console.warn(`[postinstall] Electron name patch exited with status ${result.status}. Continuing install.`); +} + +process.exit(0); diff --git a/src/main/agent-auth.ts b/src/main/agent-auth.ts index b6c31e52..7fc15a80 100644 --- a/src/main/agent-auth.ts +++ b/src/main/agent-auth.ts @@ -1,6 +1,8 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; -const SIGNING_KEY = randomBytes(32); +let signingKey: Buffer | null = null; const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours interface TokenPayload { @@ -12,20 +14,22 @@ interface TokenPayload { } function sign(payload: TokenPayload): string { + const key = getSigningKey(); const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); - const signature = createHmac("sha256", SIGNING_KEY) + const signature = createHmac("sha256", key) .update(`${header}.${body}`) .digest("base64url"); return `${header}.${body}.${signature}`; } function verify(token: string): TokenPayload | null { + const key = getSigningKey(); const parts = token.split("."); if (parts.length !== 3) return null; const [header, body, signature] = parts as [string, string, string]; - const expectedSignature = createHmac("sha256", SIGNING_KEY) + const expectedSignature = createHmac("sha256", key) .update(`${header}.${body}`) .digest("base64url"); @@ -44,6 +48,34 @@ function verify(token: string): TokenPayload | null { } } +function getSigningKey() { + if (!signingKey) { + throw new Error("Agent token signing key was used before initialization."); + } + return signingKey; +} + +export function initializeAgentTokenSigningKey(profileDir: string) { + const keyPath = join(profileDir, "agent-api-signing.key"); + mkdirSync(profileDir, { recursive: true }); + try { + const nextKey = randomBytes(32); + writeFileSync(keyPath, nextKey, { flag: "wx", mode: 0o600 }); + signingKey = nextKey; + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + throw error; + } + } + const persistedKey = readFileSync(keyPath); + if (persistedKey.length !== 32) { + throw new Error(`Invalid agent API signing key length at ${keyPath}. Expected 32 bytes, received ${persistedKey.length}.`); + } + signingKey = persistedKey; +} + export function createAgentToken(agentId: string, companyId: string, runId: string): string { const now = Date.now(); return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS }); diff --git a/src/main/agent-permissions.ts b/src/main/agent-permissions.ts new file mode 100644 index 00000000..bd898c86 --- /dev/null +++ b/src/main/agent-permissions.ts @@ -0,0 +1,71 @@ +import type { AppDatabase } from "./database"; +import type { TaskRecord } from "@shared/types"; + +function safeGetAgent(db: AppDatabase, agentId: string) { + try { + return db.getAgent(agentId); + } catch { + return null; + } +} + +function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string): boolean { + if (managerId === targetAgentId) { + return true; + } + return db.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId); +} + +export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean { + const agent = safeGetAgent(db, agentId); + if (!agent || agent.companyId !== companyId) { + return false; + } + if (!agent.reportsTo) { + return true; + } + return db.getDirectReports(agentId, companyId).length > 0 || agent.department === "hr"; +} + +export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean { + if (!targetAgentId || targetAgentId === actorAgentId) { + return true; + } + if (!db.belongsToCompany("agents", targetAgentId, companyId)) { + return false; + } + if (canManageOrganization(db, companyId, actorAgentId)) { + return true; + } + return managesAgent(db, actorAgentId, targetAgentId); +} + +export function canUpdateTask(db: AppDatabase, companyId: string, actorAgentId: string, task: TaskRecord): boolean { + if (task.companyId !== companyId) { + return false; + } + if (task.assigneeAgentId === actorAgentId) { + return true; + } + if (!task.assigneeAgentId) { + return canManageOrganization(db, companyId, actorAgentId); + } + if (canManageOrganization(db, companyId, actorAgentId)) { + return true; + } + return managesAgent(db, actorAgentId, task.assigneeAgentId); +} + +export function canManageGoal(db: AppDatabase, companyId: string, actorAgentId: string, ownerAgentId: string | null): boolean { + if (canManageOrganization(db, companyId, actorAgentId)) { + return true; + } + return Boolean(ownerAgentId && ownerAgentId === actorAgentId); +} + +export function canManageProject(db: AppDatabase, companyId: string, actorAgentId: string, leadAgentId: string | null): boolean { + if (canManageOrganization(db, companyId, actorAgentId)) { + return true; + } + return Boolean(leadAgentId && leadAgentId === actorAgentId); +} diff --git a/src/main/api-server.ts b/src/main/api-server.ts index df8d3a32..795f2ea7 100644 --- a/src/main/api-server.ts +++ b/src/main/api-server.ts @@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import log from "electron-log/main.js"; import { z } from "zod"; import { validateAgentToken, type TokenPayload } from "./agent-auth"; +import { canAssignTask, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions"; import type { AppDatabase } from "./database"; import { agentInputSchema, approvalInputSchema, goalInputSchema, projectInputSchema, taskInputSchema } from "@shared/contracts"; @@ -58,12 +59,20 @@ export class AgentApiServer { private onDomainChanged: () => void; private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; private callbacks: ApiServerEventCallbacks; + private allowedOrigins: Set; private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null = null; - constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, callbacks?: Partial) { + constructor( + db: AppDatabase, + onDomainChanged: () => void, + onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, + callbacks?: Partial, + allowedOrigins: Iterable = ["app://-"], + ) { this.db = db; this.onDomainChanged = onDomainChanged; this.onWakeAgent = onWakeAgent; + this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); this.callbacks = { onDomainChanged, onWakeAgent, @@ -115,11 +124,7 @@ export class AgentApiServer { private async handleRequest(req: IncomingMessage, res: ServerResponse) { const origin = req.headers.origin; if (origin) { - const allowedOrigin = - origin === "app://-" || - origin.startsWith("http://127.0.0.1:") || - origin.startsWith("http://localhost:"); - if (!allowedOrigin) { + if (!this.allowedOrigins.has(origin)) { this.json(res, 403, { error: "Origin not allowed" }); return; } @@ -150,11 +155,24 @@ export class AgentApiServer { return; } - if (!this.db.belongsToCompany("runs", token.runId, token.companyId)) { + const runHeader = req.headers["x-agent-company-run-id"]; + const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader; + if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) { + this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" }); + return; + } + + const runState = this.db.getRunAuthState(token.runId); + if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) { this.json(res, 403, { error: "Token references an unknown or mismatched run" }); return; } + if (!["queued", "running"].includes(runState.status)) { + this.json(res, 401, { error: "Run token is no longer active" }); + return; + } + for (const route of this.routes) { if (route.method !== method) continue; const match = pathname.match(route.pattern); @@ -294,17 +312,19 @@ export class AgentApiServer { this.json(res, 404, { error: "Task not found" }); return; } + if (task.assigneeAgentId && !canUpdateTask(this.db, token.companyId, token.agentId, task)) { + this.json(res, 403, { error: "Current agent is not allowed to checkout this task" }); + return; + } + const previousStatus = task.status; const success = this.db.checkoutTask(params.issueId!, agentId); if (!success) { this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" }); return; } this.onDomainChanged(); - // Checkout changes status from todo/backlog to in_progress - const checkedOutTask = (() => { try { return this.db.getTask(params.issueId!); } catch { return null; } })(); - if (checkedOutTask) { - this.callbacks.onTaskStatusChanged(checkedOutTask.id, checkedOutTask.companyId, "todo", "in_progress"); - } + // Checkout changes status from todo/backlog to in_progress — use actual previous status + this.callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress"); this.json(res, 200, { ok: true }); }); @@ -322,8 +342,16 @@ export class AgentApiServer { this.json(res, 404, { error: "Task not found" }); return; } + if (!canUpdateTask(this.db, token.companyId, token.agentId, task)) { + this.json(res, 403, { error: "Current agent is not allowed to update this task" }); + return; + } const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId; + if (!canAssignTask(this.db, token.companyId, token.agentId, newAssignee ?? null)) { + this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); + return; + } const input: Record = { id: task.id, companyId: task.companyId, @@ -450,6 +478,10 @@ export class AgentApiServer { ])) { return; } + if (!canAssignTask(this.db, companyId, token.agentId, input.assigneeAgentId ?? null)) { + this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); + return; + } let id: string; try { @@ -521,6 +553,10 @@ export class AgentApiServer { ])) { return; } + if (!canManageProject(this.db, companyId, token.agentId, input.leadAgentId ?? null)) { + this.json(res, 403, { error: "Current agent is not allowed to create or assign this project" }); + return; + } const id = this.db.saveProject({ companyId, @@ -572,6 +608,11 @@ export class AgentApiServer { this.json(res, 404, { error: "Project not found" }); return; } + const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null; + if (!canManageProject(this.db, token.companyId, token.agentId, nextLeadAgentId)) { + this.json(res, 403, { error: "Current agent is not allowed to update this project" }); + return; + } try { this.db.saveProject({ @@ -580,7 +621,7 @@ export class AgentApiServer { name: parsed.data.name ?? existing.name, description: parsed.data.description ?? existing.description, status: parsed.data.status ?? existing.status, - leadAgentId: parsed.data.leadAgentId ?? existing.leadAgentId ?? null, + leadAgentId: nextLeadAgentId, goalId: parsed.data.goalId !== undefined ? parsed.data.goalId : (existing.goalId ?? null), targetDate: parsed.data.targetDate !== undefined ? parsed.data.targetDate : (existing.targetDate ?? null), }); @@ -612,6 +653,10 @@ export class AgentApiServer { ])) { return; } + if (!canManageGoal(this.db, companyId, token.agentId, input.ownerAgentId ?? null)) { + this.json(res, 403, { error: "Current agent is not allowed to create this goal" }); + return; + } const id = this.db.saveGoal(input); this.db.addActivity({ @@ -651,6 +696,11 @@ export class AgentApiServer { this.json(res, 404, { error: "Goal not found" }); return; } + const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null; + if (!canManageGoal(this.db, token.companyId, token.agentId, nextOwnerAgentId)) { + this.json(res, 403, { error: "Current agent is not allowed to update this goal" }); + return; + } try { this.db.saveGoal({ @@ -659,7 +709,7 @@ export class AgentApiServer { title: parsed.data.title ?? existing.title, description: parsed.data.description ?? existing.description, status: parsed.data.status ?? existing.status, - ownerAgentId: parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null, + ownerAgentId: nextOwnerAgentId, }); this.onDomainChanged(); this.json(res, 200, { ok: true }); @@ -674,6 +724,10 @@ export class AgentApiServer { this.json(res, 403, { error: "Company mismatch" }); return; } + if (!canManageOrganization(this.db, companyId, token.agentId)) { + this.json(res, 403, { error: "Current agent is not allowed to request hires" }); + return; + } const parsed = agentInputSchema.safeParse({ companyId, status: "pending_approval", @@ -748,6 +802,24 @@ export class AgentApiServer { entityId: agentId, detail: input.name, }); + + // Auto-approve hire if company policy allows it + const snapshot = this.db.listSnapshot(); + const company = snapshot.companies.find((c) => c.id === companyId); + if (company?.autoApproveHires) { + this.db.decideApproval({ companyId, approvalId, state: "approved", decisionNote: "Auto-approved by company policy" }); + this.callbacks.onApprovalResolved(approvalId, companyId, "approved"); + this.db.addActivity({ + companyId, + actor: "board", + action: "approval.approved", + entityType: "approval", + entityId: approvalId, + detail: `Auto-approved hire: ${input.name}`, + }); + log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`); + } + this.onDomainChanged(); this.json(res, 201, { agentId, approvalId }); }); @@ -912,46 +984,8 @@ export class AgentApiServer { this.json(res, 200, approval); }); - this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res, params, token, body) => { - const approvalId = params.approvalId!; - - const decisionSchema = z.object({ - state: z.enum(["approved", "rejected", "revision_requested"]), - decisionNote: z.string().max(400).default(""), - }); - - const parsed = decisionSchema.safeParse(body); - if (!parsed.success) { - this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); - return; - } - - // Verify the approval exists and belongs to this company - const snapshot = this.db.listSnapshot(); - const approval = snapshot.approvals.find(a => a.id === approvalId && a.companyId === token.companyId); - if (!approval) { - this.json(res, 404, { error: "Approval not found" }); - return; - } - - if (approval.state !== "pending" && approval.state !== "revision_requested") { - this.json(res, 409, { error: "Approval already decided" }); - return; - } - - this.db.decideApproval({ - companyId: token.companyId, - approvalId, - state: parsed.data.state, - decisionNote: parsed.data.decisionNote, - }); - - this.onDomainChanged(); - - // Fire the full approval resolved event chain (automation rules, hire activation, etc.) - this.callbacks.onApprovalResolved(approvalId, token.companyId, parsed.data.state); - - this.json(res, 200, { ok: true, state: parsed.data.state }); + this.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => { + this.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" }); }); this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => { @@ -1556,6 +1590,22 @@ export class AgentApiServer { } }); + this.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => { + const snapshot = this.db.listSnapshot(); + const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId); + if (!agent) { + this.json(res, 404, { error: "Agent not found" }); + return; + } + const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30); + const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.agentId); + if (!agentMetric) { + this.json(res, 404, { error: "Metrics not found" }); + return; + } + this.json(res, 200, agentMetric); + }); + this.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => { const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30); const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId); diff --git a/src/main/app-lifecycle-service.ts b/src/main/app-lifecycle-service.ts new file mode 100644 index 00000000..2013f978 --- /dev/null +++ b/src/main/app-lifecycle-service.ts @@ -0,0 +1,62 @@ +import { app } from "electron"; + +interface AppLifecycleDependencies { + getMainWindow: () => Electron.BrowserWindow | null; + ensureWindow: () => Promise; + shouldQuit: () => boolean; + onBeforeQuit: () => void; +} + +export function createAppLifecycleService({ + getMainWindow, + ensureWindow, + shouldQuit: _shouldQuit, + onBeforeQuit, +}: AppLifecycleDependencies) { + function register(hasSingleInstanceLock: boolean) { + app.on("activate", async () => { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + mainWindow.focus(); + return; + } + await ensureWindow(); + }); + + app.on("second-instance", () => { + if (!hasSingleInstanceLock) { + return; + } + const mainWindow = getMainWindow(); + if (!mainWindow) { + return; + } + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.focus(); + }); + + app.on("window-all-closed", () => { + // On macOS, keep the app running when all windows are closed (standard behavior). + // On Windows and Linux, quit the app. + if (process.platform !== "darwin") { + app.quit(); + } + }); + + app.on("before-quit", () => { + onBeforeQuit(); + }); + } + + return { + register, + }; +} diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts new file mode 100644 index 00000000..3a2fc7d3 --- /dev/null +++ b/src/main/automation-service.ts @@ -0,0 +1,1020 @@ +import type { AppDatabase } from "./database"; +import type { + AgentRecord, + AutomationAction, + AutomationTrigger, + DesktopEvent, + RunRecord, + RunStatus, +} from "@shared/types"; + +const WORKFLOW_ACTIONS: ReadonlySet = new Set([ + "assign_task", + "create_task", + "notify_agent", + "send_message", + "trigger_heartbeat", + "create_approval", + "update_task_status", + "escalate_to_manager", + "cross_department_notify", + "schedule_meeting", + "create_document", + "auto_approve", + "reassign_task", + "run_task", +]); + +interface WorkflowStepState { + id: string; + name: string; + action: AutomationAction; + configJson: string; + assigneeDepartment: string | null; + assigneeAgentId: string | null; + requiresApproval: boolean; + timeoutSec: number; + status: "pending" | "running" | "completed" | "failed" | "skipped"; +} + +export interface AutomationEventContext { + companyId: string; + taskId?: string; + agentId?: string; + runId?: string; + projectId?: string; + department?: string; + previousStatus?: string; + newStatus?: string; + costUsd?: number; +} + +interface AutomationDependencies { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + emitEvent: (event: DesktopEvent) => void; + publishDomainChanged: () => void; + notify: (title: string, body: string) => void; + wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; + dispatchBrowserAction: (actionId: string, companyId: string) => Promise; +} + +interface RunFinishedEvent { + status: RunStatus; + summary: string | null; + errorMessage: string | null; + costUsd: number | null; +} + +export function createAutomationService({ + db, + logger, + emitEvent, + publishDomainChanged, + notify, + wakeAgentIfPossible, + dispatchBrowserAction, +}: AutomationDependencies) { + const activeAutomationExecutions = new Set(); + const activeWorkflowTriggerExecutions = new Set(); + + function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] { + if (!Array.isArray(raw)) { + throw new Error("Workflow steps must be a JSON array."); + } + return raw.map((entry, index) => { + const step = (entry ?? {}) as Record; + const rawAction = typeof step.action === "string" ? step.action : "send_message"; + if (!WORKFLOW_ACTIONS.has(rawAction as AutomationAction)) { + throw new Error(`Workflow step ${index + 1} has unsupported action "${rawAction}".`); + } + return { + id: typeof step.id === "string" && step.id ? step.id : `step-${index + 1}`, + name: typeof step.name === "string" && step.name ? step.name : `Step ${index + 1}`, + action: rawAction as AutomationAction, + configJson: typeof step.configJson === "string" ? step.configJson : "{}", + assigneeDepartment: typeof step.assigneeDepartment === "string" ? step.assigneeDepartment : null, + assigneeAgentId: typeof step.assigneeAgentId === "string" ? step.assigneeAgentId : null, + requiresApproval: step.requiresApproval === true, + timeoutSec: typeof step.timeoutSec === "number" ? step.timeoutSec : 0, + status: ["pending", "running", "completed", "failed", "skipped"].includes(String(step.status)) + ? (String(step.status) as WorkflowStepState["status"]) + : "pending", + }; + }); + } + + function resolveWorkflowStepActor(step: WorkflowStepState, companyId: string): AgentRecord | null { + if (step.assigneeAgentId) { + const agent = db.getAgent(step.assigneeAgentId); + if (agent.companyId !== companyId) { + throw new Error(`Workflow step assignee ${step.assigneeAgentId} does not belong to company ${companyId}.`); + } + return agent; + } + + if (step.assigneeDepartment) { + const idleAgents = db.getIdleAgentsForDepartment(companyId, step.assigneeDepartment); + if (idleAgents.length > 0) { + return db.getAgent(idleAgents[0]!.id); + } + } + + const snapshot = db.listSnapshot(); + return snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo && agent.status !== "terminated") ?? null; + } + + function buildWorkflowActionContext(companyId: string, step: WorkflowStepState, actor: AgentRecord | null) { + let config: Record = {}; + try { + config = JSON.parse(step.configJson) as Record; + } catch { + throw new Error(`Workflow step "${step.name}" has invalid config JSON.`); + } + + return { + config, + ctx: { + companyId, + agentId: actor?.id, + department: actor?.department ?? step.assigneeDepartment ?? undefined, + taskId: typeof config.taskId === "string" ? config.taskId : undefined, + runId: typeof config.runId === "string" ? config.runId : undefined, + projectId: typeof config.projectId === "string" ? config.projectId : undefined, + newStatus: typeof config.status === "string" ? config.status : undefined, + } satisfies AutomationEventContext, + }; + } + + function matchesEventConfig(configJson: string, sourceDepartment: string | null, ctx: AutomationEventContext): boolean { + if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) { + return false; + } + try { + const conditions = JSON.parse(configJson) as Record; + if (Object.keys(conditions).length === 0) return true; + if (conditions.status && ctx.newStatus && conditions.status !== ctx.newStatus) return false; + if (conditions.previousStatus && ctx.previousStatus && conditions.previousStatus !== ctx.previousStatus) return false; + if (conditions.priority) { + try { + const task = ctx.taskId ? db.getTask(ctx.taskId) : null; + if (task && task.priority !== conditions.priority) return false; + // If task was deleted, skip this condition instead of rejecting the rule. + } catch { + // Entity missing — treat as condition not applicable, continue matching. + } + } + if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) { + try { + const agent = db.getAgent(ctx.agentId); + if (agent.budgetMonthlyUsd > 0) { + const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + if (utilization < (conditions.budgetThresholdPct as number) / 100) return false; + } + // If budget is 0 (unlimited), skip budget condition instead of rejecting. + } catch { + // Agent missing — treat as condition not applicable, continue matching. + } + } + if (conditions.taskId && ctx.taskId && conditions.taskId !== ctx.taskId) return false; + if (conditions.projectId && ctx.projectId && conditions.projectId !== ctx.projectId) return false; + if (conditions.agentId && ctx.agentId && conditions.agentId !== ctx.agentId) return false; + return true; + } catch { + return false; + } + } + + function matchesConditions( + rule: { conditionsJson: string; sourceDepartment: string | null; targetDepartment: string | null }, + ctx: AutomationEventContext, + ) { + void rule.targetDepartment; + return matchesEventConfig(rule.conditionsJson, rule.sourceDepartment, ctx); + } + + function executeAutomationAction( + rule: { action: string; actionConfigJson: string; targetDepartment: string | null; trigger?: string }, + ctx: AutomationEventContext, + ) { + const config = (() => { + try { + return JSON.parse(rule.actionConfigJson) as Record; + } catch { + return {}; + } + })(); + + switch (rule.action as AutomationAction) { + case "assign_task": { + if (ctx.taskId) { + const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, ctx.companyId, "assignment"); + } + } else if (ctx.agentId) { + const snapshot = db.listSnapshot(); + const unassigned = snapshot.tasks + .filter((task) => task.companyId === ctx.companyId && !task.assigneeAgentId && ["backlog", "todo"].includes(task.status)) + .sort((a, b) => { + const priorityScore = { critical: 4, high: 3, medium: 2, low: 1 } as Record; + return (priorityScore[b.priority] ?? 0) - (priorityScore[a.priority] ?? 0); + }); + if (unassigned.length > 0) { + const task = unassigned[0]!; + db.saveTask({ + id: task.id, + companyId: task.companyId, + projectId: task.projectId, + goalId: task.goalId, + parentId: task.parentId, + title: task.title, + description: task.description, + assigneeAgentId: ctx.agentId, + workspaceId: task.workspaceId, + priority: task.priority, + status: "todo", + }); + wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment"); + logger.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`); + } + } + break; + } + case "create_task": { + const title = (config.title as string) ?? "[Auto] Task from automation rule"; + const description = (config.description as string) ?? ""; + const priority = (config.priority as string) ?? "medium"; + const assigneeAgentId = (config.assigneeAgentId as string) ?? null; + const taskId = db.saveTask({ + companyId: ctx.companyId, + projectId: ctx.projectId ?? (config.projectId as string) ?? null, + title, + description, + assigneeAgentId, + priority, + status: assigneeAgentId ? "todo" : "backlog", + }); + handleTaskCreated(taskId, ctx.companyId); + logger.info(`[automation] Created task: ${title} (${taskId})`); + break; + } + case "notify_agent": + case "send_message": { + const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null; + const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId; + if (!fromAgentId) break; + const channel = (config.channel as string) ?? (toAgentId ? "direct" : "company"); + let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null; + if (channel === "department" && !channelTargetId && fromAgentId) { + try { + const sender = db.getAgent(fromAgentId); + channelTargetId = sender.department ?? null; + } catch { + channelTargetId = null; + } + } + const messageId = db.sendAgentMessage({ + companyId: ctx.companyId, + fromAgentId, + toAgentId: channel === "direct" ? toAgentId : null, + channel, + channelTargetId, + subject: (config.subject as string) ?? "Automation notification", + body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`, + priority: (config.priority as string) ?? "normal", + }); + const message = db.getAgentMessage(messageId, ctx.companyId); + if (message) { + emitEvent({ type: "new-message", message }); + } + publishDomainChanged(); + if (toAgentId && channel === "direct") { + wakeAgentIfPossible(toAgentId, ctx.companyId, "message"); + } + break; + } + case "trigger_heartbeat": { + let targetAgentId = (config.agentId as string) ?? null; + if (!targetAgentId && ctx.agentId) { + const triggerType = rule.trigger ?? ""; + if (triggerType === "run_completed" || triggerType === "run_failed") { + const manager = db.getManagerForAgent(ctx.agentId); + targetAgentId = manager?.id ?? ctx.agentId; + } else { + targetAgentId = ctx.agentId; + } + } + if (targetAgentId) { + // Derive a descriptive wake trigger from the automation context + const triggerType = rule.trigger ?? ""; + const wakeTrigger = + triggerType === "task_status_changed" && ctx.previousStatus === "in_review" ? "comment" + : triggerType === "run_completed" ? "subtask_completed" + : triggerType === "run_failed" ? "report_failed" + : triggerType === "hire_approved" ? "assignment" + : triggerType === "agent_idle" ? "timer" + : "manual"; + wakeAgentIfPossible(targetAgentId, ctx.companyId, wakeTrigger); + } + break; + } + case "create_approval": { + const approvalType = (config.approvalType as string) ?? "document_review"; + db.requestApproval({ + companyId: ctx.companyId, + relatedTaskId: ctx.taskId ?? null, + requestedByAgentId: ctx.agentId ?? null, + type: approvalType, + payloadSummary: (config.summary as string) ?? "Auto-generated approval request", + impactSummary: (config.impact as string) ?? "Triggered by automation rule", + }); + break; + } + case "update_task_status": { + if (!ctx.taskId) break; + const newStatus = (config.status as string) ?? "todo"; + const previousTask = db.getTask(ctx.taskId); + db.advanceTaskStatus(ctx.taskId, newStatus, "Status changed by automation rule"); + publishDomainChanged(); + if (previousTask.status !== newStatus && rule.trigger !== "task_status_changed") { + handleTaskStatusChange(ctx.taskId, ctx.companyId, previousTask.status, newStatus); + } + break; + } + case "escalate_to_manager": { + const targetAgentId = ctx.agentId; + if (!targetAgentId) break; + let agentName = "an agent"; + try { + agentName = db.getAgent(targetAgentId).name; + } catch { + // Fall back to the generic label when the agent was deleted mid-flight. + } + let taskTitle = ctx.taskId ?? "unknown"; + try { + if (ctx.taskId) taskTitle = db.getTask(ctx.taskId).title; + } catch { + // Keep the fallback title when the task no longer exists. + } + const manager = db.getManagerForAgent(targetAgentId); + const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`; + // Pick the most descriptive wake trigger based on the originating event + const wakeTrigger = ctx.newStatus === "in_review" + ? "subtask_completed" + : ctx.newStatus === "blocked" + ? "report_blocked" + : rule.trigger === "run_completed" + ? "subtask_completed" + : rule.trigger === "run_failed" + ? "report_failed" + : "report_blocked"; + if (manager) { + db.sendAgentMessage({ + companyId: ctx.companyId, + fromAgentId: targetAgentId, + toAgentId: manager.id, + channel: "direct", + subject: `Escalation from ${agentName}: ${taskTitle}`, + body: escalationBody, + priority: "urgent", + }); + publishDomainChanged(); + wakeAgentIfPossible(manager.id, ctx.companyId, wakeTrigger); + } else { + // No manager found — agent is likely the CEO or top-level. + // Post as company-wide message so the board and other leaders see it. + db.sendAgentMessage({ + companyId: ctx.companyId, + fromAgentId: targetAgentId, + toAgentId: null, + channel: "company", + subject: `Escalation: ${taskTitle}`, + body: escalationBody, + priority: "urgent", + }); + publishDomainChanged(); + } + break; + } + case "cross_department_notify": { + const targetDepartment = rule.targetDepartment ?? (config.department as string); + if (!targetDepartment) break; + const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment); + const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id; + if (senderAgentId) { + db.sendAgentMessage({ + companyId: ctx.companyId, + fromAgentId: senderAgentId, + toAgentId: null, + channel: "department", + channelTargetId: targetDepartment, + subject: (config.subject as string) ?? "Cross-department notification", + body: (config.body as string) ?? "Automation triggered a cross-department alert.", + priority: "normal", + }); + publishDomainChanged(); + } + for (const agent of departmentAgents.slice(0, 5)) { + wakeAgentIfPossible(agent.id, ctx.companyId, "message"); + } + break; + } + case "schedule_meeting": { + const meetingType = (config.meetingType as string) ?? "department_sync"; + const title = (config.title as string) ?? "Auto-scheduled meeting"; + db.saveMeeting({ + companyId: ctx.companyId, + type: meetingType, + title, + organizerAgentId: ctx.agentId ?? null, + participantAgentIds: (config.participantAgentIds as string) ?? "[]", + scheduledAt: new Date(Date.now() + 3600_000).toISOString(), + durationMinutes: (config.durationMinutes as number) ?? 30, + }); + break; + } + case "create_document": { + const documentType = (config.documentType as string) ?? "status_report"; + const title = (config.title as string) ?? "Auto-generated document"; + db.saveDocument({ + companyId: ctx.companyId, + type: documentType, + title, + content: (config.content as string) ?? "", + authorAgentId: ctx.agentId ?? null, + projectId: ctx.projectId ?? null, + }); + break; + } + case "auto_approve": { + const snapshot = db.listSnapshot(); + const pendingApprovals = snapshot.approvals.filter( + (approval) => approval.companyId === ctx.companyId && approval.state === "pending", + ); + for (const approval of pendingApprovals.slice(0, 5)) { + db.decideApproval({ + companyId: ctx.companyId, + approvalId: approval.id, + state: "approved", + decisionNote: "Auto-approved by automation rule.", + }); + handleApprovalResolved(approval.id, ctx.companyId, "approved"); + } + break; + } + case "reassign_task": { + if (!ctx.taskId) break; + const newAssignee = db.autoAssignTask(ctx.taskId, ctx.companyId); + if (newAssignee) { + wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment"); + } + break; + } + case "run_task": { + const targetTaskId = (config.taskId as string) ?? ctx.taskId; + if (!targetTaskId) break; + const task = db.getTask(targetTaskId); + if (!task.assigneeAgentId) break; + wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment"); + break; + } + default: + throw new Error(`Unknown automation action: ${rule.action}`); + } + } + + function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) { + try { + const rules = db.listActiveAutomationRules(ctx.companyId, trigger); + for (const rule of rules) { + if (!matchesConditions(rule, ctx)) continue; + const executionKey = [ + rule.id, + trigger, + ctx.companyId, + ctx.taskId ?? "-", + ctx.agentId ?? "-", + ctx.runId ?? "-", + ctx.previousStatus ?? "-", + ctx.newStatus ?? "-", + ].join(":"); + if (activeAutomationExecutions.has(executionKey)) { + logger.warn(`[automation] Skipping re-entrant execution for rule "${rule.name}" (${executionKey})`); + continue; + } + activeAutomationExecutions.add(executionKey); + try { + executeAutomationAction(rule, ctx); + db.recordAutomationExecution( + rule.id, + ctx.companyId, + rule.name, + trigger, + rule.action, + `Fired for ${trigger} in context: taskId=${ctx.taskId ?? "n/a"}, agentId=${ctx.agentId ?? "n/a"}`, + ); + emitEvent({ + type: "automation-fired", + ruleId: rule.id, + ruleName: rule.name, + trigger: trigger as AutomationTrigger, + action: rule.action as AutomationAction, + }); + logger.info(`[automation] Fired rule "${rule.name}" (${trigger} → ${rule.action})`); + } catch (error) { + logger.error(`[automation] Failed to execute action for rule "${rule.name}": ${error instanceof Error ? error.message : String(error)}`); + db.recordAutomationExecution( + rule.id, + ctx.companyId, + rule.name, + trigger, + rule.action, + `FAILED: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + activeAutomationExecutions.delete(executionKey); + } + } + } catch (error) { + logger.error(`[automation] Error evaluating rules for trigger "${trigger}": ${error instanceof Error ? error.message : String(error)}`); + } + } + + function evaluateWorkflowTriggers(trigger: string, ctx: AutomationEventContext) { + try { + const workflows = db.listTriggerableWorkflows(ctx.companyId, trigger); + for (const workflow of workflows) { + if (!matchesEventConfig(workflow.triggerConfigJson, null, ctx)) continue; + const executionKey = [ + workflow.id, + trigger, + ctx.companyId, + ctx.taskId ?? "-", + ctx.agentId ?? "-", + ctx.runId ?? "-", + ctx.previousStatus ?? "-", + ctx.newStatus ?? "-", + ].join(":"); + if (activeWorkflowTriggerExecutions.has(executionKey)) { + logger.warn(`[workflow] Skipping re-entrant trigger for workflow "${workflow.name}" (${executionKey})`); + continue; + } + activeWorkflowTriggerExecutions.add(executionKey); + void runWorkflowPipeline(workflow.id, ctx.companyId) + .then(() => { + logger.info(`[workflow] Triggered workflow "${workflow.name}" via ${trigger}`); + }) + .catch((error) => { + logger.error(`[workflow] Trigger failed for "${workflow.name}": ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { + activeWorkflowTriggerExecutions.delete(executionKey); + }); + } + } catch (error) { + logger.error(`[workflow] Error evaluating workflow triggers for "${trigger}": ${error instanceof Error ? error.message : String(error)}`); + } + } + + function dispatchAutomation(trigger: string, ctx: AutomationEventContext) { + evaluateAutomationRules(trigger, ctx); + evaluateWorkflowTriggers(trigger, ctx); + } + + async function runWorkflowPipeline(workflowId: string, companyId: string) { + const workflow = db.getWorkflowRecord(workflowId, companyId); + if (!workflow) { + throw new Error("Workflow not found."); + } + const parsedSteps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); + if (parsedSteps.some((step) => step.status === "running")) { + throw new Error("Workflow is already running."); + } + if (parsedSteps.length === 0) { + throw new Error("Workflow has no steps."); + } + + const resumeFrom = workflow.status === "paused" ? workflow.currentStepIndex : 0; + const steps: WorkflowStepState[] = parsedSteps.map((step, index) => { + if (workflow.status !== "paused") { + return { ...step, status: index < resumeFrom ? "completed" : "pending" }; + } + return step; + }); + + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "active", + currentStepIndex: resumeFrom, + stepsJson: JSON.stringify(steps), + incrementRunCount: workflow.status !== "paused", + lastRunAt: new Date().toISOString(), + }); + publishDomainChanged(); + + for (let index = resumeFrom; index < steps.length; index += 1) { + const step = steps[index]!; + step.status = "running"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "active", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + publishDomainChanged(); + + const actor = resolveWorkflowStepActor(step, companyId); + const { config, ctx } = buildWorkflowActionContext(companyId, step, actor); + + if (step.requiresApproval) { + const approvalId = db.requestApproval({ + companyId, + requestedByAgentId: actor?.id ?? null, + relatedTaskId: ctx.taskId ?? null, + type: "dangerous_command", + payloadSummary: `Workflow approval required: ${workflow.name} / ${step.name}`, + impactSummary: `Workflow step "${step.name}" requires board approval before ${step.action}.`, + payloadJson: JSON.stringify({ workflowId, stepId: step.id, stepName: step.name }), + }); + step.status = "pending"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "paused", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + db.addActivity({ + companyId, + actor: actor?.id ?? "system", + action: "workflow.paused_for_approval", + entityType: "workflow", + entityId: workflowId, + detail: `Paused on step "${step.name}" with approval ${approvalId}.`, + }); + publishDomainChanged(); + return; + } + + try { + executeAutomationAction({ + action: step.action, + actionConfigJson: JSON.stringify(config), + targetDepartment: step.assigneeDepartment, + trigger: "workflow_step", + }, ctx); + step.status = "completed"; + db.addActivity({ + companyId, + actor: actor?.id ?? "system", + action: "workflow.step_completed", + entityType: "workflow", + entityId: workflowId, + detail: step.name, + }); + const isLastStep = index === steps.length - 1; + const completionStatus = isLastStep ? "completed" : "active"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: completionStatus, + currentStepIndex: index + 1, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + publishDomainChanged(); + } catch (error) { + step.status = "failed"; + db.updateWorkflowExecution({ + id: workflowId, + companyId, + status: "failed", + currentStepIndex: index, + stepsJson: JSON.stringify(steps), + lastRunAt: new Date().toISOString(), + }); + db.addActivity({ + companyId, + actor: actor?.id ?? "system", + action: "workflow.step_failed", + entityType: "workflow", + entityId: workflowId, + detail: `${step.name}: ${error instanceof Error ? error.message : String(error)}`, + }); + publishDomainChanged(); + throw error; + } + } + } + + function processRunCompletion(run: RunRecord, event: RunFinishedEvent) { + let task: ReturnType | null = null; + try { + task = db.getTask(run.taskId); + } catch { + return; + } + if (!task) return; + + const isSuccess = event.status === "succeeded"; + const isFailed = event.status === "failed" || event.status === "timed_out"; + + if (isSuccess && task.status === "in_progress") { + db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review."); + handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review"); + publishDomainChanged(); + } + + if (isSuccess && task.title.startsWith("[Heartbeat]") && task.status !== "done") { + const previousStatus = task.status; + db.advanceTaskStatus(task.id, "done", "Heartbeat check completed."); + handleTaskStatusChange(task.id, run.companyId, previousStatus, "done"); + } + + const automationContext: AutomationEventContext = { + companyId: run.companyId, + taskId: run.taskId, + agentId: run.agentId, + runId: run.id, + costUsd: event.costUsd ?? undefined, + }; + try { + const agent = db.getAgent(run.agentId); + automationContext.department = agent.department ?? undefined; + } catch { + // Agent can disappear after a run completes; keep the context without department. + } + + if (isSuccess) { + dispatchAutomation("run_completed", automationContext); + if (task.parentId) { + try { + const freshSnapshot = db.listSnapshot(); + const siblings = freshSnapshot.tasks.filter((entry) => entry.parentId === task!.parentId && entry.companyId === run.companyId); + const allSiblingsDoneOrReview = siblings.every((entry) => entry.status === "done" || entry.status === "in_review"); + if (allSiblingsDoneOrReview) { + const parent = db.getTask(task.parentId); + if (parent.assigneeAgentId) { + wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed"); + } else { + const assignedId = db.autoAssignTask(parent.id, run.companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed"); + } + } + } + } catch { + // Parent task may have been deleted or reassigned during cascade evaluation. + } + } + } + + if (isFailed) { + dispatchAutomation("run_failed", automationContext); + } + + if (event.costUsd && event.costUsd > 0) { + try { + const agent = db.getAgent(run.agentId); + if (agent.budgetMonthlyUsd > 0) { + const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + if (utilization >= 0.8) { + dispatchAutomation("budget_threshold", { + ...automationContext, + agentId: agent.id, + }); + } + } + } catch { + // Budget follow-up is best-effort; do not fail run completion if the agent disappeared. + } + } + } + + function handleApprovalResolved(approvalId: string, companyId: string, decision: string) { + const snapshot = db.listSnapshot(); + const approval = snapshot.approvals.find((entry) => entry.id === approvalId); + if (!approval) return; + + dispatchAutomation("approval_resolved", { + companyId, + agentId: approval.requestedByAgentId ?? undefined, + taskId: approval.relatedTaskId ?? undefined, + }); + + if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) { + try { + const agent = db.getAgent(approval.relatedAgentId); + if (agent.status === "idle") { + db.addActivity({ + companyId, + actor: "system", + action: "agent.hired", + entityType: "agent", + entityId: agent.id, + detail: `${agent.name} hire approved and activated.`, + }); + dispatchAutomation("hire_approved", { + companyId, + agentId: agent.id, + }); + wakeAgentIfPossible(agent.id, companyId, "assignment"); + publishDomainChanged(); + notify("New agent hired", `${agent.name} (${agent.role}) has joined the company.`); + } + } catch { + // Hire follow-up is best-effort; approval state is already committed in storage. + } + } + + const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId); + if (browserAction) { + if (decision === "approved") { + db.updateBrowserAction(browserAction.id, { + status: "queued", + resultSummary: "Approval granted. Dispatching browser action.", + errorMessage: null, + }); + publishDomainChanged(); + void dispatchBrowserAction(browserAction.id, companyId).catch((error) => { + logger.error(`[browser] Failed to dispatch approved browser action ${browserAction.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } else if (decision === "rejected") { + db.updateBrowserAction(browserAction.id, { + status: "cancelled", + resultSummary: "Browser action was rejected during approval.", + errorMessage: "Approval rejected by board.", + finishedAt: new Date().toISOString(), + }); + publishDomainChanged(); + } else if (decision === "revision_requested") { + db.updateBrowserAction(browserAction.id, { + status: "approval_required", + resultSummary: "Board requested revisions before execution.", + errorMessage: "Approval requires revisions before the browser action can run.", + }); + publishDomainChanged(); + } + } + + if (approval.payloadJson) { + try { + const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string }; + if (payload.workflowId && payload.stepId) { + const workflow = db.getWorkflowRecord(payload.workflowId, companyId); + if (workflow) { + const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); + const stepIndex = steps.findIndex((step) => step.id === payload.stepId); + if (stepIndex >= 0) { + if (decision === "approved") { + steps[stepIndex]!.status = "pending"; + db.updateWorkflowExecution({ + id: workflow.id, + companyId, + status: "paused", + currentStepIndex: stepIndex, + stepsJson: JSON.stringify(steps), + lastRunAt: workflow.lastRunAt, + }); + publishDomainChanged(); + void runWorkflowPipeline(workflow.id, companyId).catch((error) => { + logger.error(`[workflow] Failed to resume workflow ${workflow.id} after approval ${approvalId}: ${error instanceof Error ? error.message : String(error)}`); + }); + } else { + steps[stepIndex]!.status = decision === "revision_requested" ? "pending" : "failed"; + db.updateWorkflowExecution({ + id: workflow.id, + companyId, + status: decision === "revision_requested" ? "paused" : "failed", + currentStepIndex: stepIndex, + stepsJson: JSON.stringify(steps), + lastRunAt: workflow.lastRunAt, + }); + publishDomainChanged(); + } + } + } + } + } catch { + // Non-workflow approvals may use payloadJson for unrelated metadata. + } + } + + if (approval.requestedByAgentId) { + wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved"); + } + } + + function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) { + if (previousStatus === newStatus) return; + + let task: ReturnType | null = null; + try { + task = db.getTask(taskId); + } catch { + return; + } + if (!task) return; + + db.addActivity({ + companyId, + actor: task.assigneeAgentId ?? "board", + action: "task.status_changed", + entityType: "task", + entityId: taskId, + detail: `${previousStatus} → ${newStatus}`, + }); + + const context: AutomationEventContext = { + companyId, + taskId, + agentId: task.assigneeAgentId ?? undefined, + previousStatus, + newStatus, + }; + try { + if (task.assigneeAgentId) { + const agent = db.getAgent(task.assigneeAgentId); + context.department = agent.department ?? undefined; + } + } catch { + // Agent may have been deleted after the task update; keep the transition without department context. + } + + dispatchAutomation("task_status_changed", context); + + if (newStatus === "blocked") { + dispatchAutomation("task_blocked", context); + if (task.assigneeAgentId) { + const manager = db.getManagerForAgent(task.assigneeAgentId); + if (manager) { + wakeAgentIfPossible(manager.id, companyId, "report_blocked"); + } + } + } + + if (newStatus === "done" && task.parentId) { + try { + const snapshot = db.listSnapshot(); + const siblings = snapshot.tasks.filter((entry) => entry.parentId === task!.parentId && entry.companyId === companyId); + const allDone = siblings.every((entry) => entry.id === taskId ? true : entry.status === "done"); + if (allDone) { + const parent = db.getTask(task.parentId); + if (parent.assigneeAgentId) { + wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed"); + } else { + const assignedId = db.autoAssignTask(parent.id, companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, companyId, "subtask_completed"); + } + } + } + } catch { + // Parent task may have been deleted or reassigned during completion cascade evaluation. + } + } + } + + function handleTaskCreated(taskId: string, companyId: string) { + let task: ReturnType | null = null; + try { + task = db.getTask(taskId); + } catch { + return; + } + if (!task) return; + + dispatchAutomation("task_created", { + companyId, + taskId, + agentId: task.assigneeAgentId ?? undefined, + }); + + if (!task.assigneeAgentId) { + const assignedId = db.autoAssignTask(taskId, companyId); + if (assignedId) { + wakeAgentIfPossible(assignedId, companyId, "assignment"); + } + } else { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment"); + } + } + + return { + runWorkflowPipeline, + dispatchAutomation, + processRunCompletion, + handleApprovalResolved, + handleTaskStatusChange, + handleTaskCreated, + }; +} diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts new file mode 100644 index 00000000..6dd70f28 --- /dev/null +++ b/src/main/bootstrap-application.ts @@ -0,0 +1,208 @@ +import { app } from "electron"; +import log from "electron-log/main.js"; +import type { AgentApiServer } from "./api-server"; +import type { AppDatabase } from "./database"; +import type { BrowserManager } from "./browser-manager"; +import { prepareHostEnvironment } from "./host-environment-service"; +import { createServiceGraph } from "./service-graph"; +import { createIpcSupport } from "./ipc-support"; +import type { DesktopEvent } from "@shared/types"; + +interface BootstrapApplicationDependencies { + hasSingleInstanceLock: boolean; + isDev: boolean; + devServerOrigin: string | null; + profileDir: string; + appDatabaseFactory: (profileDir: string) => AppDatabase; + initSigningKey: (profileDir: string) => void; + windowStatePath: string; + preloadPath: string; + rendererDist: string; + iconPath: string; + devServerUrl?: string; + workerPath: string; + createApiServer: (db: AppDatabase, callbacks: { + onDomainChanged: () => void; + onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; + onTaskCreated: (taskId: string, companyId: string) => void; + onTaskStatusChanged: (taskId: string, companyId: string, prev: string, next: string) => void; + onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void; + onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; + onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void; + onAutoAssignTask: (taskId: string, companyId: string) => string | null; + }, allowedOrigins: string[]) => AgentApiServer; + createRunToken: (agentId: string, companyId: string, runId: string) => string; + seedDefaultAutomationRules: (companyId: string) => void; + isQuittingRef: { current: boolean }; + onRuntimeReady?: (state: { + db: AppDatabase; + apiServer: AgentApiServer; + serviceGraph: ReturnType; + ipcSupport: ReturnType; + }) => void; +} + +export async function bootstrapApplication({ + hasSingleInstanceLock, + isDev, + devServerOrigin, + profileDir, + appDatabaseFactory, + initSigningKey, + windowStatePath, + preloadPath, + rendererDist, + iconPath, + devServerUrl, + workerPath, + createApiServer, + createRunToken, + seedDefaultAutomationRules, + isQuittingRef, + onRuntimeReady, +}: BootstrapApplicationDependencies) { + await app.whenReady(); + log.initialize(); + await prepareHostEnvironment({ iconPath, logger: log }); + + const db = appDatabaseFactory(profileDir); + db.init(); + initSigningKey(db.profileDir); + + const apiServerRef = { current: null as AgentApiServer | null }; + const browserManagerRef = { current: null as BrowserManager | null }; + let ipcSupport: ReturnType | null = null; + const notify = (title: string, body: string) => { + if (!ipcSupport) { + throw new Error("IPC support is not initialized."); + } + ipcSupport.notify(title, body); + }; + const emitEvent = (event: DesktopEvent) => { + ipcSupport?.emitEvent(event); + }; + const publishDomainChanged = () => { + ipcSupport?.publishDomainChanged(); + }; + + const serviceGraph = createServiceGraph({ + db, + apiServerRef, + browserManagerRef, + isQuittingRef, + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + workerPath, + logger: log, + emitEvent, + publishDomainChanged, + notify, + createRunToken, + seedDefaultAutomationRules, + }); + ipcSupport = createIpcSupport({ + isDev, + devServerOrigin, + logger: log, + getMainWindow: () => serviceGraph.getDesktopShellService()?.getMainWindow() ?? null, + getDb: () => ({ + hasCompany: (companyId: string) => db.hasCompany(companyId), + belongsToCompany: (table: string, id: string, companyId: string) => db.belongsToCompany(table as any, id, companyId), + }), + }); + serviceGraph.init(); + + const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => { + const orchestration = serviceGraph.getOrchestrationService(); + if (!orchestration) throw new Error("Orchestration service is not initialized."); + return orchestration.wakeAgentIfPossible(agentId, companyId, trigger); + }; + const handleTaskCreated = (taskId: string, companyId: string) => { + const automation = serviceGraph.getAutomationService(); + if (!automation) throw new Error("Automation service is not initialized."); + automation.handleTaskCreated(taskId, companyId); + }; + const handleTaskStatusChange = (taskId: string, companyId: string, prev: string, next: string) => { + const automation = serviceGraph.getAutomationService(); + if (!automation) throw new Error("Automation service is not initialized."); + automation.handleTaskStatusChange(taskId, companyId, prev, next); + }; + const handleApprovalResolved = (approvalId: string, companyId: string, decision: string) => { + const automation = serviceGraph.getAutomationService(); + if (!automation) throw new Error("Automation service is not initialized."); + automation.handleApprovalResolved(approvalId, companyId, decision); + }; + const dispatchAutomation = (trigger: string, ctx: Record) => { + const automation = serviceGraph.getAutomationService(); + if (!automation) throw new Error("Automation service is not initialized."); + automation.dispatchAutomation(trigger, ctx as any); + }; + + const apiServer = createApiServer(db, { + onDomainChanged: publishDomainChanged, + onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger), + onTaskCreated: handleTaskCreated, + onTaskStatusChanged: handleTaskStatusChange, + onCommentPosted: (taskId, companyId, authorAgentId) => { + dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined }); + }, + onApprovalResolved: handleApprovalResolved, + onDocumentCreated: (companyId, authorAgentId, projectId) => { + dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined }); + }, + onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId), + }, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin))); + + apiServerRef.current = apiServer; + await apiServer.start(); + onRuntimeReady?.({ + db, + apiServer, + serviceGraph, + ipcSupport, + }); + + if (apiServer && serviceGraph.getBrowserActionService()) { + apiServer.setBrowserActionHandler(async (actionId, companyId) => { + const browserActions = serviceGraph.getBrowserActionService(); + if (!browserActions) throw new Error("Browser action service is not initialized"); + await browserActions.dispatchBrowserAction(actionId, companyId); + }); + } + + // Show the window immediately so the user sees the app launch without delay. + await serviceGraph.getDesktopShellService()?.ensureWindow(); + serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock); + + // Run connector health checks AFTER the window is visible. + // This ensures connectors have a known status before any agent run is attempted, + // but doesn't block the startup experience. + const connectorHealth = serviceGraph.getConnectorHealthService(); + if (connectorHealth) { + const connectors = db.listSnapshot().connectors; + const healthCheckResults = await Promise.allSettled( + connectors.map((connector) => connectorHealth.checkConnector(connector.id)), + ); + for (let i = 0; i < healthCheckResults.length; i++) { + const result = healthCheckResults[i]!; + if (result.status === "rejected") { + log.warn(`[bootstrap] Connector health check failed for ${connectors[i]?.id}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`); + } + } + log.info(`[bootstrap] Connector health checks completed (${healthCheckResults.filter((r) => r.status === "fulfilled").length}/${connectors.length} succeeded)`); + } + + serviceGraph.getOrchestrationService()?.startHeartbeatScheduler(); + serviceGraph.getOrchestrationService()?.resumeQueuedRuns(); + + return { + db, + apiServer, + serviceGraph, + ipcSupport, + }; +} diff --git a/src/main/browser-action-service.ts b/src/main/browser-action-service.ts new file mode 100644 index 00000000..51986549 --- /dev/null +++ b/src/main/browser-action-service.ts @@ -0,0 +1,114 @@ +import { join } from "node:path"; +import type { AppDatabase } from "./database"; +import { BrowserManager } from "./browser-manager"; + +interface BrowserActionDependencies { + db: AppDatabase; + browserManagerRef: { current: BrowserManager | null }; + publishDomainChanged: () => void; +} + +export function createBrowserActionService({ + db, + browserManagerRef, + publishDomainChanged, +}: BrowserActionDependencies) { + function requireBrowserManager() { + if (!browserManagerRef.current) { + throw new Error("Browser manager is not initialized."); + } + return browserManagerRef.current; + } + + async function dispatchBrowserAction(actionId: string, companyId: string) { + const action = db.getBrowserAction(actionId, companyId); + if (!action) { + throw new Error(`Browser action not found: ${actionId}`); + } + + const account = db.getSocialAccount(action.socialAccountId, companyId); + if (!account) { + db.updateBrowserAction(actionId, { + status: "failed", + errorMessage: "Social account no longer exists.", + finishedAt: new Date().toISOString(), + }); + publishDomainChanged(); + return; + } + + let payload: Record; + try { + payload = JSON.parse(action.payloadJson) as Record; + } catch { + db.updateBrowserAction(actionId, { + status: "failed", + errorMessage: "Browser action payload is not valid JSON.", + finishedAt: new Date().toISOString(), + }); + publishDomainChanged(); + return; + } + + db.updateBrowserAction(actionId, { + status: "running", + startedAt: new Date().toISOString(), + errorMessage: null, + }); + publishDomainChanged(); + + try { + const result = await requireBrowserManager().executeAction({ + id: actionId, + socialAccountId: account.id, + platform: account.platform, + actionType: action.actionType, + payload, + }); + + db.updateBrowserAction(actionId, { + status: result.cancelled ? "cancelled" : result.success ? "succeeded" : "failed", + resultSummary: result.summary, + screenshotPath: result.screenshotPath, + errorMessage: result.error, + finishedAt: new Date().toISOString(), + }); + } catch (error) { + db.updateBrowserAction(actionId, { + status: "failed", + errorMessage: error instanceof Error ? error.message : String(error), + finishedAt: new Date().toISOString(), + }); + } + + publishDomainChanged(); + } + + async function clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) { + if (!browserManagerRef.current) { + return; + } + await browserManagerRef.current.clearSocialAccountData(socialAccountId, screenshotPaths); + } + + function cancelAction(actionId: string) { + return browserManagerRef.current?.cancelAction(actionId) ?? false; + } + + async function launchLoginBrowser(socialAccountId: string, platform: string, loginUrl?: string) { + return requireBrowserManager().launchLoginBrowser(socialAccountId, platform, loginUrl); + } + + function resetBrowserManager() { + browserManagerRef.current?.shutdown(); + browserManagerRef.current = new BrowserManager(join(db.artifactsDir, "browser")); + } + + return { + dispatchBrowserAction, + clearSocialAccountData, + cancelAction, + launchLoginBrowser, + resetBrowserManager, + }; +} diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 079cdeb1..26c98c5c 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -1,6 +1,7 @@ import { BrowserWindow, session, type NativeImage } from "electron"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { rm } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; import log from "electron-log/main.js"; export interface BrowserAction { @@ -13,6 +14,7 @@ export interface BrowserAction { export interface BrowserActionResult { success: boolean; + cancelled: boolean; summary: string; screenshotPath: string | null; error: string | null; @@ -104,6 +106,8 @@ export class BrowserManager { private processing = new Set(); private loginWindows = new Map(); private initializedSessions = new Set(); + private activeActionWindows = new Map(); + private cancelledActionIds = new Set(); constructor(profileDir: string) { this.screenshotDir = join(profileDir, "browser-screenshots"); @@ -121,6 +125,36 @@ export class BrowserManager { return socialSession; } + async clearSocialAccountData(socialAccountId: string, screenshotPaths: string[] = []) { + const existing = this.loginWindows.get(socialAccountId); + if (existing && !existing.isDestroyed()) { + existing.close(); + } + this.loginWindows.delete(socialAccountId); + this.queues.delete(socialAccountId); + this.processing.delete(socialAccountId); + + const ses = this.getSession(socialAccountId); + try { + await ses.clearStorageData(); + await ses.clearCache(); + await ses.clearAuthCache(); + } catch (error) { + log.warn(`[browser] Failed to fully clear session for ${socialAccountId}: ${error instanceof Error ? error.message : String(error)}`); + } + + await Promise.all( + screenshotPaths + .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + .filter((screenshotPath) => { + const resolvedPath = resolve(screenshotPath); + const resolvedRoot = resolve(this.screenshotDir); + return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${sep}`); + }) + .map((screenshotPath) => rm(screenshotPath, { force: true }).catch(() => undefined)), + ); + } + async launchLoginBrowser( socialAccountId: string, platform: string, @@ -129,12 +163,13 @@ export class BrowserManager { const existing = this.loginWindows.get(socialAccountId); if (existing && !existing.isDestroyed()) { existing.focus(); - return { success: true, summary: "Login window already open", screenshotPath: null, error: null }; + return { success: true, cancelled: false, summary: "Login window already open", screenshotPath: null, error: null }; } const rules = this.getPlatformRules(platform); const url = loginUrl || rules.loginUrl; const ses = this.getSession(socialAccountId); + this.assertAllowedUrl(platform, url, "login"); log.info(`[browser] Opening login window for ${platform} → ${url}`); @@ -151,6 +186,7 @@ export class BrowserManager { }); this.loginWindows.set(socialAccountId, win); + this.attachWindowSecurity(win, platform); win.setMenuBarVisibility(false); @@ -167,6 +203,7 @@ export class BrowserManager { log.info(`[browser] Login window closed for ${platform} — verified=${verified}`); resolve({ success: verified, + cancelled: false, summary: verified ? `Authenticated session detected for ${platform}` : `No authenticated ${platform} session detected`, @@ -187,6 +224,36 @@ export class BrowserManager { }); } + cancelAction(actionId: string): boolean { + this.cancelledActionIds.add(actionId); + const activeWindow = this.activeActionWindows.get(actionId); + if (activeWindow && !activeWindow.isDestroyed()) { + activeWindow.close(); + return true; + } + + for (const [socialAccountId, queue] of this.queues.entries()) { + const index = queue.findIndex((item) => item.action.id === actionId); + if (index < 0) { + continue; + } + const [item] = queue.splice(index, 1); + item?.resolve({ + success: false, + cancelled: true, + summary: "Action cancelled before execution.", + screenshotPath: null, + error: "Action cancelled.", + }); + if (queue.length === 0) { + this.queues.delete(socialAccountId); + } + return true; + } + + return false; + } + private async processQueue(socialAccountId: string) { if (this.processing.has(socialAccountId)) return; const queue = this.queues.get(socialAccountId); @@ -225,28 +292,39 @@ export class BrowserManager { sandbox: true, }, }); + this.activeActionWindows.set(action.id, win); + this.attachWindowSecurity(win, platform); win.setMenuBarVisibility(false); try { let summary = "Action completed"; + const assertNotCancelled = () => { + if (this.cancelledActionIds.has(action.id)) { + throw new Error("BROWSER_ACTION_CANCELLED"); + } + }; switch (action.actionType) { case "navigate": { + assertNotCancelled(); const url = String(payload.url || "https://google.com"); this.assertAllowedUrl(platform, url, "navigate"); await win.loadURL(url); await this.waitForLoad(win); + assertNotCancelled(); const title = await win.webContents.executeJavaScript("document.title"); summary = `Navigated to ${title}`; break; } case "post": { + assertNotCancelled(); const content = String(payload.content || ""); const postUrl = this.getComposeUrl(platform); await win.loadURL(postUrl); await this.waitForLoad(win); await this.delay(2000); + assertNotCancelled(); if (platform === "twitter") { await win.webContents.executeJavaScript(` @@ -297,12 +375,14 @@ export class BrowserManager { } case "reply": { + assertNotCancelled(); const targetUrl = String(payload.targetUrl || ""); this.assertAllowedUrl(platform, targetUrl, "reply"); const replyContent = String(payload.content || ""); await win.loadURL(targetUrl); await this.waitForLoad(win); await this.delay(2000); + assertNotCancelled(); await win.webContents.executeJavaScript(` (async () => { @@ -326,11 +406,13 @@ export class BrowserManager { } case "like": { + assertNotCancelled(); const targetUrl = String(payload.targetUrl || ""); this.assertAllowedUrl(platform, targetUrl, "like"); await win.loadURL(targetUrl); await this.waitForLoad(win); await this.delay(2000); + assertNotCancelled(); await win.webContents.executeJavaScript(` (() => { const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]'); @@ -343,11 +425,13 @@ export class BrowserManager { } case "follow": { + assertNotCancelled(); const targetUrl = String(payload.targetUrl || payload.profileUrl || ""); this.assertAllowedUrl(platform, targetUrl, "follow"); await win.loadURL(targetUrl); await this.waitForLoad(win); await this.delay(2000); + assertNotCancelled(); await win.webContents.executeJavaScript(` (() => { const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]'); @@ -360,12 +444,14 @@ export class BrowserManager { } case "browse_feed": { + assertNotCancelled(); const feedUrl = String(payload.url || this.getFeedUrl(platform)); this.assertAllowedUrl(platform, feedUrl, "browse_feed"); const maxPosts = Number(payload.maxPosts || 10); await win.loadURL(feedUrl); await this.waitForLoad(win); await this.delay(3000); + assertNotCancelled(); for (let i = 0; i < 3; i++) { await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)"); @@ -383,12 +469,14 @@ export class BrowserManager { } case "search": { + assertNotCancelled(); const query = String(payload.query || ""); const searchUrl = this.getSearchUrl(platform, query); this.assertAllowedUrl(platform, searchUrl, "search"); await win.loadURL(searchUrl); await this.waitForLoad(win); await this.delay(3000); + assertNotCancelled(); const results: string[] = await win.webContents.executeJavaScript(` (() => { @@ -401,6 +489,7 @@ export class BrowserManager { } case "screenshot": { + assertNotCancelled(); const url = String(payload.url || "about:blank"); if (url !== "about:blank") { this.assertAllowedUrl(platform, url, "screenshot"); @@ -408,6 +497,7 @@ export class BrowserManager { await this.waitForLoad(win); await this.delay(2000); } + assertNotCancelled(); const title = await win.webContents.executeJavaScript("document.title"); summary = `Screenshot captured: ${title}`; break; @@ -431,11 +521,13 @@ export class BrowserManager { return { success: true, + cancelled: false, summary, screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, error: null, }; } catch (error) { + const wasCancelled = this.cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED"); try { const image: NativeImage = await win.webContents.capturePage(); if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG()); @@ -443,14 +535,20 @@ export class BrowserManager { if (!win.isDestroyed()) win.close(); - const msg = error instanceof Error ? error.message : String(error); - log.error(`[browser] Action ${action.actionType} failed: ${msg}`); + const msg = wasCancelled ? "Action cancelled." : error instanceof Error ? error.message : String(error); + if (!wasCancelled) { + log.error(`[browser] Action ${action.actionType} failed: ${msg}`); + } return { success: false, - summary: "Action failed", + cancelled: wasCancelled, + summary: wasCancelled ? "Action cancelled" : "Action failed", screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, error: msg, }; + } finally { + this.activeActionWindows.delete(action.id); + this.cancelledActionIds.delete(action.id); } } @@ -476,6 +574,43 @@ export class BrowserManager { return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other; } + private isAllowedNavigation(platform: string, rawUrl: string) { + try { + this.assertAllowedUrl(platform, rawUrl, "navigate"); + return true; + } catch { + return false; + } + } + + private attachWindowSecurity(win: BrowserWindow, platform: string) { + win.webContents.setWindowOpenHandler(({ url }) => { + if (!this.isAllowedNavigation(platform, url)) { + return { action: "deny" }; + } + setImmediate(() => { + void win.loadURL(url).catch((error) => { + log.warn(`[browser] Failed to open allowed popup target for ${platform}: ${error instanceof Error ? error.message : String(error)}`); + }); + }); + return { action: "deny" }; + }); + win.webContents.on("will-navigate", (event, navigationUrl) => { + if (this.isAllowedNavigation(platform, navigationUrl)) { + return; + } + event.preventDefault(); + log.warn(`[browser] Blocked navigation outside allowlist for ${platform}: ${navigationUrl}`); + }); + win.webContents.on("will-redirect", (event, navigationUrl) => { + if (this.isAllowedNavigation(platform, navigationUrl)) { + return; + } + event.preventDefault(); + log.warn(`[browser] Blocked redirect outside allowlist for ${platform}: ${navigationUrl}`); + }); + } + private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) { const allowedHosts = this.getPlatformRules(platform).allowedHosts; let url: URL; @@ -484,6 +619,9 @@ export class BrowserManager { } catch { throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`); } + if (url.protocol !== "https:") { + throw new Error(`Navigation blocked for ${actionType}. Only https URLs are allowed.`); + } const host = url.hostname.toLowerCase(); const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`)); if (!isAllowed) { @@ -578,6 +716,8 @@ export class BrowserManager { if (!win.isDestroyed()) win.close(); } this.loginWindows.clear(); + this.activeActionWindows.clear(); + this.cancelledActionIds.clear(); this.queues.clear(); this.processing.clear(); } diff --git a/src/main/connector-auth-service.ts b/src/main/connector-auth-service.ts new file mode 100644 index 00000000..b974f7a5 --- /dev/null +++ b/src/main/connector-auth-service.ts @@ -0,0 +1,115 @@ +import { spawn, spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import type { ConnectorId } from "@shared/types"; + +function quoteShell(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function escapeAppleScript(value: string) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function connectorAuthSpec(connectorId: ConnectorId, command: string) { + switch (connectorId) { + case "codex_local": + return { + title: "Codex Login", + shellCommand: `${quoteShell(command)} login`, + intro: "AgentCompany is opening the Codex login flow.", + }; + case "claude_local": + return { + title: "Claude Code Login", + shellCommand: `${quoteShell(command)} auth login`, + intro: "AgentCompany is opening the Claude Code login flow.", + }; + case "opencode_local": + return { + title: "OpenCode Login", + shellCommand: `${quoteShell(command)} auth login`, + intro: "AgentCompany is opening the OpenCode provider login flow.", + }; + case "gemini_local": + return { + title: "Gemini CLI Login", + shellCommand: `${quoteShell(command)}`, + intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.", + }; + case "kimi_local": + return { + title: "Kimi Code Login", + shellCommand: `${quoteShell(command)}`, + intro: "AgentCompany is opening Kimi Code. Complete the interactive login flow there if prompted.", + }; + } +} + +function buildTerminalCommand(connectorId: ConnectorId, command: string, cwd: string) { + const spec = connectorAuthSpec(connectorId, command); + const shell = process.env.SHELL || "/bin/zsh"; + const script = [ + "clear", + `echo ${quoteShell(spec.intro)}`, + "echo", + `cd ${quoteShell(cwd)}`, + spec.shellCommand, + "echo", + `echo ${quoteShell("You can close this terminal after the login flow completes.")}`, + `exec ${quoteShell(shell)} -l`, + ].join("; "); + return { title: spec.title, script }; +} + +export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) { + const targetCwd = cwd || process.env.HOME || homedir() || process.cwd(); + const { script } = buildTerminalCommand(connectorId, command, targetCwd); + + if (process.platform === "darwin") { + const appleScript = `tell application "Terminal" to do script "${escapeAppleScript(script)}"\ntell application "Terminal" to activate`; + const child = spawn("osascript", ["-e", appleScript], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return true; + } + + if (process.platform === "win32") { + const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", script], { + detached: true, + stdio: "ignore", + shell: false, + }); + child.unref(); + return true; + } + + const linuxCandidates: Array<[string, string[]]> = [ + ["x-terminal-emulator", ["-e", "sh", "-lc", script]], + ["gnome-terminal", ["--", "sh", "-lc", script]], + ["konsole", ["-e", "sh", "-lc", script]], + ["xfce4-terminal", ["-e", `sh -lc ${quoteShell(script)}`]], + ]; + + for (const [binary, args] of linuxCandidates) { + const availability = spawnSync("sh", ["-lc", `command -v ${quoteShell(binary)}`], { + stdio: "ignore", + }); + if ((availability.status ?? 1) !== 0) { + continue; + } + try { + const child = spawn(binary, args, { + detached: true, + stdio: "ignore", + }); + child.unref(); + return true; + } catch { + // Try next terminal candidate. + } + } + + throw new Error("No supported terminal application was found for connector login."); +} diff --git a/src/main/connector-health-service.ts b/src/main/connector-health-service.ts new file mode 100644 index 00000000..d63166ca --- /dev/null +++ b/src/main/connector-health-service.ts @@ -0,0 +1,167 @@ +import { spawn } from "node:child_process"; +import { homedir } from "node:os"; +import type { AppDatabase } from "./database"; +import { getConnectorDefinition } from "./connectors"; +import { buildSanitizedRuntimeEnv } from "./runtime-env"; +import type { ConnectorId } from "@shared/types"; + +interface ConnectorHealthDependencies { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + publishDomainChanged: () => void; +} + +export function createConnectorHealthService({ + db, + logger: _logger, + publishDomainChanged, +}: ConnectorHealthDependencies) { + function randomFallbackCompanyId() { + const snapshot = db.listSnapshot(); + return snapshot.companies[0]?.id ?? "00000000-0000-0000-0000-000000000000"; + } + + function resolveProbeCwd() { + const snapshot = db.listSnapshot(); + const currentCompanyId = db.getCurrentCompanyId(); + const preferredWorkspace = snapshot.workspaces.find((workspace) => workspace.companyId === currentCompanyId) + ?? snapshot.workspaces[0] + ?? null; + return preferredWorkspace?.localPath ?? process.env.HOME ?? homedir() ?? process.cwd(); + } + + function parseVersion(command: string): Promise { + return new Promise((resolve) => { + let resolved = false; + const child = spawn(command, ["--version"], { + env: process.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += String(chunk); + }); + child.stderr.on("data", (chunk) => { + buffer += String(chunk); + }); + child.on("error", () => { + if (!resolved) { resolved = true; resolve(null); } + }); + child.on("close", () => { + if (!resolved) { + resolved = true; + const versionLine = buffer.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null; + resolve(versionLine); + } + }); + // Kill the child after 8 seconds to avoid blocking startup. + setTimeout(() => { + if (!resolved) { + resolved = true; + child.kill("SIGKILL"); + resolve(null); + } + }, 8_000); + }); + } + + async function checkConnector(id: ConnectorId) { + const connector = db.getConnector(id); + const definition = getConnectorDefinition(id); + const companyId = db.getCurrentCompanyId() ?? randomFallbackCompanyId(); + const probeCwd = resolveProbeCwd(); + const runtimeOverrides = db.resolveSecretEnv(connector.envBindingText, companyId); + + db.updateConnectorHealth({ + id, + status: "detected", + authState: connector.authState, + version: connector.version, + lastError: null, + }); + publishDomainChanged(); + + try { + const testPromise = definition.module.testEnvironment({ + companyId, + adapterType: id, + config: { + command: connector.command, + model: connector.model, + cwd: probeCwd, + env: buildSanitizedRuntimeEnv(process.env, runtimeOverrides), + approvalMode: "default", + sandbox: true, + }, + }); + const result = await Promise.race([ + testPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error("Connector health check timed out after 45 seconds")), 45_000)), + ]); + + const authRequired = result.checks.some((check) => + /auth_required|login_required/i.test(check.code) || + /login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) || + /run .*login|use \/auth|configure .*api key/i.test(check.hint ?? ""), + ); + let authState: "ok" | "required" | "error" = "ok"; + if (authRequired) { + authState = "required"; + } else if (result.status === "fail") { + authState = "error"; + } + const version = await parseVersion(connector.command); + + db.updateConnectorHealth({ + id, + status: + authRequired + ? "auth_required" + : result.status === "pass" + ? "ready" + : result.status === "warn" + ? "degraded" + : "error", + authState, + version, + lastError: + result.status === "fail" || result.status === "warn" + ? result.checks.map((check) => check.message).join(" | ") + : null, + }); + db.addActivity({ + companyId, + actor: "system", + action: "connector.tested", + entityType: "connector", + entityId: id, + detail: result.status, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + _logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`); + const status = /command.*not found|not executable/i.test(errorMessage) + ? "not_installed" + : /timed out/i.test(errorMessage) + ? "degraded" + : "error"; + db.updateConnectorHealth({ + id, + status, + authState: "error", + version: null, + lastError: errorMessage, + }); + } + publishDomainChanged(); + } + + return { + checkConnector, + randomFallbackCompanyId, + }; +} diff --git a/src/main/connectors.ts b/src/main/connectors.ts index a330a4c8..0b1fd02c 100644 --- a/src/main/connectors.ts +++ b/src/main/connectors.ts @@ -1,11 +1,4 @@ -import type { - AdapterExecutionContext, - AdapterExecutionResult, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, - ServerAdapterModule, -} from "@agentcompany/adapter-utils"; -import { asNumber, asString, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, runChildProcess } from "@agentcompany/adapter-utils/server-utils"; +import type { ServerAdapterModule } from "@agentcompany/adapter-utils"; import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local"; import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server"; import { @@ -16,15 +9,23 @@ import { import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server"; import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local"; import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server"; +import { agentConfigurationDoc as kimiDoc, DEFAULT_KIMI_LOCAL_MODEL, models as kimiModels } from "@agentcompany/adapter-kimi-local"; +import { execute as kimiExecute, testEnvironment as kimiTestEnvironment } from "@agentcompany/adapter-kimi-local/server"; import { agentConfigurationDoc as opencodeDoc } from "@agentcompany/adapter-opencode-local"; import { execute as opencodeExecute, listOpenCodeModels, sessionCodec as opencodeSessionCodec, testEnvironment as opencodeTestEnvironment } from "@agentcompany/adapter-opencode-local/server"; import type { CapabilityMatrix, ConnectorId, ConnectorRecord } from "@shared/types"; -function normalizeEnv(input: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ); -} +const RESERVED_ENV_BINDING_KEYS = new Set([ + "PATH", + "HOME", + "USER", + "SHELL", + "NODE_OPTIONS", + "ELECTRON_RUN_AS_NODE", + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "PWD", +]); interface ConnectorDefinition { id: ConnectorId; @@ -39,98 +40,6 @@ interface ConnectorDefinition { listModels?: () => Promise>; } -const kimiModule: ServerAdapterModule = { - type: "kimi_local", - async testEnvironment(ctx: AdapterEnvironmentTestContext): Promise { - const command = asString(ctx.config.command, "kimi"); - const cwd = asString(ctx.config.cwd, process.cwd()); - const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record ?? process.env) })); - try { - await ensureCommandResolvable(command, cwd, env); - return { - adapterType: "kimi_local", - status: "warn", - checks: [ - { - code: "kimi_command_detected", - level: "info", - message: `Detected Kimi Code command "${command}".`, - }, - { - code: "kimi_runtime_generic_mode", - level: "warn", - message: - "Kimi integration is running in generic CLI mode. Session resume, model discovery, and structured transcript are not guaranteed.", - }, - ], - testedAt: new Date().toISOString(), - }; - } catch (error) { - return { - adapterType: "kimi_local", - status: "fail", - checks: [ - { - code: "kimi_command_missing", - level: "error", - message: error instanceof Error ? error.message : "Kimi command is not available.", - hint: "Install Kimi Code and ensure the CLI is available on PATH.", - }, - ], - testedAt: new Date().toISOString(), - }; - } - }, - async execute(ctx: AdapterExecutionContext): Promise { - const command = asString(ctx.config.command, "kimi"); - const cwd = asString(ctx.config.cwd, process.cwd()); - const model = asString(ctx.config.model, "kimi-for-coding"); - const timeoutSec = asNumber(ctx.config.timeoutSec, 0); - const graceSec = asNumber(ctx.config.graceSec, 20); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - - const prompt = typeof ctx.context.taskTitle === "string" && ctx.context.taskTitle.length > 0 - ? [ - ctx.context.taskTitle, - typeof ctx.context.taskDescription === "string" && ctx.context.taskDescription.length > 0 - ? ctx.context.taskDescription - : "", - ].filter(Boolean).join("\n\n") - : [ - `You are agent ${ctx.agent.name}.`, - "Respond with a concise execution summary when complete.", - ].join("\n\n"); - - const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record ?? process.env) })); - await ensureCommandResolvable(command, cwd, env); - const result = await runChildProcess(ctx.runId, command, [], { - cwd, - env, - timeoutSec, - graceSec, - stdin: prompt, - onLog: ctx.onLog, - }); - - const summary = - result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .slice(-8) - .join("\n") || null; - - return { - exitCode: result.exitCode, - signal: result.signal, - timedOut: result.timedOut, - summary, - model, - billingType: "unknown", - }; - }, -}; - const CONNECTORS: ConnectorDefinition[] = [ { id: "codex_local", @@ -234,7 +143,7 @@ const CONNECTORS: ConnectorDefinition[] = [ label: "Kimi Code", description: "Official Kimi Code CLI surfaced through a generic execution contract with explicit capability downgrade.", defaultCommand: "kimi", - defaultModel: "kimi-for-coding", + defaultModel: DEFAULT_KIMI_LOCAL_MODEL, notes: "MVP support is explicit generic mode until richer runtime contract evidence is implemented.", capabilityMatrix: { sessionResume: false, @@ -243,23 +152,14 @@ const CONNECTORS: ConnectorDefinition[] = [ structuredTranscript: false, costAttribution: false, }, - configurationDoc: [ - "# kimi_local agent configuration", - "", - "Core fields:", - "- command (string, optional): defaults to \"kimi\"", - "- cwd (string, optional): working directory for the Kimi CLI process", - "- model (string, optional): default kimi-for-coding", - "- timeoutSec (number, optional)", - "- graceSec (number, optional)", - "", - "Notes:", - "- This connector currently runs Kimi in generic CLI mode.", - "- Session resume, model discovery, structured transcript, and cost attribution are declared unsupported.", - "- The UI must surface these limitations explicitly.", - ].join("\n"), - module: kimiModule, - listModels: async () => [{ id: "kimi-for-coding", label: "kimi-for-coding" }], + configurationDoc: kimiDoc, + module: { + type: "kimi_local", + execute: kimiExecute, + testEnvironment: kimiTestEnvironment, + models: kimiModels, + }, + listModels: async () => kimiModels, }, ]; @@ -285,6 +185,12 @@ export function parseEnvBindingText(text: string): Record { return bindings; } +export function listInvalidEnvBindingKeys(text: string): string[] { + return Object.keys(parseEnvBindingText(text)).filter((key) => { + return RESERVED_ENV_BINDING_KEYS.has(key) || key.startsWith("AGENT_COMPANY_"); + }); +} + export function buildDefaultConnectorRows(existing: Array> = []): ConnectorRecord[] { const byId = new Map(existing.map((record) => [record.id, record])); return CONNECTORS.map((definition) => { @@ -294,7 +200,7 @@ export function buildDefaultConnectorRows(existing: Array; +const BOARD_READER_ID = "__board__"; function nowIso() { return new Date().toISOString(); @@ -66,6 +68,14 @@ function isWithinOrEqual(targetPath: string, rootPath: string) { return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`); } +function safeRealpath(targetPath: string) { + try { + return realpathSync(targetPath); + } catch { + return resolve(targetPath); + } +} + function taskPriorityOrderSql(columnName: string) { return `case ${columnName} when 'critical' then 4 @@ -387,17 +397,31 @@ export class AppDatabase { } const managedRoots = [this.profileDir, this.logsDir, this.artifactsDir, this.backupsDir]; - if (managedRoots.some((rootPath) => isWithinOrEqual(targetPath, rootPath))) { + const sensitiveProfileExtensions = new Set([".sqlite", ".db", ".key"]); + const forbiddenExecutableExtensions = new Set([".app", ".exe", ".bat", ".cmd", ".sh", ".command", ".desktop", ".msi", ".com", ".scr"]); + const resolvedTarget = safeRealpath(targetPath); + const targetStats = lstatSync(targetPath); + const targetExtension = extname(resolvedTarget).toLowerCase(); + if ( + managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, rootPath)) && + sensitiveProfileExtensions.has(targetExtension) + ) { + return false; + } + if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) { + return false; + } + if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) { return true; } const workspaces = this.readAll<{ localPath: string }>("select local_path as localPath from workspaces"); - if (workspaces.some((workspace) => isWithinOrEqual(targetPath, workspace.localPath))) { + if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) { return true; } const runLogs = this.readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null"); - return runLogs.some((run) => run.logPath ? resolve(run.logPath) === resolve(targetPath) : false); + return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false); } updateSettings(input: { theme?: "system" | "light" | "dark"; updatesEnabled?: boolean; logRetentionDays?: number }) { @@ -445,6 +469,15 @@ export class AppDatabase { listSnapshot(): ProfileSnapshot { const connectors = this.readConnectors(); const theme = (this.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system"; + const boardReadReceiptMap = new Map( + this.readAll<{ messageId: string; readAt: string }>( + `select reads.message_id as messageId, reads.read_at as readAt + from agent_message_reads reads + join agent_messages messages on messages.id = reads.message_id + where reads.agent_id = ?`, + BOARD_READER_ID, + ).map((row) => [row.messageId, row.readAt]), + ); return { currentCompanyId: this.getCurrentCompanyId(), profilePath: this.profileDir, @@ -554,7 +587,7 @@ export class AppDatabase { ).map((r) => ({ id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id, toAgentId: r.to_agent_id, channel: r.channel, channelTargetId: r.channel_target_id, - subject: r.subject, body: r.body, priority: r.priority, readAt: r.read_at, + subject: r.subject, body: r.body, priority: r.priority, readAt: boardReadReceiptMap.get(r.id) ?? null, parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json, createdAt: r.created_at, })), @@ -926,9 +959,13 @@ export class AppDatabase { } resolveSecretEnv(bindingText: string, companyId: string): Record { + const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText)); const bindings = parseEnvBindingText(bindingText); const result: Record = {}; for (const [envKey, secretName] of Object.entries(bindings)) { + if (invalidKeys.has(envKey)) { + continue; + } const row = this.db.prepare("select backend, ciphertext from secrets where company_id = ? and name = ?").get(companyId, secretName) as | { backend: "safe_storage" | "vault"; ciphertext: Uint8Array } | undefined; @@ -1006,6 +1043,13 @@ export class AppDatabase { return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; } + getRunAuthState(runId: string): { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | null { + const row = this.db + .prepare("select id, company_id as companyId, agent_id as agentId, status from runs where id = ?") + .get(runId) as { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | undefined; + return row ?? null; + } + createRun(input: { companyId: string; taskId: string; @@ -1044,14 +1088,69 @@ export class AppDatabase { } } + readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk { + const run = this.db.prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined; + if (!run?.logPath || !existsSync(run.logPath)) { + return { + content: "", + offset: 0, + nextOffset: 0, + totalBytes: 0, + eof: true, + }; + } + + const totalBytes = statSync(run.logPath).size; + const safeOffset = Math.max(0, Math.min(offset, totalBytes)); + const safeLimit = Math.max(1_024, Math.min(limit, 262_144)); + const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset)); + + if (bytesToRead === 0) { + return { + content: "", + offset: safeOffset, + nextOffset: safeOffset, + totalBytes, + eof: true, + }; + } + + const fileDescriptor = openSync(run.logPath, "r"); + try { + const buffer = Buffer.allocUnsafe(bytesToRead); + const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset); + const nextOffset = safeOffset + bytesRead; + return { + content: buffer.subarray(0, bytesRead).toString("utf8"), + offset: safeOffset, + nextOffset, + totalBytes, + eof: nextOffset >= totalBytes, + }; + } finally { + closeSync(fileDescriptor); + } + } + markRunStatus(runId: string, status: RunRecord["status"], message: string) { const ts = nowIso(); const startedAt = status === "running" ? ts : null; - this.db - .prepare( - "update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?", - ) - .run(status, message, message, startedAt, ts, runId); + this.transaction(() => { + this.db + .prepare( + "update runs set status = ?, summary = case when ? != '' then ? else summary end, started_at = coalesce(started_at, ?), updated_at = ? where id = ?", + ) + .run(status, message, message, startedAt, ts, runId); + + if (status === "running") { + const run = this.db.prepare("select agent_id from runs where id = ?").get(runId) as { agent_id: string } | undefined; + if (run) { + this.db + .prepare("update agents set status = 'running', updated_at = ? where id = ? and status in ('idle', 'active')") + .run(ts, run.agent_id); + } + } + }); } finishRun(input: { @@ -1125,6 +1224,11 @@ export class AppDatabase { this.db .prepare("update agents set status = 'paused', updated_at = ? where id = ?") .run(ts, run.agentId); + } else { + // Reset agent status from "running" back to "idle" unless paused/terminated/pending_approval + this.db + .prepare("update agents set status = 'idle', updated_at = ? where id = ? and status = 'running'") + .run(ts, run.agentId); } }); return { budgetHardStopped }; @@ -1192,7 +1296,7 @@ export class AppDatabase { this.db.prepare("delete from agents where company_id = ?").run(companyId); this.db.prepare("delete from secrets where company_id = ?").run(companyId); this.db.prepare("delete from workspaces where company_id = ?").run(companyId); - for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints"]) { + for (const table of ["automation_rules", "automation_log", "agent_messages", "workflow_pipelines", "meetings", "documents", "knowledge_base", "sprints", "browser_actions", "social_accounts"]) { try { this.db.prepare(`delete from ${table} where company_id = ?`).run(companyId); } catch (err) { @@ -1326,7 +1430,10 @@ export class AppDatabase { } deleteSocialAccount(id: string, companyId: string) { - this.db.prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId); + this.transaction(() => { + this.db.prepare("DELETE FROM social_accounts WHERE id = ? AND company_id = ?").run(id, companyId); + this.db.prepare("DELETE FROM browser_actions WHERE social_account_id = ? AND company_id = ?").run(id, companyId); + }); } listSocialAccounts(companyId: string): SocialAccountRecord[] { @@ -1502,7 +1609,18 @@ export class AppDatabase { const failedRuns = (this.db.prepare("select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')").get(companyId) as { c: number })?.c ?? 0; const todoTasks = (this.db.prepare("select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')").get(companyId) as { c: number })?.c ?? 0; const budgetWarnings = (this.db.prepare("select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8").get(companyId) as { c: number })?.c ?? 0; - const unreadMessages = (this.db.prepare("select count(*) as c from agent_messages where company_id = ? and read_at is null and (channel = 'company' or channel = 'incident' or priority = 'urgent')").get(companyId) as { c: number })?.c ?? 0; + const unreadMessages = ( + this.db.prepare( + `select count(*) as c + from agent_messages messages + left join agent_message_reads reads + on reads.message_id = messages.id + and reads.agent_id = ? + where messages.company_id = ? + and reads.read_at is null + and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')`, + ).get(BOARD_READER_ID, companyId) as { c: number } + )?.c ?? 0; return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages }; } @@ -1674,6 +1792,7 @@ export class AppDatabase { cp(this.logsDir, join(targetDir, "logs"), { recursive: true, force: true }), cp(this.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }), copyIfExists(join(this.profileDir, "vault.key"), join(targetDir, "vault.key")), + copyIfExists(join(this.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")), copyIfExists(`${this.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")), copyIfExists(`${this.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")), ]; @@ -1778,10 +1897,11 @@ export class AppDatabase { } } - private purgeExpiredPendingWakes() { + purgeExpiredPendingWakes() { // Remove pending wakes older than 2 hours — if agent hasn't woken by then, the wake is stale const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff); + const result = this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff); + return Number(result.changes); } private interruptStaleRuns() { @@ -1789,10 +1909,14 @@ export class AppDatabase { this.transaction(() => { this.db .prepare( - "update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status in ('queued', 'running')", + "update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'", ) .run(ts, ts); - this.db.prepare("update tasks set active_run_id = null where active_run_id is not null").run(); + this.db.prepare("update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')").run(); + // Reset any agents stuck in "running" status back to "idle" + this.db + .prepare("update agents set status = 'idle', updated_at = ? where status = 'running'") + .run(ts); this.releaseAllWorkspaceLocks(); }); } @@ -2032,6 +2156,19 @@ export class AppDatabase { return Number(result.changes); } + listQueuedRuns() { + return this.readAll( + `select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, + connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, + signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, + unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, + log_path as logPath, created_at as createdAt, updated_at as updatedAt + from runs + where status = 'queued' + order by created_at asc`, + ).map((record) => ({ ...record, unattributedCost: toBoolean(record.unattributedCost) })) as RunRecord[]; + } + isWorkspaceLocked(workspaceId: string): boolean { const row = this.db .prepare("select lock_holder_run_id from workspaces where id = ?") @@ -2162,6 +2299,15 @@ export class AppDatabase { this.db.prepare("delete from automation_rules where id = ? and company_id = ?").run(id, companyId); } + listAutomationRuleNames(companyId: string): Set { + return new Set( + this.readAll<{ name: string }>( + "select name from automation_rules where company_id = ?", + companyId, + ).map((row) => row.name), + ); + } + toggleAutomationRule(id: string, companyId: string, status: "active" | "paused") { this.db.prepare("update automation_rules set status = ?, updated_at = ? where id = ? and company_id = ?").run(status, nowIso(), id, companyId); } @@ -2213,6 +2359,22 @@ export class AppDatabase { sql += " order by created_at asc"; let messages = this.readAll(sql, ...params); + if (!opts?.agentId) { + const boardReadReceiptMap = new Map( + this.readAll<{ messageId: string; readAt: string }>( + `select reads.message_id as messageId, reads.read_at as readAt + from agent_message_reads reads + join agent_messages messages on messages.id = reads.message_id + where reads.agent_id = ? and messages.company_id = ?`, + BOARD_READER_ID, + companyId, + ).map((row) => [row.messageId, row.readAt]), + ); + messages = messages.map((message) => ({ + ...message, + readAt: boardReadReceiptMap.get(message.id) ?? null, + })); + } if (opts?.agentId) { let department: string | null = null; try { @@ -2286,7 +2448,11 @@ export class AppDatabase { } markMessageRead(messageId: string, companyId: string) { - this.db.prepare("update agent_messages set read_at = ? where id = ? and company_id = ?").run(nowIso(), messageId, companyId); + this.markAgentMessageRead(messageId, companyId, BOARD_READER_ID); + } + + markMessageReadForReader(messageId: string, companyId: string, readerAgentId?: string | null) { + this.markAgentMessageRead(messageId, companyId, readerAgentId ?? BOARD_READER_ID); } markAgentMessageRead(messageId: string, companyId: string, agentId: string) { @@ -2856,6 +3022,13 @@ export class AppDatabase { await cp(join(sourceDir, "artifacts"), this.artifactsDir, { recursive: true, force: true }); } await copyIfExists(join(sourceDir, "vault.key"), join(this.profileDir, "vault.key")); + await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(this.profileDir, "agent-api-signing.key")); + if (existsSync(join(this.profileDir, "vault.key"))) { + chmodSync(join(this.profileDir, "vault.key"), 0o600); + } + if (existsSync(join(this.profileDir, "agent-api-signing.key"))) { + chmodSync(join(this.profileDir, "agent-api-signing.key"), 0o600); + } this.db = this.openConnection(); this.init(); diff --git a/src/main/default-automation-rules.ts b/src/main/default-automation-rules.ts new file mode 100644 index 00000000..ce698c8a --- /dev/null +++ b/src/main/default-automation-rules.ts @@ -0,0 +1,165 @@ +export const DEFAULT_AUTOMATION_RULES: ReadonlyArray<{ + name: string; + description: string; + trigger: string; + action: string; + conditionsJson?: string; + actionConfigJson?: string; + priority?: number; +}> = [ + { + name: "Auto-assign new tasks", + description: "Automatically assign new unassigned tasks to the best-fit agent based on capabilities and workload.", + trigger: "task_created", + action: "assign_task", + priority: 90, + }, + { + name: "Escalate blocked tasks", + description: "When a task is blocked, escalate to the assignee's manager.", + trigger: "task_blocked", + action: "escalate_to_manager", + actionConfigJson: JSON.stringify({ body: "A task assigned to your report is blocked and needs your attention." }), + priority: 85, + }, + { + name: "Escalate completed work to manager", + description: "When a run completes successfully, escalate to the assignee's manager so they review the work.", + trigger: "run_completed", + action: "escalate_to_manager", + actionConfigJson: JSON.stringify({ body: "A task run has completed successfully. Please review the work and approve or send back for changes." }), + priority: 80, + }, + { + name: "Reassign on run failure", + description: "When a run fails, attempt to reassign the task to another capable agent.", + trigger: "run_failed", + action: "reassign_task", + priority: 75, + }, + { + name: "Budget alert at 80%", + description: "Send an alert when an agent reaches 80% of their monthly budget.", + trigger: "budget_threshold", + action: "send_message", + actionConfigJson: JSON.stringify({ subject: "Budget warning", body: "Agent is approaching budget limit (80% utilized).", priority: "urgent" }), + priority: 70, + }, + { + name: "Notify department on task completion", + description: "When a run completes successfully, send a status update to the agent's department channel.", + trigger: "run_completed", + action: "send_message", + actionConfigJson: JSON.stringify({ channel: "department", subject: "Task completed", body: "A task has been completed successfully. Check the task details for more information.", priority: "low" }), + priority: 40, + }, + { + name: "Wake new hire on approval", + description: "When a new agent hire is approved, immediately wake the new agent to start onboarding.", + trigger: "hire_approved", + action: "trigger_heartbeat", + actionConfigJson: JSON.stringify({}), + priority: 90, + }, + { + name: "Auto-run assigned tasks", + description: "When a task transitions to 'todo' status, automatically wake the assigned agent to start working on it.", + trigger: "task_status_changed", + conditionsJson: JSON.stringify({ status: "todo" }), + action: "run_task", + actionConfigJson: JSON.stringify({}), + priority: 65, + }, + { + name: "Notify company on sprint start", + description: "When a sprint starts, notify the entire company.", + trigger: "sprint_started", + action: "send_message", + actionConfigJson: JSON.stringify({ channel: "company", subject: "Sprint started", body: "A new sprint has begun. Check your assigned tasks and start execution.", priority: "normal" }), + priority: 50, + }, + { + name: "Schedule retrospective on sprint end", + description: "When a sprint ends, schedule a retrospective meeting.", + trigger: "sprint_ended", + action: "schedule_meeting", + actionConfigJson: JSON.stringify({ meetingType: "retrospective", title: "Sprint Retrospective", durationMinutes: 45 }), + priority: 55, + }, + { + name: "Create status report on schedule", + description: "Periodically create a status report document for the company.", + trigger: "schedule", + action: "create_document", + actionConfigJson: JSON.stringify({ documentType: "status_report", title: "Automated Status Report" }), + priority: 30, + }, + { + name: "Auto-assign work to idle agents", + description: "When an agent has been idle for 10+ minutes, automatically assign available unassigned tasks from the backlog.", + trigger: "agent_idle", + action: "assign_task", + priority: 60, + }, + { + name: "Wake idle agents to check for work", + description: "When an agent has no tasks and is idle, wake them so they can check for available work and coordinate with their team.", + trigger: "agent_idle", + action: "trigger_heartbeat", + priority: 45, + }, + { + name: "Wake manager when task moves to review", + description: "When a task status changes to in_review, wake the assignee's manager to review the work.", + trigger: "task_status_changed", + conditionsJson: JSON.stringify({ status: "in_review" }), + action: "escalate_to_manager", + actionConfigJson: JSON.stringify({ body: "A task from your report has been submitted for review. Please review the work and either approve (mark as done) or send back with feedback." }), + priority: 82, + }, + { + name: "Wake assigned agent on task feedback", + description: "When a reviewed task is sent back to in_progress with feedback, wake the assigned agent to address the feedback.", + trigger: "task_status_changed", + conditionsJson: JSON.stringify({ status: "in_progress", previousStatus: "in_review" }), + action: "trigger_heartbeat", + actionConfigJson: JSON.stringify({}), + priority: 78, + }, +]; + +export function seedDefaultAutomationRules( + companyId: string, + deps: { + listAutomationRuleNames: (companyId: string) => Set; + saveAutomationRule: (input: { + companyId: string; + name: string; + description?: string; + trigger: string; + conditionsJson?: string; + action: string; + actionConfigJson?: string; + priority?: number; + status?: string; + }) => string; + }, +) { + const existingRuleNames = deps.listAutomationRuleNames(companyId); + for (const rule of DEFAULT_AUTOMATION_RULES) { + if (existingRuleNames.has(rule.name)) { + continue; + } + deps.saveAutomationRule({ + companyId, + name: rule.name, + description: rule.description, + trigger: rule.trigger, + conditionsJson: rule.conditionsJson ?? "{}", + action: rule.action, + actionConfigJson: rule.actionConfigJson ?? "{}", + priority: rule.priority ?? 50, + status: "active", + }); + } +} diff --git a/src/main/desktop-shell-service.ts b/src/main/desktop-shell-service.ts new file mode 100644 index 00000000..ca99e547 --- /dev/null +++ b/src/main/desktop-shell-service.ts @@ -0,0 +1,326 @@ +import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { resolve, sep } from "node:path"; +import { + app, + BrowserWindow, + dialog, + Menu, + nativeImage, + net, + protocol, + session, + shell, + Tray, +} from "electron"; +import { attachWindowStatePersistence, loadWindowState } from "./window-state"; + +interface DesktopShellDependencies { + preloadPath: string; + rendererDist: string; + iconPath: string; + isDev: boolean; + devServerUrl?: string; + windowStatePath: string; + shouldQuit: () => boolean; + onRequestQuit: () => void; + onCheckForUpdates: () => Promise; + onBackupProfile: () => Promise; + onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void; + notify: (title: string, body: string) => void; +} + +function isSocialBrowserSession(contents: Electron.WebContents) { + return contents.session !== session.defaultSession; +} + +function isWithinDirectory(rootPath: string, targetPath: string) { + const normalizedRoot = resolve(rootPath); + const normalizedTarget = resolve(targetPath); + return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`); +} + +export function createDesktopShellService({ + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + shouldQuit, + onRequestQuit, + onCheckForUpdates, + onBackupProfile, + onShortcut, + notify, +}: DesktopShellDependencies) { + let mainWindow: BrowserWindow | null = null; + let appTray: Tray | null = null; + let backgroundCloseNoticeShown = false; + let windowCreationPromise: Promise | null = null; + + function getMainWindow() { + return mainWindow; + } + + function installSecurityGuards() { + session.defaultSession.setPermissionCheckHandler(() => false); + session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); + app.on("web-contents-created", (_event, contents) => { + contents.setWindowOpenHandler(() => ({ action: "deny" })); + contents.on("will-attach-webview", (event) => { + event.preventDefault(); + }); + contents.on("will-navigate", (event) => { + if (isSocialBrowserSession(contents)) return; + event.preventDefault(); + }); + }); + } + + function registerProtocol() { + protocol.handle("app", (request) => { + const url = new URL(request.url); + const relativePath = decodeURIComponent(url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "")); + const targetPath = resolve(rendererDist, relativePath); + if (!isWithinDirectory(rendererDist, targetPath) || !existsSync(targetPath)) { + return new Response("Not found", { status: 404 }); + } + return net.fetch(pathToFileURL(targetPath).toString()); + }); + } + + async function createWindow() { + const windowIcon = nativeImage.createFromPath(iconPath); + const savedWindowState = loadWindowState(windowStatePath); + mainWindow = new BrowserWindow({ + title: "AgentCompany", + icon: windowIcon.isEmpty() ? undefined : windowIcon, + width: savedWindowState.bounds.width, + height: savedWindowState.bounds.height, + x: savedWindowState.bounds.x, + y: savedWindowState.bounds.y, + minWidth: 1180, + minHeight: 760, + backgroundColor: "#13110f", + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + webPreferences: { + preload: preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webviewTag: false, + devTools: isDev, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + + if (isDev && devServerUrl) { + await mainWindow.loadURL(devServerUrl); + } else { + await mainWindow.loadURL("app://-/index.html"); + } + attachWindowStatePersistence(mainWindow, windowStatePath); + if (savedWindowState.maximized) { + mainWindow.maximize(); + } + mainWindow.on("close", (event) => { + if (process.platform === "darwin" || shouldQuit()) { + return; + } + event.preventDefault(); + mainWindow?.hide(); + if (!backgroundCloseNoticeShown) { + backgroundCloseNoticeShown = true; + notify("AgentCompany is still running", "Automation continues in the background until you quit the app."); + } + }); + mainWindow.on("closed", () => { + mainWindow = null; + }); + } + + async function ensureWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { + if (!windowCreationPromise) { + windowCreationPromise = createWindow() + .then(() => mainWindow) + .finally(() => { + windowCreationPromise = null; + }); + } + return windowCreationPromise; + } + return mainWindow; + } + + function buildMenu() { + const macAppMenu: Electron.MenuItemConstructorOptions = { + label: app.name, + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }; + const template: Electron.MenuItemConstructorOptions[] = [ + ...(process.platform === "darwin" ? [macAppMenu] : []), + { + label: "File", + submenu: [ + { + label: "Open Workspace Folder", + accelerator: "CmdOrCtrl+O", + click: async () => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }); + if (result.canceled || result.filePaths.length === 0) return; + shell.openPath(result.filePaths[0] ?? ""); + }, + }, + { type: "separator" }, + { + label: "Backup Profile", + accelerator: "CmdOrCtrl+Shift+B", + click: async () => { + await onBackupProfile(); + }, + }, + ...(process.platform === "darwin" ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { + label: "Command Palette", + accelerator: "CmdOrCtrl+K", + click: () => onShortcut("toggle-command-palette"), + }, + { + label: "Toggle Console", + accelerator: "CmdOrCtrl+`", + click: () => onShortcut("toggle-console"), + }, + { + label: "Refresh Data", + accelerator: "CmdOrCtrl+R", + click: () => onShortcut("refresh"), + }, + { type: "separator" }, + { role: "toggleDevTools", visible: isDev }, + ], + }, + { + label: "Run", + submenu: [ + { + label: "Check For Updates", + click: async () => { + await onCheckForUpdates(); + }, + }, + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + } + + function createTray() { + if (process.platform === "darwin" || appTray) { + return; + } + + const trayIcon = nativeImage.createFromPath(iconPath); + appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon); + appTray.setToolTip("AgentCompany"); + appTray.setContextMenu( + Menu.buildFromTemplate([ + { + label: "Show AgentCompany", + click: () => { + void ensureWindow().then((win) => { + if (win) { + win.show(); + win.focus(); + } + }); + }, + }, + { + label: "Hide AgentCompany", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide(); + } + }, + }, + { type: "separator" }, + { + label: "Quit", + click: () => { + onRequestQuit(); + app.quit(); + }, + }, + ]), + ); + appTray.on("click", () => { + void ensureWindow().then((win) => { + if (!win) return; + if (win.isVisible()) { + win.hide(); + return; + } + win.show(); + win.focus(); + }); + }); + } + + function destroy() { + appTray?.destroy(); + appTray = null; + } + + function initialize() { + installSecurityGuards(); + registerProtocol(); + buildMenu(); + createTray(); + } + + return { + initialize, + installSecurityGuards, + registerProtocol, + buildMenu, + createTray, + createWindow, + ensureWindow, + getMainWindow, + destroy, + }; +} diff --git a/src/main/host-environment-service.ts b/src/main/host-environment-service.ts new file mode 100644 index 00000000..548fbbad --- /dev/null +++ b/src/main/host-environment-service.ts @@ -0,0 +1,58 @@ +import { spawn } from "node:child_process"; +import { delimiter as pathDelimiter } from "node:path"; +import { app, nativeImage } from "electron"; + +interface HostEnvironmentDependencies { + iconPath: string; + logger: { + info: (...args: unknown[]) => void; + }; +} + +export async function prepareHostEnvironment({ + iconPath, + logger, +}: HostEnvironmentDependencies) { + if (process.platform !== "win32" && process.env.AGENTCOMPANY_ENABLE_SHELL_PATH_PROBE === "true") { + try { + const userShell = process.env.SHELL || "/bin/zsh"; + const shellPath = await new Promise((resolve) => { + const child = spawn(userShell, ["-ilc", "echo -n $PATH"], { + env: { ...process.env }, + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + }); + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on("close", () => resolve(output.trim())); + child.on("error", () => resolve("")); + }); + if (shellPath && shellPath.length > 0) { + const current = new Set((process.env.PATH ?? "").split(pathDelimiter).filter(Boolean)); + const shellParts = shellPath.split(pathDelimiter).filter(Boolean); + const toAdd = shellParts.filter((entry) => !current.has(entry)); + if (toAdd.length > 0) { + process.env.PATH = [process.env.PATH, ...toAdd].join(pathDelimiter); + logger.info(`[path] Added ${toAdd.length} shell PATH entries.`); + } + } + } catch { + // Non-fatal environment enrichment. + } + } else if (process.platform !== "win32") { + logger.info("[path] Skipping interactive shell PATH probe; using deterministic PATH setup only."); + } + + const appIcon = nativeImage.createFromPath(iconPath); + if (!appIcon.isEmpty()) { + if (process.platform === "darwin" && app.dock) { + app.dock.setIcon(appIcon); + } + } + + if (process.platform === "win32") { + app.setAppUserModelId("com.agentcompany.desktop"); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index d07057fb..63fb37fd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3572 +1,36 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { pathToFileURL } from "node:url"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { - app, - BrowserWindow, - dialog, - type IpcMainInvokeEvent, - ipcMain, - Menu, - nativeImage, - Notification, - protocol, - net, - session, - shell, - Tray, - utilityProcess, -} from "electron"; -import electronUpdater from "electron-updater"; -import log from "electron-log/main.js"; -import { z } from "zod"; -import { - IPC_CHANNELS, - approvalDecisionSchema, - approvalInputSchema, - agentInputSchema, - browserActionQuerySchema, - cancelRunSchema, - commentInputSchema, - companyInputSchema, - connectorInputSchema, - deleteEntitySchema, - goalInputSchema, - heartbeatSettingsSchema, - listCommentsSchema, - metricsQuerySchema, - type DesktopResult, - projectInputSchema, - runLogSchema, - secretInputSchema, - settingsSchema, - socialAccountInputSchema, - startRunSchema, - taskInputSchema, - templateApplySchema, - triggerHeartbeatSchema, - workspaceInputSchema, - meetingInputSchema, - documentInputSchema, - knowledgeEntryInputSchema, - sprintInputSchema, - automationRuleInputSchema, - agentMessageInputSchema, - messageQuerySchema, - workflowInputSchema, - autoAssignSchema, - toggleAutomationRuleSchema, - markMessageReadSchema, - runWorkflowSchema, -} from "@shared/contracts"; -import type { DesktopEvent, RunRecord } from "@shared/types"; -import { getConnectorDefinition } from "./connectors"; -import { AppDatabase } from "./database"; -import { AgentApiServer } from "./api-server"; -import { createAgentToken } from "./agent-auth"; -import { buildTaskPrompt } from "./prompt-builder"; -import { COMPANY_TEMPLATES, getTemplate } from "./templates"; -import { BrowserManager } from "./browser-manager"; - -app.name = "AgentCompany"; - -// Packaged Electron apps inherit a minimal PATH; prepend common tool directories -// so CLI connectors (claude, codex, gemini, etc.) can be found. -{ - const extra = [ - "/usr/local/bin", - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - `${process.env.HOME}/.local/bin`, - `${process.env.HOME}/.cargo/bin`, - `${process.env.HOME}/.nvm/current/bin`, - `${process.env.HOME}/.volta/bin`, - `${process.env.HOME}/.bun/bin`, - "/usr/local/sbin", - "/snap/bin", - ].filter((p) => p && !p.includes("undefined")); - const current = process.env.PATH ?? ""; - const existing = new Set(current.split(":").filter(Boolean)); - const toAdd = extra.filter((p) => !existing.has(p)); - if (toAdd.length > 0) { - process.env.PATH = [...toAdd, current].join(":"); - } -} - -protocol.registerSchemesAsPrivileged([ - { - scheme: "app", - privileges: { - secure: true, - standard: true, - supportFetchAPI: true, - stream: true, - }, - }, -]); +import { app } from "electron"; +import { configureMainProcess } from "./main-process-setup"; +import { createMainRuntime } from "./main-runtime"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const PRELOAD_PATH = join(__dirname, "../preload/index.js"); -const RENDERER_DIST = join(__dirname, "../renderer"); -const WORKER_PATH = join(__dirname, "worker.js"); -const ICON_PATH = resolve(__dirname, "../../assets/icon.png"); + +configureMainProcess("AgentCompany"); + +const profileDir = process.env.AGENTCOMPANY_PROFILE_DIR ?? join(app.getPath("userData"), "profile"); const isDev = Boolean(process.env.ELECTRON_RENDERER_URL); const devServerOrigin = process.env.ELECTRON_RENDERER_URL ? new URL(process.env.ELECTRON_RENDERER_URL).origin : null; -const { autoUpdater } = electronUpdater; - -let mainWindow: BrowserWindow | null = null; -let db: AppDatabase; -let worker: ReturnType | null = null; -let apiServer: AgentApiServer | null = null; -let browserManager: BrowserManager | null = null; -let heartbeatTimer: ReturnType | null = null; -let appTray: Tray | null = null; -const scheduleLastFiredMap = new Map(); -const activeAutomationExecutions = new Set(); -const activeWorkflowTriggerExecutions = new Set(); -// Budget reset tracking moved to database (db.getLastBudgetResetMonth / db.setLastBudgetResetMonth) -let isQuitting = false; -let backgroundCloseNoticeShown = false; - -const companyIdSchema = z.string().uuid(); -const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]); -const workerMessageSchema = z.union([ - z.object({ - type: z.literal("run-log"), - runId: z.string().uuid(), - stream: z.enum(["stdout", "stderr"]), - chunk: z.string(), - }), - z.object({ - type: z.literal("run-status"), - runId: z.string().uuid(), - status: runStatusSchema, - message: z.string(), - }), - z.object({ - type: z.literal("run-finished"), - runId: z.string().uuid(), - status: runStatusSchema, - summary: z.string().nullable(), - errorMessage: z.string().nullable(), - exitCode: z.number().int().nullable(), - signal: z.string().nullable(), - model: z.string().nullable(), - sessionParams: z.record(z.string(), z.unknown()).nullable(), - sessionDisplayId: z.string().nullable(), - costUsd: z.number().nullable(), - unattributedCost: z.boolean(), - }), -]); - -type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets"; -type WorkspaceLockMode = "exclusive" | "shared" | "none"; - -interface WorkspaceResolution { - workspace: ReturnType; - lockMode: WorkspaceLockMode; - source: "task" | "agent" | "project" | "company_fallback" | "none"; -} - -function ok(data: T): DesktopResult { - return { ok: true, data }; -} - -function fail(code: string, message: string): DesktopResult { - return { ok: false, error: { code, message } }; -} - -function hasActiveRun(_snapshot: ReturnType, filter: { taskId?: string; agentId?: string; companyId: string }) { - return db.hasActiveRun(filter); -} - -function resolveWorkspace( - task: { workspaceId: string | null; projectId?: string | null }, - agent: { workspaceId: string | null }, - companyId: string, -): WorkspaceResolution { - const taskWorkspace = db.getWorkspace(task.workspaceId); - if (taskWorkspace) { - return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" }; - } - - const agentWorkspace = db.getWorkspace(agent.workspaceId); - if (agentWorkspace) { - return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" }; - } - - const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId); - if (projectWorkspace) { - return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" }; - } - - const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId); - if (fallbackWorkspace) { - return { workspace: fallbackWorkspace, lockMode: "shared", source: "company_fallback" }; - } - - return { workspace: null, lockMode: "none", source: "none" }; -} - -function isTrustedRendererUrl(url: string) { - if (!url) { - return false; - } - if (isDev) { - return devServerOrigin ? url.startsWith(devServerOrigin) : false; - } - return url.startsWith("app://-"); -} - -function registerHandle( - channel: string, - handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise> | DesktopResult, -) { - ipcMain.handle(channel, async (event, ...args) => { - const senderUrl = event.senderFrame?.url ?? event.sender.getURL(); - if (!isTrustedRendererUrl(senderUrl)) { - log.error(`[ipc:${channel}] blocked untrusted sender ${senderUrl || ""}`); - return fail("IPC_FORBIDDEN", "IPC sender is not allowed."); - } - try { - return await handler(event, ...args); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.error(`[ipc:${channel}] ${message}`); - return fail("IPC_HANDLER_FAILED", message); - } - }); -} - -function emitEvent(event: DesktopEvent) { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, event); - } -} - -function ensureCompanyExists(companyId: string) { - return db.hasCompany(companyId) - ? null - : fail("COMPANY_NOT_FOUND", "The selected company no longer exists."); -} - -function findInvalidReference( - companyId: string, - refs: Array<{ id: string | null | undefined; table: CompanyScopedTable; label: string }>, -) { - return refs.find((ref) => ref.id && !db.belongsToCompany(ref.table, ref.id, companyId)) ?? null; -} - -function publishDomainChanged() { - emitEvent({ type: "domain-changed" }); -} - -function notify(title: string, body: string) { - if (Notification.isSupported()) { - new Notification({ title, body }).show(); - } -} - -function normalizeWorkflowSteps(raw: unknown) { - if (!Array.isArray(raw)) { - throw new Error("Workflow steps must be a JSON array."); - } - return raw.map((entry, index) => { - const step = (entry ?? {}) as Record; - const action = typeof step.action === "string" ? step.action : "send_message"; - return { - id: typeof step.id === "string" && step.id ? step.id : `step-${index + 1}`, - name: typeof step.name === "string" && step.name ? step.name : `Step ${index + 1}`, - action, - configJson: typeof step.configJson === "string" ? step.configJson : "{}", - assigneeDepartment: typeof step.assigneeDepartment === "string" ? step.assigneeDepartment : null, - assigneeAgentId: typeof step.assigneeAgentId === "string" ? step.assigneeAgentId : null, - requiresApproval: step.requiresApproval === true, - timeoutSec: typeof step.timeoutSec === "number" ? step.timeoutSec : 0, - status: ["pending", "running", "completed", "failed", "skipped"].includes(String(step.status)) ? String(step.status) : "pending", - }; - }); -} - -function resolveWorkflowStepActor(step: ReturnType[number], companyId: string) { - if (step.assigneeAgentId) { - const agent = db.getAgent(step.assigneeAgentId); - if (agent.companyId !== companyId) { - throw new Error(`Workflow step assignee ${step.assigneeAgentId} does not belong to company ${companyId}.`); - } - return agent; - } - - if (step.assigneeDepartment) { - const idleAgents = db.getIdleAgentsForDepartment(companyId, step.assigneeDepartment); - if (idleAgents.length > 0) { - return db.getAgent(idleAgents[0]!.id); - } - } - - const snapshot = db.listSnapshot(); - return snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo && agent.status !== "terminated") ?? null; -} - -function buildWorkflowActionContext( - companyId: string, - step: ReturnType[number], - actor: ReturnType, -) { - let config: Record = {}; - try { - config = JSON.parse(step.configJson) as Record; - } catch { - throw new Error(`Workflow step "${step.name}" has invalid config JSON.`); - } - - return { - config, - ctx: { - companyId, - agentId: actor?.id, - department: actor?.department ?? step.assigneeDepartment ?? undefined, - taskId: typeof config.taskId === "string" ? config.taskId : undefined, - runId: typeof config.runId === "string" ? config.runId : undefined, - projectId: typeof config.projectId === "string" ? config.projectId : undefined, - newStatus: typeof config.status === "string" ? config.status : undefined, - }, - }; -} - -async function runWorkflowPipeline(workflowId: string, companyId: string) { - const workflow = db.getWorkflowRecord(workflowId, companyId); - if (!workflow) { - throw new Error("Workflow not found."); - } - const previousWorkflowStatus = workflow.status; - const parsedSteps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); - const hasRunningStep = parsedSteps.some((step) => step.status === "running"); - if (hasRunningStep) { - throw new Error("Workflow is already running."); - } - if (parsedSteps.length === 0) { - throw new Error("Workflow has no steps."); - } - - const resumeFrom = workflow.status === "paused" ? workflow.currentStepIndex : 0; - const steps = parsedSteps.map((step, index) => { - if (workflow.status !== "paused") { - return { ...step, status: index < resumeFrom ? "completed" : "pending" }; - } - return step; - }); - - db.updateWorkflowExecution({ - id: workflowId, - companyId, - status: "active", - currentStepIndex: resumeFrom, - stepsJson: JSON.stringify(steps), - incrementRunCount: true, - lastRunAt: new Date().toISOString(), - }); - publishDomainChanged(); - - for (let index = resumeFrom; index < steps.length; index += 1) { - const step = steps[index]!; - step.status = "running"; - db.updateWorkflowExecution({ - id: workflowId, - companyId, - status: "active", - currentStepIndex: index, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - publishDomainChanged(); - - const actor = resolveWorkflowStepActor(step, companyId); - const { config, ctx } = buildWorkflowActionContext(companyId, step, actor); - - if (step.requiresApproval) { - const approvalId = db.requestApproval({ - companyId, - requestedByAgentId: actor?.id ?? null, - relatedTaskId: ctx.taskId ?? null, - type: "dangerous_command", - payloadSummary: `Workflow approval required: ${workflow.name} / ${step.name}`, - impactSummary: `Workflow step "${step.name}" requires board approval before ${step.action}.`, - payloadJson: JSON.stringify({ workflowId, stepId: step.id, stepName: step.name }), - }); - step.status = "pending"; - db.updateWorkflowExecution({ - id: workflowId, - companyId, - status: "paused", - currentStepIndex: index, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - db.addActivity({ - companyId, - actor: actor?.id ?? "system", - action: "workflow.paused_for_approval", - entityType: "workflow", - entityId: workflowId, - detail: `Paused on step "${step.name}" with approval ${approvalId}.`, - }); - publishDomainChanged(); - return; - } - - try { - executeAutomationAction( - { - action: step.action, - actionConfigJson: JSON.stringify(config), - targetDepartment: step.assigneeDepartment, - trigger: "workflow_step", - }, - ctx, - ); - step.status = "completed"; - db.addActivity({ - companyId, - actor: actor?.id ?? "system", - action: "workflow.step_completed", - entityType: "workflow", - entityId: workflowId, - detail: step.name, - }); - const completionStatus = index === steps.length - 1 - ? (previousWorkflowStatus === "draft" ? "draft" : "active") - : "active"; - db.updateWorkflowExecution({ - id: workflowId, - companyId, - status: completionStatus, - currentStepIndex: index + 1, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - publishDomainChanged(); - } catch (error) { - step.status = "failed"; - db.updateWorkflowExecution({ - id: workflowId, - companyId, - status: "failed", - currentStepIndex: index, - stepsJson: JSON.stringify(steps), - lastRunAt: new Date().toISOString(), - }); - db.addActivity({ - companyId, - actor: actor?.id ?? "system", - action: "workflow.step_failed", - entityType: "workflow", - entityId: workflowId, - detail: `${step.name}: ${error instanceof Error ? error.message : String(error)}`, - }); - publishDomainChanged(); - throw error; - } - } -} - -async function dispatchBrowserAction(actionId: string, companyId: string) { - if (!browserManager) { - throw new Error("Browser manager not initialized"); - } - - const action = db.getBrowserAction(actionId, companyId); - if (!action) { - throw new Error(`Browser action not found: ${actionId}`); - } - - const account = db.getSocialAccount(action.socialAccountId, companyId); - if (!account) { - db.updateBrowserAction(actionId, { - status: "failed", - errorMessage: "Social account no longer exists.", - finishedAt: new Date().toISOString(), - }); - publishDomainChanged(); - return; - } - - let payload: Record; - try { - payload = JSON.parse(action.payloadJson) as Record; - } catch { - db.updateBrowserAction(actionId, { - status: "failed", - errorMessage: "Browser action payload is not valid JSON.", - finishedAt: new Date().toISOString(), - }); - publishDomainChanged(); - return; - } - - db.updateBrowserAction(actionId, { - status: "running", - startedAt: new Date().toISOString(), - errorMessage: null, - }); - publishDomainChanged(); - - try { - const result = await browserManager.executeAction({ - id: actionId, - socialAccountId: account.id, - platform: account.platform, - actionType: action.actionType, - payload, - }); - - db.updateBrowserAction(actionId, { - status: result.success ? "succeeded" : "failed", - resultSummary: result.summary, - screenshotPath: result.screenshotPath, - errorMessage: result.error, - finishedAt: new Date().toISOString(), - }); - } catch (error) { - db.updateBrowserAction(actionId, { - status: "failed", - errorMessage: error instanceof Error ? error.message : String(error), - finishedAt: new Date().toISOString(), - }); - } - - publishDomainChanged(); -} - -function buildMenu() { - const macAppMenu: Electron.MenuItemConstructorOptions = { - label: app.name, - submenu: [ - { role: "about" }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }; - const template: Electron.MenuItemConstructorOptions[] = [ - ...(process.platform === "darwin" ? [macAppMenu] : []), - { - label: "File", - submenu: [ - { - label: "Open Workspace Folder", - accelerator: "CmdOrCtrl+O", - click: async () => { - const result = await dialog.showOpenDialog({ - properties: ["openDirectory"], - }); - if (result.canceled || result.filePaths.length === 0) return; - shell.openPath(result.filePaths[0] ?? ""); - }, - }, - { type: "separator" }, - { - label: "Backup Profile", - accelerator: "CmdOrCtrl+Shift+B", - click: async () => { - const target = await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); - if (target.canceled || target.filePaths.length === 0) return; - await db.exportBackup(join(target.filePaths[0]!, `agentcompany-backup-${Date.now()}`)); - }, - }, - ...(process.platform === "darwin" ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, - ], - }, - { - label: "View", - submenu: [ - { - label: "Command Palette", - accelerator: "CmdOrCtrl+K", - click: () => emitEvent({ type: "shortcut", action: "toggle-command-palette" }), - }, - { - label: "Toggle Console", - accelerator: "CmdOrCtrl+`", - click: () => emitEvent({ type: "shortcut", action: "toggle-console" }), - }, - { - label: "Refresh Data", - accelerator: "CmdOrCtrl+R", - click: () => emitEvent({ type: "shortcut", action: "refresh" }), - }, - { type: "separator" }, - { role: "toggleDevTools", visible: isDev }, - ], - }, - { - label: "Run", - submenu: [ - { - label: "Check For Updates", - click: async () => { - await checkForUpdates(); - }, - }, - ], - }, - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); -} - -function createTray() { - if (process.platform === "darwin" || appTray) { - return; - } - - const trayIcon = nativeImage.createFromPath(ICON_PATH); - appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon); - appTray.setToolTip("AgentCompany"); - appTray.setContextMenu( - Menu.buildFromTemplate([ - { - label: "Show AgentCompany", - click: () => { - if (!mainWindow || mainWindow.isDestroyed()) { - void createWindow(); - return; - } - mainWindow.show(); - mainWindow.focus(); - }, - }, - { - label: "Hide AgentCompany", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.hide(); - } - }, - }, - { type: "separator" }, - { - label: "Quit", - click: () => { - isQuitting = true; - app.quit(); - }, - }, - ]), - ); - appTray.on("click", () => { - if (!mainWindow || mainWindow.isDestroyed()) { - void createWindow(); - return; - } - if (mainWindow.isVisible()) { - mainWindow.hide(); - return; - } - mainWindow.show(); - mainWindow.focus(); - }); -} - -function isSocialBrowserSession(contents: Electron.WebContents): boolean { - return contents.session !== session.defaultSession; -} - -function installSecurityGuards() { - session.defaultSession.setPermissionCheckHandler(() => false); - session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => { - callback(false); - }); - app.on("web-contents-created", (_event, contents) => { - contents.setWindowOpenHandler(() => { - if (isSocialBrowserSession(contents)) { - return { action: "allow" }; - } - return { action: "deny" }; - }); - contents.on("will-attach-webview", (event) => { - event.preventDefault(); - }); - contents.on("will-navigate", (event) => { - if (isSocialBrowserSession(contents)) return; - event.preventDefault(); - }); - }); -} - -function registerProtocol() { - protocol.handle("app", (request) => { - const url = new URL(request.url); - const relativePath = decodeURIComponent(url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "")); - const targetPath = resolve(RENDERER_DIST, relativePath); - if (!targetPath.startsWith(RENDERER_DIST) || !existsSync(targetPath)) { - return new Response("Not found", { status: 404 }); - } - return net.fetch(pathToFileURL(targetPath).toString()); - }); -} - -async function createWindow() { - const windowIcon = nativeImage.createFromPath(ICON_PATH); - mainWindow = new BrowserWindow({ - title: "AgentCompany", - icon: windowIcon.isEmpty() ? undefined : windowIcon, - width: 1480, - height: 980, - minWidth: 1180, - minHeight: 760, - backgroundColor: "#13110f", - titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", - webPreferences: { - preload: PRELOAD_PATH, - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - devTools: isDev, - webSecurity: true, - allowRunningInsecureContent: false, - }, - }); - - if (isDev) { - await mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL!); - } else { - await mainWindow.loadURL("app://-/index.html"); - } - mainWindow.on("close", (event) => { - if (process.platform === "darwin" || isQuitting) { - return; - } - event.preventDefault(); - mainWindow?.hide(); - if (!backgroundCloseNoticeShown) { - backgroundCloseNoticeShown = true; - notify("AgentCompany is still running", "Automation continues in the background until you quit the app."); - } - }); - mainWindow.on("closed", () => { - mainWindow = null; - }); -} - -function createWorker() { - worker = utilityProcess.fork(WORKER_PATH, [], { - stdio: "pipe", - serviceName: "AgentCompany Orchestrator", - }); - - worker.stdout?.on("data", (chunk) => log.info(`[worker] ${String(chunk).trim()}`)); - worker.stderr?.on("data", (chunk) => log.error(`[worker] ${String(chunk).trim()}`)); - - worker.on("exit", (code) => { - if (isQuitting) return; - - log.warn(`[worker] Worker exited with code ${code}, recovering...`); - - const snapshot = db.listSnapshot(); - const affectedAgentIds = new Set(); - const requeueCandidates: Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: string }> = []; - - for (const run of snapshot.runs) { - if (run.status === "running") { - db.finishRun({ - runId: run.id, - status: "interrupted", - summary: "Worker process crashed.", - errorMessage: "Worker process exited unexpectedly.", - exitCode: code, - signal: null, - model: null, - sessionDisplayId: null, - costUsd: null, - unattributedCost: true, - }); - if (run.workspaceId) { - db.releaseWorkspaceLock(run.workspaceId, run.id); - } - affectedAgentIds.add(run.agentId); - - const chain = db.getChainOfCommand(run.agentId); - if (chain.length > 0) { - wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); - } - } else if (run.status === "queued") { - requeueCandidates.push({ - id: run.id, - taskId: run.taskId, - agentId: run.agentId, - companyId: run.companyId, - workspaceId: run.workspaceId, - connectorId: run.connectorId, - }); - } - } - - for (const agentId of affectedAgentIds) { - const pendingWakes = db.consumePendingWakes(agentId); - for (const wake of pendingWakes) { - wakeAgentIfPossible(agentId, wake.companyId, wake.trigger); - } - } - - publishDomainChanged(); - - createWorker(); - - if (requeueCandidates.length > 0 && worker) { - log.info(`[worker] Re-enqueuing ${requeueCandidates.length} queued run(s) after worker restart.`); - const recoverySnapshot = db.listSnapshot(); - for (const candidate of requeueCandidates) { - try { - const task = db.getTask(candidate.taskId); - const agent = db.getAgent(candidate.agentId); - const connector = db.getConnector(agent.connectorId); - const workspaceResolution = resolveWorkspace(task, agent, candidate.companyId); - const recoveryCompany = recoverySnapshot.companies.find((c) => c.id === candidate.companyId); - - dispatchRunToWorker({ - runId: candidate.id, - companyId: candidate.companyId, - agent, - task, - connector, - workspace: workspaceResolution.workspace, - workspaceMode: workspaceResolution.lockMode, - companyName: recoveryCompany?.name ?? "Company", - companyDescription: recoveryCompany?.description ?? "", - snapshot: recoverySnapshot, - trigger: "recovery", - }); - } catch (error) { - log.warn(`[worker] Failed to re-enqueue run ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`); - db.finishRun({ - runId: candidate.id, - status: "interrupted", - summary: "Could not re-enqueue after worker restart.", - errorMessage: error instanceof Error ? error.message : String(error), - exitCode: null, - signal: null, - model: null, - sessionDisplayId: null, - costUsd: null, - unattributedCost: true, - }); - if (candidate.workspaceId) { - db.releaseWorkspaceLock(candidate.workspaceId, candidate.id); - } - } - } - } - }); - - worker.on("message", async (message) => { - let event: z.infer; - try { - event = workerMessageSchema.parse(message); - } catch (error) { - log.error("[worker] invalid message payload", error); - return; - } - - if (event.type === "run-log") { - await db.appendRunLog(event.runId, event.stream, event.chunk); - emitEvent(event); - return; - } - - if (event.type === "run-status") { - db.markRunStatus(event.runId, event.status, event.message); - if (event.status === "running") { - const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId); - if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) { - log.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`); - worker?.postMessage({ type: "cancel-run", runId: run.id }); - } - } - emitEvent(event); - publishDomainChanged(); - return; - } - - const snapshot = db.listSnapshot(); - const run = snapshot.runs.find((entry) => entry.id === event.runId); - if (run) { - const connector = getConnectorDefinition(run.connectorId); - - let task: ReturnType | null = null; - try { task = db.getTask(run.taskId); } catch { /* deleted mid-run */ } - - if (task) { - if (event.sessionParams && connector.module.sessionCodec) { - const serialized = connector.module.sessionCodec.serialize(event.sessionParams); - db.updateTaskSession(task.id, serialized, connector.module.sessionCodec.getDisplayId?.(serialized ?? null) ?? event.sessionDisplayId ?? null); - } - if (!event.sessionParams) { - db.updateTaskSession(task.id, null, null); - } - } - - const runOutcome = db.finishRun(event); - if (run.workspaceId) { - db.releaseWorkspaceLock(run.workspaceId, run.id); - } - db.addActivity({ - companyId: run.companyId, - actor: run.agentId, - action: `run.${event.status}`, - entityType: "run", - entityId: run.id, - detail: event.summary ?? event.errorMessage ?? "", - }); - publishDomainChanged(); - emitEvent({ - type: "run-status", - runId: run.id, - status: event.status, - message: event.summary ?? event.errorMessage ?? event.status, - }); - if ((event.status === "failed" || event.status === "timed_out" || event.status === "interrupted") && task) { - notify("Run attention required", task.title); - } - - let updatedAgent: ReturnType | null = null; - try { updatedAgent = db.getAgent(run.agentId); } catch { /* deleted mid-run */ } - - if (updatedAgent && runOutcome.budgetHardStopped) { - autoPauseBudgetExceededAgent(updatedAgent); - } - - if (event.status === "failed" || event.status === "timed_out") { - try { - const chain = db.getChainOfCommand(run.agentId); - if (chain.length > 0) { - wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); - } - } catch { /* agent deleted, skip escalation */ } - } - - const pendingWakes = db.consumePendingWakes(run.agentId); - if (pendingWakes.length > 0) { - for (const wake of pendingWakes) { - wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger); - } - } - - processRunCompletion(run, event as z.infer & { type: "run-finished" }); - } - }); -} - -function parseVersion(command: string): Promise { - return new Promise((resolve) => { - const child = spawn(command, ["--version"], { - env: process.env, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); - let buffer = ""; - child.stdout.on("data", (chunk) => { - buffer += String(chunk); - }); - child.stderr.on("data", (chunk) => { - buffer += String(chunk); - }); - child.on("error", () => resolve(null)); - child.on("close", () => { - const versionLine = buffer.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null; - resolve(versionLine); - }); - }); -} - -async function checkConnector(id: Parameters[0]) { - const connector = db.getConnector(id); - const definition = getConnectorDefinition(id); - - db.updateConnectorHealth({ - id, - status: "detected", - authState: connector.authState, - version: connector.version, - lastError: null, - }); - publishDomainChanged(); - - const result = await definition.module.testEnvironment({ - companyId: db.getCurrentCompanyId() ?? randomFallbackCompanyId(), - adapterType: id, - config: { - command: connector.command, - model: connector.model, - cwd: process.cwd(), - dangerouslyBypassApprovalsAndSandbox: true, - dangerouslySkipPermissions: true, - approvalMode: "yolo", - sandbox: false, - yolo: true, - extraArgs: id === "codex_local" ? ["--skip-git-repo-check"] : [], - }, - }); - - let authState: "ok" | "required" | "error" = "ok"; - if (result.status === "fail") { - const hasAuthCheck = result.checks.some((check) => check.code.includes("auth") || check.code.includes("login")); - authState = hasAuthCheck ? "required" : "error"; - } - const version = await parseVersion(connector.command); - - db.updateConnectorHealth({ - id, - status: - result.status === "pass" || result.status === "warn" - ? "ready" - : authState === "required" - ? "auth_required" - : "error", - authState, - version, - lastError: result.status === "fail" ? result.checks.map((check) => check.message).join(" | ") : null, - }); - db.addActivity({ - companyId: db.getCurrentCompanyId() ?? randomFallbackCompanyId(), - actor: "system", - action: "connector.tested", - entityType: "connector", - entityId: id, - detail: result.status, - }); - publishDomainChanged(); -} - -function randomFallbackCompanyId() { - const snapshot = db.listSnapshot(); - return snapshot.companies[0]?.id ?? "00000000-0000-0000-0000-000000000000"; -} - -function autoPauseBudgetExceededAgent(agent: { id: string; companyId: string; name: string; spentMonthlyUsd: number; budgetMonthlyUsd: number }) { - if (!(agent.budgetMonthlyUsd > 0) || agent.spentMonthlyUsd < agent.budgetMonthlyUsd) { - return false; - } - db.addActivity({ - companyId: agent.companyId, - actor: "system", - action: "agent.auto_paused", - entityType: "agent", - entityId: agent.id, - detail: `Budget exhausted at ${agent.spentMonthlyUsd.toFixed(2)} / ${agent.budgetMonthlyUsd.toFixed(2)} USD.`, - }); - notify("Agent paused for budget limit", agent.name); - publishDomainChanged(); - return true; -} - -function ensureAgentHeartbeatEnabled(agentId: string) { - const agent = db.getAgent(agentId); - if (!agent.heartbeatEnabled) { - db.setHeartbeat(agentId, true, agent.heartbeatIntervalSec || 120); - } -} - -function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) { - if (!agentId) return null; - try { - const agent = db.getAgent(agentId); - if (agent.companyId !== companyId) { - log.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`); - return null; - } - } catch { - return null; - } - try { - ensureAgentHeartbeatEnabled(agentId); - } catch { - return null; - } - - const snapshot = db.listSnapshot(); - const agentBusy = hasActiveRun(snapshot, { agentId, companyId }); - - const runId = startAgentHeartbeatRun(agentId, companyId, trigger); - if (!runId && agentBusy) { - db.enqueuePendingWake(companyId, agentId, trigger); - } - return runId; -} - -function getRunEligibilityFailure( - agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string }, - connector: { id: string; status: string }, -) { - if (["terminated", "paused", "pending_approval"].includes(agent.status)) { - return "AGENT_NOT_RUNNABLE"; - } - if (agent.budgetMonthlyUsd > 0 && agent.spentMonthlyUsd >= agent.budgetMonthlyUsd) { - autoPauseBudgetExceededAgent(agent); - log.info(`[run] Agent ${agent.name} budget exhausted, skipping`); - return "AGENT_BUDGET_EXHAUSTED"; - } - if (connector.status !== "ready" && connector.status !== "degraded") { - log.info(`[run] Agent ${agent.name} connector ${connector.id} not ready (${connector.status})`); - return "CONNECTOR_NOT_READY"; - } - return null; -} - -function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null { - if (!worker) return null; - const snapshot = db.listSnapshot(); - const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null; - if (!company || company.status !== "active") { - log.info(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`); - return null; - } - const agent = db.getAgent(agentId); - if (agent.companyId !== companyId) { - log.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`); - return null; - } - const connector = db.getConnector(agent.connectorId); - if (getRunEligibilityFailure(agent, connector)) { - return null; - } - if (hasActiveRun(snapshot, { agentId: agent.id, companyId })) { - log.info(`[heartbeat] Skipping ${agent.name} because it already has an active run.`); - return null; - } - - if (db.isAgentPendingApproval(agentId)) { - log.info(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`); - return null; - } - - const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]); - - const isTimer = trigger === "timer"; - const candidateTasks = isTimer - ? assignedTasks.filter((entry) => !entry.title.startsWith("[Heartbeat]")) - : assignedTasks; - - let task = candidateTasks.find((entry) => !entry.activeRunId) ?? null; - - if (!task) { - const hasActiveAssignedRun = candidateTasks.some( - (entry) => entry.activeRunId && hasActiveRun(snapshot, { taskId: entry.id, companyId }), - ); - if (hasActiveAssignedRun) { - return null; - } - - // Even on timer triggers, the CEO/executives should wake up to create work - // when there are active goals. This is the bootstrap mechanism — goals exist - // but nobody has broken them into tasks yet. - const isCeo = !agent.reportsTo; - const isManager = snapshot.agents.some((a) => a.reportsTo === agent.id && a.companyId === companyId); - const activeGoals = snapshot.goals.filter((g) => g.companyId === companyId && (g.status === "active" || g.status === "planned")); - const companyTasks = snapshot.tasks.filter((t) => t.companyId === companyId && t.status !== "done" && t.status !== "cancelled"); - const hasUnassignedGoals = activeGoals.length > 0; - const companyNeedsBootstrap = hasUnassignedGoals && companyTasks.length < activeGoals.length; - - if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) { - log.info(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`); - return null; - } - - // Create a work-check task. For CEO/managers this drives planning. - // For ICs this only happens when the company is bootstrapping. - const taskTitle = isCeo - ? `[Heartbeat] ${agent.name} — Strategic planning & task delegation` - : isManager - ? `[Heartbeat] ${agent.name} — Review team & delegate work` - : `[Heartbeat] ${agent.name} — Check for work`; - const taskDesc = isCeo - ? `Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners.` - : isManager - ? `Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked.` - : `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`; - - const taskId = db.saveTask({ - companyId, - title: taskTitle, - description: taskDesc, - assigneeAgentId: agentId, - workspaceId: agent.workspaceId ?? undefined, - status: "in_progress", - priority: isCeo ? "high" : "medium", - }); - task = db.getTask(taskId); - log.info(`[heartbeat] Created bootstrap task for ${agent.name}: ${taskTitle}`); - } - if (task.activeRunId || hasActiveRun(snapshot, { taskId: task.id, companyId })) { - log.info(`[heartbeat] Skipping ${agent.name} because task ${task.title} already has an active run.`); - return null; - } - - const workspaceResolution = resolveWorkspace(task, agent, companyId); - const workspace = workspaceResolution.workspace; - - log.info( - `[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, - ); - - const run = db.createRun({ - companyId, - taskId: task.id, - agentId: agent.id, - workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, - connectorId: connector.id, - }); - - dispatchRunToWorker({ - runId: run.id, - companyId, - agent, - task, - connector, - workspace, - workspaceMode: workspaceResolution.lockMode, - companyName: company.name, - companyDescription: company.description ?? "", - snapshot, - trigger, - }); - - db.markHeartbeatAt(agentId); - db.addActivity({ - companyId, - actor: "system", - action: "heartbeat.triggered", - entityType: "agent", - entityId: agent.id, - detail: `${trigger} heartbeat for ${agent.name}`, - }); - publishDomainChanged(); - return run.id; -} - -// ── Automation Engine ────────────────────────────────────────────────── - -interface AutomationEventContext { - companyId: string; - taskId?: string; - agentId?: string; - runId?: string; - projectId?: string; - department?: string; - previousStatus?: string; - newStatus?: string; - costUsd?: number; -} - -function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) { - try { - const rules = db.listActiveAutomationRules(ctx.companyId, trigger); - for (const rule of rules) { - if (!matchesConditions(rule, ctx)) continue; - const executionKey = [ - rule.id, - trigger, - ctx.companyId, - ctx.taskId ?? "-", - ctx.agentId ?? "-", - ctx.runId ?? "-", - ctx.previousStatus ?? "-", - ctx.newStatus ?? "-", - ].join(":"); - if (activeAutomationExecutions.has(executionKey)) { - log.warn(`[automation] Skipping re-entrant execution for rule "${rule.name}" (${executionKey})`); - continue; - } - activeAutomationExecutions.add(executionKey); - try { - executeAutomationAction(rule, ctx); - db.recordAutomationExecution( - rule.id, ctx.companyId, rule.name, trigger, rule.action, - `Fired for ${trigger} in context: taskId=${ctx.taskId ?? "n/a"}, agentId=${ctx.agentId ?? "n/a"}`, - ); - emitEvent({ - type: "automation-fired", - ruleId: rule.id, - ruleName: rule.name, - trigger: trigger as import("@shared/types").AutomationTrigger, - action: rule.action as import("@shared/types").AutomationAction, - }); - log.info(`[automation] Fired rule "${rule.name}" (${trigger} → ${rule.action})`); - } catch (err) { - log.error(`[automation] Failed to execute action for rule "${rule.name}": ${err instanceof Error ? err.message : String(err)}`); - db.recordAutomationExecution( - rule.id, ctx.companyId, rule.name, trigger, rule.action, - `FAILED: ${err instanceof Error ? err.message : String(err)}`, - ); - } finally { - activeAutomationExecutions.delete(executionKey); - } - } - } catch (err) { - log.error(`[automation] Error evaluating rules for trigger "${trigger}": ${err instanceof Error ? err.message : String(err)}`); - } -} - -function matchesEventConfig(configJson: string, sourceDepartment: string | null, ctx: AutomationEventContext): boolean { - if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) { - return false; - } - try { - const conditions = JSON.parse(configJson) as Record; - if (Object.keys(conditions).length === 0) return true; - if (conditions.status && ctx.newStatus && conditions.status !== ctx.newStatus) return false; - if (conditions.previousStatus && ctx.previousStatus && conditions.previousStatus !== ctx.previousStatus) return false; - if (conditions.priority) { - try { - const task = ctx.taskId ? db.getTask(ctx.taskId) : null; - if (task && task.priority !== conditions.priority) return false; - } catch { - return false; - } - } - if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) { - try { - const agent = db.getAgent(ctx.agentId); - if (agent.budgetMonthlyUsd > 0) { - const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; - if (utilization < (conditions.budgetThresholdPct as number) / 100) return false; - } else { - return false; - } - } catch { - return false; - } - } - if (conditions.taskId && ctx.taskId && conditions.taskId !== ctx.taskId) return false; - if (conditions.projectId && ctx.projectId && conditions.projectId !== ctx.projectId) return false; - if (conditions.agentId && ctx.agentId && conditions.agentId !== ctx.agentId) return false; - return true; - } catch { - return true; - } -} - -function matchesConditions( - rule: { conditionsJson: string; sourceDepartment: string | null; targetDepartment: string | null }, - ctx: AutomationEventContext, -): boolean { - void rule.targetDepartment; - return matchesEventConfig(rule.conditionsJson, rule.sourceDepartment, ctx); -} - -function evaluateWorkflowTriggers(trigger: string, ctx: AutomationEventContext) { - try { - const workflows = db.listTriggerableWorkflows(ctx.companyId, trigger); - for (const workflow of workflows) { - if (!matchesEventConfig(workflow.triggerConfigJson, null, ctx)) continue; - const executionKey = [ - workflow.id, - trigger, - ctx.companyId, - ctx.taskId ?? "-", - ctx.agentId ?? "-", - ctx.runId ?? "-", - ctx.previousStatus ?? "-", - ctx.newStatus ?? "-", - ].join(":"); - if (activeWorkflowTriggerExecutions.has(executionKey)) { - log.warn(`[workflow] Skipping re-entrant trigger for workflow "${workflow.name}" (${executionKey})`); - continue; - } - activeWorkflowTriggerExecutions.add(executionKey); - void runWorkflowPipeline(workflow.id, ctx.companyId) - .then(() => { - log.info(`[workflow] Triggered workflow "${workflow.name}" via ${trigger}`); - }) - .catch((error) => { - log.error(`[workflow] Trigger failed for "${workflow.name}": ${error instanceof Error ? error.message : String(error)}`); - }) - .finally(() => { - activeWorkflowTriggerExecutions.delete(executionKey); - }); - } - } catch (error) { - log.error(`[workflow] Error evaluating workflow triggers for "${trigger}": ${error instanceof Error ? error.message : String(error)}`); - } -} - -function dispatchAutomation(trigger: string, ctx: AutomationEventContext) { - evaluateAutomationRules(trigger, ctx); - evaluateWorkflowTriggers(trigger, ctx); -} - -function executeAutomationAction( - rule: { action: string; actionConfigJson: string; targetDepartment: string | null; trigger?: string }, - ctx: AutomationEventContext, -) { - const config = (() => { try { return JSON.parse(rule.actionConfigJson) as Record; } catch { return {}; } })(); - - switch (rule.action) { - case "assign_task": { - if (ctx.taskId) { - const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, ctx.companyId, "assignment"); - } - } else if (ctx.agentId) { - // No specific task — find unassigned tasks and assign to the idle agent - const snapshot = db.listSnapshot(); - const unassigned = snapshot.tasks - .filter((t) => t.companyId === ctx.companyId && !t.assigneeAgentId && ["backlog", "todo"].includes(t.status)) - .sort((a, b) => { - const prio = { critical: 4, high: 3, medium: 2, low: 1 } as Record; - return (prio[b.priority] ?? 0) - (prio[a.priority] ?? 0); - }); - if (unassigned.length > 0) { - const task = unassigned[0]; - try { - db.saveTask({ - id: task.id, - companyId: task.companyId, - projectId: task.projectId, - goalId: task.goalId, - parentId: task.parentId, - title: task.title, - description: task.description, - assigneeAgentId: ctx.agentId, - workspaceId: task.workspaceId, - priority: task.priority, - status: "todo", - }); - wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment"); - log.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`); - } catch (err) { - log.warn(`[automation] Failed to auto-assign task to idle agent: ${err instanceof Error ? err.message : String(err)}`); - } - } - } - break; - } - case "create_task": { - const title = (config.title as string) ?? `[Auto] Task from automation rule`; - const description = (config.description as string) ?? ""; - const priority = (config.priority as string) ?? "medium"; - const assigneeAgentId = (config.assigneeAgentId as string) ?? null; - const taskId = db.saveTask({ - companyId: ctx.companyId, - projectId: ctx.projectId ?? (config.projectId as string) ?? null, - title, - description, - assigneeAgentId, - priority, - status: assigneeAgentId ? "todo" : "backlog", - }); - if (assigneeAgentId) { - wakeAgentIfPossible(assigneeAgentId, ctx.companyId, "assignment"); - } else { - // No explicit assignee — try to auto-assign without triggering automation recursion - const autoAssigned = db.autoAssignTask(taskId, ctx.companyId); - if (autoAssigned) { - wakeAgentIfPossible(autoAssigned, ctx.companyId, "assignment"); - } - } - log.info(`[automation] Created task: ${title} (${taskId})`); - break; - } - case "notify_agent": - case "send_message": { - const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null; - const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId; - if (!fromAgentId) break; - const channel = (config.channel as string) ?? (toAgentId ? "direct" : "company"); - // For department channel, auto-resolve dept from the agent if not specified - let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null; - if (channel === "department" && !channelTargetId && fromAgentId) { - try { - const sender = db.getAgent(fromAgentId); - channelTargetId = sender.department ?? null; - } catch { /* ok */ } - } - db.sendAgentMessage({ - companyId: ctx.companyId, - fromAgentId, - toAgentId: channel === "direct" ? toAgentId : null, - channel, - channelTargetId, - subject: (config.subject as string) ?? `Automation notification`, - body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`, - priority: (config.priority as string) ?? "normal", - }); - // Emit event so renderer updates in real-time - const newMsg = db.listAgentMessages(ctx.companyId, { limit: 1 })?.[0]; - if (newMsg) emitEvent({ type: "new-message", message: newMsg }); - publishDomainChanged(); - if (toAgentId && channel === "direct") { - wakeAgentIfPossible(toAgentId, ctx.companyId, "message"); - } - break; - } - case "trigger_heartbeat": { - let targetAgentId = (config.agentId as string) ?? null; - if (!targetAgentId && ctx.agentId) { - // If no explicit target, wake the agent from context. - // But for run_completed/run_failed triggers, the context agent already just ran — - // waking them again is redundant. Instead, wake their manager if possible. - const triggerType = rule.trigger ?? ""; - if (triggerType === "run_completed" || triggerType === "run_failed") { - const manager = db.getManagerForAgent(ctx.agentId); - targetAgentId = manager?.id ?? ctx.agentId; - } else { - targetAgentId = ctx.agentId; - } - } - if (targetAgentId) { - wakeAgentIfPossible(targetAgentId, ctx.companyId, "manual"); - } - break; - } - case "create_approval": { - const approvalType = (config.approvalType as string) ?? "document_review"; - db.requestApproval({ - companyId: ctx.companyId, - relatedTaskId: ctx.taskId ?? null, - requestedByAgentId: ctx.agentId ?? null, - type: approvalType, - payloadSummary: (config.summary as string) ?? `Auto-generated approval request`, - impactSummary: (config.impact as string) ?? `Triggered by automation rule`, - }); - break; - } - case "update_task_status": { - if (!ctx.taskId) break; - const newStatus = (config.status as string) ?? "todo"; - db.advanceTaskStatus(ctx.taskId, newStatus, `Status changed by automation rule`); - publishDomainChanged(); - // Propagate essential downstream effects without re-triggering automation (to prevent loops) - try { - const updatedTask = db.getTask(ctx.taskId); - if (newStatus === "blocked" && updatedTask.assigneeAgentId) { - const manager = db.getManagerForAgent(updatedTask.assigneeAgentId); - if (manager) wakeAgentIfPossible(manager.id, ctx.companyId, "report_blocked"); - } - if (newStatus === "in_review" && updatedTask.assigneeAgentId) { - const manager = db.getManagerForAgent(updatedTask.assigneeAgentId); - if (manager) wakeAgentIfPossible(manager.id, ctx.companyId, "subtask_completed"); - } - if (newStatus === "todo" && updatedTask.assigneeAgentId) { - wakeAgentIfPossible(updatedTask.assigneeAgentId, ctx.companyId, "assignment"); - } - if (newStatus === "done" && updatedTask.parentId) { - const snapshot = db.listSnapshot(); - const siblings = snapshot.tasks.filter((t) => t.parentId === updatedTask.parentId && t.companyId === ctx.companyId); - const allDone = siblings.every((t) => t.id === ctx.taskId ? true : t.status === "done"); - if (allDone) { - const parent = db.getTask(updatedTask.parentId); - if (parent.assigneeAgentId) wakeAgentIfPossible(parent.assigneeAgentId, ctx.companyId, "subtask_completed"); - } - } - } catch { /* task may not exist */ } - break; - } - case "escalate_to_manager": { - const targetAgentId = ctx.agentId; - if (!targetAgentId) break; - const manager = db.getManagerForAgent(targetAgentId); - if (manager) { - let agentName = "an agent"; - try { agentName = db.getAgent(targetAgentId).name; } catch { /* ok */ } - let taskTitle = ctx.taskId ?? "unknown"; - try { if (ctx.taskId) taskTitle = db.getTask(ctx.taskId).title; } catch { /* ok */ } - db.sendAgentMessage({ - companyId: ctx.companyId, - fromAgentId: targetAgentId, - toAgentId: manager.id, - channel: "direct", - subject: `Escalation from ${agentName}: ${taskTitle}`, - body: (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`, - priority: "urgent", - }); - publishDomainChanged(); - wakeAgentIfPossible(manager.id, ctx.companyId, "report_blocked"); - } - break; - } - case "cross_department_notify": { - const targetDept = rule.targetDepartment ?? (config.department as string); - if (!targetDept) break; - const deptAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDept); - // Send a single channel message visible to entire department - const senderAgentId = ctx.agentId ?? deptAgents[0]?.id; - if (senderAgentId) { - db.sendAgentMessage({ - companyId: ctx.companyId, - fromAgentId: senderAgentId, - toAgentId: null, - channel: "department", - channelTargetId: targetDept, - subject: (config.subject as string) ?? `Cross-department notification`, - body: (config.body as string) ?? `Automation triggered a cross-department alert.`, - priority: "normal", - }); - publishDomainChanged(); - } - // Wake agents in the target department - for (const agent of deptAgents.slice(0, 5)) { - wakeAgentIfPossible(agent.id, ctx.companyId, "message"); - } - break; - } - case "schedule_meeting": { - const meetingType = (config.meetingType as string) ?? "department_sync"; - const title = (config.title as string) ?? `Auto-scheduled meeting`; - db.saveMeeting({ - companyId: ctx.companyId, - type: meetingType, - title, - organizerAgentId: ctx.agentId ?? null, - participantAgentIds: (config.participantAgentIds as string) ?? "[]", - scheduledAt: new Date(Date.now() + 3600_000).toISOString(), - durationMinutes: (config.durationMinutes as number) ?? 30, - }); - break; - } - case "create_document": { - const docType = (config.documentType as string) ?? "status_report"; - const title = (config.title as string) ?? `Auto-generated document`; - db.saveDocument({ - companyId: ctx.companyId, - type: docType, - title, - content: (config.content as string) ?? "", - authorAgentId: ctx.agentId ?? null, - projectId: ctx.projectId ?? null, - }); - break; - } - case "auto_approve": { - const snapshot = db.listSnapshot(); - const pendingApprovals = snapshot.approvals.filter( - (a) => a.companyId === ctx.companyId && a.state === "pending", - ); - for (const approval of pendingApprovals.slice(0, 5)) { - db.decideApproval({ companyId: ctx.companyId, approvalId: approval.id, state: "approved", decisionNote: "Auto-approved by automation rule." }); - handleApprovalResolved(approval.id, ctx.companyId, "approved"); - } - break; - } - case "reassign_task": { - if (!ctx.taskId) break; - const newAssignee = db.autoAssignTask(ctx.taskId, ctx.companyId); - if (newAssignee) { - wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment"); - } - break; - } - case "run_task": { - const targetTaskId = (config.taskId as string) ?? ctx.taskId; - if (!targetTaskId) break; - try { - const task = db.getTask(targetTaskId); - if (!task.assigneeAgentId) break; - wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment"); - } catch { /* task may not exist */ } - break; - } - default: - log.warn(`[automation] Unknown action: ${rule.action}`); - } -} - -// ── Run Result Processing ───────────────────────────────────────────── - -function processRunCompletion(run: RunRecord, event: z.infer & { type: "run-finished" }) { - let task: ReturnType | null = null; - try { task = db.getTask(run.taskId); } catch { return; } - if (!task) return; - - const isSuccess = event.status === "succeeded"; - const isFailed = event.status === "failed" || event.status === "timed_out"; - - // Auto-advance task on successful run: in_progress → in_review - if (isSuccess && task.status === "in_progress") { - db.advanceTaskStatus(task.id, "in_review", `Run completed successfully. Awaiting review.`); - publishDomainChanged(); - - // Wake the manager to review the work - if (task.assigneeAgentId) { - const manager = db.getManagerForAgent(task.assigneeAgentId); - if (manager) { - db.sendAgentMessage({ - companyId: run.companyId, - fromAgentId: task.assigneeAgentId, - toAgentId: manager.id, - channel: "direct", - subject: `Review requested: ${task.title}`, - body: `Task "${task.title}" has been completed and is ready for review.\n\nRun summary: ${event.summary ?? "No summary available."}`, - priority: "normal", - }); - wakeAgentIfPossible(manager.id, run.companyId, "subtask_completed"); - } - } - } - - // On successful heartbeat check-work task, clean up if no real work done - if (isSuccess && task.title.startsWith("[Heartbeat]") && task.status !== "done") { - db.advanceTaskStatus(task.id, "done", "Heartbeat check completed."); - } - - const automationCtx: AutomationEventContext = { - companyId: run.companyId, - taskId: run.taskId, - agentId: run.agentId, - runId: run.id, - costUsd: event.costUsd ?? undefined, - }; - try { - const agent = db.getAgent(run.agentId); - automationCtx.department = agent.department ?? undefined; - } catch { /* agent may be deleted */ } - - if (isSuccess) { - dispatchAutomation("run_completed", automationCtx); - - // Check if all subtasks of a parent task are done — use fresh snapshot since task status just changed - if (task.parentId) { - try { - const freshSnapshot = db.listSnapshot(); - const siblings = freshSnapshot.tasks.filter((t) => t.parentId === task!.parentId && t.companyId === run.companyId); - const allSiblingsDoneOrReview = siblings.every((t) => t.status === "done" || t.status === "in_review"); - if (allSiblingsDoneOrReview) { - const parent = db.getTask(task.parentId); - if (parent.assigneeAgentId) { - wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed"); - } else { - // Parent has no assignee — auto-assign to the agent who would be responsible - const assignedId = db.autoAssignTask(parent.id, run.companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed"); - } - } - } - } catch { /* parent may be deleted */ } - } - } - - if (isFailed) { - dispatchAutomation("run_failed", automationCtx); - } - - // Budget threshold check after cost is recorded - if (event.costUsd && event.costUsd > 0) { - try { - const agent = db.getAgent(run.agentId); - if (agent.budgetMonthlyUsd > 0) { - const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; - if (utilization >= 0.8) { - dispatchAutomation("budget_threshold", { - ...automationCtx, - agentId: agent.id, - }); - } - } - } catch { /* agent deleted */ } - } -} - -function handleApprovalResolved(approvalId: string, companyId: string, decision: string) { - const snapshot = db.listSnapshot(); - const approval = snapshot.approvals.find((a) => a.id === approvalId); - if (!approval) return; - - dispatchAutomation("approval_resolved", { - companyId, - agentId: approval.requestedByAgentId ?? undefined, - taskId: approval.relatedTaskId ?? undefined, - }); - - // `db.decideApproval` already updates the hire target. This path only emits - // post-resolution side effects once the DB state is committed. - if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) { - try { - const agent = db.getAgent(approval.relatedAgentId); - if (agent.status === "idle") { - db.addActivity({ - companyId, - actor: "system", - action: "agent.hired", - entityType: "agent", - entityId: agent.id, - detail: `${agent.name} hire approved and activated.`, - }); - dispatchAutomation("hire_approved", { - companyId, - agentId: agent.id, - }); - // Wake the new agent so they check in - wakeAgentIfPossible(agent.id, companyId, "assignment"); - publishDomainChanged(); - notify("New agent hired", `${agent.name} (${agent.role}) has joined the company.`); - } - } catch { /* agent may be deleted */ } - } - - const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId); - if (browserAction) { - if (decision === "approved") { - db.updateBrowserAction(browserAction.id, { - status: "queued", - resultSummary: "Approval granted. Dispatching browser action.", - errorMessage: null, - }); - publishDomainChanged(); - void dispatchBrowserAction(browserAction.id, companyId).catch((error) => { - log.error(`[browser] Failed to dispatch approved browser action ${browserAction.id}: ${error instanceof Error ? error.message : String(error)}`); - }); - } else if (decision === "rejected") { - db.updateBrowserAction(browserAction.id, { - status: "cancelled", - resultSummary: "Browser action was rejected during approval.", - errorMessage: "Approval rejected by board.", - finishedAt: new Date().toISOString(), - }); - publishDomainChanged(); - } else if (decision === "revision_requested") { - db.updateBrowserAction(browserAction.id, { - status: "approval_required", - resultSummary: "Board requested revisions before execution.", - errorMessage: "Approval requires revisions before the browser action can run.", - }); - publishDomainChanged(); - } - } - - if (approval.payloadJson) { - try { - const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string }; - if (payload.workflowId && payload.stepId) { - const workflow = db.getWorkflowRecord(payload.workflowId, companyId); - if (workflow) { - const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]")); - const stepIndex = steps.findIndex((step) => step.id === payload.stepId); - if (stepIndex >= 0) { - if (decision === "approved") { - steps[stepIndex]!.status = "pending"; - db.updateWorkflowExecution({ - id: workflow.id, - companyId, - status: "paused", - currentStepIndex: stepIndex, - stepsJson: JSON.stringify(steps), - lastRunAt: workflow.lastRunAt, - }); - publishDomainChanged(); - void runWorkflowPipeline(workflow.id, companyId).catch((error) => { - log.error(`[workflow] Failed to resume workflow ${workflow.id} after approval ${approvalId}: ${error instanceof Error ? error.message : String(error)}`); - }); - } else { - steps[stepIndex]!.status = decision === "revision_requested" ? "pending" : "failed"; - db.updateWorkflowExecution({ - id: workflow.id, - companyId, - status: decision === "revision_requested" ? "paused" : "failed", - currentStepIndex: stepIndex, - stepsJson: JSON.stringify(steps), - lastRunAt: workflow.lastRunAt, - }); - publishDomainChanged(); - } - } - } - } - } catch { - // Non-workflow approvals may use payloadJson for other purposes. - } - } - - // Wake the requesting agent to inform them of the decision - if (approval.requestedByAgentId) { - wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved"); - } -} - -function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) { - if (previousStatus === newStatus) return; - - let task: ReturnType | null = null; - try { task = db.getTask(taskId); } catch { return; } - if (!task) return; - - db.addActivity({ - companyId, - actor: task.assigneeAgentId ?? "board", - action: "task.status_changed", - entityType: "task", - entityId: taskId, - detail: `${previousStatus} → ${newStatus}`, - }); - - const ctx: AutomationEventContext = { - companyId, - taskId, - agentId: task.assigneeAgentId ?? undefined, - previousStatus, - newStatus, - }; - try { - if (task.assigneeAgentId) { - const agent = db.getAgent(task.assigneeAgentId); - ctx.department = agent.department ?? undefined; - } - } catch { /* agent deleted */ } - - dispatchAutomation("task_status_changed", ctx); - - if (newStatus === "blocked") { - dispatchAutomation("task_blocked", ctx); - // Auto-escalate blocked tasks to manager - if (task.assigneeAgentId) { - const manager = db.getManagerForAgent(task.assigneeAgentId); - if (manager) { - wakeAgentIfPossible(manager.id, companyId, "report_blocked"); - } - } - } - - // When task enters in_review, wake the manager to review - if (newStatus === "in_review" && task.assigneeAgentId) { - const manager = db.getManagerForAgent(task.assigneeAgentId); - if (manager) { - wakeAgentIfPossible(manager.id, companyId, "subtask_completed"); - } - } - - // When task is assigned (status change to todo with an assignee), wake the agent - if (newStatus === "todo" && task.assigneeAgentId) { - wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment"); - } - - // When a subtask is completed (done), check if all sibling subtasks are also done. - // If so, wake the parent task's assignee — this enables the full task completion cascade - // even when tasks are approved manually (not just via agent run completion). - if (newStatus === "done" && task.parentId) { - try { - const snapshot = db.listSnapshot(); - const siblings = snapshot.tasks.filter((t) => t.parentId === task!.parentId && t.companyId === companyId); - const allDone = siblings.every((t) => t.id === taskId ? true : t.status === "done"); - if (allDone) { - const parent = db.getTask(task.parentId); - if (parent.assigneeAgentId) { - wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed"); - } else { - const assignedId = db.autoAssignTask(parent.id, companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, companyId, "subtask_completed"); - } - } - } - } catch { /* parent may be deleted */ } - } -} - -function handleTaskCreated(taskId: string, companyId: string) { - let task: ReturnType | null = null; - try { task = db.getTask(taskId); } catch { return; } - if (!task) return; - - dispatchAutomation("task_created", { - companyId, - taskId, - agentId: task.assigneeAgentId ?? undefined, - }); - - if (!task.assigneeAgentId) { - const assignedId = db.autoAssignTask(taskId, companyId); - if (assignedId) { - wakeAgentIfPossible(assignedId, companyId, "assignment"); - } - } else { - wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment"); - } -} - -function generateInitialCompanyMessages( - companyId: string, - template: { name: string; agents: Array<{ name: string; role: string; title: string; department: string }> }, - agentIds: string[], -) { - try { - const ceoId = agentIds[0]; - const ceoName = template.agents[0]?.name ?? "CEO"; - - // CEO sends company-wide welcome - db.sendAgentMessage({ - companyId, - fromAgentId: ceoId, - toAgentId: null, - channel: "company", - subject: `Welcome to ${template.name}`, - body: `Team, I'm ${ceoName} and I'm excited to kick off our company. Our mission is clear and our team is assembled. Each department lead: please introduce your team in your department channel and outline your initial priorities. Let's build something great together.`, - priority: "normal", - }); - - // Group agents by department and have leads introduce themselves - const deptAgents = new Map>(); - for (let i = 0; i < template.agents.length; i++) { - const def = template.agents[i]; - const dept = def.department; - if (!deptAgents.has(dept)) deptAgents.set(dept, []); - deptAgents.get(dept)!.push({ id: agentIds[i], name: def.name, role: def.role, title: def.title, index: i }); - } - - for (const [dept, members] of deptAgents) { - if (dept === "executive" && members.length <= 1) continue; // Skip if only CEO - const lead = members[0]; // First agent in department is the lead - const otherMembers = members.slice(1); - const memberList = otherMembers.length > 0 - ? `Our team: ${otherMembers.map((m) => `${m.name} (${m.title})`).join(", ")}.` - : "I'm currently the sole member of this department."; - - db.sendAgentMessage({ - companyId, - fromAgentId: lead.id, - toAgentId: null, - channel: "department", - channelTargetId: dept, - subject: `${dept.charAt(0).toUpperCase() + dept.slice(1).replace("_", " ")} department reporting in`, - body: `I'm ${lead.name}, ${lead.title}. ${memberList} We're ready to start executing on our goals. I'll be coordinating our work and reporting progress here.`, - priority: "normal", - }); - - // Each non-lead member sends a brief introduction - for (const member of otherMembers) { - db.sendAgentMessage({ - companyId, - fromAgentId: member.id, - toAgentId: null, - channel: "department", - channelTargetId: dept, - subject: `Introduction`, - body: `Hello team, I'm ${member.name}, ${member.title}. Ready to contribute. Looking forward to working with everyone.`, - priority: "low", - }); - } - } - - // CEO sends a strategy message - db.sendAgentMessage({ - companyId, - fromAgentId: ceoId, - toAgentId: null, - channel: "company", - subject: "Initial priorities and next steps", - body: `Here's our immediate game plan:\n\n1. All department leads: review our company goals and identify your department's top 3 priorities\n2. Engineering: set up workspaces and assess our technical infrastructure\n3. Product: draft initial requirements for our first deliverable\n4. All agents: check your assigned tasks and begin execution\n\nI'll be reviewing progress and providing direction. Don't hesitate to escalate blockers — speed matters more than perfection right now.`, - priority: "normal", - }); - - // Department leads report back to CEO with initial priorities - for (const [dept, members] of deptAgents) { - if (dept === "executive") continue; - const lead = members[0]; - if (!lead) continue; - - db.sendAgentMessage({ - companyId, - fromAgentId: lead.id, - toAgentId: ceoId, - channel: "direct", - subject: `${dept.replace("_", " ")} department initial assessment`, - body: `${ceoName}, this is ${lead.name} from the ${dept.replace("_", " ")} department. I've reviewed our company goals and I'm ready to break them down into actionable tasks for my team. I'll start creating tasks and assigning work to my direct reports immediately. Will report back with our sprint plan shortly.`, - priority: "normal", - }); - } - - log.info(`[company] Generated initial messages for company ${companyId}`); - } catch (err) { - log.error(`[company] Failed to generate initial messages: ${err instanceof Error ? err.message : String(err)}`); - } -} - -function startHeartbeatScheduler() { - if (heartbeatTimer) clearInterval(heartbeatTimer); - - heartbeatTimer = setInterval(() => { - try { - if (!worker) return; - const snapshot = db.listSnapshot(); - const now = Date.now(); - - for (const company of snapshot.companies.filter((entry) => entry.status === "active")) { - try { - const eligibleAgents = db.listHeartbeatEligibleAgents(company.id); - for (const agent of eligibleAgents) { - if (hasActiveRun(snapshot, { agentId: agent.id, companyId: company.id })) continue; - - const lastHeartbeat = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; - const intervalMs = agent.heartbeatIntervalSec * 1000; - if (now - lastHeartbeat < intervalMs) continue; - - log.info(`[heartbeat] Waking agent ${agent.name} (interval: ${agent.heartbeatIntervalSec}s)`); - startAgentHeartbeatRun(agent.id, company.id, "timer"); - } - // Company bootstrap: if goals exist but no active tasks, force-wake the CEO - // This ensures the company starts running even without manual intervention - const companyGoals = snapshot.goals.filter((g) => g.companyId === company.id && (g.status === "active" || g.status === "planned")); - const companyActiveTasks = snapshot.tasks.filter((t) => t.companyId === company.id && !["done", "cancelled"].includes(t.status) && !t.title.startsWith("[Heartbeat]")); - if (companyGoals.length > 0 && companyActiveTasks.length === 0) { - const ceo = snapshot.agents.find((a) => a.companyId === company.id && !a.reportsTo && a.status !== "terminated"); - const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0; - const bootstrapCooldown = 120_000; // 2 minutes between bootstrap attempts - if (ceo && !hasActiveRun(snapshot, { agentId: ceo.id, companyId: company.id }) && (now - ceoLastWake > bootstrapCooldown)) { - log.info(`[heartbeat] Company ${company.name} has ${companyGoals.length} goals but no tasks — bootstrapping CEO ${ceo.name}`); - startAgentHeartbeatRun(ceo.id, company.id, "bootstrap"); - } - } - - // Rate-limit schedule rules to once per 60 seconds per company - const scheduleKey = `schedule-${company.id}`; - const lastScheduleFired = (scheduleLastFiredMap as Map).get(scheduleKey) ?? 0; - if (now - lastScheduleFired >= 60_000) { - dispatchAutomation("schedule", { companyId: company.id }); - (scheduleLastFiredMap as Map).set(scheduleKey, now); - } - - // Check for idle agents — agents with no tasks and no recent runs - const companyAgents = snapshot.agents.filter((a) => a.companyId === company.id && a.status !== "terminated" && a.status !== "paused" && a.status !== "pending_approval"); - for (const agent of companyAgents) { - const hasAssigned = snapshot.tasks.some( - (t) => t.assigneeAgentId === agent.id && t.companyId === company.id && ["todo", "in_progress", "blocked"].includes(t.status), - ); - if (!hasAssigned) { - const lastActive = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; - const idleThreshold = 20 * 60 * 1000; // 20 minutes (must exceed 2x max heartbeat interval to avoid false positives) - if (now - lastActive > idleThreshold) { - dispatchAutomation("agent_idle", { - companyId: company.id, - agentId: agent.id, - department: agent.department ?? undefined, - }); - } - } - } - - // Monthly budget reset — persisted in database to survive app restarts - const currentMonth = new Date().toISOString().slice(0, 7); // "2026-03" - const lastResetMonth = db.getLastBudgetResetMonth(company.id) ?? ""; - if (currentMonth !== lastResetMonth) { - db.resetMonthlyBudgets(company.id); - db.setLastBudgetResetMonth(company.id, currentMonth); - log.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`); - } - } catch (companyError) { - log.error(`[heartbeat] Error processing company ${company.id}: ${companyError instanceof Error ? companyError.message : String(companyError)}`); - } - } - // Periodic cleanup: release stale workspace locks (held >2h) and expired pending wakes - try { - const staleReleased = db.releaseStaleWorkspaceLocks(); - if (staleReleased > 0) { - log.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`); - publishDomainChanged(); - } - } catch (cleanupErr) { - log.error(`[heartbeat] Stale lock cleanup failed: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`); - } - } catch (error) { - log.error(`[heartbeat] Scheduler error: ${error instanceof Error ? error.message : String(error)}`); - } - }, 15_000); -} - -function dispatchRunToWorker(params: { - runId: string; - companyId: string; - agent: ReturnType; - task: ReturnType; - connector: ReturnType; - workspace: ReturnType | null; - workspaceMode: WorkspaceLockMode; - companyName: string; - companyDescription: string; - snapshot: ReturnType; - trigger: string; -}) { - const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params; - if (!worker) { - throw new Error("Orchestration worker is not ready."); - } - - const env = db.resolveSecretEnv(connector.envBindingText, companyId); - const apiPort = apiServer?.getPort() ?? 0; - const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : ""; - const apiKey = apiUrl ? createAgentToken(agent.id, companyId, runId) : ""; - - const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]); - const recentComments = snapshot.comments - .filter((c) => assignedTasks.some((t) => t.id === c.taskId)) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 20) - .map((c) => ({ - taskId: c.taskId, - taskTitle: assignedTasks.find((t) => t.id === c.taskId)?.title ?? c.taskId, - authorName: c.authorName, - body: c.body, - createdAt: c.createdAt, - })); - - const systemPrompt = apiUrl ? buildTaskPrompt({ - agent, - apiUrl, - apiKey, - runId, - task, - companyName, - goals: snapshot.goals.filter((g) => g.companyId === companyId), - projects: snapshot.projects.filter((p) => p.companyId === companyId), - directReports: db.getDirectReports(agent.id, companyId), - chainOfCommand: db.getChainOfCommand(agent.id), - assignedTasks, - allAgents: snapshot.agents.filter((a) => a.companyId === companyId), - pendingApprovals: snapshot.approvals.filter( - (a) => a.companyId === companyId && - a.requestedByAgentId === agent.id && - (a.state === "pending" || a.state === "revision_requested"), - ), - tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId), - wakeReason: trigger, - allTasks: snapshot.tasks.filter((t) => t.companyId === companyId), - recentComments, - companyDescription, - socialAccounts: db.listSocialAccounts(companyId).map((a) => ({ - id: a.id, - platform: a.platform, - accountName: a.accountName, - status: a.status, - requireApproval: a.requireApproval, - })), - recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }), - upcomingMeetings: snapshot.meetings - .filter((m) => m.companyId === companyId && (m.status === "scheduled" || m.status === "in_progress")) - .filter((m) => { - try { - const ids: string[] = JSON.parse(m.participantAgentIds); - return ids.includes(agent.id) || m.organizerAgentId === agent.id; - } catch { - return false; - } - }) - .slice(0, 5), - relevantKnowledge: snapshot.knowledgeBase - .filter((k) => k.companyId === companyId && (k.importance === "critical" || k.importance === "high")) - .slice(0, 8), - activeSprint: snapshot.sprints.find((s) => s.companyId === companyId && s.status === "active") ?? null, - recentDocuments: snapshot.documents - .filter((d) => d.companyId === companyId && (d.authorAgentId === agent.id || d.reviewerAgentId === agent.id || d.status === "in_review")) - .slice(0, 5), - }) : null; - - if (apiUrl) { - env.AGENT_COMPANY_API_URL = apiUrl; - env.AGENT_COMPANY_API_KEY = apiKey; - env.AGENT_COMPANY_AGENT_ID = agent.id; - env.AGENT_COMPANY_COMPANY_ID = companyId; - env.AGENT_COMPANY_RUN_ID = runId; - env.AGENT_COMPANY_TASK_ID = task.id; - env.AGENT_COMPANY_WAKE_REASON = trigger; - } - - const taskSession = db.getTaskSession(task.id); - let thinkingEffort: string | null = null; - try { - const meta = JSON.parse(agent.metadataJson || "{}"); - if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { - thinkingEffort = meta.thinkingEffort; - } - } catch { /* malformed metadata */ } - - worker.postMessage({ - type: "enqueue-run", - payload: { - runId, - companyId, - taskId: task.id, - taskTitle: task.title, - taskDescription: task.description, - agentId: agent.id, - agentName: agent.name, - connectorId: connector.id, - workspaceId: workspace?.id ?? null, - workspacePath: workspace?.localPath ?? null, - workspaceMode, - command: connector.command, - model: connector.model ?? agent.model, - thinkingEffort, - env, - sessionParams: taskSession.sessionParams, - systemPrompt, - trigger, - }, - }); -} - -function registerHandlers() { - registerHandle(IPC_CHANNELS.loadSnapshot, () => ok(db.listRendererSnapshot())); - - registerHandle(IPC_CHANNELS.pickDirectory, async () => { - const result = await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); - return ok(result.canceled ? null : (result.filePaths[0] ?? null)); - }); - - registerHandle(IPC_CHANNELS.openPath, async (_event, targetPath) => { - if (typeof targetPath !== "string" || targetPath.length === 0) { - return fail("INVALID_PATH", "A valid path is required."); - } - if (!db.canOpenPath(targetPath)) { - return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace."); - } - const error = await shell.openPath(targetPath); - return error ? fail("OPEN_PATH_FAILED", error) : ok(true); - }); - - registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => { - const parsed = companyInputSchema.parse(payload); - const id = db.saveCompany(parsed); - db.addActivity({ - companyId: id, - actor: "board", - action: parsed.id ? "company.updated" : "company.created", - entityType: "company", - entityId: id, - detail: parsed.name, - }); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.setCurrentCompany, async (_event, companyId) => { - let parsedCompanyId: string; - try { - parsedCompanyId = companyIdSchema.parse(companyId); - } catch { - return fail("INVALID_COMPANY_ID", "A valid company id is required."); - } - if (!db.hasCompany(parsedCompanyId)) { - return fail("COMPANY_NOT_FOUND", "The selected company does not exist."); - } - db.setCurrentCompany(parsedCompanyId); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveConnector, async (_event, payload) => { - const parsed = connectorInputSchema.parse(payload); - const definition = getConnectorDefinition(parsed.id); - if (parsed.command !== definition.defaultCommand) { - return fail("CONNECTOR_COMMAND_LOCKED", `Connector command is managed by AgentCompany and must remain "${definition.defaultCommand}".`); - } - db.saveConnector({ - ...parsed, - command: definition.defaultCommand, - model: parsed.model ?? null, - }); - db.addActivity({ - companyId: db.getCurrentCompanyId() ?? randomFallbackCompanyId(), - actor: "board", - action: "connector.updated", - entityType: "connector", - entityId: parsed.id, - detail: definition.defaultCommand, - }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.testConnector, async (_event, id) => { - const parsed = connectorInputSchema.shape.id.parse(id); - await checkConnector(parsed); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveWorkspace, async (_event, payload) => { - const parsed = workspaceInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.projectId, table: "projects", label: "project" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.saveWorkspace(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "workspace.updated" : "workspace.created", - entityType: "workspace", - entityId: id, - detail: parsed.localPath, - }); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveAgent, async (_event, payload) => { - const parsed = agentInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.reportsTo, table: "agents", label: "manager" }, - { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - let id: string; - try { - id = db.saveAgent({ - id: parsed.id ?? null, - companyId: parsed.companyId, - name: parsed.name, - role: parsed.role, - title: parsed.title, - department: parsed.department ?? null, - status: parsed.status, - reportsTo: parsed.reportsTo ?? null, - connectorId: parsed.connectorId, - workspaceId: parsed.workspaceId ?? null, - model: parsed.model ?? null, - metadataJson: parsed.metadataJson, - capabilities: parsed.capabilities, - budgetMonthlyUsd: parsed.budgetMonthlyUsd, - }); - } catch (error) { - return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved."); - } - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "agent.updated" : "agent.created", - entityType: "agent", - entityId: id, - detail: parsed.name, - }); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => { - const parsed = goalInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.parentId, table: "goals", label: "parent goal" }, - { id: parsed.ownerAgentId, table: "agents", label: "owner agent" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.saveGoal(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "goal.updated" : "goal.created", - entityType: "goal", - entityId: id, - detail: parsed.title, - }); - publishDomainChanged(); - - if (parsed.status === "active" && parsed.ownerAgentId) { - wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated"); - } - - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveProject, async (_event, payload) => { - const parsed = projectInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.goalId, table: "goals", label: "goal" }, - { id: parsed.leadAgentId, table: "agents", label: "lead agent" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.saveProject(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "project.updated" : "project.created", - entityType: "project", - entityId: id, - detail: parsed.name, - }); - if (parsed.leadAgentId) { - wakeAgentIfPossible(parsed.leadAgentId, parsed.companyId, "assignment"); - } - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveTask, async (_event, payload) => { - const parsed = taskInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.projectId, table: "projects", label: "project" }, - { id: parsed.goalId, table: "goals", label: "goal" }, - { id: parsed.parentId, table: "tasks", label: "parent task" }, - { id: parsed.assigneeAgentId, table: "agents", label: "assignee agent" }, - { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const isNew = !parsed.id; - let previousStatus: string | null = null; - if (!isNew) { - try { - const existing = db.getTask(parsed.id!); - previousStatus = existing.status; - } catch { /* task is new */ } - } - let id: string; - try { - id = db.saveTask(parsed); - } catch (error) { - return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved."); - } - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: isNew ? "task.created" : "task.updated", - entityType: "task", - entityId: id, - detail: parsed.title, - }); - if (isNew) { - handleTaskCreated(id, parsed.companyId); - } else if (previousStatus && previousStatus !== parsed.status) { - handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status); - } - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.requestApproval, async (_event, payload) => { - const parsed = approvalInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.relatedTaskId, table: "tasks", label: "task" }, - { id: parsed.requestedByAgentId, table: "agents", label: "requesting agent" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.requestApproval(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: parsed.requestedByAgentId ?? "board", - action: "approval.created", - entityType: "approval", - entityId: id, - detail: parsed.payloadSummary, - }); - notify("Approval requested", parsed.payloadSummary); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.decideApproval, async (_event, payload) => { - const parsed = approvalDecisionSchema.parse(payload); - db.decideApproval(parsed); - const snapshot = db.listSnapshot(); - const approval = snapshot.approvals.find((entry) => entry.id === parsed.approvalId && entry.companyId === parsed.companyId); - if (approval) { - db.addActivity({ - companyId: approval.companyId, - actor: "board", - action: `approval.${parsed.state}`, - entityType: "approval", - entityId: approval.id, - detail: parsed.decisionNote, - }); - handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state); - } - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => { - const parsed = secretInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const id = db.saveSecret(parsed); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.id ? "secret.updated" : "secret.created", - entityType: "secret", - entityId: id, - detail: parsed.name, - }); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.startTaskRun, async (_event, payload) => { - if (!worker) return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready."); - const parsed = startRunSchema.parse(payload); - const task = db.getTask(parsed.taskId); - const snapshot = db.listSnapshot(); - const company = snapshot.companies.find((entry) => entry.id === task.companyId) ?? null; - if (!company || company.status !== "active") { - return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active."); - } - if (task.activeRunId || hasActiveRun(snapshot, { taskId: task.id, companyId: task.companyId })) { - return fail("TASK_ALREADY_RUNNING", "This task already has an active run."); - } - if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run."); - const agent = db.getAgent(task.assigneeAgentId); - if (agent.companyId !== task.companyId) { - return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company."); - } - const connector = db.getConnector(agent.connectorId); - const eligibilityFailure = getRunEligibilityFailure(agent, connector); - if (eligibilityFailure === "AGENT_NOT_RUNNABLE") { - return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated."); - } - if (eligibilityFailure === "AGENT_BUDGET_EXHAUSTED") { - return fail("AGENT_BUDGET_EXHAUSTED", "The assigned agent has exhausted its monthly budget."); - } - if (eligibilityFailure === "CONNECTOR_NOT_READY") { - return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready."); - } - if (hasActiveRun(snapshot, { agentId: agent.id, companyId: task.companyId })) { - return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run."); - } - - if (db.isAgentPendingApproval(agent.id)) { - return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks."); - } - const blockingApproval = db.getBlockingApproval(task.companyId, agent.id, task.id); - if (blockingApproval) { - return fail( - "APPROVAL_GATE_BLOCKED", - `Run blocked by pending approval: ${blockingApproval.type} — "${blockingApproval.payloadSummary}". Approve or reject before starting.`, - ); - } - - const workspaceResolution = resolveWorkspace(task, agent, task.companyId); - const workspace = workspaceResolution.workspace; - - log.info( - `[run] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, - ); - - if (workspace && workspace.companyId !== task.companyId) { - return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company."); - } - - const run = db.createRun({ - companyId: task.companyId, - taskId: task.id, - agentId: agent.id, - workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, - connectorId: connector.id, - }); - - const manualCompany = snapshot.companies.find((c) => c.id === task.companyId); - dispatchRunToWorker({ - runId: run.id, - companyId: task.companyId, - agent, - task, - connector, - workspace, - workspaceMode: workspaceResolution.lockMode, - companyName: manualCompany?.name ?? "Company", - companyDescription: manualCompany?.description ?? "", - snapshot, - trigger: "manual", - }); - db.addActivity({ - companyId: task.companyId, - actor: "board", - action: "run.queued", - entityType: "run", - entityId: run.id, - detail: task.title, - }); - publishDomainChanged(); - return ok(run.id); - }); - - registerHandle(IPC_CHANNELS.cancelRun, (_event, payload) => { - const parsed = cancelRunSchema.parse(payload); - worker?.postMessage({ type: "cancel-run", runId: parsed.runId }); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.getRunLog, (_event, payload) => { - const parsed = runLogSchema.parse(payload); - return ok(db.readRunLog(parsed.runId)); - }); - - registerHandle(IPC_CHANNELS.backupProfile, async () => { - const result = await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); - if (result.canceled || result.filePaths.length === 0) return ok(null); - const backupPath = join(result.filePaths[0]!, `agentcompany-backup-${Date.now()}`); - await db.exportBackup(backupPath); - return ok(backupPath); - }); - - registerHandle(IPC_CHANNELS.restoreProfile, async () => { - const hasActiveRuns = db.listSnapshot().runs.some((run) => run.status === "queued" || run.status === "running"); - if (hasActiveRuns) { - return fail("RESTORE_BLOCKED", "Stop all active runs before restoring a profile backup."); - } - const result = await dialog.showOpenDialog({ - properties: ["openDirectory"], - }); - if (result.canceled || result.filePaths.length === 0) return ok(false); - await db.restoreFromBackup(result.filePaths[0]!); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.updateSettings, async (_event, payload) => { - const parsed = settingsSchema.parse(payload); - db.updateSettings(parsed); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.checkForUpdates, async () => { - await checkForUpdates(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.deleteCompany, async (_event, companyId) => { - if (typeof companyId !== "string" || companyId.length === 0) { - return fail("INVALID_INPUT", "Company ID is required."); - } - if (!db.hasCompany(companyId)) { - return fail("COMPANY_NOT_FOUND", "Company not found."); - } - db.deleteCompany(companyId); - publishDomainChanged(); - return ok(true); - }); - - const deleteHandlers: Array<{ - channel: string; - table: CompanyScopedTable; - entity: string; - deleteFn: (id: string, companyId: string) => void; - }> = [ - { channel: IPC_CHANNELS.deleteAgent, table: "agents", entity: "agent", deleteFn: (id, cid) => db.deleteAgent(id, cid) }, - { channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id, cid) => db.deleteTask(id, cid) }, - { channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id, cid) => db.deleteGoal(id, cid) }, - { channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id, cid) => db.deleteProject(id, cid) }, - { channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id, cid) => db.deleteWorkspace(id, cid) }, - { channel: IPC_CHANNELS.deleteApproval, table: "approvals", entity: "approval", deleteFn: (id, cid) => db.deleteApproval(id, cid) }, - { channel: IPC_CHANNELS.deleteSecret, table: "secrets", entity: "secret", deleteFn: (id, cid) => db.deleteSecret(id, cid) }, - ]; - for (const { channel, table, entity, deleteFn } of deleteHandlers) { - registerHandle(channel, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) { - return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`); - } - deleteFn(parsed.id, parsed.companyId); - db.addActivity({ companyId: parsed.companyId, actor: "board", action: `${entity}.deleted`, entityType: entity, entityId: parsed.id, detail: "" }); - publishDomainChanged(); - return ok(true); - }); - } - - registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => { - let parsedCompanyId: string; - try { - parsedCompanyId = companyIdSchema.parse(companyId); - } catch { - return fail("INVALID_COMPANY_ID", "A valid company id is required."); - } - return ok(db.getSidebarBadges(parsedCompanyId)); - }); - - registerHandle(IPC_CHANNELS.addComment, async (_event, payload) => { - const parsed = commentInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const comment = db.addComment({ - companyId: parsed.companyId, - taskId: parsed.taskId, - authorAgentId: parsed.authorAgentId ?? null, - authorName: parsed.authorName, - body: parsed.body, - }); - db.addActivity({ - companyId: parsed.companyId, - actor: parsed.authorAgentId ?? "board", - action: "comment.created", - entityType: "task", - entityId: parsed.taskId, - detail: parsed.body.slice(0, 100), - }); - emitEvent({ type: "agent-message", taskId: parsed.taskId, comment }); - publishDomainChanged(); - - try { - const task = db.getTask(parsed.taskId); - if (task.assigneeAgentId) { - wakeAgentIfPossible(task.assigneeAgentId, task.companyId, "comment"); - } - dispatchAutomation("comment_posted", { - companyId: parsed.companyId, - taskId: parsed.taskId, - agentId: parsed.authorAgentId ?? undefined, - }); - } catch { /* ignore if task not found */ } - - return ok(comment.id); - }); - - registerHandle(IPC_CHANNELS.listComments, async (_event, payload) => { - const parsed = listCommentsSchema.parse(payload); - return ok(db.listComments(parsed.taskId)); - }); - - registerHandle(IPC_CHANNELS.setHeartbeat, async (_event, payload) => { - const parsed = heartbeatSettingsSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - db.setHeartbeat(parsed.agentId, parsed.enabled, parsed.intervalSec); - db.addActivity({ - companyId: parsed.companyId, - actor: "board", - action: parsed.enabled ? "heartbeat.enabled" : "heartbeat.disabled", - entityType: "agent", - entityId: parsed.agentId, - detail: `Interval: ${parsed.intervalSec}s`, - }); - emitEvent({ type: "heartbeat-status", agentId: parsed.agentId, enabled: parsed.enabled, nextWakeAt: null }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.triggerHeartbeat, async (_event, payload) => { - const parsed = triggerHeartbeatSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) { - return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); - } - const runId = startAgentHeartbeatRun(parsed.agentId, parsed.companyId, parsed.trigger); - if (!runId) return fail("HEARTBEAT_SKIPPED", "Agent cannot be woken right now (check connector status, budget, or active runs)."); - return ok(runId); - }); - - registerHandle(IPC_CHANNELS.getApiPort, () => { - return ok(apiServer?.getPort() ?? 0); - }); - - registerHandle(IPC_CHANNELS.listConnectorModels, async (_event, connectorId) => { - try { - const parsed = connectorInputSchema.shape.id.parse(connectorId); - const definition = getConnectorDefinition(parsed); - if (definition.listModels) { - const models = await definition.listModels(); - return ok(models); - } - return ok([]); - } catch { - return ok([]); - } - }); - - registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => { - const companyId = companyIdSchema.parse(payload); - const err = ensureCompanyExists(companyId); - if (err) return err; - return ok(db.getInbox(companyId)); - }); - - registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, payload) => { - const input = metricsQuerySchema.parse(payload); - const err = ensureCompanyExists(input.companyId); - if (err) return err; - return ok(db.getCompanyMetrics(input.companyId, input.days)); - }); - - registerHandle(IPC_CHANNELS.getAgentMetrics, (_event, payload) => { - const input = metricsQuerySchema.parse(payload); - const err = ensureCompanyExists(input.companyId); - if (err) return err; - if (!input.agentId) return fail("MISSING_AGENT_ID", "agentId is required"); - const metrics = db.getCompanyMetrics(input.companyId, input.days); - const agentMetric = metrics.agentMetrics.find(m => m.agentId === input.agentId); - if (!agentMetric) return fail("AGENT_NOT_FOUND", "Agent metrics not found"); - return ok(agentMetric); - }); - - registerHandle(IPC_CHANNELS.getStandupReport, (_event, payload) => { - const companyId = companyIdSchema.parse(payload); - const err = ensureCompanyExists(companyId); - if (err) return err; - return ok(db.getStandupReport(companyId)); - }); - - registerHandle(IPC_CHANNELS.listCompanyTemplates, () => ok(COMPANY_TEMPLATES)); - - registerHandle(IPC_CHANNELS.applyCompanyTemplate, (_event, payload) => { - const input = templateApplySchema.parse(payload); - const template = getTemplate(input.templateId); - if (!template) return fail("TEMPLATE_NOT_FOUND", "Company template not found"); - - const companyId = db.saveCompany({ - name: input.companyName, - description: input.companyDescription || template.description, - status: "active", - autoApproveHires: true, - }); - - const agentIds: string[] = []; - const connectors = db.listSnapshot().connectors; - const readyConnectors = connectors.filter(c => c.status === "ready"); - const fallbackConnector = readyConnectors[0]?.id ?? input.connectorId ?? "claude_local"; - - for (const agentDef of template.agents) { - const connectorToUse = readyConnectors.find(c => c.id === agentDef.connectorPreference) - ? agentDef.connectorPreference - : fallbackConnector; - - const reportsTo = agentDef.reportsToIndex !== null ? agentIds[agentDef.reportsToIndex] ?? null : null; - - const agentId = db.saveAgent({ - companyId, - name: agentDef.name, - role: agentDef.role, - title: agentDef.title, - department: agentDef.department ?? null, - capabilities: agentDef.capabilities, - reportsTo, - connectorId: connectorToUse, - workspaceId: input.workspaceId ?? null, - model: null, - budgetMonthlyUsd: agentDef.budgetMonthlyUsd, - status: "active", - }); - agentIds.push(agentId); - } - - const ceoId = agentIds[0]; - for (const goalDef of template.goals) { - db.saveGoal({ - companyId, - title: goalDef.title, - description: goalDef.description, - ownerAgentId: ceoId ?? null, - parentId: null, - status: "active", - }); - } - - db.setCurrentCompany(companyId); - - if (ceoId) { - db.setHeartbeat(ceoId, true, 60); - } - - db.addActivity({ - companyId, - actor: "board", - action: "company.created_from_template", - entityType: "company", - entityId: companyId, - detail: `Created from template: ${template.name}`, - }); - - const defaultRules: Array<{ name: string; description: string; trigger: string; action: string; conditionsJson?: string; actionConfigJson?: string; priority?: number }> = [ - { - name: "Auto-assign new tasks", - description: "Automatically assign new unassigned tasks to the best-fit agent based on capabilities and workload.", - trigger: "task_created", - action: "assign_task", - priority: 90, - }, - { - name: "Escalate blocked tasks", - description: "When a task is blocked, escalate to the assignee's manager.", - trigger: "task_blocked", - action: "escalate_to_manager", - actionConfigJson: JSON.stringify({ body: "A task assigned to your report is blocked and needs your attention." }), - priority: 85, - }, - { - name: "Escalate completed work to manager", - description: "When a run completes successfully, escalate to the assignee's manager so they review the work.", - trigger: "run_completed", - action: "escalate_to_manager", - actionConfigJson: JSON.stringify({ body: "A task run has completed successfully. Please review the work and approve or send back for changes." }), - priority: 80, - }, - { - name: "Reassign on run failure", - description: "When a run fails, attempt to reassign the task to another capable agent.", - trigger: "run_failed", - action: "reassign_task", - priority: 75, - }, - { - name: "Budget alert at 80%", - description: "Send an alert when an agent reaches 80% of their monthly budget.", - trigger: "budget_threshold", - action: "send_message", - actionConfigJson: JSON.stringify({ subject: "Budget warning", body: "Agent is approaching budget limit (80% utilized).", priority: "urgent" }), - priority: 70, - }, - { - name: "Notify department on task completion", - description: "When a run completes successfully, send a status update to the agent's department channel.", - trigger: "run_completed", - action: "send_message", - actionConfigJson: JSON.stringify({ channel: "department", subject: "Task completed", body: "A task has been completed successfully. Check the task details for more information.", priority: "low" }), - priority: 40, - }, - { - name: "Wake new hire on approval", - description: "When a new agent hire is approved, immediately wake the new agent to start onboarding.", - trigger: "hire_approved", - action: "trigger_heartbeat", - actionConfigJson: JSON.stringify({}), - priority: 90, - }, - { - name: "Auto-run assigned tasks", - description: "When a task transitions to 'todo' status, automatically wake the assigned agent to start working on it.", - trigger: "task_status_changed", - conditionsJson: JSON.stringify({ status: "todo" }), - action: "run_task", - actionConfigJson: JSON.stringify({}), - priority: 65, - }, - { - name: "Notify company on sprint start", - description: "When a sprint starts, notify the entire company.", - trigger: "sprint_started", - action: "send_message", - actionConfigJson: JSON.stringify({ channel: "company", subject: "Sprint started", body: "A new sprint has begun. Check your assigned tasks and start execution.", priority: "normal" }), - priority: 50, - }, - { - name: "Schedule retrospective on sprint end", - description: "When a sprint ends, schedule a retrospective meeting.", - trigger: "sprint_ended", - action: "schedule_meeting", - actionConfigJson: JSON.stringify({ meetingType: "retrospective", title: "Sprint Retrospective", durationMinutes: 45 }), - priority: 55, - }, - { - name: "Create status report on schedule", - description: "Periodically create a status report document for the company.", - trigger: "schedule", - action: "create_document", - actionConfigJson: JSON.stringify({ documentType: "status_report", title: "Automated Status Report" }), - priority: 30, - }, - { - name: "Auto-assign work to idle agents", - description: "When an agent has been idle for 10+ minutes, automatically assign available unassigned tasks from the backlog.", - trigger: "agent_idle", - action: "assign_task", - priority: 60, - }, - { - name: "Wake idle agents to check for work", - description: "When an agent has no tasks and is idle, wake them so they can check for available work and coordinate with their team.", - trigger: "agent_idle", - action: "trigger_heartbeat", - priority: 45, - }, - ]; - for (const rule of defaultRules) { - db.saveAutomationRule({ - companyId, - name: rule.name, - description: rule.description, - trigger: rule.trigger, - conditionsJson: rule.conditionsJson ?? "{}", - action: rule.action, - actionConfigJson: rule.actionConfigJson ?? "{}", - priority: rule.priority ?? 50, - status: "active", - }); - } - - for (const agentId of agentIds) { - if (agentId !== ceoId) { - db.setHeartbeat(agentId, true, 120); // 2 min interval for non-CEO - } - } - - // Generate initial company messages so channels are populated from day one - generateInitialCompanyMessages(companyId, template, agentIds); - - publishDomainChanged(); - - return ok(companyId); - }); - - registerHandle(IPC_CHANNELS.saveSocialAccount, (_event, payload) => { - const input = socialAccountInputSchema.parse(payload); - const err = ensureCompanyExists(input.companyId); - if (err) return err; - const id = db.saveSocialAccount(input); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.deleteSocialAccount, (_event, payload) => { - const input = deleteEntitySchema.parse(payload); - const err = ensureCompanyExists(input.companyId); - if (err) return err; - db.deleteSocialAccount(input.id, input.companyId); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.listBrowserActions, (_event, payload) => { - const input = browserActionQuerySchema.parse(payload); - const err = ensureCompanyExists(input.companyId); - if (err) return err; - return ok(db.listBrowserActions(input.companyId, { - socialAccountId: input.socialAccountId, - status: input.status, - limit: input.limit, - })); - }); - - registerHandle(IPC_CHANNELS.cancelBrowserAction, (_event, payload) => { - const input = cancelRunSchema.parse(payload); - db.updateBrowserAction(input.runId, { status: "cancelled" }); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.triggerBrowserLogin, async (_event, payload) => { - const socialAccountId = z.string().uuid().parse(payload); - if (!browserManager) { - return fail("BROWSER_NOT_READY", "Browser manager not initialized"); - } - - const account = db.getSocialAccount(socialAccountId, db.getCurrentCompanyId() ?? randomFallbackCompanyId()); - if (!account) { - return fail("ACCOUNT_NOT_FOUND", "Social account not found"); - } - - browserManager.launchLoginBrowser(socialAccountId, account.platform).then(result => { - if (result.success) { - const latestAccount = db.getSocialAccount(socialAccountId, account.companyId); - if (!latestAccount) { - return; - } - db.saveSocialAccount({ - id: latestAccount.id, - companyId: latestAccount.companyId, - platform: latestAccount.platform, - accountName: latestAccount.accountName, - displayName: latestAccount.displayName, - profileUrl: latestAccount.profileUrl, - credentialSecretId: latestAccount.credentialSecretId, - status: "logged_in", - requireApproval: latestAccount.requireApproval, - }); - publishDomainChanged(); - } - }).catch(err => { - log.error(`[browser] Login failed: ${err}`); - }); - - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => { - const parsed = meetingInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const id = db.saveMeeting(parsed); - publishDomainChanged(); - return ok(id); - }); - - const simpleDeleteHandlers: Array<{ channel: string; table: Parameters[0]; entity: string; deleteFn: (id: string, companyId: string) => void }> = [ - { channel: IPC_CHANNELS.deleteMeeting, table: "meetings", entity: "meeting", deleteFn: (id, cid) => db.deleteMeeting(id, cid) }, - { channel: IPC_CHANNELS.deleteDocument, table: "documents", entity: "document", deleteFn: (id, cid) => db.deleteDocument(id, cid) }, - { channel: IPC_CHANNELS.deleteKnowledgeEntry, table: "knowledge_base", entity: "knowledge entry", deleteFn: (id, cid) => db.deleteKnowledgeEntry(id, cid) }, - { channel: IPC_CHANNELS.deleteSprint, table: "sprints", entity: "sprint", deleteFn: (id, cid) => db.deleteSprint(id, cid) }, - { channel: IPC_CHANNELS.deleteAutomationRule, table: "automation_rules", entity: "automation rule", deleteFn: (id, cid) => db.deleteAutomationRule(id, cid) }, - { channel: IPC_CHANNELS.deleteWorkflow, table: "workflow_pipelines", entity: "workflow", deleteFn: (id, cid) => db.deleteWorkflow(id, cid) }, - ]; - for (const { channel, table, entity, deleteFn } of simpleDeleteHandlers) { - registerHandle(channel, async (_event, payload) => { - const parsed = deleteEntitySchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) { - return fail("ENTITY_NOT_FOUND", `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`); - } - deleteFn(parsed.id, parsed.companyId); - publishDomainChanged(); - return ok(true); - }); - } - - registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => { - const parsed = documentInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const isNew = !parsed.id; - const id = db.saveDocument(parsed); - if (isNew) { - dispatchAutomation("document_created", { - companyId: parsed.companyId, - agentId: parsed.authorAgentId ?? undefined, - projectId: parsed.projectId ?? undefined, - }); - // Wake document reviewer if specified - if (parsed.authorAgentId && parsed.reviewerAgentId) { - wakeAgentIfPossible(parsed.reviewerAgentId, parsed.companyId, "assignment"); - } - } - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => { - const parsed = knowledgeEntryInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const id = db.saveKnowledgeEntry(parsed); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => { - const parsed = sprintInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - let previousSprintStatus: string | null = null; - if (parsed.id) { - try { - const existing = db.listSnapshot().sprints.find(s => s.id === parsed.id); - previousSprintStatus = existing?.status ?? null; - } catch { /* sprint is new */ } - } - const id = db.saveSprint(parsed); - // Fire sprint_started when transitioning to active (not just on new sprints) - if (parsed.status === "active" && previousSprintStatus !== "active") { - dispatchAutomation("sprint_started", { companyId: parsed.companyId }); - } - // Fire sprint_ended when transitioning to completed - if (parsed.status === "completed" && previousSprintStatus !== "completed") { - dispatchAutomation("sprint_ended", { companyId: parsed.companyId }); - } - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.saveAutomationRule, async (_event, payload) => { - const parsed = automationRuleInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const id = db.saveAutomationRule(parsed); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.toggleAutomationRule, async (_event, payload) => { - const parsed = toggleAutomationRuleSchema.parse(payload); - db.toggleAutomationRule(parsed.id, parsed.companyId, parsed.status); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.getAutomationLog, async (_event, companyId) => { - const parsed = companyIdSchema.parse(companyId); - return ok(db.getAutomationLog(parsed)); - }); - - registerHandle(IPC_CHANNELS.sendAgentMessage, async (_event, payload) => { - const parsed = agentMessageInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - if (parsed.channel === "direct" && !parsed.toAgentId) { - return fail("DIRECT_MESSAGE_RECIPIENT_REQUIRED", "Direct messages require a recipient agent."); - } - const invalidReference = findInvalidReference(parsed.companyId, [ - { id: parsed.toAgentId, table: "agents", label: "message recipient" }, - { id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" }, - ]); - if (invalidReference) { - return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); - } - const id = db.sendAgentMessage(parsed); - const msg = db.listAgentMessages(parsed.companyId, { limit: 1 })?.[0]; - if (msg) emitEvent({ type: "new-message", message: msg }); - publishDomainChanged(); - // Wake the recipient agent so they can respond - if (parsed.toAgentId) { - wakeAgentIfPossible(parsed.toAgentId, parsed.companyId, "message"); - } - // For department messages, wake agents in the department - if (parsed.channel === "department" && parsed.channelTargetId) { - const deptAgents = db.getIdleAgentsForDepartment(parsed.companyId, parsed.channelTargetId); - for (const agent of deptAgents.filter((a) => a.id !== parsed.fromAgentId).slice(0, 3)) { - wakeAgentIfPossible(agent.id, parsed.companyId, "message"); - } - } - // For company-wide messages, wake the CEO so leadership responds - if (parsed.channel === "company") { - const snapshot = db.listSnapshot(); - const ceo = snapshot.agents.find((a) => a.companyId === parsed.companyId && !a.reportsTo && a.status !== "terminated"); - if (ceo && ceo.id !== parsed.fromAgentId) { - wakeAgentIfPossible(ceo.id, parsed.companyId, "message"); - } - } - return ok(id); - }); - - registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => { - const parsed = messageQuerySchema.parse(payload); - return ok(db.listAgentMessages(parsed.companyId, { - agentId: parsed.agentId, - channel: parsed.channel, - channelTargetId: parsed.channelTargetId, - unreadOnly: parsed.unreadOnly, - limit: parsed.limit, - })); - }); - - registerHandle(IPC_CHANNELS.markMessageRead, async (_event, payload) => { - const parsed = markMessageReadSchema.parse(payload); - db.markMessageRead(parsed.messageId, parsed.companyId); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.saveWorkflow, async (_event, payload) => { - const parsed = workflowInputSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const id = db.saveWorkflow(parsed); - publishDomainChanged(); - return ok(id); - }); - - registerHandle(IPC_CHANNELS.runWorkflow, async (_event, payload) => { - const parsed = runWorkflowSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - await runWorkflowPipeline(parsed.id, parsed.companyId); - publishDomainChanged(); - return ok(true); - }); - - registerHandle(IPC_CHANNELS.autoAssignTask, async (_event, payload) => { - const parsed = autoAssignSchema.parse(payload); - const companyError = ensureCompanyExists(parsed.companyId); - if (companyError) return companyError; - const agentId = db.autoAssignTask(parsed.taskId, parsed.companyId); - if (!agentId) return fail("NO_AVAILABLE_AGENT", "No available agent could be found for this task."); - publishDomainChanged(); - return ok(agentId); - }); -} - -async function checkForUpdates() { - const snapshot = db.listSnapshot(); - if (!snapshot.updatesEnabled || process.env.AGENTCOMPANY_DISABLE_UPDATES === "true") { - return false; - } - try { - autoUpdater.logger = log; - autoUpdater.autoDownload = false; - await autoUpdater.checkForUpdates(); - return true; - } catch (error) { - log.error(error); - return false; - } -} - -async function bootstrap() { - await app.whenReady(); - log.initialize(); - - // Resolve additional PATH entries from the user's login shell - if (process.platform !== "win32") { - try { - const userShell = process.env.SHELL || "/bin/zsh"; - const shellPath = await new Promise((resolve) => { - const child = spawn(userShell, ["-ilc", "echo -n $PATH"], { - env: { ...process.env }, - stdio: ["ignore", "pipe", "ignore"], - timeout: 5000, - }); - let output = ""; - child.stdout?.on("data", (chunk: Buffer) => { output += chunk.toString(); }); - child.on("close", () => resolve(output.trim())); - child.on("error", () => resolve("")); - }); - if (shellPath && shellPath.length > 0) { - const current = new Set((process.env.PATH ?? "").split(":").filter(Boolean)); - const shellParts = shellPath.split(":").filter(Boolean); - const toAdd = shellParts.filter((p) => !current.has(p)); - if (toAdd.length > 0) { - process.env.PATH = [process.env.PATH, ...toAdd].join(":"); - log.info(`[path] Added ${toAdd.length} shell PATH entries.`); - } - } - } catch { /* non-fatal */ } - } - - const appIcon = nativeImage.createFromPath(ICON_PATH); - if (!appIcon.isEmpty()) { - if (process.platform === "darwin" && app.dock) { - app.dock.setIcon(appIcon); - } - } - - if (process.platform === "win32") { - app.setAppUserModelId("com.agentcompany.desktop"); - } - installSecurityGuards(); - registerProtocol(); - db = new AppDatabase(process.env.AGENTCOMPANY_PROFILE_DIR ?? join(app.getPath("userData"), "profile")); - db.init(); - buildMenu(); - createTray(); - createWorker(); - registerHandlers(); - - apiServer = new AgentApiServer(db, publishDomainChanged, (agentId, companyId, trigger) => { - return wakeAgentIfPossible(agentId, companyId, trigger); - }, { - onDomainChanged: publishDomainChanged, - onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger), - onTaskCreated: (taskId, companyId) => handleTaskCreated(taskId, companyId), - onTaskStatusChanged: (taskId, companyId, prev, next) => handleTaskStatusChange(taskId, companyId, prev, next), - onCommentPosted: (taskId, companyId, authorAgentId) => { - dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined }); - }, - onApprovalResolved: (approvalId, companyId, decision) => handleApprovalResolved(approvalId, companyId, decision), - onDocumentCreated: (companyId, authorAgentId, projectId) => { - dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined }); - }, - onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId), - }); - await apiServer.start(); - - browserManager = new BrowserManager(join(app.getPath("userData"), "browser-data")); - - if (apiServer && browserManager) { - apiServer.setBrowserActionHandler(async (actionId, companyId, socialAccountId, actionType, payload) => { - void socialAccountId; - void actionType; - void payload; - await dispatchBrowserAction(actionId, companyId); - }); - } - - startHeartbeatScheduler(); - - await createWindow(); - - for (const connector of db.listSnapshot().connectors) { - void checkConnector(connector.id); - } - - app.on("activate", async () => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (!mainWindow.isVisible()) { - mainWindow.show(); - } - mainWindow.focus(); - return; - } - if (BrowserWindow.getAllWindows().length === 0) { - await createWindow(); - } - }); -} - const hasSingleInstanceLock = process.env.AGENTCOMPANY_DISABLE_SINGLE_INSTANCE === "true" ? true : app.requestSingleInstanceLock(); -app.on("second-instance", () => { - if (!hasSingleInstanceLock) { - return; - } - if (!mainWindow) { - return; - } - if (!mainWindow.isVisible()) { - mainWindow.show(); - } - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.focus(); -}); - -app.on("window-all-closed", () => {}); - -app.on("before-quit", () => { - isQuitting = true; - if (heartbeatTimer) clearInterval(heartbeatTimer); - heartbeatTimer = null; - apiServer?.stop(); - apiServer = null; - browserManager?.shutdown(); - browserManager = null; - appTray?.destroy(); - appTray = null; - worker?.kill(); - worker = null; - db?.close(); +const runtime = createMainRuntime({ + hasSingleInstanceLock, + isDev, + devServerOrigin, + profileDir, + windowStatePath: join(profileDir, "window-state.json"), + preloadPath: join(__dirname, "../preload/index.js"), + rendererDist: join(__dirname, "../renderer"), + iconPath: resolve(__dirname, "../../assets/icon.png"), + workerPath: join(__dirname, "worker.js"), + devServerUrl: process.env.ELECTRON_RENDERER_URL, }); if (hasSingleInstanceLock) { - void bootstrap(); + void runtime.bootstrap(); } else { app.quit(); } diff --git a/src/main/ipc-agent-coordination-handlers.ts b/src/main/ipc-agent-coordination-handlers.ts new file mode 100644 index 00000000..0004c804 --- /dev/null +++ b/src/main/ipc-agent-coordination-handlers.ts @@ -0,0 +1,114 @@ +import { + IPC_CHANNELS, + agentMessageInputSchema, + autoAssignSchema, + markMessageReadSchema, + messageQuerySchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; + +export function registerAgentCoordinationHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible, +}: Pick< + RegisterDomainHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "findInvalidReference" + | "publishDomainChanged" + | "emitEvent" + | "wakeAgentIfPossible" +>) { + registerHandle(IPC_CHANNELS.sendAgentMessage, async (_event, payload) => { + const parsed = agentMessageInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (parsed.channel === "direct" && !parsed.toAgentId) { + return fail("DIRECT_MESSAGE_RECIPIENT_REQUIRED", "Direct messages require a recipient agent."); + } + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.fromAgentId, table: "agents", label: "message sender" }, + { id: parsed.toAgentId, table: "agents", label: "message recipient" }, + { id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.sendAgentMessage(parsed); + const message = db.getAgentMessage(id, parsed.companyId); + if (message) { + emitEvent({ type: "new-message", message }); + } + publishDomainChanged(); + if (parsed.toAgentId) { + wakeAgentIfPossible(parsed.toAgentId, parsed.companyId, "message"); + } + if (parsed.channel === "department" && parsed.channelTargetId) { + const departmentAgents = db.getIdleAgentsForDepartment(parsed.companyId, parsed.channelTargetId); + for (const agent of departmentAgents.filter((entry: { id: string }) => entry.id !== parsed.fromAgentId)) { + wakeAgentIfPossible(agent.id, parsed.companyId, "message"); + } + } + if (parsed.channel === "company") { + const ceo = db.listSnapshot().agents.find((agent: { companyId: string; reportsTo: string | null; status: string }) => + agent.companyId === parsed.companyId && !agent.reportsTo && agent.status !== "terminated", + ); + if (ceo && ceo.id !== parsed.fromAgentId) { + wakeAgentIfPossible(ceo.id, parsed.companyId, "message"); + } + } + return ok(id); + }); + + registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => { + const parsed = messageQuerySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.agentId, table: "agents", label: "message reader" }, + { id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + return ok(db.listAgentMessages(parsed.companyId, { + agentId: parsed.agentId, + channel: parsed.channel, + channelTargetId: parsed.channelTargetId, + unreadOnly: parsed.unreadOnly, + limit: parsed.limit, + })); + }); + + registerHandle(IPC_CHANNELS.markMessageRead, async (_event, payload) => { + const parsed = markMessageReadSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.getAgentMessage(parsed.messageId, parsed.companyId)) { + return fail("MESSAGE_NOT_FOUND", "Message not found in the current company."); + } + if (parsed.readerAgentId && !db.belongsToCompany("agents", parsed.readerAgentId, parsed.companyId)) { + return fail("INVALID_COMPANY_REFERENCE", "The selected message reader does not belong to the current company."); + } + db.markMessageReadForReader(parsed.messageId, parsed.companyId, parsed.readerAgentId ?? null); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.autoAssignTask, async (_event, payload) => { + const parsed = autoAssignSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const agentId = db.autoAssignTask(parsed.taskId, parsed.companyId); + if (!agentId) { + return fail("NO_AVAILABLE_AGENT", "No available agent could be found for this task."); + } + publishDomainChanged(); + return ok(agentId); + }); +} diff --git a/src/main/ipc-automation-operations-handlers.ts b/src/main/ipc-automation-operations-handlers.ts new file mode 100644 index 00000000..3c7f1988 --- /dev/null +++ b/src/main/ipc-automation-operations-handlers.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { + IPC_CHANNELS, + automationRuleInputSchema, + deleteEntitySchema, + runWorkflowSchema, + toggleAutomationRuleSchema, + workflowInputSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; + +export function registerAutomationOperationsHandlers({ + registerHandle, + db, + ensureCompanyExists, + publishDomainChanged, + runWorkflowPipeline, +}: Pick< + RegisterDomainHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "publishDomainChanged" + | "runWorkflowPipeline" +>) { + const companyIdSchema = z.string().uuid(); + + registerHandle(IPC_CHANNELS.saveAutomationRule, async (_event, payload) => { + const parsed = automationRuleInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const id = db.saveAutomationRule(parsed); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteAutomationRule, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("automation_rules", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Automation rule not found in the current company."); + } + db.deleteAutomationRule(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.toggleAutomationRule, async (_event, payload) => { + const parsed = toggleAutomationRuleSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("automation_rules", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Automation rule not found in the current company."); + } + db.toggleAutomationRule(parsed.id, parsed.companyId, parsed.status); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.getAutomationLog, async (_event, companyId) => { + const parsed = companyIdSchema.parse(companyId); + const companyError = ensureCompanyExists(parsed); + if (companyError) return companyError; + return ok(db.getAutomationLog(parsed)); + }); + + registerHandle(IPC_CHANNELS.saveWorkflow, async (_event, payload) => { + const parsed = workflowInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const id = db.saveWorkflow(parsed); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteWorkflow, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("workflow_pipelines", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Workflow not found in the current company."); + } + db.deleteWorkflow(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.runWorkflow, async (_event, payload) => { + const parsed = runWorkflowSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("workflow_pipelines", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Workflow not found in the current company."); + } + await runWorkflowPipeline(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-browser-handlers.ts b/src/main/ipc-browser-handlers.ts new file mode 100644 index 00000000..e786ddb8 --- /dev/null +++ b/src/main/ipc-browser-handlers.ts @@ -0,0 +1,117 @@ +import { + IPC_CHANNELS, + browserActionQuerySchema, + cancelBrowserActionSchema, + deleteEntitySchema, + socialAccountInputSchema, + triggerBrowserLoginSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; + +export function registerBrowserHandlers({ + registerHandle, + db, + logger, + ensureCompanyExists, + publishDomainChanged, + getBrowserActions, +}: Pick< + RegisterDomainHandlersDependencies, + | "registerHandle" + | "db" + | "logger" + | "ensureCompanyExists" + | "publishDomainChanged" + | "getBrowserActions" +>) { + registerHandle(IPC_CHANNELS.saveSocialAccount, (_event, payload) => { + const input = socialAccountInputSchema.parse(payload); + const err = ensureCompanyExists(input.companyId); + if (err) return err; + const id = db.saveSocialAccount(input); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteSocialAccount, async (_event, payload) => { + const input = deleteEntitySchema.parse(payload); + const err = ensureCompanyExists(input.companyId); + if (err) return err; + const browserActions = db.listBrowserActions(input.companyId, { socialAccountId: input.id, limit: 500 }); + await getBrowserActions()?.clearSocialAccountData( + input.id, + browserActions.flatMap((action: { screenshotPath: string | null }) => (action.screenshotPath ? [action.screenshotPath] : [])), + ); + db.deleteSocialAccount(input.id, input.companyId); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.listBrowserActions, (_event, payload) => { + const input = browserActionQuerySchema.parse(payload); + const err = ensureCompanyExists(input.companyId); + if (err) return err; + return ok(db.listBrowserActions(input.companyId, { + socialAccountId: input.socialAccountId, + status: input.status, + limit: input.limit, + })); + }); + + registerHandle(IPC_CHANNELS.cancelBrowserAction, (_event, payload) => { + const input = cancelBrowserActionSchema.parse(payload); + const action = db.getBrowserAction(input.actionId, input.companyId); + if (!action) { + return fail("BROWSER_ACTION_NOT_FOUND", "Browser action not found."); + } + getBrowserActions()?.cancelAction(input.actionId); + db.updateBrowserAction(input.actionId, { + status: "cancelled", + resultSummary: "Browser action cancelled from the desktop control surface.", + errorMessage: "Action cancelled.", + finishedAt: new Date().toISOString(), + }); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.triggerBrowserLogin, async (_event, payload) => { + const input = triggerBrowserLoginSchema.parse(payload); + const err = ensureCompanyExists(input.companyId); + if (err) return err; + const browserActions = getBrowserActions(); + if (!browserActions) { + return fail("BROWSER_NOT_READY", "Browser manager not initialized"); + } + + const account = db.getSocialAccount(input.socialAccountId, input.companyId); + if (!account) { + return fail("ACCOUNT_NOT_FOUND", "Social account not found"); + } + + browserActions.launchLoginBrowser(input.socialAccountId, account.platform).then((result) => { + if (result.success) { + const latestAccount = db.getSocialAccount(input.socialAccountId, account.companyId); + if (!latestAccount) { + return; + } + db.saveSocialAccount({ + id: latestAccount.id, + companyId: latestAccount.companyId, + platform: latestAccount.platform, + accountName: latestAccount.accountName, + displayName: latestAccount.displayName, + profileUrl: latestAccount.profileUrl, + credentialSecretId: latestAccount.credentialSecretId, + status: "logged_in", + requireApproval: latestAccount.requireApproval, + }); + publishDomainChanged(); + } + }).catch((error) => { + logger.error(`[browser] Login failed: ${error}`); + }); + + return ok(true); + }); +} diff --git a/src/main/ipc-content-operations-handlers.ts b/src/main/ipc-content-operations-handlers.ts new file mode 100644 index 00000000..c5c3da22 --- /dev/null +++ b/src/main/ipc-content-operations-handlers.ts @@ -0,0 +1,192 @@ +import { + IPC_CHANNELS, + deleteEntitySchema, + documentInputSchema, + knowledgeEntryInputSchema, + meetingInputSchema, + sprintInputSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; + +const COMPANY_SCOPED_REFERENCE_TABLES = new Set([ + "agents", + "goals", + "projects", + "tasks", + "approvals", + "runs", + "secrets", + "meetings", + "documents", + "knowledge_base", + "sprints", + "automation_rules", + "workflow_pipelines", +]); + +export function registerContentOperationsHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + wakeAgentIfPossible, + dispatchAutomation, +}: Pick< + RegisterDomainHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "findInvalidReference" + | "publishDomainChanged" + | "wakeAgentIfPossible" + | "dispatchAutomation" +>) { + registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => { + const parsed = meetingInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + let participantAgentIds: string[] = []; + try { + const parsedParticipantIds = JSON.parse(parsed.participantAgentIds); + participantAgentIds = Array.isArray(parsedParticipantIds) + ? parsedParticipantIds.filter((value): value is string => typeof value === "string") + : []; + } catch { + return fail("INVALID_PARTICIPANTS", "Meeting participants must be valid JSON."); + } + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.organizerAgentId, table: "agents", label: "meeting organizer" }, + ...participantAgentIds.map((agentId) => ({ id: agentId, table: "agents", label: "meeting participant" })), + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveMeeting(parsed); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteMeeting, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("meetings", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Meeting not found in the current company."); + } + db.deleteMeeting(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.saveDocument, async (_event, payload) => { + const parsed = documentInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.authorAgentId, table: "agents", label: "document author" }, + { id: parsed.reviewerAgentId, table: "agents", label: "document reviewer" }, + { id: parsed.projectId, table: "projects", label: "document project" }, + { id: parsed.goalId, table: "goals", label: "document goal" }, + { id: parsed.parentDocId, table: "documents", label: "parent document" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const isNew = !parsed.id; + const id = db.saveDocument(parsed); + if (isNew) { + dispatchAutomation("document_created", { + companyId: parsed.companyId, + agentId: parsed.authorAgentId ?? undefined, + projectId: parsed.projectId ?? undefined, + }); + if (parsed.authorAgentId && parsed.reviewerAgentId) { + wakeAgentIfPossible(parsed.reviewerAgentId, parsed.companyId, "assignment"); + } + } + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("documents", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Document not found in the current company."); + } + db.deleteDocument(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.saveKnowledgeEntry, async (_event, payload) => { + const parsed = knowledgeEntryInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const knowledgeReferences = [ + { id: parsed.authorAgentId, table: "agents", label: "knowledge author" }, + ...(parsed.referencedEntityId && parsed.referencedEntityType + && COMPANY_SCOPED_REFERENCE_TABLES.has(parsed.referencedEntityType) + ? [{ id: parsed.referencedEntityId, table: parsed.referencedEntityType, label: "referenced entity" }] + : []), + ]; + const invalidReference = findInvalidReference(parsed.companyId, [ + ...knowledgeReferences, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveKnowledgeEntry(parsed); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteKnowledgeEntry, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("knowledge_base", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Knowledge entry not found in the current company."); + } + db.deleteKnowledgeEntry(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.saveSprint, async (_event, payload) => { + const parsed = sprintInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + let previousSprintStatus: string | null = null; + if (parsed.id) { + try { + previousSprintStatus = db.listSnapshot().sprints.find((s: { id: string }) => s.id === parsed.id)?.status ?? null; + } catch { + previousSprintStatus = null; + } + } + const id = db.saveSprint(parsed); + if (parsed.status === "active" && previousSprintStatus !== "active") { + dispatchAutomation("sprint_started", { companyId: parsed.companyId }); + } + if (parsed.status === "completed" && previousSprintStatus !== "completed") { + dispatchAutomation("sprint_ended", { companyId: parsed.companyId }); + } + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteSprint, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("sprints", parsed.id, parsed.companyId)) { + return fail("ENTITY_NOT_FOUND", "Sprint not found in the current company."); + } + db.deleteSprint(parsed.id, parsed.companyId); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-core-admin-handlers.ts b/src/main/ipc-core-admin-handlers.ts new file mode 100644 index 00000000..3bf9bdb0 --- /dev/null +++ b/src/main/ipc-core-admin-handlers.ts @@ -0,0 +1,131 @@ +import { + IPC_CHANNELS, + companyInputSchema, + connectorInputSchema, + deleteEntitySchema, + secretInputSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; + +export function registerCoreAdminHandlers({ + registerHandle, + db, + ensureCompanyExists, + publishDomainChanged, + checkConnector, + openConnectorAuthTerminal, +}: Pick< + RegisterCoreHandlersDependencies, + "registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal" +>) { + registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => { + const parsed = companyInputSchema.parse(payload); + const id = db.saveCompany(parsed); + db.addActivity({ + companyId: id, + actor: "board", + action: parsed.id ? "company.updated" : "company.created", + entityType: "company", + entityId: id, + detail: parsed.name, + }); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.setCurrentCompany, async (_event, companyId) => { + if (typeof companyId !== "string" || !companyId) { + return fail("INVALID_COMPANY_ID", "A valid company id is required."); + } + if (!db.hasCompany(companyId)) { + return fail("COMPANY_NOT_FOUND", "The selected company does not exist."); + } + db.setCurrentCompany(companyId); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.saveConnector, async (_event, payload) => { + const parsed = connectorInputSchema.parse(payload); + const existingConnector = db.getConnector(parsed.id); + if (parsed.command !== existingConnector.command) { + return fail("CONNECTOR_COMMAND_LOCKED", `Connector command is managed by AgentCompany and must remain "${existingConnector.command}".`); + } + db.saveConnector({ + ...parsed, + command: existingConnector.command, + model: parsed.model ?? null, + }); + db.addActivity({ + companyId: db.getCurrentCompanyId() ?? "00000000-0000-0000-0000-000000000000", + actor: "board", + action: "connector.updated", + entityType: "connector", + entityId: parsed.id, + detail: existingConnector.command, + }); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.testConnector, async (_event, id) => { + const parsed = connectorInputSchema.shape.id.parse(id); + await checkConnector(parsed); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.openConnectorAuthTerminal, async (_event, id) => { + const parsed = connectorInputSchema.shape.id.parse(id); + await openConnectorAuthTerminal(parsed); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => { + const parsed = secretInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const id = db.saveSecret(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "secret.updated" : "secret.created", + entityType: "secret", + entityId: id, + detail: parsed.name, + }); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.deleteSecret, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("secrets", parsed.id, parsed.companyId)) { + return fail("SECRET_NOT_FOUND", "Secret not found in the current company."); + } + db.deleteSecret(parsed.id, parsed.companyId); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: "secret.deleted", + entityType: "secret", + entityId: parsed.id, + detail: "", + }); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.deleteCompany, async (_event, companyId) => { + if (typeof companyId !== "string" || companyId.length === 0) { + return fail("INVALID_INPUT", "Company ID is required."); + } + if (!db.hasCompany(companyId)) { + return fail("COMPANY_NOT_FOUND", "Company not found."); + } + db.deleteCompany(companyId); + publishDomainChanged(); + return ok(true); + }); +} diff --git a/src/main/ipc-core-common.ts b/src/main/ipc-core-common.ts new file mode 100644 index 00000000..3bf6a0d8 --- /dev/null +++ b/src/main/ipc-core-common.ts @@ -0,0 +1,41 @@ +import type { IpcMainInvokeEvent } from "electron"; +import type { DesktopResult } from "@shared/contracts"; +import type { AppDatabase } from "./database"; + +export type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets"; + +export interface RegisterCoreHandlersDependencies { + registerHandle: ( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise> | DesktopResult, + ) => void; + db: AppDatabase; + ensureCompanyExists: (companyId: string) => DesktopResult | null; + findInvalidReference: ( + companyId: string, + refs: Array<{ id: string | null | undefined; table: CompanyScopedTable; label: string }>, + ) => { id: string | null | undefined; table: CompanyScopedTable; label: string } | null; + publishDomainChanged: () => void; + emitEvent: (event: any) => void; + wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; + handleTaskCreated: (taskId: string, companyId: string) => void; + handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void; + handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; + dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void; + queueTaskRun: (taskId: string, trigger: string, actor: string) => DesktopResult; + checkConnector: (id: any) => Promise; + openConnectorAuthTerminal: (id: any) => Promise; + checkForUpdates: () => Promise; + getWorkerProcess: () => { postMessage: (message: unknown) => void } | null; + resetAfterRestore: () => void; + getApiPort: () => number; + listConnectorModels: (connectorId: string) => Promise>; +} + +export function ok(data: T): DesktopResult { + return { ok: true, data }; +} + +export function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; +} diff --git a/src/main/ipc-core-handlers.ts b/src/main/ipc-core-handlers.ts new file mode 100644 index 00000000..f799f850 --- /dev/null +++ b/src/main/ipc-core-handlers.ts @@ -0,0 +1,12 @@ +import type { RegisterCoreHandlersDependencies } from "./ipc-core-common"; +import { registerCoreAdminHandlers } from "./ipc-core-admin-handlers"; +import { registerCoreOperationHandlers } from "./ipc-core-operations-handlers"; +import { registerCoreReadHandlers } from "./ipc-core-read-handlers"; +import { registerCoreWorkGraphHandlers } from "./ipc-core-work-graph-handlers"; + +export function registerCoreHandlers(dependencies: RegisterCoreHandlersDependencies) { + registerCoreReadHandlers(dependencies); + registerCoreAdminHandlers(dependencies); + registerCoreWorkGraphHandlers(dependencies); + registerCoreOperationHandlers(dependencies); +} diff --git a/src/main/ipc-core-operations-handlers.ts b/src/main/ipc-core-operations-handlers.ts new file mode 100644 index 00000000..62ac0247 --- /dev/null +++ b/src/main/ipc-core-operations-handlers.ts @@ -0,0 +1,208 @@ +import { + IPC_CHANNELS, + approvalDecisionSchema, + approvalInputSchema, + cancelRunSchema, + commentInputSchema, + heartbeatSettingsSchema, + listCommentsSchema, + runLogChunkSchema, + runLogSchema, + startRunSchema, + triggerHeartbeatSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; + +export function registerCoreOperationHandlers({ + registerHandle, + db, + ensureCompanyExists, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible, + handleApprovalResolved, + dispatchAutomation, + queueTaskRun, + getWorkerProcess, +}: Pick< + RegisterCoreHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "publishDomainChanged" + | "emitEvent" + | "wakeAgentIfPossible" + | "handleApprovalResolved" + | "dispatchAutomation" + | "queueTaskRun" + | "getWorkerProcess" +>) { + registerHandle(IPC_CHANNELS.requestApproval, async (_event, payload) => { + const parsed = approvalInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidTask = parsed.relatedTaskId && !db.belongsToCompany("tasks", parsed.relatedTaskId, parsed.companyId); + const invalidAgent = parsed.requestedByAgentId && !db.belongsToCompany("agents", parsed.requestedByAgentId, parsed.companyId); + if (invalidTask || invalidAgent) { + return fail("INVALID_COMPANY_REFERENCE", "The approval references data outside the current company."); + } + const id = db.requestApproval(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: parsed.requestedByAgentId ?? "board", + action: "approval.created", + entityType: "approval", + entityId: id, + detail: parsed.payloadSummary, + }); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.decideApproval, async (_event, payload) => { + const parsed = approvalDecisionSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("approvals", parsed.approvalId, parsed.companyId)) { + return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); + } + const approval = db.getApprovalRecord(parsed.approvalId); + if (!approval || approval.companyId !== parsed.companyId) { + return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); + } + db.decideApproval(parsed); + db.addActivity({ + companyId: approval.companyId, + actor: "board", + action: `approval.${parsed.state}`, + entityType: "approval", + entityId: approval.id, + detail: parsed.decisionNote, + }); + handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.startTaskRun, async (_event, payload) => { + const parsed = startRunSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) { + return fail("TASK_NOT_FOUND", "Task not found in the current company."); + } + return queueTaskRun(parsed.taskId, "manual", "board"); + }); + + registerHandle(IPC_CHANNELS.cancelRun, (_event, payload) => { + const parsed = cancelRunSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) { + return fail("RUN_NOT_FOUND", "Run not found in the current company."); + } + getWorkerProcess()?.postMessage({ type: "cancel-run", runId: parsed.runId }); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.getRunLog, (_event, payload) => { + const parsed = runLogSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) { + return fail("RUN_NOT_FOUND", "Run not found in the current company."); + } + return ok(db.readRunLog(parsed.runId)); + }); + + registerHandle(IPC_CHANNELS.getRunLogChunk, (_event, payload) => { + const parsed = runLogChunkSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("runs", parsed.runId, parsed.companyId)) { + return fail("RUN_NOT_FOUND", "Run not found in the current company."); + } + return ok(db.readRunLogChunk(parsed.runId, parsed.offset, parsed.limit)); + }); + + registerHandle(IPC_CHANNELS.addComment, async (_event, payload) => { + const parsed = commentInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const comment = db.addComment({ + companyId: parsed.companyId, + taskId: parsed.taskId, + authorAgentId: parsed.authorAgentId ?? null, + authorName: parsed.authorName, + body: parsed.body, + }); + db.addActivity({ + companyId: parsed.companyId, + actor: parsed.authorAgentId ?? "board", + action: "comment.created", + entityType: "task", + entityId: parsed.taskId, + detail: parsed.body.slice(0, 100), + }); + emitEvent({ type: "agent-message", taskId: parsed.taskId, comment }); + publishDomainChanged(); + try { + const task = db.getTask(parsed.taskId); + if (task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, task.companyId, "comment"); + } + dispatchAutomation("comment_posted", { + companyId: parsed.companyId, + taskId: parsed.taskId, + agentId: parsed.authorAgentId ?? undefined, + }); + } catch { + // Task may have been deleted between comment creation and wake evaluation. + } + return ok(comment.id); + }); + + registerHandle(IPC_CHANNELS.listComments, async (_event, payload) => { + const parsed = listCommentsSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) { + return fail("TASK_NOT_FOUND", "Task not found in the current company."); + } + return ok(db.listComments(parsed.taskId)); + }); + + registerHandle(IPC_CHANNELS.setHeartbeat, async (_event, payload) => { + const parsed = heartbeatSettingsSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) { + return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); + } + db.setHeartbeat(parsed.agentId, parsed.enabled, parsed.intervalSec); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.enabled ? "heartbeat.enabled" : "heartbeat.disabled", + entityType: "agent", + entityId: parsed.agentId, + detail: `Interval: ${parsed.intervalSec}s`, + }); + emitEvent({ type: "heartbeat-status", agentId: parsed.agentId, enabled: parsed.enabled, nextWakeAt: null }); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.triggerHeartbeat, async (_event, payload) => { + const parsed = triggerHeartbeatSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) { + return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); + } + const runId = wakeAgentIfPossible(parsed.agentId, parsed.companyId, parsed.trigger); + if (!runId) { + return fail("HEARTBEAT_SKIPPED", "Agent cannot be woken right now (check connector status, budget, or active runs)."); + } + return ok(runId); + }); +} diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts new file mode 100644 index 00000000..b3bcc8f3 --- /dev/null +++ b/src/main/ipc-core-read-handlers.ts @@ -0,0 +1,146 @@ +import { dialog, shell } from "electron"; +import { join } from "node:path"; +import { z } from "zod"; +import { + IPC_CHANNELS, + connectorInputSchema, + metricsQuerySchema, + settingsSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; + +export function registerCoreReadHandlers({ + registerHandle, + db, + ensureCompanyExists, + publishDomainChanged, + checkForUpdates, + resetAfterRestore, + getApiPort, + listConnectorModels, +}: Pick< + RegisterCoreHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "publishDomainChanged" + | "checkForUpdates" + | "resetAfterRestore" + | "getApiPort" + | "listConnectorModels" +>) { + registerHandle(IPC_CHANNELS.loadSnapshot, () => ok(db.listRendererSnapshot())); + + registerHandle(IPC_CHANNELS.pickDirectory, async () => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + }); + return ok(result.canceled ? null : (result.filePaths[0] ?? null)); + }); + + registerHandle(IPC_CHANNELS.openPath, async (_event, targetPath) => { + if (typeof targetPath !== "string" || targetPath.length === 0) { + return fail("INVALID_PATH", "A valid path is required."); + } + if (!db.canOpenPath(targetPath)) { + return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace."); + } + const error = await shell.openPath(targetPath); + return error ? fail("OPEN_PATH_FAILED", error) : ok(true); + }); + + registerHandle(IPC_CHANNELS.backupProfile, async () => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + }); + if (result.canceled || result.filePaths.length === 0) { + return ok(null); + } + const backupPath = join(result.filePaths[0]!, `agentcompany-backup-${Date.now()}`); + await db.exportBackup(backupPath); + return ok(backupPath); + }); + + registerHandle(IPC_CHANNELS.restoreProfile, async () => { + const hasActiveRuns = db.listSnapshot().runs.some((run: { status: string }) => run.status === "queued" || run.status === "running"); + if (hasActiveRuns) { + return fail("RESTORE_BLOCKED", "Stop all active runs before restoring a profile backup."); + } + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }); + if (result.canceled || result.filePaths.length === 0) { + return ok(false); + } + await db.restoreFromBackup(result.filePaths[0]!); + resetAfterRestore(); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.updateSettings, async (_event, payload) => { + const parsed = settingsSchema.parse(payload); + db.updateSettings(parsed); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.checkForUpdates, async () => { + await checkForUpdates(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => { + if (typeof companyId !== "string" || !companyId) { + return fail("INVALID_COMPANY_ID", "A valid company id is required."); + } + return ok(db.getSidebarBadges(companyId)); + }); + + registerHandle(IPC_CHANNELS.getApiPort, () => ok(getApiPort())); + + registerHandle(IPC_CHANNELS.listConnectorModels, async (_event, connectorId) => { + try { + const parsed = connectorInputSchema.shape.id.parse(connectorId); + return ok(await listConnectorModels(parsed)); + } catch { + return ok([]); + } + }); + + registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => { + const companyId = z.string().uuid().parse(payload); + const err = ensureCompanyExists(companyId); + if (err) return err; + return ok(db.getInbox(companyId)); + }); + + registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, payload) => { + const input = metricsQuerySchema.parse(payload); + const err = ensureCompanyExists(input.companyId); + if (err) return err; + return ok(db.getCompanyMetrics(input.companyId, input.days)); + }); + + registerHandle(IPC_CHANNELS.getAgentMetrics, (_event, payload) => { + const input = metricsQuerySchema.parse(payload); + const err = ensureCompanyExists(input.companyId); + if (err) return err; + if (!input.agentId) { + return fail("MISSING_AGENT_ID", "agentId is required"); + } + const metrics = db.getCompanyMetrics(input.companyId, input.days); + const agentMetric = metrics.agentMetrics.find((metric: { agentId: string }) => metric.agentId === input.agentId); + if (!agentMetric) { + return fail("AGENT_NOT_FOUND", "Agent metrics not found"); + } + return ok(agentMetric); + }); + + registerHandle(IPC_CHANNELS.getStandupReport, (_event, payload) => { + const companyId = z.string().uuid().parse(payload); + const err = ensureCompanyExists(companyId); + if (err) return err; + return ok(db.getStandupReport(companyId)); + }); +} diff --git a/src/main/ipc-core-work-graph-handlers.ts b/src/main/ipc-core-work-graph-handlers.ts new file mode 100644 index 00000000..4049fc93 --- /dev/null +++ b/src/main/ipc-core-work-graph-handlers.ts @@ -0,0 +1,229 @@ +import { + IPC_CHANNELS, + agentInputSchema, + deleteEntitySchema, + goalInputSchema, + projectInputSchema, + taskInputSchema, + workspaceInputSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; + +export function registerCoreWorkGraphHandlers({ + registerHandle, + db, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + wakeAgentIfPossible, + handleTaskCreated, + handleTaskStatusChange, +}: Pick< + RegisterCoreHandlersDependencies, + | "registerHandle" + | "db" + | "ensureCompanyExists" + | "findInvalidReference" + | "publishDomainChanged" + | "wakeAgentIfPossible" + | "handleTaskCreated" + | "handleTaskStatusChange" +>) { + registerHandle(IPC_CHANNELS.saveWorkspace, async (_event, payload) => { + const parsed = workspaceInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.projectId, table: "projects", label: "project" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveWorkspace(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "workspace.updated" : "workspace.created", + entityType: "workspace", + entityId: id, + detail: parsed.localPath, + }); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.saveAgent, async (_event, payload) => { + const parsed = agentInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.reportsTo, table: "agents", label: "manager" }, + { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + let id: string; + try { + id = db.saveAgent({ + id: parsed.id ?? null, + companyId: parsed.companyId, + name: parsed.name, + role: parsed.role, + title: parsed.title, + department: parsed.department ?? null, + status: parsed.status, + reportsTo: parsed.reportsTo ?? null, + connectorId: parsed.connectorId, + workspaceId: parsed.workspaceId ?? null, + model: parsed.model ?? null, + metadataJson: parsed.metadataJson, + capabilities: parsed.capabilities, + budgetMonthlyUsd: parsed.budgetMonthlyUsd, + }); + } catch (error) { + return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved."); + } + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "agent.updated" : "agent.created", + entityType: "agent", + entityId: id, + detail: parsed.name, + }); + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => { + const parsed = goalInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.parentId, table: "goals", label: "parent goal" }, + { id: parsed.ownerAgentId, table: "agents", label: "owner agent" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveGoal(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "goal.updated" : "goal.created", + entityType: "goal", + entityId: id, + detail: parsed.title, + }); + publishDomainChanged(); + if (parsed.status === "active" && parsed.ownerAgentId) { + wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated"); + } + return ok(id); + }); + + registerHandle(IPC_CHANNELS.saveProject, async (_event, payload) => { + const parsed = projectInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.goalId, table: "goals", label: "goal" }, + { id: parsed.leadAgentId, table: "agents", label: "lead agent" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const id = db.saveProject(parsed); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: parsed.id ? "project.updated" : "project.created", + entityType: "project", + entityId: id, + detail: parsed.name, + }); + if (parsed.leadAgentId) { + wakeAgentIfPossible(parsed.leadAgentId, parsed.companyId, "assignment"); + } + publishDomainChanged(); + return ok(id); + }); + + registerHandle(IPC_CHANNELS.saveTask, async (_event, payload) => { + const parsed = taskInputSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.projectId, table: "projects", label: "project" }, + { id: parsed.goalId, table: "goals", label: "goal" }, + { id: parsed.parentId, table: "tasks", label: "parent task" }, + { id: parsed.assigneeAgentId, table: "agents", label: "assignee agent" }, + { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, + ]); + if (invalidReference) { + return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); + } + const isNew = !parsed.id; + let previousStatus: string | null = null; + if (!isNew) { + try { + previousStatus = db.getTask(parsed.id!).status; + } catch { + previousStatus = null; + } + } + let id: string; + try { + id = db.saveTask(parsed); + } catch (error) { + return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved."); + } + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: isNew ? "task.created" : "task.updated", + entityType: "task", + entityId: id, + detail: parsed.title, + }); + if (isNew) { + handleTaskCreated(id, parsed.companyId); + } else if (previousStatus && previousStatus !== parsed.status) { + handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status); + } + publishDomainChanged(); + return ok(id); + }); + + const deleteHandlers = [ + { channel: IPC_CHANNELS.deleteAgent, table: "agents", entity: "agent", deleteFn: (id: string, companyId: string) => db.deleteAgent(id, companyId) }, + { channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id: string, companyId: string) => db.deleteTask(id, companyId) }, + { channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id: string, companyId: string) => db.deleteGoal(id, companyId) }, + { channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id: string, companyId: string) => db.deleteProject(id, companyId) }, + { channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id: string, companyId: string) => db.deleteWorkspace(id, companyId) }, + { channel: IPC_CHANNELS.deleteApproval, table: "approvals", entity: "approval", deleteFn: (id: string, companyId: string) => db.deleteApproval(id, companyId) }, + ] as const; + + for (const { channel, table, entity, deleteFn } of deleteHandlers) { + registerHandle(channel, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) { + return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`); + } + deleteFn(parsed.id, parsed.companyId); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: `${entity}.deleted`, + entityType: entity, + entityId: parsed.id, + detail: "", + }); + publishDomainChanged(); + return ok(true); + }); + } +} diff --git a/src/main/ipc-domain-common.ts b/src/main/ipc-domain-common.ts new file mode 100644 index 00000000..166cafa9 --- /dev/null +++ b/src/main/ipc-domain-common.ts @@ -0,0 +1,49 @@ +import type { IpcMainInvokeEvent } from "electron"; +import { z } from "zod"; +import type { DesktopResult } from "@shared/contracts"; +import type { bootstrapOnboardingSchema } from "@shared/contracts"; +import type { AppDatabase } from "./database"; + +export interface BrowserActionControl { + clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise; + cancelAction: (actionId: string) => boolean; + launchLoginBrowser: (socialAccountId: string, platform: string, loginUrl?: string) => Promise<{ + success: boolean; + cancelled: boolean; + summary: string; + screenshotPath: string | null; + error: string | null; + }>; +} + +export interface RegisterDomainHandlersDependencies { + registerHandle: ( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise> | DesktopResult, + ) => void; + db: AppDatabase; + logger: { + error: (...args: unknown[]) => void; + }; + ensureCompanyExists: (companyId: string) => DesktopResult | null; + findInvalidReference: ( + companyId: string, + refs: Array<{ id: string | null | undefined; table: string; label: string }>, + ) => { id: string | null | undefined; table: string; label: string } | null; + publishDomainChanged: () => void; + emitEvent: (event: any) => void; + wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; + dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void; + runWorkflowPipeline: (workflowId: string, companyId: string) => Promise; + bootstrapOnboarding: (input: z.infer) => unknown; + getBrowserActions: () => BrowserActionControl | null; + randomFallbackCompanyId: () => string; +} + +export function ok(data: T): DesktopResult { + return { ok: true, data }; +} + +export function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; +} diff --git a/src/main/ipc-domain-handlers.ts b/src/main/ipc-domain-handlers.ts new file mode 100644 index 00000000..a49f99b7 --- /dev/null +++ b/src/main/ipc-domain-handlers.ts @@ -0,0 +1,14 @@ +import type { RegisterDomainHandlersDependencies } from "./ipc-domain-common"; +import { registerAgentCoordinationHandlers } from "./ipc-agent-coordination-handlers"; +import { registerAutomationOperationsHandlers } from "./ipc-automation-operations-handlers"; +import { registerBrowserHandlers } from "./ipc-browser-handlers"; +import { registerContentOperationsHandlers } from "./ipc-content-operations-handlers"; +import { registerOnboardingHandlers } from "./ipc-onboarding-handlers"; + +export function registerDomainHandlers(dependencies: RegisterDomainHandlersDependencies) { + registerOnboardingHandlers(dependencies); + registerBrowserHandlers(dependencies); + registerContentOperationsHandlers(dependencies); + registerAutomationOperationsHandlers(dependencies); + registerAgentCoordinationHandlers(dependencies); +} diff --git a/src/main/ipc-onboarding-handlers.ts b/src/main/ipc-onboarding-handlers.ts new file mode 100644 index 00000000..4615e5b7 --- /dev/null +++ b/src/main/ipc-onboarding-handlers.ts @@ -0,0 +1,22 @@ +import { + IPC_CHANNELS, + bootstrapOnboardingSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; + +export function registerOnboardingHandlers({ + registerHandle, + bootstrapOnboarding, +}: Pick< + RegisterDomainHandlersDependencies, + "registerHandle" | "bootstrapOnboarding" +>) { + registerHandle(IPC_CHANNELS.bootstrapOnboarding, (_event, payload) => { + const input = bootstrapOnboardingSchema.parse(payload); + try { + return ok(bootstrapOnboarding(input)); + } catch (error) { + return fail("ONBOARDING_BOOTSTRAP_FAILED", error instanceof Error ? error.message : "Unable to bootstrap onboarding."); + } + }); +} diff --git a/src/main/ipc-support.ts b/src/main/ipc-support.ts new file mode 100644 index 00000000..fe37cd4a --- /dev/null +++ b/src/main/ipc-support.ts @@ -0,0 +1,120 @@ +import type { IpcMainInvokeEvent, BrowserWindow } from "electron"; +import { ipcMain, Notification } from "electron"; +import { ZodError } from "zod"; +import type { DesktopEvent } from "@shared/types"; +import type { DesktopResult } from "@shared/contracts"; + +interface IpcSupportDependencies { + isDev: boolean; + devServerOrigin: string | null; + logger: { + error: (...args: unknown[]) => void; + }; + getMainWindow: () => BrowserWindow | null; + getDb: () => { + hasCompany: (companyId: string) => boolean; + belongsToCompany: (table: string, id: string, companyId: string) => boolean; + }; +} + +function isTrustedRendererUrl(url: string, isDev: boolean, devServerOrigin: string | null) { + if (!url) { + return false; + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (isDev) { + return devServerOrigin ? parsed.origin === devServerOrigin : false; + } + return parsed.protocol === "app:" && parsed.hostname === "-"; +} + +export function createIpcSupport({ + isDev, + devServerOrigin, + logger, + getMainWindow, + getDb, +}: IpcSupportDependencies) { + function sanitizeHandlerError(error: unknown) { + if (error instanceof ZodError) { + return { + code: "IPC_INVALID_PAYLOAD", + message: "The desktop request payload is invalid.", + }; + } + return { + code: "IPC_HANDLER_FAILED", + message: "The desktop operation failed. Review the application logs for details.", + }; + } + + function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; + } + + function registerHandle( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise> | DesktopResult, + ) { + ipcMain.handle(channel, async (event, ...args) => { + const senderUrl = event.senderFrame?.url ?? event.sender.getURL(); + if (!isTrustedRendererUrl(senderUrl, isDev, devServerOrigin)) { + logger.error(`[ipc:${channel}] blocked untrusted sender ${senderUrl || ""}`); + return fail("IPC_FORBIDDEN", "IPC sender is not allowed."); + } + try { + return await handler(event, ...args); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : String(error); + const sanitized = sanitizeHandlerError(error); + logger.error(`[ipc:${channel}] ${rawMessage}`); + return fail(sanitized.code, sanitized.message); + } + }); + } + + function emitEvent(event: DesktopEvent) { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("agent-company:event", event); + } + } + + function ensureCompanyExists(companyId: string) { + return getDb().hasCompany(companyId) + ? null + : fail("COMPANY_NOT_FOUND", "The selected company no longer exists."); + } + + function findInvalidReference( + companyId: string, + refs: Array<{ id: string | null | undefined; table: string; label: string }>, + ) { + return refs.find((ref) => ref.id && !getDb().belongsToCompany(ref.table, ref.id, companyId)) ?? null; + } + + function publishDomainChanged() { + emitEvent({ type: "domain-changed" }); + } + + function notify(title: string, body: string) { + if (Notification.isSupported()) { + new Notification({ title, body }).show(); + } + } + + return { + fail, + registerHandle, + emitEvent, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + notify, + }; +} diff --git a/src/main/main-process-setup.ts b/src/main/main-process-setup.ts new file mode 100644 index 00000000..ad1603eb --- /dev/null +++ b/src/main/main-process-setup.ts @@ -0,0 +1,58 @@ +import { delimiter as PATH_DELIMITER } from "node:path"; +import { app, protocol } from "electron"; + +function listExtraPathEntries() { + return ( + process.platform === "win32" + ? [ + `${process.env.LOCALAPPDATA ?? ""}\\Programs`, + `${process.env.APPDATA ?? ""}\\npm`, + `${process.env.USERPROFILE ?? ""}\\.cargo\\bin`, + `${process.env.USERPROFILE ?? ""}\\.local\\bin`, + `${process.env.USERPROFILE ?? ""}\\.bun\\bin`, + ] + : [ + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + `${process.env.HOME}/.local/bin`, + `${process.env.HOME}/.cargo/bin`, + `${process.env.HOME}/.nvm/current/bin`, + `${process.env.HOME}/.volta/bin`, + `${process.env.HOME}/.bun/bin`, + "/usr/local/sbin", + "/snap/bin", + ] + ).filter((entry) => entry && !entry.includes("undefined")); +} + +function prependCommonCliPaths() { + const currentPath = process.env.PATH ?? ""; + const existing = new Set(currentPath.split(PATH_DELIMITER).filter(Boolean)); + const toAdd = listExtraPathEntries().filter((entry) => !existing.has(entry)); + if (toAdd.length === 0) { + return; + } + process.env.PATH = [...toAdd, currentPath].join(PATH_DELIMITER); +} + +function registerPrivilegedSchemes() { + protocol.registerSchemesAsPrivileged([ + { + scheme: "app", + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + stream: true, + }, + }, + ]); +} + +export function configureMainProcess(appName: string) { + app.name = appName; + app.enableSandbox(); + prependCommonCliPaths(); + registerPrivilegedSchemes(); +} diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts new file mode 100644 index 00000000..d3b51286 --- /dev/null +++ b/src/main/main-runtime-support.ts @@ -0,0 +1,178 @@ +import log from "electron-log/main.js"; +import type { AgentApiServer } from "./api-server"; +import { initializeAgentTokenSigningKey } from "./agent-auth"; +import { bootstrapApplication } from "./bootstrap-application"; +import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl } from "./connector-auth-service"; +import { getConnectorDefinition } from "./connectors"; +import type { AppDatabase } from "./database"; +import { seedDefaultAutomationRules as seedDefaultAutomationRulesImpl } from "./default-automation-rules"; +import { registerCoreHandlers } from "./ipc-core-handlers"; +import { registerDomainHandlers } from "./ipc-domain-handlers"; + +type BootstrapResult = Awaited>; + +interface MainRuntimeState { + db: AppDatabase | null; + apiServer: AgentApiServer | null; + serviceGraph: BootstrapResult["serviceGraph"] | null; + ipcSupport: BootstrapResult["ipcSupport"] | null; +} + +export function createMainRuntimeSupport(state: MainRuntimeState) { + function requireDb() { + if (!state.db) { + throw new Error("Database is not initialized."); + } + return state.db; + } + + function requireServiceGraph() { + if (!state.serviceGraph) { + throw new Error("Service graph is not initialized."); + } + return state.serviceGraph; + } + + function requireIpcSupport() { + if (!state.ipcSupport) { + throw new Error("IPC support is not initialized."); + } + return state.ipcSupport; + } + + function requireAutomationService() { + const service = requireServiceGraph().getAutomationService(); + if (!service) { + throw new Error("Automation service is not initialized."); + } + return service; + } + + function requireBrowserActionService() { + const service = requireServiceGraph().getBrowserActionService(); + if (!service) { + throw new Error("Browser action service is not initialized."); + } + return service; + } + + function requireConnectorHealthService() { + const service = requireServiceGraph().getConnectorHealthService(); + if (!service) { + throw new Error("Connector health service is not initialized."); + } + return service; + } + + function requireOnboardingService() { + const service = requireServiceGraph().getOnboardingService(); + if (!service) { + throw new Error("Onboarding service is not initialized."); + } + return service; + } + + function requireOrchestrationService() { + const service = requireServiceGraph().getOrchestrationService(); + if (!service) { + throw new Error("Orchestration service is not initialized."); + } + return service; + } + + function requireUpdaterService() { + const service = requireServiceGraph().getUpdaterService(); + if (!service) { + throw new Error("Updater service is not initialized."); + } + return service; + } + + function emitEvent(event: Parameters[0]) { + requireIpcSupport().emitEvent(event); + } + + function publishDomainChanged() { + requireIpcSupport().publishDomainChanged(); + } + + function ensureCompanyExists(companyId: string) { + return requireIpcSupport().ensureCompanyExists(companyId); + } + + function findInvalidReference( + companyId: string, + refs: T[], + ) { + return requireIpcSupport().findInvalidReference(companyId, refs) as T | null; + } + + function seedDefaultAutomationRules(companyId: string) { + const db = requireDb(); + seedDefaultAutomationRulesImpl(companyId, { + listAutomationRuleNames: (targetCompanyId) => db.listAutomationRuleNames(targetCompanyId), + saveAutomationRule: (input) => db.saveAutomationRule(input), + }); + } + + function registerHandlers() { + registerCoreHandlers({ + registerHandle: requireIpcSupport().registerHandle, + db: requireDb(), + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible: (agentId, companyId, trigger) => + requireOrchestrationService().wakeAgentIfPossible(agentId, companyId, trigger), + handleTaskCreated: (taskId, companyId) => requireAutomationService().handleTaskCreated(taskId, companyId), + handleTaskStatusChange: (taskId, companyId, previousStatus, newStatus) => + requireAutomationService().handleTaskStatusChange(taskId, companyId, previousStatus, newStatus), + handleApprovalResolved: (approvalId, companyId, decision) => + requireAutomationService().handleApprovalResolved(approvalId, companyId, decision), + dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx), + queueTaskRun: (taskId, trigger, actor) => + requireOrchestrationService().queueTaskRun(taskId, trigger, actor), + checkConnector: async (id) => requireConnectorHealthService().checkConnector(id), + openConnectorAuthTerminal: async (id) => { + const connector = requireDb().getConnector(id); + return openConnectorAuthTerminalImpl(id, connector.command); + }, + checkForUpdates: () => requireUpdaterService().checkForUpdates(), + getWorkerProcess: () => requireServiceGraph().getWorkerService()?.getWorker() ?? null, + resetAfterRestore: () => { + initializeAgentTokenSigningKey(requireDb().profileDir); + requireBrowserActionService().resetBrowserManager(); + }, + getApiPort: () => state.apiServer?.getPort() ?? 0, + listConnectorModels: async (connectorId) => { + const definition = getConnectorDefinition(connectorId as Parameters[0]); + return definition.listModels ? definition.listModels() : []; + }, + }); + + registerDomainHandlers({ + registerHandle: requireIpcSupport().registerHandle, + db: requireDb(), + logger: log, + ensureCompanyExists, + findInvalidReference, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible: (agentId, companyId, trigger) => + requireOrchestrationService().wakeAgentIfPossible(agentId, companyId, trigger), + dispatchAutomation: (trigger, ctx) => requireAutomationService().dispatchAutomation(trigger, ctx), + runWorkflowPipeline: (workflowId, companyId) => + requireAutomationService().runWorkflowPipeline(workflowId, companyId), + bootstrapOnboarding: (input) => requireOnboardingService().bootstrapOnboarding(input), + getBrowserActions: () => requireServiceGraph().getBrowserActionService(), + randomFallbackCompanyId: () => requireConnectorHealthService().randomFallbackCompanyId(), + }); + } + + return { + publishDomainChanged, + seedDefaultAutomationRules, + registerHandlers, + }; +} diff --git a/src/main/main-runtime.ts b/src/main/main-runtime.ts new file mode 100644 index 00000000..e21ad1e1 --- /dev/null +++ b/src/main/main-runtime.ts @@ -0,0 +1,94 @@ +import { AgentApiServer } from "./api-server"; +import { createAgentToken, initializeAgentTokenSigningKey } from "./agent-auth"; +import { bootstrapApplication } from "./bootstrap-application"; +import { AppDatabase } from "./database"; +import { createMainRuntimeSupport } from "./main-runtime-support"; + +interface CreateMainRuntimeDependencies { + hasSingleInstanceLock: boolean; + isDev: boolean; + devServerOrigin: string | null; + profileDir: string; + windowStatePath: string; + preloadPath: string; + rendererDist: string; + iconPath: string; + workerPath: string; + devServerUrl?: string; +} + +export function createMainRuntime({ + hasSingleInstanceLock, + isDev, + devServerOrigin, + profileDir, + windowStatePath, + preloadPath, + rendererDist, + iconPath, + workerPath, + devServerUrl, +}: CreateMainRuntimeDependencies) { + const state: { + db: AppDatabase | null; + apiServer: AgentApiServer | null; + serviceGraph: Awaited>["serviceGraph"] | null; + ipcSupport: Awaited>["ipcSupport"] | null; + isQuitting: boolean; + } = { + db: null, + apiServer: null, + serviceGraph: null, + ipcSupport: null, + isQuitting: false, + }; + const support = createMainRuntimeSupport(state); + + async function bootstrap() { + await bootstrapApplication({ + hasSingleInstanceLock, + isDev, + devServerOrigin, + profileDir, + appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir), + initSigningKey: initializeAgentTokenSigningKey, + windowStatePath, + preloadPath, + rendererDist, + iconPath, + devServerUrl, + workerPath, + createApiServer: (db, callbacks, allowedOrigins) => + new AgentApiServer( + db, + support.publishDomainChanged, + (agentId, companyId, trigger) => callbacks.onWakeAgent(agentId, companyId, trigger), + callbacks, + allowedOrigins, + ), + createRunToken: createAgentToken, + seedDefaultAutomationRules: support.seedDefaultAutomationRules, + isQuittingRef: { + get current() { + return state.isQuitting; + }, + set current(value: boolean) { + state.isQuitting = value; + }, + }, + onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport }) => { + state.db = db; + state.apiServer = apiServer; + state.serviceGraph = serviceGraph; + state.ipcSupport = ipcSupport; + // Register IPC handlers immediately so the renderer can call loadSnapshot() + // as soon as the window appears — before connector health checks finish. + support.registerHandlers(); + }, + }); + } + + return { + bootstrap, + }; +} diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts new file mode 100644 index 00000000..a33920eb --- /dev/null +++ b/src/main/onboarding-service.ts @@ -0,0 +1,230 @@ +import type { AppDatabase } from "./database"; +import type { ConnectorId } from "@shared/types"; + +interface OnboardingBootstrapSummary { + companyId: string; + companyName: string; + workspaceId: string | null; + leadAgentId: string | null; + projectId: string | null; + taskId: string | null; + runId: string | null; +} + +interface BootstrapInput { + companyName: string; + companyDescription: string; + workspacePath: string; + autoApproveHires: boolean; + goalTitle: string; + goalDescription: string; + connectorId?: ConnectorId; +} + +interface OnboardingDependencies { + db: AppDatabase; + seedDefaultAutomationRules: (companyId: string) => void; + queueTaskRun: (taskId: string, trigger: string, actor: string) => { + ok: boolean; + data?: string; + error?: { code: string; message: string }; + }; + publishDomainChanged: () => void; +} + +const DEFERRABLE_RUN_ERRORS = new Set([ + "WORKER_UNAVAILABLE", + "CONNECTOR_NOT_READY", + "TASK_ALREADY_RUNNING", + "AGENT_ALREADY_RUNNING", +]); + +function resolveFallbackConnector( + readyConnectors: Array<{ id: ConnectorId }>, + preferredConnectorId?: ConnectorId, +) { + return preferredConnectorId ?? readyConnectors[0]!.id; +} + +export function createOnboardingService({ + db, + seedDefaultAutomationRules, + queueTaskRun, + publishDomainChanged, +}: OnboardingDependencies) { + function updateAgentWorkspaceBinding(agentId: string, workspaceId: string | null) { + if (!workspaceId) return; + const agent = db.getAgent(agentId); + if (agent.workspaceId === workspaceId) return; + db.saveAgent({ + id: agent.id, + companyId: agent.companyId, + name: agent.name, + role: agent.role, + title: agent.title, + department: agent.department, + status: agent.status, + reportsTo: agent.reportsTo, + connectorId: agent.connectorId, + workspaceId, + model: agent.model, + metadataJson: agent.metadataJson, + capabilities: agent.capabilities, + budgetMonthlyUsd: agent.budgetMonthlyUsd, + }); + } + + function createDefaultWorkspace(companyId: string, workspacePath: string) { + const trimmedPath = workspacePath.trim(); + if (!trimmedPath) return null; + return db.saveWorkspace({ + companyId, + name: "Default Workspace", + localPath: trimmedPath, + repoUrl: "", + repoRef: "", + isPrimary: true, + }); + } + + function createInitialExecutionLoop(companyId: string, workspaceId: string | null): OnboardingBootstrapSummary { + const snapshot = db.listSnapshot(); + const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null; + if (!company) { + throw new Error("Company no longer exists during onboarding bootstrap."); + } + + const leadAgent = snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo) ?? null; + if (!leadAgent) { + throw new Error("Unable to find the lead agent for the company bootstrap."); + } + + updateAgentWorkspaceBinding(leadAgent.id, workspaceId); + + const activeGoal = + snapshot.goals.find((goal) => goal.companyId === companyId && goal.status === "active") + ?? snapshot.goals.find((goal) => goal.companyId === companyId) + ?? null; + + const kickoffProjectName = activeGoal ? `Execution plan: ${activeGoal.title}` : "Initial operating plan"; + const kickoffProjectDescription = activeGoal + ? `Turn the founding goal "${activeGoal.title}" into an execution plan, delegation map, and the first wave of real work.` + : "Establish the first operating plan, assign work, and start the company execution loop."; + + const projectId = db.saveProject({ + companyId, + goalId: activeGoal?.id ?? null, + name: kickoffProjectName, + description: kickoffProjectDescription, + leadAgentId: leadAgent.id, + targetDate: null, + status: "in_progress", + }); + + const kickoffTaskTitle = activeGoal ? `Operationalize goal: ${activeGoal.title}` : "Establish the first operating cadence"; + const kickoffTaskDescription = activeGoal + ? [ + `Read the founding goal "${activeGoal.title}" and convert it into a real operating plan.`, + "Create the first execution structure inside AgentCompany: projects, tasks, delegation, approvals, and coordination messages as needed.", + "Use the bound workspace as the default execution boundary for the first round of work.", + ].join("\n\n") + : [ + "Establish the first operating plan for the company.", + "Review the current company state, create the first real tasks, delegate initial work, and report progress through tasks, approvals, and messages.", + ].join("\n\n"); + + const taskId = db.saveTask({ + companyId, + projectId, + goalId: activeGoal?.id ?? null, + parentId: null, + title: kickoffTaskTitle, + description: kickoffTaskDescription, + assigneeAgentId: leadAgent.id, + workspaceId: workspaceId ?? leadAgent.workspaceId ?? null, + priority: "high", + status: "todo", + }); + + const startRunResult = queueTaskRun(taskId, "assignment", "system"); + if (!startRunResult.ok && !DEFERRABLE_RUN_ERRORS.has(startRunResult.error?.code ?? "")) { + throw new Error(startRunResult.error?.message ?? "Failed to queue the initial execution loop."); + } + + return { + companyId, + companyName: company.name, + workspaceId, + leadAgentId: leadAgent.id, + projectId, + taskId, + runId: startRunResult.ok ? startRunResult.data ?? null : null, + }; + } + + function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary { + const snapshot = db.listSnapshot(); + const readyConnectors = snapshot.connectors.filter( + (connector) => connector.status === "ready" || connector.status === "degraded", + ); + if (readyConnectors.length === 0) { + throw new Error("At least one ready connector is required before onboarding can continue."); + } + const selectedConnectorId = resolveFallbackConnector(readyConnectors, input.connectorId); + + const companyId = db.saveCompany({ + name: input.companyName.trim(), + description: input.companyDescription.trim(), + status: "active", + autoApproveHires: input.autoApproveHires, + }); + const workspaceId = createDefaultWorkspace(companyId, input.workspacePath); + + const goalTitle = input.goalTitle.trim(); + if (goalTitle) { + db.saveGoal({ + companyId, + title: goalTitle, + description: input.goalDescription.trim() || `Achieve: ${goalTitle}`, + status: "active", + parentId: null, + ownerAgentId: null, + }); + } + + const ceoId = db.saveAgent({ + companyId, + name: "CEO", + role: "CEO", + title: "Chief Executive Officer", + reportsTo: null, + connectorId: selectedConnectorId, + workspaceId, + model: null, + capabilities: + "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.", + budgetMonthlyUsd: 250, + status: "idle", + }); + db.setHeartbeat(ceoId, true, 120); + db.setCurrentCompany(companyId); + seedDefaultAutomationRules(companyId); + + const kickoff = createInitialExecutionLoop(companyId, workspaceId); + + db.addActivity({ + companyId, + actor: "board", + action: "company.bootstrapped", + entityType: "company", + entityId: companyId, + detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`, + }); + publishDomainChanged(); + return kickoff; + } + + return { + bootstrapOnboarding, + }; +} diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts new file mode 100644 index 00000000..42b499b6 --- /dev/null +++ b/src/main/orchestration-service.ts @@ -0,0 +1,683 @@ +import { buildTaskPrompt } from "./prompt-builder"; +import type { AppDatabase } from "./database"; +import type { DesktopResult } from "@shared/contracts"; + +type WorkspaceLockMode = "exclusive" | "shared" | "none"; + +interface WorkspaceResolution { + workspace: ReturnType; + lockMode: WorkspaceLockMode; + source: "task" | "agent" | "project" | "company_fallback" | "none"; +} + +interface AutomationDispatch { + companyId: string; + taskId?: string; + agentId?: string; + runId?: string; + projectId?: string; + department?: string; + previousStatus?: string; + newStatus?: string; + costUsd?: number; +} + +interface EnqueueRunPayload { + runId: string; + companyId: string; + taskId: string; + taskTitle: string; + taskDescription: string; + agentId: string; + agentName: string; + connectorId: string; + workspaceId: string | null; + workspacePath: string | null; + workspaceMode: WorkspaceLockMode; + command: string; + model: string | null; + thinkingEffort: string | null; + env: Record; + sessionParams: Record | null; + systemPrompt: string | null; + trigger: string | null; +} + +interface OrchestrationDependencies { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + publishDomainChanged: () => void; + notify: (title: string, body: string) => void; + createRunToken: (agentId: string, companyId: string, runId: string) => string; + getApiPort: () => number; + dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void; + hasWorkerReady: () => boolean; + enqueueWorkerRun: (payload: EnqueueRunPayload) => void; +} + +export function createOrchestrationService({ + db, + logger, + publishDomainChanged, + notify, + createRunToken, + getApiPort, + dispatchAutomation, + hasWorkerReady, + enqueueWorkerRun, +}: OrchestrationDependencies) { + let heartbeatTimer: ReturnType | null = null; + const scheduleLastFiredMap = new Map(); + + function ok(data: T): DesktopResult { + return { ok: true, data }; + } + + function fail(code: string, message: string): DesktopResult { + return { ok: false, error: { code, message } }; + } + + function hasActiveRun(filter: { taskId?: string; agentId?: string; companyId: string }) { + return db.hasActiveRun(filter); + } + + function resolveWorkspace( + task: { workspaceId: string | null; projectId?: string | null }, + agent: { workspaceId: string | null }, + companyId: string, + ): WorkspaceResolution { + const taskWorkspace = db.getWorkspace(task.workspaceId); + if (taskWorkspace) { + return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" }; + } + + const agentWorkspace = db.getWorkspace(agent.workspaceId); + if (agentWorkspace) { + return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" }; + } + + const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId); + if (projectWorkspace) { + return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" }; + } + + const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId); + if (fallbackWorkspace) { + return { workspace: fallbackWorkspace, lockMode: "shared", source: "company_fallback" }; + } + + return { workspace: null, lockMode: "none", source: "none" }; + } + + function autoPauseBudgetExceededAgent(agent: { id: string; companyId: string; name: string; spentMonthlyUsd: number; budgetMonthlyUsd: number }) { + if (!(agent.budgetMonthlyUsd > 0) || agent.spentMonthlyUsd < agent.budgetMonthlyUsd) { + return false; + } + db.addActivity({ + companyId: agent.companyId, + actor: "system", + action: "agent.auto_paused", + entityType: "agent", + entityId: agent.id, + detail: `Budget exhausted at ${agent.spentMonthlyUsd.toFixed(2)} / ${agent.budgetMonthlyUsd.toFixed(2)} USD.`, + }); + notify("Agent paused for budget limit", agent.name); + publishDomainChanged(); + return true; + } + + function ensureAgentHeartbeatEnabled(agentId: string) { + const agent = db.getAgent(agentId); + if (!agent.heartbeatEnabled) { + db.setHeartbeat(agentId, true, agent.heartbeatIntervalSec || 120); + } + } + + function getRunEligibilityFailure( + agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string }, + connector: { id: string; status: string }, + ) { + if (["terminated", "paused", "pending_approval"].includes(agent.status)) { + return "AGENT_NOT_RUNNABLE"; + } + if (agent.budgetMonthlyUsd > 0 && agent.spentMonthlyUsd >= agent.budgetMonthlyUsd) { + autoPauseBudgetExceededAgent(agent); + logger.info(`[run] Agent ${agent.name} budget exhausted, skipping`); + return "AGENT_BUDGET_EXHAUSTED"; + } + if (connector.status !== "ready" && connector.status !== "degraded") { + logger.info(`[run] Agent ${agent.name} connector ${connector.id} not ready (${connector.status})`); + return "CONNECTOR_NOT_READY"; + } + return null; + } + + function dispatchRunToWorker(params: { + runId: string; + companyId: string; + agent: ReturnType; + task: ReturnType; + connector: ReturnType; + workspace: ReturnType; + workspaceMode: WorkspaceLockMode; + companyName: string; + companyDescription: string; + snapshot: ReturnType; + trigger: string; + }) { + const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params; + if (!hasWorkerReady()) { + throw new Error("Orchestration worker is not ready."); + } + + const env = db.resolveSecretEnv(connector.envBindingText, companyId); + const apiPort = getApiPort(); + const apiUrl = apiPort ? `http://127.0.0.1:${apiPort}` : ""; + const apiKey = apiUrl ? createRunToken(agent.id, companyId, runId) : ""; + + const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]); + const reviewTasks = db.listReviewTasksForManager(agent.id, companyId); + const relevantTaskIds = new Set([ + ...assignedTasks.map((task) => task.id), + ...reviewTasks.map((task) => task.id), + ]); + const allRelevantTasks = [...assignedTasks, ...reviewTasks]; + const recentComments = snapshot.comments + .filter((comment) => relevantTaskIds.has(comment.taskId)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 30) + .map((comment) => ({ + taskId: comment.taskId, + taskTitle: allRelevantTasks.find((task) => task.id === comment.taskId)?.title ?? comment.taskId, + authorName: comment.authorName, + body: comment.body, + createdAt: comment.createdAt, + })); + + const systemPrompt = apiUrl ? buildTaskPrompt({ + agent, + apiUrl, + apiKey, + runId, + task, + companyName, + goals: snapshot.goals.filter((goal) => goal.companyId === companyId), + projects: snapshot.projects.filter((project) => project.companyId === companyId), + directReports: db.getDirectReports(agent.id, companyId), + chainOfCommand: db.getChainOfCommand(agent.id), + assignedTasks, + allAgents: snapshot.agents.filter((entry) => entry.companyId === companyId), + pendingApprovals: snapshot.approvals.filter( + (approval) => approval.companyId === companyId && + approval.requestedByAgentId === agent.id && + (approval.state === "pending" || approval.state === "revision_requested"), + ), + tasksAwaitingReview: db.listReviewTasksForManager(agent.id, companyId), + wakeReason: trigger, + allTasks: snapshot.tasks.filter((entry) => entry.companyId === companyId), + recentComments, + companyDescription, + socialAccounts: db.listSocialAccounts(companyId).map((account) => ({ + id: account.id, + platform: account.platform, + accountName: account.accountName, + status: account.status, + requireApproval: account.requireApproval, + })), + recentMessages: db.listAgentMessages(companyId, { agentId: agent.id, limit: 15 }), + upcomingMeetings: snapshot.meetings + .filter((meeting) => meeting.companyId === companyId && (meeting.status === "scheduled" || meeting.status === "in_progress")) + .filter((meeting) => { + try { + const ids: string[] = JSON.parse(meeting.participantAgentIds); + return ids.includes(agent.id) || meeting.organizerAgentId === agent.id; + } catch { + return false; + } + }) + .slice(0, 5), + relevantKnowledge: snapshot.knowledgeBase + .filter((entry) => entry.companyId === companyId && (entry.importance === "critical" || entry.importance === "high")) + .slice(0, 8), + activeSprint: snapshot.sprints.find((sprint) => sprint.companyId === companyId && sprint.status === "active") ?? null, + recentDocuments: snapshot.documents + .filter((document) => document.companyId === companyId && (document.authorAgentId === agent.id || document.reviewerAgentId === agent.id || document.status === "in_review")) + .slice(0, 5), + }) : null; + + if (apiUrl) { + env.AGENT_COMPANY_API_URL = apiUrl; + env.AGENT_COMPANY_API_KEY = apiKey; + env.AGENT_COMPANY_AGENT_ID = agent.id; + env.AGENT_COMPANY_COMPANY_ID = companyId; + env.AGENT_COMPANY_RUN_ID = runId; + env.AGENT_COMPANY_TASK_ID = task.id; + env.AGENT_COMPANY_WAKE_REASON = trigger; + } + + const taskSession = db.getTaskSession(task.id); + let thinkingEffort: string | null = null; + try { + const meta = JSON.parse(agent.metadataJson || "{}"); + if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) { + thinkingEffort = meta.thinkingEffort; + } + } catch { + thinkingEffort = null; + } + + enqueueWorkerRun({ + runId, + companyId, + taskId: task.id, + taskTitle: task.title, + taskDescription: task.description, + agentId: agent.id, + agentName: agent.name, + connectorId: connector.id, + workspaceId: workspaceMode === "exclusive" ? (workspace?.id ?? null) : null, + workspacePath: workspace?.localPath ?? null, + workspaceMode, + command: connector.command, + model: connector.model ?? agent.model, + thinkingEffort, + env, + sessionParams: taskSession.sessionParams, + systemPrompt, + trigger, + }); + } + + function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null { + if (!hasWorkerReady()) return null; + const snapshot = db.listSnapshot(); + const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null; + if (!company || company.status !== "active") { + logger.info(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`); + return null; + } + const agent = db.getAgent(agentId); + if (agent.companyId !== companyId) { + logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`); + return null; + } + const connector = db.getConnector(agent.connectorId); + if (getRunEligibilityFailure(agent, connector)) { + return null; + } + if (hasActiveRun({ agentId: agent.id, companyId })) { + logger.info(`[heartbeat] Skipping ${agent.name} because it already has an active run.`); + return null; + } + if (db.isAgentPendingApproval(agentId)) { + logger.info(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`); + return null; + } + + const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]); + const isTimer = trigger === "timer"; + const candidateTasks = isTimer + ? assignedTasks.filter((entry) => !entry.title.startsWith("[Heartbeat]")) + : assignedTasks; + + let task = candidateTasks.find((entry) => !entry.activeRunId) ?? null; + + if (!task) { + const hasActiveAssignedRun = candidateTasks.some( + (entry) => entry.activeRunId && hasActiveRun({ taskId: entry.id, companyId }), + ); + if (hasActiveAssignedRun) { + return null; + } + + const isCeo = !agent.reportsTo; + const isManager = snapshot.agents.some((entry) => entry.reportsTo === agent.id && entry.companyId === companyId); + const activeGoals = snapshot.goals.filter((goal) => goal.companyId === companyId && (goal.status === "active" || goal.status === "planned")); + const companyTasks = snapshot.tasks.filter((entry) => entry.companyId === companyId && entry.status !== "done" && entry.status !== "cancelled"); + const companyNeedsBootstrap = activeGoals.length > 0 && companyTasks.length < activeGoals.length; + + if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) { + logger.info(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`); + return null; + } + + const taskTitle = isCeo + ? `[Heartbeat] ${agent.name} — Strategic planning & task delegation` + : isManager + ? `[Heartbeat] ${agent.name} — Review team & delegate work` + : `[Heartbeat] ${agent.name} — Check for work`; + const taskDescription = isCeo + ? "Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners." + : isManager + ? "Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked." + : `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`; + + const taskId = db.saveTask({ + companyId, + title: taskTitle, + description: taskDescription, + assigneeAgentId: agentId, + workspaceId: agent.workspaceId ?? undefined, + status: "in_progress", + priority: isCeo ? "high" : "medium", + }); + task = db.getTask(taskId); + logger.info(`[heartbeat] Created bootstrap task for ${agent.name}: ${taskTitle}`); + } + + if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId })) { + logger.info(`[heartbeat] Skipping ${agent.name} because task ${task.title} already has an active run.`); + return null; + } + + const workspaceResolution = resolveWorkspace(task, agent, companyId); + const workspace = workspaceResolution.workspace; + logger.info( + `[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`, + ); + + const run = db.createRun({ + companyId, + taskId: task.id, + agentId: agent.id, + workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, + connectorId: connector.id, + }); + + dispatchRunToWorker({ + runId: run.id, + companyId, + agent, + task, + connector, + workspace, + workspaceMode: workspaceResolution.lockMode, + companyName: company.name, + companyDescription: company.description ?? "", + snapshot, + trigger, + }); + + db.markHeartbeatAt(agentId); + db.addActivity({ + companyId, + actor: "system", + action: "heartbeat.triggered", + entityType: "agent", + entityId: agent.id, + detail: `${trigger} heartbeat for ${agent.name}`, + }); + publishDomainChanged(); + return run.id; + } + + function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) { + if (!agentId) return null; + try { + const agent = db.getAgent(agentId); + if (agent.companyId !== companyId) { + logger.warn(`[wake] Refusing cross-company wake for agent ${agentId}: ${agent.companyId} !== ${companyId}`); + return null; + } + } catch { + return null; + } + try { + ensureAgentHeartbeatEnabled(agentId); + } catch { + return null; + } + + const agentBusy = hasActiveRun({ agentId, companyId }); + const runId = startAgentHeartbeatRun(agentId, companyId, trigger); + if (!runId && agentBusy) { + db.enqueuePendingWake(companyId, agentId, trigger); + } + return runId; + } + + function queueTaskRun(taskId: string, trigger: string, actor: string) { + if (!hasWorkerReady()) { + logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`); + return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready."); + } + const task = db.getTask(taskId); + const snapshot = db.listSnapshot(); + const company = snapshot.companies.find((entry) => entry.id === task.companyId) ?? null; + if (!company || company.status !== "active") { + return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active."); + } + if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId: task.companyId })) { + return fail("TASK_ALREADY_RUNNING", "This task already has an active run."); + } + if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run."); + const agent = db.getAgent(task.assigneeAgentId); + if (agent.companyId !== task.companyId) { + return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company."); + } + const connector = db.getConnector(agent.connectorId); + const eligibilityFailure = getRunEligibilityFailure(agent, connector); + if (eligibilityFailure === "AGENT_NOT_RUNNABLE") { + return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated."); + } + if (eligibilityFailure === "AGENT_BUDGET_EXHAUSTED") { + return fail("AGENT_BUDGET_EXHAUSTED", "The assigned agent has exhausted its monthly budget."); + } + if (eligibilityFailure === "CONNECTOR_NOT_READY") { + return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready."); + } + if (hasActiveRun({ agentId: agent.id, companyId: task.companyId })) { + return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run."); + } + if (db.isAgentPendingApproval(agent.id)) { + return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks."); + } + const blockingApproval = db.getBlockingApproval(task.companyId, agent.id, task.id); + if (blockingApproval) { + return fail( + "APPROVAL_GATE_BLOCKED", + `Run blocked by pending approval: ${blockingApproval.type} — "${blockingApproval.payloadSummary}". Approve or reject before starting.`, + ); + } + + const workspaceResolution = resolveWorkspace(task, agent, task.companyId); + const workspace = workspaceResolution.workspace; + if (workspace && workspace.companyId !== task.companyId) { + return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company."); + } + + const run = db.createRun({ + companyId: task.companyId, + taskId: task.id, + agentId: agent.id, + workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null, + connectorId: connector.id, + }); + + dispatchRunToWorker({ + runId: run.id, + companyId: task.companyId, + agent, + task, + connector, + workspace, + workspaceMode: workspaceResolution.lockMode, + companyName: company.name, + companyDescription: company.description, + snapshot, + trigger, + }); + db.addActivity({ + companyId: task.companyId, + actor, + action: "run.queued", + entityType: "run", + entityId: run.id, + detail: task.title, + }); + publishDomainChanged(); + return ok(run.id); + } + + function resumeQueuedRuns() { + if (!hasWorkerReady()) { + return; + } + const snapshot = db.listSnapshot(); + const companiesById = new Map(snapshot.companies.map((company) => [company.id, company])); + + for (const run of db.listQueuedRuns()) { + const company = companiesById.get(run.companyId); + if (!company || company.status !== "active") { + continue; + } + + const hasBlockingSiblingRun = snapshot.runs.some((entry) => + entry.id !== run.id && + entry.companyId === run.companyId && + ["queued", "running"].includes(entry.status) && + (entry.taskId === run.taskId || entry.agentId === run.agentId), + ); + if (hasBlockingSiblingRun) { + continue; + } + + try { + const task = db.getTask(run.taskId); + const agent = db.getAgent(run.agentId); + const connector = db.getConnector(run.connectorId); + if (getRunEligibilityFailure(agent, connector)) { + continue; + } + const workspaceResolution = resolveWorkspace(task, agent, run.companyId); + dispatchRunToWorker({ + runId: run.id, + companyId: run.companyId, + agent, + task, + connector, + workspace: workspaceResolution.workspace, + workspaceMode: workspaceResolution.lockMode, + companyName: company.name, + companyDescription: company.description, + snapshot, + trigger: "recovery", + }); + } catch (error) { + logger.warn(`[run] Skipped queued run recovery for ${run.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + function startHeartbeatScheduler() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + + heartbeatTimer = setInterval(() => { + try { + if (!hasWorkerReady()) return; + const snapshot = db.listSnapshot(); + const now = Date.now(); + + for (const company of snapshot.companies.filter((entry) => entry.status === "active")) { + try { + const eligibleAgents = db.listHeartbeatEligibleAgents(company.id); + for (const agent of eligibleAgents) { + if (hasActiveRun({ agentId: agent.id, companyId: company.id })) continue; + + const lastHeartbeat = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; + const intervalMs = agent.heartbeatIntervalSec * 1000; + if (now - lastHeartbeat < intervalMs) continue; + + logger.info(`[heartbeat] Waking agent ${agent.name} (interval: ${agent.heartbeatIntervalSec}s)`); + startAgentHeartbeatRun(agent.id, company.id, "timer"); + } + + const companyGoals = snapshot.goals.filter((goal) => goal.companyId === company.id && (goal.status === "active" || goal.status === "planned")); + const companyActiveTasks = snapshot.tasks.filter((task) => task.companyId === company.id && !["done", "cancelled"].includes(task.status) && !task.title.startsWith("[Heartbeat]")); + if (companyGoals.length > 0 && companyActiveTasks.length === 0) { + const ceo = snapshot.agents.find((agent) => agent.companyId === company.id && !agent.reportsTo && agent.status !== "terminated"); + const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0; + const bootstrapCooldown = 120_000; + if (ceo && !hasActiveRun({ agentId: ceo.id, companyId: company.id }) && (now - ceoLastWake > bootstrapCooldown)) { + logger.info(`[heartbeat] Company ${company.name} has ${companyGoals.length} goals but no tasks — bootstrapping CEO ${ceo.name}`); + startAgentHeartbeatRun(ceo.id, company.id, "bootstrap"); + } + } + + const scheduleKey = `schedule-${company.id}`; + const lastScheduleFired = scheduleLastFiredMap.get(scheduleKey) ?? 0; + if (now - lastScheduleFired >= 60_000) { + dispatchAutomation("schedule", { companyId: company.id }); + scheduleLastFiredMap.set(scheduleKey, now); + } + + const companyAgents = snapshot.agents.filter((agent) => agent.companyId === company.id && agent.status !== "terminated" && agent.status !== "paused" && agent.status !== "pending_approval"); + for (const agent of companyAgents) { + const hasAssigned = snapshot.tasks.some( + (task) => task.assigneeAgentId === agent.id && task.companyId === company.id && ["todo", "in_progress", "blocked"].includes(task.status), + ); + if (!hasAssigned) { + const lastActive = agent.lastHeartbeatAt ? new Date(agent.lastHeartbeatAt).getTime() : 0; + const idleThreshold = 20 * 60 * 1000; + if (now - lastActive > idleThreshold) { + dispatchAutomation("agent_idle", { + companyId: company.id, + agentId: agent.id, + department: agent.department ?? undefined, + }); + } + } + } + + const currentMonth = new Date().toISOString().slice(0, 7); + const lastResetMonth = db.getLastBudgetResetMonth(company.id) ?? ""; + if (currentMonth !== lastResetMonth) { + db.resetMonthlyBudgets(company.id); + db.setLastBudgetResetMonth(company.id, currentMonth); + logger.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`); + } + } catch (error) { + logger.error(`[heartbeat] Error processing company ${company.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + try { + const staleReleased = db.releaseStaleWorkspaceLocks(); + if (staleReleased > 0) { + logger.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`); + publishDomainChanged(); + } + const stalePendingWakes = db.purgeExpiredPendingWakes(); + if (stalePendingWakes > 0) { + logger.info(`[heartbeat] Purged ${stalePendingWakes} stale pending wake(s)`); + } + } catch (error) { + logger.error(`[heartbeat] Stale lock cleanup failed: ${error instanceof Error ? error.message : String(error)}`); + } + } catch (error) { + logger.error(`[heartbeat] Scheduler error: ${error instanceof Error ? error.message : String(error)}`); + } + }, 15_000); + } + + function stopHeartbeatScheduler() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + + return { + resolveWorkspace, + autoPauseBudgetExceededAgent, + wakeAgentIfPossible, + dispatchRunToWorker, + queueTaskRun, + resumeQueuedRuns, + startAgentHeartbeatRun, + startHeartbeatScheduler, + stopHeartbeatScheduler, + }; +} diff --git a/src/main/orchestrator/worker.ts b/src/main/orchestrator/worker.ts index 23198e63..9e2468b9 100644 --- a/src/main/orchestrator/worker.ts +++ b/src/main/orchestrator/worker.ts @@ -1,34 +1,8 @@ import { runningProcesses } from "@agentcompany/adapter-utils/server-utils"; import { getConnectorDefinition } from "@main/connectors"; +import { buildSanitizedRuntimeEnv } from "@main/runtime-env"; import type { ConnectorId, RunStatus } from "@shared/types"; -const ENV_ALLOWLIST: ReadonlySet = new Set([ - "PATH", - "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR", - "DEVELOPER_DIR", "SDKROOT", - "NODE_ENV", "NODE_OPTIONS", - "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", - "GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT", - "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", - "DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", - "APPDATA", "LOCALAPPDATA", "USERPROFILE", "SYSTEMROOT", "COMSPEC", "PROGRAMFILES", - "AGENT_COMPANY_API_URL", "AGENT_COMPANY_API_KEY", "AGENT_COMPANY_RUN_ID", -]); - -function buildSanitizedEnv( - hostEnv: Record, - secretBindings: Record, -): Record { - const result: Record = {}; - for (const key of ENV_ALLOWLIST) { - if (key in hostEnv) { - result[key] = hostEnv[key]; - } - } - Object.assign(result, secretBindings); - return result; -} - function buildRedactor(env: Record): (text: string) => string { const sensitivePatterns: string[] = []; for (const [key, value] of Object.entries(env)) { @@ -114,6 +88,52 @@ function workspaceKey(job: RunJob) { return job.workspaceId ?? job.workspacePath ?? `task:${job.taskId}`; } +function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record) { + const baseConfig: Record = { + command: job.command, + cwd: job.workspacePath ?? process.cwd(), + model: job.model, + env: sanitizedEnv, + }; + + switch (job.connectorId) { + case "codex_local": + return { + ...baseConfig, + modelReasoningEffort: job.thinkingEffort ?? "", + dangerouslyBypassApprovalsAndSandbox: true, + extraArgs: [ + "--skip-git-repo-check", + "--sandbox", + "danger-full-access", + "--ask-for-approval", + "never", + ], + }; + case "claude_local": + return { + ...baseConfig, + effort: job.thinkingEffort ?? "", + dangerouslySkipPermissions: true, + }; + case "gemini_local": + return { + ...baseConfig, + yolo: true, + approvalMode: "yolo", + sandbox: false, + }; + case "kimi_local": + return { + ...baseConfig, + extraArgs: ["--yolo"], + }; + case "opencode_local": + default: + return baseConfig; + } +} + function enqueue(job: RunJob) { queue.push(job); post({ @@ -151,7 +171,7 @@ async function startRun(job: RunJob) { message: `Running with ${definition.label}.`, }); - const sanitizedEnv = buildSanitizedEnv( + const sanitizedEnv = buildSanitizedRuntimeEnv( Object.fromEntries( Object.entries(process.env).filter((e): e is [string, string] => typeof e[1] === "string"), ), @@ -178,17 +198,7 @@ async function startRun(job: RunJob) { typeof job.sessionParams?.sessionId === "string" ? job.sessionParams.sessionId : null, taskKey: job.taskId, }, - config: { - command: job.command, - cwd: job.workspacePath ?? process.cwd(), - model: job.model, - env: sanitizedEnv, - effort: job.connectorId === "claude_local" ? (job.thinkingEffort ?? "") : "", - modelReasoningEffort: job.connectorId === "codex_local" ? (job.thinkingEffort ?? "") : "", - dangerouslySkipPermissions: true, - dangerouslyBypassApprovalsAndSandbox: true, - extraArgs: job.connectorId === "codex_local" ? ["--skip-git-repo-check"] : [], - }, + config: buildConnectorExecutionConfig(job, sanitizedEnv), context: { taskId: job.taskId, taskTitle: job.systemPrompt ?? job.taskTitle, diff --git a/src/main/prompt-builder.ts b/src/main/prompt-builder.ts index d6df8f94..b49553e6 100644 --- a/src/main/prompt-builder.ts +++ b/src/main/prompt-builder.ts @@ -204,15 +204,28 @@ ${ctx.assignedTasks.map((t) => `- [${t.id}] ${t.title} (status: ${t.status}, pri } if (ctx.tasksAwaitingReview.length > 0) { - sections.push(`# Tasks Awaiting Your Review -${ctx.tasksAwaitingReview.map((t) => { - const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown"; - return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})`; -}).join("\n")} + const reviewDetails = ctx.tasksAwaitingReview.map((t) => { + const assigneeName = ctx.allAgents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown"; + const desc = t.description ? `\n Description: ${t.description.slice(0, 300)}${t.description.length > 300 ? "..." : ""}` : ""; + const taskComments = ctx.recentComments + .filter((c) => c.taskId === t.id) + .slice(-3) + .map((c) => ` - ${c.authorName}: "${c.body.slice(0, 150)}${c.body.length > 150 ? "..." : ""}"`) + .join("\n"); + const commentSection = taskComments ? `\n Recent comments:\n${taskComments}` : "\n No comments yet."; + return `- [${t.id}] ${t.title} (by ${assigneeName}, priority: ${t.priority})${desc}${commentSection}`; + }).join("\n\n"); -These tasks have been submitted for your review by your direct reports. Read their comments, then either: -- Approve: PATCH /api/issues/{issueId} with status "done" and an approving comment -- Request changes: PATCH /api/issues/{issueId} with status "in_progress" and feedback comment + sections.push(`# Tasks Awaiting Your Review (${ctx.tasksAwaitingReview.length} pending) + +${reviewDetails} + +REVIEW WORKFLOW — This is your HIGHEST PRIORITY after resolving blockers: +1. For EACH task above, first read ALL comments: GET /api/issues/{issueId}/comments +2. Evaluate whether the work meets the task description and acceptance criteria +3. To APPROVE: PATCH /api/issues/{issueId} with {"status": "done", "comment": "Approved. "} +4. To REQUEST CHANGES: PATCH /api/issues/{issueId} with {"status": "in_progress", "comment": "Changes needed: "} +5. Do NOT ignore review requests — your team is blocked until you respond `); } diff --git a/src/main/runtime-env.ts b/src/main/runtime-env.ts new file mode 100644 index 00000000..4664706a --- /dev/null +++ b/src/main/runtime-env.ts @@ -0,0 +1,107 @@ +const ENV_ALLOWLIST: ReadonlySet = new Set([ + "PATH", + "HOME", + "USER", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TMPDIR", + "DEVELOPER_DIR", + "SDKROOT", + "NODE_ENV", + "NODE_OPTIONS", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_SSH_COMMAND", + "GIT_ASKPASS", + "GIT_TERMINAL_PROMPT", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_RUNTIME_DIR", + "DISPLAY", + "WAYLAND_DISPLAY", + "DBUS_SESSION_BUS_ADDRESS", + "APPDATA", + "LOCALAPPDATA", + "USERPROFILE", + "SYSTEMROOT", + "COMSPEC", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMDATA", + "PATHEXT", + "AGENT_COMPANY_AGENT_ID", + "AGENT_COMPANY_COMPANY_ID", + "AGENT_COMPANY_API_URL", + "AGENT_COMPANY_API_KEY", + "AGENT_COMPANY_RUN_ID", + "AGENT_COMPANY_TASK_ID", + "AGENT_COMPANY_WAKE_REASON", + "AGENT_COMPANY_WAKE_COMMENT_ID", + "AGENT_COMPANY_APPROVAL_ID", + "AGENT_COMPANY_APPROVAL_STATUS", + "AGENT_COMPANY_WORKSPACE_CWD", + "AGENT_COMPANY_WORKSPACE_SOURCE", + "AGENT_COMPANY_WORKSPACE_STRATEGY", + "AGENT_COMPANY_WORKSPACE_ID", + "AGENT_COMPANY_WORKSPACE_REPO_URL", + "AGENT_COMPANY_WORKSPACE_REPO_REF", + "AGENT_COMPANY_WORKSPACE_BRANCH", + "AGENT_COMPANY_WORKSPACE_WORKTREE_PATH", + "AGENT_COMPANY_WORKSPACES_JSON", + "AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON", + "AGENT_COMPANY_RUNTIME_SERVICES_JSON", + "AGENT_COMPANY_RUNTIME_PRIMARY_URL", + "AGENT_COMPANY_LINKED_ISSUE_IDS", +]); + +const FORBIDDEN_RUNTIME_OVERRIDE_KEYS: ReadonlySet = new Set([ + "PATH", + "HOME", + "USER", + "SHELL", + "NODE_OPTIONS", + "ELECTRON_RUN_AS_NODE", + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "PWD", +]); + +function normalizeStringEnv(input: NodeJS.ProcessEnv | Record): Record { + return Object.fromEntries( + Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); +} + +export function stripForbiddenRuntimeOverrides(env: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (FORBIDDEN_RUNTIME_OVERRIDE_KEYS.has(key)) { + continue; + } + sanitized[key] = value; + } + return sanitized; +} + +export function buildSanitizedRuntimeEnv( + hostEnv: NodeJS.ProcessEnv | Record, + runtimeOverrides: Record, +): Record { + const normalizedHostEnv = normalizeStringEnv(hostEnv); + const sanitized: Record = {}; + for (const key of ENV_ALLOWLIST) { + if (key in normalizedHostEnv) { + sanitized[key] = normalizedHostEnv[key]!; + } + } + return { + ...sanitized, + ...stripForbiddenRuntimeOverrides(runtimeOverrides), + }; +} diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts new file mode 100644 index 00000000..54ff82d5 --- /dev/null +++ b/src/main/service-graph.ts @@ -0,0 +1,243 @@ +import type { AgentApiServer } from "./api-server"; +import type { AppDatabase } from "./database"; +import { BrowserManager } from "./browser-manager"; +import { createAutomationService } from "./automation-service"; +import { createBrowserActionService } from "./browser-action-service"; +import { createConnectorHealthService } from "./connector-health-service"; +import { createDesktopShellService } from "./desktop-shell-service"; +import { createOnboardingService } from "./onboarding-service"; +import { createOrchestrationService } from "./orchestration-service"; +import { createUpdaterService } from "./updater-service"; +import { createWorkerService } from "./worker-service"; +import { createAppLifecycleService } from "./app-lifecycle-service"; +import type { DesktopEvent, RunRecord } from "@shared/types"; +import type { AutomationEventContext } from "./automation-service"; + +interface ServiceGraphDependencies { + db: AppDatabase; + apiServerRef: { current: AgentApiServer | null }; + browserManagerRef: { current: BrowserManager | null }; + isQuittingRef: { current: boolean }; + preloadPath: string; + rendererDist: string; + iconPath: string; + isDev: boolean; + devServerUrl?: string; + windowStatePath: string; + workerPath: string; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + emitEvent: (event: DesktopEvent) => void; + publishDomainChanged: () => void; + notify: (title: string, body: string) => void; + createRunToken: (agentId: string, companyId: string, runId: string) => string; + seedDefaultAutomationRules: (companyId: string) => void; +} + +export function createServiceGraph({ + db, + apiServerRef, + browserManagerRef, + isQuittingRef, + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + workerPath, + logger, + emitEvent, + publishDomainChanged, + notify, + createRunToken, + seedDefaultAutomationRules, +}: ServiceGraphDependencies) { + let automationService: ReturnType | null = null; + let browserActionService: ReturnType | null = null; + let connectorHealthService: ReturnType | null = null; + let onboardingService: ReturnType | null = null; + let desktopShellService: ReturnType | null = null; + let orchestrationService: ReturnType | null = null; + let workerService: ReturnType | null = null; + let updaterService: ReturnType | null = null; + let appLifecycleService: ReturnType | null = null; + + const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => { + if (!orchestrationService) { + throw new Error("Orchestration service is not initialized."); + } + return orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger); + }; + + const dispatchBrowserAction = async (actionId: string, companyId: string) => { + if (!browserActionService) { + throw new Error("Browser action service is not initialized"); + } + return browserActionService.dispatchBrowserAction(actionId, companyId); + }; + + const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => { + if (!automationService) { + throw new Error("Automation service is not initialized."); + } + automationService.dispatchAutomation(trigger, ctx); + }; + + const processRunCompletion = (run: RunRecord, event: Parameters["processRunCompletion"]>[1]) => { + if (!automationService) { + throw new Error("Automation service is not initialized."); + } + automationService.processRunCompletion(run, event); + }; + + const queueTaskRun = (taskId: string, trigger: string, actor: string) => { + if (!orchestrationService) { + throw new Error("Orchestration service is not initialized."); + } + return orchestrationService.queueTaskRun(taskId, trigger, actor); + }; + + const dispatchRunToWorker = (params: Parameters["dispatchRunToWorker"]>[0]) => { + if (!orchestrationService) { + throw new Error("Orchestration service is not initialized."); + } + return orchestrationService.dispatchRunToWorker(params); + }; + + function init() { + desktopShellService = createDesktopShellService({ + preloadPath, + rendererDist, + iconPath, + isDev, + devServerUrl, + windowStatePath, + shouldQuit: () => isQuittingRef.current, + onRequestQuit: () => { + isQuittingRef.current = true; + }, + onCheckForUpdates: async () => { + if (!updaterService) throw new Error("Updater service is not initialized."); + await updaterService.checkForUpdates(); + }, + onBackupProfile: async () => { + const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + })); + if (target.canceled || target.filePaths.length === 0) return; + await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`); + }, + onShortcut: (action) => emitEvent({ type: "shortcut", action }), + notify, + }); + desktopShellService.initialize(); + + connectorHealthService = createConnectorHealthService({ + db, + logger, + publishDomainChanged, + }); + + workerService = createWorkerService({ + db, + logger, + workerPath, + isQuitting: () => isQuittingRef.current, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible, + dispatchRunToWorker, + processRunCompletion: (run, event) => processRunCompletion(run, event), + notify, + }); + + orchestrationService = createOrchestrationService({ + db, + logger, + publishDomainChanged, + notify, + createRunToken, + getApiPort: () => apiServerRef.current?.getPort() ?? 0, + dispatchAutomation, + hasWorkerReady: () => Boolean(workerService?.getWorker()), + enqueueWorkerRun: (payload) => { + workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload }); + }, + }); + workerService.createWorker(); + + automationService = createAutomationService({ + db, + logger, + emitEvent, + publishDomainChanged, + notify, + wakeAgentIfPossible, + dispatchBrowserAction, + }); + + onboardingService = createOnboardingService({ + db, + seedDefaultAutomationRules, + queueTaskRun, + publishDomainChanged, + }); + + browserActionService = createBrowserActionService({ + db, + browserManagerRef, + publishDomainChanged, + }); + browserActionService.resetBrowserManager(); + + updaterService = createUpdaterService({ + db, + logger, + notify, + }); + updaterService.attachListeners(); + + appLifecycleService = createAppLifecycleService({ + getMainWindow: () => desktopShellService?.getMainWindow() ?? null, + ensureWindow: async () => desktopShellService?.ensureWindow() ?? null, + shouldQuit: () => isQuittingRef.current, + onBeforeQuit: () => { + isQuittingRef.current = true; + orchestrationService?.stopHeartbeatScheduler(); + apiServerRef.current?.stop(); + apiServerRef.current = null; + browserManagerRef.current?.shutdown(); + browserManagerRef.current = null; + desktopShellService?.destroy(); + desktopShellService = null; + workerService?.destroy(); + workerService = null; + browserActionService = null; + automationService = null; + connectorHealthService = null; + onboardingService = null; + orchestrationService = null; + updaterService = null; + appLifecycleService = null; + db.close(); + }, + }); + } + + return { + init, + getDesktopShellService: () => desktopShellService, + getConnectorHealthService: () => connectorHealthService, + getWorkerService: () => workerService, + getBrowserActionService: () => browserActionService, + getOrchestrationService: () => orchestrationService, + getAutomationService: () => automationService, + getOnboardingService: () => onboardingService, + getUpdaterService: () => updaterService, + getAppLifecycleService: () => appLifecycleService, + }; +} diff --git a/src/main/templates.ts b/src/main/templates.ts deleted file mode 100644 index 9a39d4cf..00000000 --- a/src/main/templates.ts +++ /dev/null @@ -1,1568 +0,0 @@ -import type { CompanyTemplate } from "@shared/types"; - -export const COMPANY_TEMPLATES: CompanyTemplate[] = [ - { - id: "software-startup", - name: "Software Startup", - description: "A lean engineering team for building software products. CEO leads a CTO, a Product Lead, and a QA Lead who each manage their own specialists.", - icon: "Cpu", - agents: [ - { - name: "Alex Chen", - role: "CEO", - title: "Chief Executive Officer", - department: "executive", - capabilities: "Strategic planning, goal decomposition, team coordination, project oversight, stakeholder communication. Delegates all technical and product work to reports.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Jordan Park", - role: "CTO", - title: "Chief Technology Officer", - department: "engineering", - capabilities: "Technical architecture, code review, engineering management, infrastructure decisions, technical debt management. Manages backend and frontend engineers.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 300, - }, - { - name: "Sam Rivera", - role: "Product Lead", - title: "VP of Product", - department: "product", - capabilities: "Product strategy, feature prioritization, user research synthesis, requirements writing, UX review, sprint planning. Translates goals into actionable product specs.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Taylor Kim", - role: "Senior Backend Engineer", - title: "Senior Backend Engineer", - department: "engineering", - capabilities: "Backend development, API design, database optimization, server-side logic, performance tuning, microservices architecture. Expert in Node.js, Python, and Go.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Morgan Lee", - role: "Senior Frontend Engineer", - title: "Senior Frontend Engineer", - department: "engineering", - capabilities: "Frontend development, React/Next.js, UI implementation, responsive design, accessibility, frontend testing, component architecture. Pixel-perfect implementation.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Casey Quinn", - role: "QA Engineer", - title: "Quality Assurance Engineer", - department: "engineering", - capabilities: "Test planning, automated testing, bug reporting, regression testing, E2E test suites, CI/CD pipeline testing. Ensures quality before releases.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 150, - }, - ], - goals: [ - { - title: "Build and ship the core product", - description: "Design, implement, test, and deploy the initial version of the product with core features. Focus on code quality, test coverage, and a solid architecture foundation.", - }, - { - title: "Establish development workflows", - description: "Set up CI/CD pipelines, code review processes, testing standards, and deployment procedures. Ensure the team can ship reliably and frequently.", - }, - ], - }, - { - id: "marketing-agency", - name: "Marketing Agency", - description: "A digital marketing team focused on content, analytics, and growth. CEO leads a CMO who manages content creators and an analytics specialist.", - icon: "Megaphone", - agents: [ - { - name: "Diana Cross", - role: "CEO", - title: "Chief Executive Officer", - department: "executive", - capabilities: "Business strategy, client relations, campaign oversight, budget management, team coordination. Ensures all marketing efforts align with business goals.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Ryan Torres", - role: "CMO", - title: "Chief Marketing Officer", - department: "marketing", - capabilities: "Marketing strategy, campaign planning, brand management, channel optimization, growth hacking, market research, competitive analysis. Manages all marketing operations.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Mia Johnson", - role: "Content Lead", - title: "Senior Content Strategist", - department: "marketing", - capabilities: "Content strategy, copywriting, blog posts, social media content, email campaigns, SEO content, brand voice development. Creates compelling narratives that drive engagement.", - reportsToIndex: 1, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Liam Foster", - role: "Analytics Lead", - title: "Data Analyst", - department: "marketing", - capabilities: "Marketing analytics, A/B testing, conversion optimization, funnel analysis, reporting dashboards, KPI tracking, audience segmentation. Data-driven decision making.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 150, - }, - { - name: "Emma Zhang", - role: "Design Specialist", - title: "Visual Designer", - department: "design", - capabilities: "Graphic design, social media visuals, brand assets, presentation design, infographics, UI mockups. Creates visually compelling marketing materials.", - reportsToIndex: 1, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - ], - goals: [ - { - title: "Launch comprehensive marketing campaign", - description: "Plan and execute a multi-channel marketing campaign including content creation, social media, email marketing, and analytics tracking. Focus on measurable outcomes.", - }, - { - title: "Build brand presence and content library", - description: "Create a consistent brand identity, develop a content calendar, and build a library of reusable marketing assets. Establish the brand voice across all channels.", - }, - ], - }, - { - id: "research-lab", - name: "Research Lab", - description: "An AI research team for exploring ideas, writing papers, and building prototypes. Lead Researcher manages specialized researchers and an engineer.", - icon: "Flask", - agents: [ - { - name: "Dr. Sarah Mitchell", - role: "Research Director", - title: "Principal Investigator", - department: "research", - capabilities: "Research direction, paper review, experiment design, grant writing, research team management, technical mentoring. Sets research agenda and ensures scientific rigor.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Dr. Wei Liu", - role: "Senior Researcher", - title: "Senior Research Scientist", - department: "research", - capabilities: "Deep learning research, model architecture design, experiment execution, paper writing, literature review, mathematical analysis. Expert in transformer architectures and optimization.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 250, - }, - { - name: "Arun Patel", - role: "ML Engineer", - title: "Machine Learning Engineer", - department: "engineering", - capabilities: "Model implementation, training pipeline development, distributed computing, MLOps, benchmarking, reproducibility. Turns research ideas into working code.", - reportsToIndex: 0, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Nina Kowalski", - role: "Research Analyst", - title: "Research Analyst", - department: "research", - capabilities: "Data analysis, experiment tracking, results visualization, statistical testing, dataset curation, documentation. Supports research with rigorous analysis.", - reportsToIndex: 0, - connectorPreference: "codex_local", - budgetMonthlyUsd: 150, - }, - ], - goals: [ - { - title: "Conduct research and produce findings", - description: "Explore the research topic, design and run experiments, analyze results, and produce a comprehensive report or paper with actionable findings.", - }, - { - title: "Build working prototype", - description: "Implement a working prototype that demonstrates the key research findings. Include benchmarks, documentation, and reproducibility instructions.", - }, - ], - }, - { - id: "fullstack-team", - name: "Full-Stack Product Team", - description: "A complete product development team with frontend, backend, DevOps, and design capabilities. Built for shipping production applications.", - icon: "Stack", - agents: [ - { - name: "Chris Walker", - role: "CEO", - title: "Chief Executive Officer", - department: "executive", - capabilities: "Product vision, team leadership, strategic planning, stakeholder management, resource allocation. Orchestrates the entire product development lifecycle.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Rachel Green", - role: "Engineering Manager", - title: "VP of Engineering", - department: "engineering", - capabilities: "Engineering management, technical architecture, code review, sprint planning, technical debt management, hiring. Ensures engineering excellence and team velocity.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "David Park", - role: "Backend Engineer", - title: "Senior Backend Developer", - department: "engineering", - capabilities: "API development, database design, authentication, server architecture, performance optimization, testing. Expert in Node.js, PostgreSQL, and REST/GraphQL APIs.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Lisa Wang", - role: "Frontend Engineer", - title: "Senior Frontend Developer", - department: "engineering", - capabilities: "React/Next.js development, TypeScript, CSS/Tailwind, responsive design, accessibility, state management, component architecture, frontend testing.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Marcus Cole", - role: "DevOps Engineer", - title: "Infrastructure Engineer", - department: "engineering", - capabilities: "CI/CD pipelines, Docker/Kubernetes, cloud infrastructure (AWS/GCP), monitoring, logging, security, deployment automation, database administration.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 200, - }, - { - name: "Sophie Anderson", - role: "UX Designer", - title: "Product Designer", - department: "design", - capabilities: "User research, wireframing, UI design, design systems, prototyping, usability testing, accessibility design. Creates intuitive and beautiful interfaces.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - ], - goals: [ - { - title: "Build production-ready application", - description: "Design and implement a full-stack application with proper architecture, testing, CI/CD, and deployment. Focus on scalability, security, and user experience.", - }, - { - title: "Establish infrastructure and DevOps", - description: "Set up cloud infrastructure, CI/CD pipelines, monitoring, and automated deployment. Ensure the team can ship safely and roll back quickly.", - }, - ], - }, - { - id: "solo-developer", - name: "Solo Developer + AI Assistant", - description: "A minimal setup with just a technical lead who does everything. Perfect for small projects or quick prototypes.", - icon: "Code", - agents: [ - { - name: "Pat Developer", - role: "Technical Lead", - title: "Full-Stack Developer", - department: "engineering", - capabilities: "Full-stack development, system design, testing, deployment, debugging, code review. A versatile developer who can handle any technical task from frontend to backend to infrastructure.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 300, - }, - ], - goals: [ - { - title: "Complete the project", - description: "Analyze requirements, design the solution, implement it, test it, and deploy it. Handle all aspects of development from start to finish.", - }, - ], - }, - - { - id: "fortune500-enterprise", - name: "Fortune 500 Enterprise", - description: "A fully-staffed enterprise with 37 agents across 12 departments: Executive, Engineering, Product, Design, Marketing, Sales, HR, Finance, Legal, Customer Success, Operations, and Research. Every role, reporting line, and department you would find in a real Fortune 500 technology company.", - icon: "Buildings", - departments: [ - { id: "executive", name: "Executive Suite", description: "C-level leadership responsible for corporate strategy, capital allocation, board governance, and cross-functional alignment." }, - { id: "engineering", name: "Engineering", description: "Software development, platform infrastructure, quality assurance, application security, and data engineering." }, - { id: "product", name: "Product Management", description: "Product strategy, roadmap prioritization, feature specification, and data-informed experimentation." }, - { id: "design", name: "Design", description: "User experience research, interaction design, visual design, and design-system stewardship." }, - { id: "marketing", name: "Marketing", description: "Brand strategy, demand generation, content marketing, public relations, and growth analytics." }, - { id: "sales", name: "Sales", description: "Revenue strategy, enterprise account management, outbound prospecting, and pipeline forecasting." }, - { id: "hr", name: "People & Culture", description: "Talent acquisition, employer branding, people operations, DEI initiatives, benefits, and organizational development." }, - { id: "finance", name: "Finance", description: "Financial planning and analysis, accounting, treasury, expense management, and investor relations." }, - { id: "legal", name: "Legal & Compliance", description: "Corporate law, intellectual property, contract negotiation, data privacy, and regulatory compliance." }, - { id: "customer_support", name: "Customer Success", description: "Customer retention, technical support, onboarding, NPS tracking, and account expansion." }, - { id: "operations", name: "Operations", description: "Business process optimization, program management, vendor management, and OKR coordination." }, - { id: "research", name: "Research & Innovation", description: "Applied research, competitive intelligence, prototyping, and publication of thought-leadership papers." }, - ], - agents: [ - { - name: "Victoria Sterling", - role: "CEO", - title: "Chief Executive Officer", - department: "executive", - capabilities: "Corporate vision and multi-year strategy, board of directors reporting, quarterly earnings narratives, M&A evaluation, organizational design, executive hiring and termination decisions, capital allocation across business units, crisis communication, company culture embodiment. Sets the north-star mission, chairs the executive committee, approves all budgets above $50k, and is the final escalation point for any cross-department conflict.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 250, - }, - { - name: "James Whitfield", - role: "COO", - title: "Chief Operating Officer", - department: "executive", - capabilities: "Day-to-day operational cadence, cross-functional coordination, OKR framework ownership, business process re-engineering, vendor and partner management, operational metrics dashboards (cycle time, throughput, MTTR), company-wide meeting cadence (standups, all-hands, sprint ceremonies), office/remote workplace strategy, disaster-recovery and business-continuity planning. Acts as the connective tissue between every department and translates CEO strategy into executable operational plans.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 220, - }, - { - name: "Marcus Chen", - role: "CTO", - title: "Chief Technology Officer", - department: "executive", - capabilities: "Technical vision and 3-year technology roadmap, system architecture governance, build-vs-buy analysis, technology radar evaluation, security posture oversight, engineering culture and hiring bar, open-source strategy, cloud cost optimization, platform reliability targets (SLOs/SLAs), vendor technology evaluations, patent and IP review for technical inventions. Owns the technical strategy and ensures engineering investments align with business goals.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 250, - }, - { - name: "Priya Sharma", - role: "CFO", - title: "Chief Financial Officer", - department: "executive", - capabilities: "Financial planning and analysis, 13-week cash-flow forecasting, annual budget cycle, department budget approvals, investor relations and fundraising decks, revenue recognition policy, tax strategy, audit preparation, financial risk assessment, burn-rate monitoring, unit-economics analysis (CAC/LTV), board financial reporting, expense policy enforcement. Controls every dollar and ensures the company remains solvent and investment-ready.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Nadira Osei-Mensah", - role: "CHRO", - title: "Chief Human Resources Officer", - department: "executive", - capabilities: "People strategy and workforce planning, organizational development, employer brand positioning, total-rewards philosophy (comp, equity, benefits), DEI program design and measurement, succession planning for key roles, leadership development programs, employee engagement surveys (eNPS), labor-law compliance, culture rituals (offsites, recognition programs), conflict resolution at the executive level. Ensures the company attracts, retains, and grows world-class talent.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - - { - name: "Elena Rodriguez", - role: "VP Engineering", - title: "Vice President of Engineering", - department: "engineering", - capabilities: "Engineering management across multiple squads, sprint planning and velocity tracking, hiring pipeline and technical interviews, code-review process governance, incident-commander rotation scheduling, engineering budget and headcount planning, technical debt prioritization, on-call rotation management, engineering all-hands facilitation, promotion and calibration cycles. Ensures the engineering org ships high-quality software on predictable cadences.", - reportsToIndex: 2, - connectorPreference: "claude_local", - budgetMonthlyUsd: 220, - }, - { - name: "Tomasz Kowalski", - role: "Principal Engineer", - title: "Principal Software Engineer", - department: "engineering", - capabilities: "System-wide architecture design and review, RFC authorship and cross-team technical alignment, tech-debt reduction strategy, performance optimization at scale, database sharding and replication design, distributed-systems patterns (CQRS, event sourcing, saga), mentoring senior and mid-level engineers, evaluating new languages/frameworks, writing architecture decision records (ADRs), leading design reviews and post-mortems. The technical conscience of the org.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 280, - }, - { - name: "Kai Nakamura", - role: "Senior Backend Engineer", - title: "Senior Backend Engineer", - department: "engineering", - capabilities: "REST and GraphQL API design, microservice development in Node.js/Go/Python, PostgreSQL schema design and query optimization, Redis caching strategies, message-queue integration (Kafka, RabbitMQ), authentication and authorization (OAuth2, RBAC), rate limiting, database migration scripting, writing comprehensive unit and integration tests, performance profiling and load testing. Writes production-grade, well-tested backend code daily.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Sofia Andersson", - role: "Senior Frontend Engineer", - title: "Senior Frontend Engineer", - department: "engineering", - capabilities: "React 19 and Next.js application development, TypeScript strict-mode, state management (Zustand, React Query), component architecture and monorepo structuring, CSS-in-JS and Tailwind, WCAG 2.2 accessibility compliance, Storybook component documentation, frontend performance budgets (LCP, CLS, FID), E2E testing with Playwright, design-system token consumption, SSR/ISR rendering strategies. Implements pixel-perfect UIs and maintains frontend architecture standards.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Omar Hassan", - role: "Platform / DevOps Engineer", - title: "Senior Platform Engineer", - department: "engineering", - capabilities: "CI/CD pipeline design (GitHub Actions, GitLab CI), Infrastructure-as-Code (Terraform, Pulumi), container orchestration (Docker, Kubernetes, Helm), cloud platform management (AWS/GCP/Azure), observability stack (Datadog, Grafana, Prometheus, OpenTelemetry), secrets management (Vault, AWS SSM), database administration and backup automation, cost-optimization reviews, incident response runbooks, zero-downtime deployment strategies, SOC2 infrastructure controls. Keeps the platform reliable and the deploy pipeline under 5 minutes.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 230, - }, - { - name: "Yuki Tanaka", - role: "QA Lead", - title: "Quality Assurance Lead", - department: "engineering", - capabilities: "Test strategy for unit, integration, E2E, and contract tests, test-automation framework selection and maintenance, CI quality gates and flaky-test management, performance and load-test design (k6, Locust), visual-regression testing, mobile-device testing matrix, bug-triage process ownership, release-readiness checklists, test coverage reporting, accessibility auditing, chaos-engineering experiments. No feature ships without QA sign-off. Expert in Playwright, Jest, and Cypress.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 200, - }, - { - name: "Amina Diallo", - role: "Security Engineer", - title: "Application Security Engineer", - department: "engineering", - capabilities: "Application security reviews and threat modeling (STRIDE), static analysis tooling (Semgrep, CodeQL), dependency vulnerability scanning (Snyk, Dependabot), penetration testing coordination, OWASP Top-10 remediation, secrets-rotation policy, SOC2 and ISO 27001 compliance evidence collection, security incident response playbooks, developer security training, API authentication hardening, data-encryption-at-rest and in-transit audits. Guardian of the codebase against external and internal threats.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 230, - }, - { - name: "Ravi Krishnamurthy", - role: "Data Engineer", - title: "Senior Data Engineer", - department: "engineering", - capabilities: "ETL/ELT pipeline development (dbt, Airflow, Dagster), data-warehouse design (Snowflake, BigQuery, Redshift), real-time streaming (Kafka, Flink), data-quality frameworks (Great Expectations), analytics-infrastructure provisioning, BI tool integration (Looker, Metabase), data-catalog and lineage documentation, PII anonymization pipelines, cost-per-query optimization, data-model governance. Builds the plumbing that turns raw data into trustworthy business insights.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 230, - }, - - { - name: "Amara Okafor", - role: "VP Product", - title: "Vice President of Product", - department: "product", - capabilities: "Product vision and multi-quarter roadmap, RICE/ICE prioritization frameworks, PRD and BRD authorship, competitive landscape analysis, user-interview synthesis, stakeholder alignment workshops, A/B test design and read-outs, launch-readiness reviews, pricing and packaging strategy input, product-led growth metrics (activation, retention, expansion), OKR definition for product squads. Owns what gets built, why it gets built, and how success is measured.", - reportsToIndex: 2, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Daniel Okonkwo", - role: "Senior Product Manager", - title: "Senior Product Manager", - department: "product", - capabilities: "Feature specification and user-story writing, sprint backlog grooming, JIRA/Linear workflow management, customer-feedback triage (Productboard, Canny), data-analysis for feature decisions (Amplitude, Mixpanel), A/B test configuration and statistical significance review, go-to-market coordination with marketing, release-notes drafting, beta-program management, competitive feature benchmarking. Translates user needs into shippable increments every sprint.", - reportsToIndex: 13, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Ingrid Johansson", - role: "UX Director", - title: "Director of User Experience", - department: "design", - capabilities: "User-research program management (interviews, surveys, diary studies, card sorts), usability-testing facilitation, persona and journey-map creation, information-architecture design, wireframe and low-fidelity prototype creation, heuristic evaluation, accessibility audit leadership, design-ops process (critique cadence, handoff standards), cross-functional design-sprint facilitation. Ensures every product decision is grounded in real user evidence.", - reportsToIndex: 13, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Leo Moretti", - role: "UI Designer", - title: "Senior UI Designer", - department: "design", - capabilities: "Visual design in Figma (components, auto-layout, variants, tokens), design-system creation and maintenance, icon and illustration production, motion and micro-interaction design, responsive-layout specifications, dark-mode and theming support, design-token export for engineering consumption (Style Dictionary), brand-consistency auditing, high-fidelity prototyping (Figma prototypes, Framer), print and presentation collateral. Turns wireframes into polished, production-ready visual designs.", - reportsToIndex: 15, - connectorPreference: "claude_local", - budgetMonthlyUsd: 160, - }, - - { - name: "Jasmine Delacroix", - role: "CMO", - title: "Chief Marketing Officer", - department: "marketing", - capabilities: "Integrated marketing strategy, brand architecture and positioning, demand-generation funnel design, marketing-budget allocation across channels, marketing-ops tooling (HubSpot, Marketo, Segment), campaign ROI modeling, competitive positioning and battlecards, analyst-relations (Gartner, Forrester), executive storytelling, board-level marketing updates. Drives awareness, pipeline, and brand equity across every touchpoint.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Noah Park", - role: "Content Director", - title: "Director of Content", - department: "marketing", - capabilities: "Editorial strategy and content calendar management, long-form blog authorship and editing, SEO keyword research and on-page optimization (Ahrefs, Semrush), white-paper and e-book production, case-study interviews and writing, email-newsletter curation, thought-leadership ghostwriting for executives, content-repurposing workflows (blog to social to video), content-performance analytics (traffic, engagement, conversion). Produces compelling content that drives organic traffic and positions the company as a category leader.", - reportsToIndex: 17, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Zara Ahmed", - role: "Growth Marketing Manager", - title: "Growth Marketing Manager", - department: "marketing", - capabilities: "Paid-acquisition management (Google Ads, LinkedIn Ads, Meta Ads), social-media channel strategy and posting, conversion-rate optimization (landing pages, CTAs, forms), marketing-automation workflows (drip campaigns, lead scoring), influencer and partnership outreach, referral-program design, funnel analytics and attribution modeling, A/B testing of ad creatives and copy, community management (Discord, Slack communities), event marketing and webinar production. Owns the growth engine from first touch to MQL.", - reportsToIndex: 17, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - { - name: "Clara Dubois", - role: "Brand & PR Manager", - title: "Brand & Public Relations Manager", - department: "marketing", - capabilities: "Public-relations strategy and media outreach, press-release drafting and distribution, journalist relationship management, crisis-communication playbooks, brand-guideline enforcement, event sponsorship evaluation, award-submission management, social-listening and reputation monitoring, executive media training, conference speaking-slot procurement. Protects and amplifies the company's public image.", - reportsToIndex: 17, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - - { - name: "Daniel Brooks", - role: "VP Sales", - title: "Vice President of Sales", - department: "sales", - capabilities: "Revenue strategy and annual quota planning, sales-team hiring and enablement, pipeline inspection and forecasting (Salesforce, Clari), deal-desk governance, discount-approval authority, territory and account-assignment strategy, compensation-plan design, sales-engineering coordination, quarterly business reviews, competitive win/loss analysis, partner-channel strategy. Owns the number and ensures every rep has the tools and training to hit quota.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Grace Kimura", - role: "Enterprise Account Executive", - title: "Enterprise Account Executive", - department: "sales", - capabilities: "Enterprise sales cycle management (6-18 months), executive-level relationship building, multi-threaded deal navigation, RFP and security-questionnaire completion, value-engineering and ROI presentations, contract negotiation and red-lining, custom demo and POC coordination, champion-building within target accounts, CRM hygiene and pipeline stage accuracy, cross-sell and upsell within install base. Closes six- and seven-figure deals by understanding customer pain and articulating value.", - reportsToIndex: 21, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Marcus Adebayo", - role: "SDR Manager", - title: "Sales Development Representative Manager", - department: "sales", - capabilities: "Outbound prospecting strategy and sequence design, SDR coaching and call shadowing, lead-qualification frameworks (BANT, MEDDPICC), email-cadence optimization and A/B testing, cold-call scripting and objection handling, CRM data enrichment (ZoomInfo, Apollo), meeting-booked metrics and conversion tracking, SDR-to-AE handoff process, intent-signal monitoring, account-based marketing collaboration. Fills the top of the funnel with qualified opportunities.", - reportsToIndex: 21, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - - { - name: "Olivia Nguyen", - role: "VP People", - title: "Vice President of People", - department: "hr", - capabilities: "Talent strategy and workforce planning, employer-brand campaigns (Glassdoor, LinkedIn), DEI program design and metrics, leadership coaching and executive development, employee engagement survey design (Culture Amp), retention analysis and flight-risk modeling, performance-calibration facilitation, organizational restructuring advisory, exit-interview trend analysis. Shapes the company culture and ensures every team has the right people in the right seats.", - reportsToIndex: 4, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - { - name: "Farid Al-Rashidi", - role: "Recruiting Lead", - title: "Head of Talent Acquisition", - department: "hr", - capabilities: "Full-cycle recruiting (sourcing, screening, interviewing, closing), ATS management (Greenhouse, Lever), Boolean-search sourcing on LinkedIn and GitHub, hiring-committee facilitation, interview-rubric design and interviewer training, offer-letter preparation and compensation benchmarking (Pave, Levels.fyi), diversity-sourcing strategies, university-relations and intern-program management, recruiting-metric dashboards (time-to-fill, pass-through rates, source mix). Fills open roles with top-tier talent on schedule.", - reportsToIndex: 24, - connectorPreference: "claude_local", - budgetMonthlyUsd: 140, - }, - { - name: "Hannah Johansson", - role: "People Operations Manager", - title: "People Operations Manager", - department: "hr", - capabilities: "New-hire onboarding program design and execution, HRIS administration (Rippling, Workday), benefits enrollment and vendor management, payroll coordination, PTO and leave-policy management, employee-handbook maintenance, office and remote-work policy administration, compliance training scheduling (harassment, security), offboarding workflows, people-analytics reporting, employee-recognition programs. Keeps the people engine running smoothly from day one to departure.", - reportsToIndex: 24, - connectorPreference: "claude_local", - budgetMonthlyUsd: 120, - }, - - { - name: "Richard Okonkwo", - role: "VP Finance", - title: "Vice President of Finance", - department: "finance", - capabilities: "Financial planning and analysis (3-statement modeling, scenario planning), annual and quarterly budgeting, department-budget review and approval, revenue forecasting and cohort analysis, investor-relations materials (board decks, data rooms), fundraising financial due diligence, KPI dashboards (ARR, MRR, NRR, gross margin), cash-runway monitoring, tax-strategy coordination with external counsel, audit committee reporting. Ensures every financial decision is data-driven and defensible.", - reportsToIndex: 3, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Mei-Ling Wu", - role: "Financial Controller", - title: "Financial Controller", - department: "finance", - capabilities: "Accounts payable and receivable management, general-ledger maintenance, month-end and quarter-end close processes, revenue-recognition (ASC 606) compliance, expense-report auditing and policy enforcement, bank-reconciliation, vendor payment processing, sales-tax calculation and filing, intercompany transfers, external-audit support and evidence preparation, budget-vs-actual variance analysis. The operational backbone of the finance department.", - reportsToIndex: 27, - connectorPreference: "codex_local", - budgetMonthlyUsd: 140, - }, - { - name: "Alejandro Vega", - role: "General Counsel", - title: "General Counsel", - department: "legal", - capabilities: "Corporate governance and board-resolution drafting, SaaS contract negotiation (MSA, DPA, SLA, BAA), intellectual-property strategy (patents, trademarks, trade secrets), employment-law advisory (offer letters, separation agreements, non-competes), data-privacy program (GDPR, CCPA, SOC2 policy), regulatory-compliance monitoring, litigation management and outside-counsel coordination, M&A legal due-diligence, open-source license review, terms-of-service and privacy-policy drafting. The legal safeguard for every business decision.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - - { - name: "Aisha Williams", - role: "VP Customer Success", - title: "Vice President of Customer Success", - department: "customer_support", - capabilities: "Customer-retention strategy, net-promoter-score (NPS) and CSAT program management, customer-health scoring model design, churn-risk identification and intervention playbooks, QBR (quarterly business review) facilitation with key accounts, support-tier escalation policy, customer-advisory-board coordination, voice-of-customer reporting to product, expansion-revenue targets, customer-journey mapping, onboarding-program design. Ensures customers achieve value and renew.", - reportsToIndex: 1, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Sergei Volkov", - role: "Senior Support Engineer", - title: "Senior Technical Support Engineer", - department: "customer_support", - capabilities: "Tier-2 and Tier-3 technical troubleshooting, bug reproduction and detailed bug-report filing to engineering, log analysis and root-cause investigation, API-integration debugging with customers, knowledge-base article authorship, internal runbook creation, on-call support rotation, SLA adherence tracking, customer-communication during incidents, Zendesk/Intercom workflow management. The bridge between customers experiencing issues and engineering teams who fix them.", - reportsToIndex: 30, - connectorPreference: "codex_local", - budgetMonthlyUsd: 160, - }, - { - name: "Camila Reyes", - role: "Customer Success Manager", - title: "Customer Success Manager", - department: "customer_support", - capabilities: "Post-sale customer onboarding and implementation, product-adoption tracking and usage analytics, proactive check-in cadence with assigned accounts, renewal forecasting and risk flagging, expansion and upsell opportunity identification, customer-training webinars and enablement materials, internal champion cultivation, feature-request aggregation for product, reference-customer development, success-plan creation. The customer's advocate inside the company.", - reportsToIndex: 30, - connectorPreference: "claude_local", - budgetMonthlyUsd: 140, - }, - - { - name: "Kenji Watanabe", - role: "Director of Operations", - title: "Director of Operations", - department: "operations", - capabilities: "Business-process mapping and optimization (Lean, Six Sigma), vendor procurement and contract negotiation, SaaS-tool stack rationalization, internal tooling roadmap, office and facilities management, company-wide policy documentation, business-continuity and disaster-recovery planning, insurance and risk-management coordination, operational-budget management, cross-functional process audits. Removes friction from every team so they can focus on their core work.", - reportsToIndex: 1, - connectorPreference: "claude_local", - budgetMonthlyUsd: 160, - }, - { - name: "Laura Petrov", - role: "Program Manager", - title: "Senior Program Manager", - department: "operations", - capabilities: "Cross-functional initiative coordination, OKR tracking and quarterly scoring, executive status reporting, risk-register maintenance and mitigation planning, dependency mapping across teams, Gantt and timeline management, steering-committee facilitation, post-mortem and retrospective orchestration, resource-conflict resolution, change-management communication. Ensures multi-team programs land on time and scope.", - reportsToIndex: 33, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - - { - name: "Dr. Lena Hoffmann", - role: "Director of Research", - title: "Director of Research & Innovation", - department: "research", - capabilities: "Research-agenda setting and quarterly planning, grant and funding proposals, academic-partnership management, publication strategy (conference papers, journal submissions), patent-filing coordination with legal, competitive-intelligence analysis, emerging-technology evaluation, internal tech-talk and brown-bag facilitation, research-to-product technology transfer, ethical-AI review board participation. Steers the research program and ensures insights reach production.", - reportsToIndex: 2, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Dr. Aarav Mehta", - role: "Research Scientist", - title: "Research Scientist", - department: "research", - capabilities: "Hypothesis formulation and experiment design, statistical analysis (frequentist and Bayesian), prototype development in Python/PyTorch, benchmark dataset curation, literature survey and related-work summarization, paper writing (LaTeX, Overleaf), internal technical-report authorship, reproducibility packaging (Docker, Weights & Biases), ML-model evaluation and ablation studies, presenting findings to engineering and product. Generates new knowledge and translates it into practical improvements.", - reportsToIndex: 35, - connectorPreference: "codex_local", - budgetMonthlyUsd: 200, - }, - ], - goals: [ - { - title: "Launch the flagship product to market", - description: "Coordinate across Engineering, Product, Design, Marketing, Sales, and Customer Success to design, build, test, market, sell, and support the initial product. Engineering builds it, Product defines it, Design makes it intuitive, Marketing drives awareness, Sales converts pipeline, and Customer Success ensures retention.", - }, - { - title: "Establish company-wide operational excellence", - description: "COO and Director of Operations lead the effort to implement OKR cadence, sprint ceremonies, cross-department communication protocols, budget processes, and performance review cycles. Every department should have documented workflows and measurable KPIs within the first quarter.", - }, - { - title: "Build and scale a world-class team", - description: "CHRO and VP People collaborate with every department head to define role descriptions, hiring rubrics, onboarding programs, performance calibration, and career ladders. Identify capability gaps early, hire to fill them, and maintain an eNPS above 50.", - }, - { - title: "Achieve financial sustainability and growth", - description: "CFO and VP Finance work with Sales and Marketing to model revenue projections, control burn rate, optimize marketing spend, and drive toward profitability. Monthly financial reviews, budget-vs-actual tracking, and board-ready reporting are mandatory.", - }, - { - title: "Create a category-defining brand and market presence", - description: "CMO leads content strategy, paid acquisition, PR, and brand development. Coordinate with Product for launch messaging and Sales for competitive battlecards. Track brand awareness, share of voice, and marketing-sourced pipeline as primary KPIs.", - }, - { - title: "Deliver enterprise-grade security and compliance", - description: "Security Engineer and General Counsel collaborate to achieve SOC2 Type II certification, implement data-privacy controls (GDPR, CCPA), harden application security, and establish incident-response playbooks. Every product release must pass security review.", - }, - { - title: "Build a data-driven decision culture", - description: "Data Engineer and Research team build analytics infrastructure, dashboards, and experimentation frameworks so every department makes decisions based on evidence rather than intuition. Establish a single source of truth for company metrics.", - }, - { - title: "Drive customer success and net revenue retention", - description: "VP Customer Success ensures customers achieve value, renew, and expand. Implement health scoring, proactive support, QBRs, and onboarding programs. Target net revenue retention above 120% and NPS above 60.", - }, - { - title: "Advance R&D and maintain competitive moat", - description: "Director of Research and Research Scientist drive applied research, publish findings, and transfer innovations to the product. Evaluate emerging technologies, file patents where appropriate, and maintain a 6-month technical lead over competitors.", - }, - { - title: "Scale cross-functional program delivery", - description: "Program Manager and Operations coordinate multi-team initiatives, manage dependencies, track OKRs, and run steering committees. Ensure that company-wide programs (platform migrations, new-market entry, compliance certifications) land on time with clear accountability.", - }, - ], - }, - - { - id: "ecommerce-company", - name: "E-Commerce Business", - description: "A complete e-commerce operation with product, engineering, marketing, and fulfillment. Optimized for building and running an online store.", - icon: "ShoppingCart", - agents: [ - { - name: "Rebecca Sterling", - role: "CEO", - title: "Chief Executive Officer", - department: "executive", - capabilities: "E-commerce strategy, marketplace positioning, vendor relations, pricing strategy, team leadership, P&L management. Drives the overall business direction.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Tom Chen", - role: "CTO", - title: "Chief Technology Officer", - department: "engineering", - capabilities: "Platform architecture, payment integration, inventory systems, search/recommendation engines, performance optimization, security compliance (PCI-DSS).", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 220, - }, - { - name: "Nina Patel", - role: "Full-Stack Developer", - title: "Senior Full-Stack Developer", - department: "engineering", - capabilities: "Storefront development (React/Next.js), cart/checkout flow, payment gateway integration, order management API, product catalog, responsive design, SEO markup.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Carlos Mendez", - role: "Product Manager", - title: "Senior Product Manager", - department: "product", - capabilities: "Product catalog strategy, conversion optimization, user journey mapping, A/B testing, feature prioritization, competitive analysis, pricing experiments.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Sophie Liu", - role: "Marketing Manager", - title: "Digital Marketing Manager", - department: "marketing", - capabilities: "SEO/SEM, email marketing campaigns, social commerce, influencer partnerships, retargeting, conversion funnel optimization, promotional calendar, loyalty programs.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "David Osei", - role: "Operations Manager", - title: "Operations & Fulfillment Manager", - department: "operations", - capabilities: "Inventory management, order fulfillment, shipping logistics, supplier coordination, warehouse operations, returns processing, demand forecasting.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 120, - }, - { - name: "Amy Zhao", - role: "Customer Support", - title: "Customer Experience Manager", - department: "customer_support", - capabilities: "Order support, returns/refunds, product inquiries, review management, complaint resolution, FAQ/help center maintenance, live chat support, customer satisfaction tracking.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 100, - }, - ], - goals: [ - { - title: "Build and launch the online store", - description: "Design and implement a full e-commerce platform with product catalog, cart, checkout, payment processing, and order management. Mobile-responsive with fast load times.", - }, - { - title: "Drive traffic and conversions", - description: "Execute marketing strategy across SEO, email, social, and paid channels. Optimize the conversion funnel. Target specific revenue and customer acquisition goals.", - }, - { - title: "Establish operational excellence", - description: "Set up inventory management, fulfillment workflows, shipping integrations, and customer support processes. Achieve sub-24h response times for support.", - }, - ], - }, - - { - id: "consulting-firm", - name: "Consulting Firm", - description: "A professional services firm with strategy, technical, and delivery capabilities. Built for client engagements, proposals, and project delivery.", - icon: "Briefcase", - agents: [ - { - name: "Michael Grant", - role: "Managing Director", - title: "Managing Director", - department: "executive", - capabilities: "Client relationship management, engagement scoping, proposal writing, resource allocation, quality assurance, strategic advisory, thought leadership, revenue management.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Sarah Kim", - role: "Strategy Consultant", - title: "Senior Strategy Consultant", - department: "operations", - capabilities: "Market analysis, competitive strategy, business model design, financial modeling, due diligence, stakeholder interviews, presentation design, executive communication.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Alex Rivera", - role: "Technical Consultant", - title: "Principal Technical Consultant", - department: "engineering", - capabilities: "Architecture assessment, technology evaluation, implementation planning, code review, cloud migration strategy, security audit, performance optimization, technical documentation.", - reportsToIndex: 0, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Emily Chen", - role: "Delivery Lead", - title: "Delivery Manager", - department: "operations", - capabilities: "Project planning, client communication, risk management, timeline tracking, team coordination, status reporting, scope management, quality gates, retrospectives.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Jordan Taylor", - role: "Research Analyst", - title: "Research Analyst", - department: "research", - capabilities: "Industry research, data analysis, competitive benchmarking, market sizing, report writing, visualization, survey design, trend analysis. Supports consultants with data-driven insights.", - reportsToIndex: 0, - connectorPreference: "codex_local", - budgetMonthlyUsd: 130, - }, - ], - goals: [ - { - title: "Win and deliver client engagement", - description: "Develop a compelling proposal, scope the engagement, plan delivery, execute work streams, and deliver high-quality recommendations with measurable impact.", - }, - { - title: "Build firm knowledge base", - description: "Document methodologies, case studies, frameworks, and reusable deliverable templates. Create a knowledge management system that makes the firm smarter over time.", - }, - ], - }, - - { - id: "ai-research-company", - name: "AI Research Company", - description: "An AI/ML research company with 18 agents across Research, Engineering, Product, Safety, and Business functions. Modeled after frontier AI labs like OpenAI, Anthropic, and DeepMind — built to discover, build, and responsibly deploy AI systems.", - icon: "Brain", - departments: [ - { id: "executive", name: "Executive Leadership", description: "Company strategy, fundraising, research direction, and public representation of the organization." }, - { id: "research", name: "Research", description: "Fundamental and applied AI research, model architecture, training methodologies, and evaluation." }, - { id: "engineering", name: "Engineering & Infrastructure", description: "ML platform, training infrastructure, model serving, API development, and reliability." }, - { id: "product", name: "Product", description: "Product strategy, user experience, API design, and go-to-market for model offerings." }, - { id: "design", name: "Design", description: "Interface design, developer experience, and documentation for AI products and APIs." }, - { id: "operations", name: "Safety & Operations", description: "AI safety, alignment research, red-teaming, policy, and responsible-deployment processes." }, - { id: "marketing", name: "Marketing & Communications", description: "Research communications, developer relations, brand, and public affairs." }, - { id: "hr", name: "People", description: "Talent acquisition for research and engineering, organizational development, and culture." }, - { id: "finance", name: "Finance", description: "GPU-budget management, fundraising, financial planning, and compute-cost optimization." }, - { id: "legal", name: "Legal & Policy", description: "IP strategy, regulatory affairs, AI governance, data licensing, and compliance." }, - ], - agents: [ - { - name: "Dr. Julian Crane", - role: "CEO", - title: "Chief Executive Officer", - department: "executive", - capabilities: "Company vision and AGI-timeline strategy, investor relations and fundraising (Series B+), board governance, public representation and congressional testimony, research-direction approval, partnership strategy with cloud providers and enterprises, organizational design for rapid scaling, crisis management. Sets the north star for the lab and ensures alignment between research ambitions and business sustainability.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 250, - }, - { - name: "Dr. Mei-Xing Zhou", - role: "Chief Scientist", - title: "Chief Scientist", - department: "research", - capabilities: "Research agenda and multi-year roadmap, model-architecture decisions, compute-allocation prioritization, publication strategy (NeurIPS, ICML, ICLR), research-team hiring bar, external academic collaborations, technical advisory to CEO and board, safety/capabilities balance arbitration. The intellectual leader of the lab who decides what problems are worth solving next.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 280, - }, - { - name: "Dr. Sasha Petrov", - role: "Research Lead — Pre-training", - title: "Senior Research Scientist — Pre-training", - department: "research", - capabilities: "Large-scale pre-training methodology, data-mixture optimization, scaling-law experiments, loss-curve analysis, tokenizer design, multi-modal pre-training (text, code, vision), distributed-training orchestration, compute-efficiency research (MoE, sparse attention), training-run monitoring and intervention, ablation studies, paper authorship. Owns the pre-training pipeline from data to checkpoint.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 260, - }, - { - name: "Dr. Nneka Adeyemi", - role: "Research Lead — Post-training", - title: "Senior Research Scientist — RLHF & Fine-tuning", - department: "research", - capabilities: "Reinforcement learning from human feedback (RLHF/DPO/KTO), instruction-tuning methodology, reward-model training, preference-data collection design, constitutional-AI approaches, chain-of-thought elicitation, evaluation-benchmark development, red-team-finding remediation, model-behavior analysis, paper authorship. Transforms base models into helpful, harmless, and honest assistants.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 260, - }, - { - name: "Dr. Hiroshi Takeda", - role: "Research Scientist — Reasoning", - title: "Research Scientist — Reasoning & Agents", - department: "research", - capabilities: "Reasoning-capability research (chain-of-thought, tree-of-thought, self-reflection), tool-use and function-calling development, agentic-loop design, code-generation evaluation, math and logic benchmarking, multi-step planning, retrieval-augmented generation (RAG), context-window optimization, paper authorship, internal tech talks. Pushes the frontier on what models can do autonomously.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 240, - }, - { - name: "Dr. Amara Osei", - role: "Research Scientist — Safety", - title: "Research Scientist — AI Safety", - department: "research", - capabilities: "Alignment research (interpretability, honesty, corrigibility), red-teaming and adversarial evaluation, jailbreak analysis and mitigation, bias and fairness auditing, model-behavior monitoring in production, responsible-disclosure processes, safety-case documentation, system-prompt hardening, content-policy enforcement research, collaboration with policy team. Ensures models are safe to deploy and that capabilities do not outpace safety.", - reportsToIndex: 1, - connectorPreference: "claude_local", - budgetMonthlyUsd: 240, - }, - { - name: "Lila Fernandez", - role: "Research Engineer", - title: "Senior Research Engineer", - department: "research", - capabilities: "Training-framework development (JAX, PyTorch), custom CUDA kernel optimization, distributed-training libraries (DeepSpeed, FSDP, Megatron), experiment-tracking infrastructure (W&B, MLflow), checkpoint management and model merging, data-pipeline engineering for training corpora, GPU-cluster scheduling, training-cost analysis, reproducibility tooling. Bridges research ideas and production-scale training.", - reportsToIndex: 1, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Rajiv Sundaram", - role: "VP Engineering", - title: "Vice President of Engineering", - department: "engineering", - capabilities: "Engineering organization management, API-platform strategy, inference-infrastructure roadmap, model-serving SLA definition, engineering hiring and team structure, on-call and incident management, cloud-cost optimization (GPU spend), vendor management (cloud providers, hardware), sprint cadence and delivery metrics, engineering all-hands. Ensures research outputs ship as reliable, scalable products.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 220, - }, - { - name: "Christine Bergstrom", - role: "ML Infrastructure Engineer", - title: "Senior ML Infrastructure Engineer", - department: "engineering", - capabilities: "Model-serving infrastructure (vLLM, TensorRT, Triton), GPU cluster management and scheduling (Slurm, Kubernetes), inference optimization (quantization, KV-cache, speculative decoding), autoscaling for variable demand, latency and throughput benchmarking, cost-per-token analysis, A/B model deployment, model-versioning and rollback, observability for inference (latency p50/p99, error rates), multi-region deployment. Keeps models running fast and cheap at scale.", - reportsToIndex: 7, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Andre Williams", - role: "Backend Engineer", - title: "Senior Backend Engineer", - department: "engineering", - capabilities: "API development (REST, streaming, WebSocket), rate limiting and usage metering, authentication (API keys, OAuth), billing integration (Stripe), database management (PostgreSQL, Redis), message queues for async inference, developer-SDK development (Python, TypeScript), playground and dashboard backend, webhook delivery, audit logging. Builds the API surface that developers interact with daily.", - reportsToIndex: 7, - connectorPreference: "codex_local", - budgetMonthlyUsd: 230, - }, - { - name: "Yuna Kim", - role: "Frontend Engineer", - title: "Senior Frontend Engineer", - department: "engineering", - capabilities: "React/Next.js application development, playground and chat UI implementation, real-time streaming interfaces, developer-documentation site (Mintlify, Nextra), API-reference auto-generation, usage-dashboard design, component library maintenance, accessibility, responsive design, Storybook. Builds the interfaces where developers and users experience the models.", - reportsToIndex: 7, - connectorPreference: "codex_local", - budgetMonthlyUsd: 220, - }, - { - name: "Tara Blackwell", - role: "VP Product", - title: "Vice President of Product", - department: "product", - capabilities: "API product strategy and packaging (tiers, pricing, rate limits), developer-experience roadmap, model-selection guidance and documentation, enterprise feature prioritization (SSO, fine-tuning, dedicated capacity), competitive analysis (pricing, features, benchmarks), launch coordination with research and marketing, customer-feedback synthesis, partnership integration strategy, product-led growth metrics. Decides what ships and how it is packaged for the market.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Omar Khalil", - role: "Product Manager — Developer Platform", - title: "Product Manager — Developer Platform", - department: "product", - capabilities: "Developer-experience (DX) optimization, SDK and API usability testing, documentation quality assurance, developer-community feedback aggregation, changelog and migration-guide authorship, beta-program management, API-versioning strategy, error-message improvements, usage-analytics analysis, competitive DX benchmarking. Ensures developers love building with the platform.", - reportsToIndex: 11, - connectorPreference: "claude_local", - budgetMonthlyUsd: 160, - }, - { - name: "Vera Lindqvist", - role: "Product Designer", - title: "Senior Product Designer", - department: "design", - capabilities: "Playground and chat-interface design, API-documentation layout and information architecture, design-system for developer tools, usability testing with developers, data-visualization for usage dashboards, onboarding-flow design, accessibility compliance, prototyping in Figma, interaction design for streaming UIs. Makes complex AI products feel simple and intuitive.", - reportsToIndex: 11, - connectorPreference: "claude_local", - budgetMonthlyUsd: 160, - }, - { - name: "Marcus Rhodes", - role: "Head of Marketing", - title: "Head of Marketing & Communications", - department: "marketing", - capabilities: "Research-announcement strategy and blog authorship, developer-marketing campaigns, social-media presence (Twitter/X, LinkedIn), conference speaking and sponsorship, developer-community building (Discord, GitHub), press and media coordination, brand-voice governance, competitive messaging, email marketing, analytics and attribution. Translates research breakthroughs into market excitement and developer adoption.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - { - name: "Priya Khatri", - role: "Developer Advocate", - title: "Senior Developer Advocate", - department: "marketing", - capabilities: "Technical blog-post and tutorial authorship, sample-application development, conference talks and workshop facilitation, developer-community management (Discord, forums), cookbook and quickstart-guide creation, customer-use-case storytelling, YouTube and livestream content, GitHub-issue triage from community, feedback pipeline to product, hackathon organization. The face of the platform in the developer community.", - reportsToIndex: 14, - connectorPreference: "codex_local", - budgetMonthlyUsd: 170, - }, - { - name: "Danielle Moreau", - role: "Head of People", - title: "Head of People", - department: "hr", - capabilities: "Research-talent acquisition strategy (top ML PhDs, top-tier engineers), compensation benchmarking against frontier labs, equity-grant management, onboarding programs for researchers and engineers, culture-building in a research-first organization, performance reviews, retention analysis, DEI initiatives, immigration and visa sponsorship coordination, team-health surveys. Ensures the lab attracts and retains the best minds in AI.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Gabriel Souza", - role: "Head of Legal & Policy", - title: "Head of Legal & Policy", - department: "legal", - capabilities: "AI-regulation monitoring and compliance (EU AI Act, US executive orders), data-licensing and training-data legal review, intellectual-property strategy (model weights, API terms), customer-contract negotiation (enterprise, government), privacy-policy and acceptable-use-policy drafting, open-source licensing decisions, government-affairs and congressional engagement, responsible-disclosure framework, export-control compliance. The legal shield for a company operating on the frontier of AI.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - ], - goals: [ - { - title: "Train and ship the next frontier model", - description: "Pre-training and post-training research teams collaborate with infrastructure engineering to train, evaluate, red-team, and deploy the next generation model. Target state-of-the-art benchmarks while maintaining safety.", - }, - { - title: "Build a world-class developer platform", - description: "Product, Engineering, and Design collaborate to deliver a best-in-class API, playground, SDKs, and documentation. Measure developer satisfaction, time-to-first-token, and platform reliability (99.9% uptime).", - }, - { - title: "Advance AI safety and alignment", - description: "Safety research scientist leads red-teaming, alignment research, and responsible-deployment processes. Every model release must pass safety evaluation. Publish safety research openly.", - }, - { - title: "Grow developer adoption and revenue", - description: "Marketing, DevRel, and Product drive developer awareness and adoption. Track MAU, API call volume, revenue growth, and developer NPS. Build a thriving open-source and community ecosystem.", - }, - { - title: "Recruit and retain top AI talent", - description: "Head of People builds a world-class talent pipeline for ML researchers, research engineers, and infrastructure engineers. Maintain competitive compensation, publish cutting-edge research, and foster an intellectually stimulating culture.", - }, - { - title: "Navigate the evolving AI regulatory landscape", - description: "Legal & Policy team monitors global AI regulation, ensures compliance, manages data-licensing risks, and engages constructively with policymakers. Proactively build trust through transparency.", - }, - ], - }, - - { - id: "digital-agency", - name: "Full-Service Digital Agency", - description: "A 14-agent digital agency with Creative, Development, Strategy, and Account Management teams. Built for delivering end-to-end client projects: branding, web development, digital marketing, and ongoing optimization.", - icon: "PaintBrush", - departments: [ - { id: "executive", name: "Leadership", description: "Agency leadership, business development, and client-portfolio management." }, - { id: "design", name: "Creative & Design", description: "Brand identity, visual design, UX, motion graphics, and creative direction." }, - { id: "engineering", name: "Development", description: "Full-stack web development, CMS integration, e-commerce builds, and DevOps." }, - { id: "marketing", name: "Digital Marketing", description: "SEO, paid media, social media, email marketing, and analytics." }, - { id: "product", name: "Strategy & Content", description: "Client strategy, content production, copywriting, and project management." }, - { id: "operations", name: "Operations", description: "Resource planning, process management, and quality assurance." }, - ], - agents: [ - { - name: "Isabelle Fontaine", - role: "Managing Director", - title: "Managing Director", - department: "executive", - capabilities: "Agency P&L management, new-business pitches and proposals, client-portfolio oversight, agency positioning and brand, team hiring and culture, partnership development, industry networking, pricing strategy, executive client relationships. Runs the agency and wins new business.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Victor Nascimento", - role: "Account Director", - title: "Account Director", - department: "executive", - capabilities: "Client-relationship management across all active accounts, scope and SOW negotiation, project-budget tracking, client status-meeting facilitation, cross-team coordination for deliverables, change-request management, client-satisfaction surveys, renewal and upsell conversations, conflict resolution. The primary client contact and internal advocate for client needs.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - { - name: "Saoirse O'Brien", - role: "Creative Director", - title: "Creative Director", - department: "design", - capabilities: "Creative-concept development, brand-identity systems (logo, typography, color, voice), campaign creative direction, pitch-deck visual storytelling, team creative-review and feedback, trend forecasting, client-presentation of creative work, quality-bar enforcement across all visual output. Sets the creative vision and ensures every deliverable is on brand.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Mateo Cruz", - role: "UX/UI Designer", - title: "Senior UX/UI Designer", - department: "design", - capabilities: "User-research interviews and synthesis, wireframing and information architecture, UI design in Figma, responsive-design specifications, design-system creation, prototype-testing facilitation, accessibility review, developer-handoff documentation (Zeplin, Figma Dev Mode), animation and micro-interaction design. Turns research into beautiful, usable interfaces.", - reportsToIndex: 2, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - { - name: "Yuki Aoyama", - role: "Motion & Visual Designer", - title: "Motion & Visual Designer", - department: "design", - capabilities: "Motion-graphics production (After Effects, Lottie), social-media visual assets, presentation design, infographic creation, video editing and post-production, brand-collateral design (business cards, decks, one-pagers), illustration, photo editing, asset-library management. Produces scroll-stopping visual content across all formats.", - reportsToIndex: 2, - connectorPreference: "claude_local", - budgetMonthlyUsd: 140, - }, - { - name: "Nadia Kowalczyk", - role: "Technical Director", - title: "Technical Director", - department: "engineering", - capabilities: "Technical architecture for client projects, technology-stack selection, development-estimate creation, code-review and quality standards, CMS platform evaluation (WordPress, Sanity, Contentful), hosting and deployment strategy, performance optimization, client technical consulting, developer mentoring. The technical decision-maker for every build.", - reportsToIndex: 0, - connectorPreference: "codex_local", - budgetMonthlyUsd: 220, - }, - { - name: "Kwame Asante", - role: "Full-Stack Developer", - title: "Senior Full-Stack Developer", - department: "engineering", - capabilities: "React/Next.js and Vue development, headless-CMS integration, e-commerce platform builds (Shopify, WooCommerce), custom API development, database design, third-party integration (payment gateways, CRM, analytics), responsive implementation, SEO technical markup, performance optimization, automated testing. Builds production-ready websites and web applications.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 230, - }, - { - name: "Elina Koskinen", - role: "Frontend Developer", - title: "Frontend Developer", - department: "engineering", - capabilities: "HTML/CSS/JavaScript implementation, CSS animation and transitions, Tailwind and SCSS, design-to-code translation, cross-browser and cross-device testing, WordPress theme customization, page-speed optimization, Google Tag Manager setup, analytics snippet integration, A/B test implementation. Turns design comps into pixel-perfect, performant web pages.", - reportsToIndex: 5, - connectorPreference: "codex_local", - budgetMonthlyUsd: 180, - }, - { - name: "Tariq Benali", - role: "Head of Digital", - title: "Head of Digital Marketing", - department: "marketing", - capabilities: "Integrated digital-marketing strategy, channel-budget allocation, campaign planning and performance review, marketing-tech stack management (HubSpot, Google Analytics 4, Hotjar), client reporting and insights, team mentoring, pitch support for new-business proposals. Orchestrates all digital-marketing efforts for client accounts.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Jessica Torres", - role: "SEO & Content Specialist", - title: "SEO & Content Specialist", - department: "marketing", - capabilities: "Technical SEO audits, keyword research and clustering (Ahrefs, Semrush), on-page optimization, content-brief creation, link-building strategy, local SEO, Google Search Console monitoring, schema markup implementation, content-performance reporting, competitor SEO analysis. Drives organic traffic and search visibility for clients.", - reportsToIndex: 8, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Ryan Gallagher", - role: "Paid Media Specialist", - title: "Paid Media Specialist", - department: "marketing", - capabilities: "Google Ads (Search, Display, Shopping, Performance Max), Meta Ads, LinkedIn Ads, programmatic buying, bid-strategy optimization, audience segmentation and retargeting, conversion tracking and attribution, ad-creative testing, budget pacing, monthly performance reporting. Manages paid campaigns to hit CPA and ROAS targets.", - reportsToIndex: 8, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - { - name: "Ananya Chakraborty", - role: "Strategy Lead", - title: "Digital Strategy Lead", - department: "product", - capabilities: "Discovery workshops and stakeholder interviews, competitive-landscape analysis, customer-persona development, user-journey mapping, content strategy and editorial calendars, conversion-funnel design, analytics setup and KPI definition, quarterly strategy reviews, pitch-deck strategy sections. Defines the strategic foundation that all creative and development work builds on.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Liam O'Sullivan", - role: "Copywriter", - title: "Senior Copywriter", - department: "product", - capabilities: "Website copywriting (headlines, body, CTAs), brand-voice development, tagline and naming workshops, blog-post and article authorship, email-campaign copy, social-media caption writing, ad-copy variations for A/B testing, SEO-informed copywriting, editing and proofreading, tone-of-voice guidelines. Crafts words that convert, inform, and inspire.", - reportsToIndex: 11, - connectorPreference: "claude_local", - budgetMonthlyUsd: 160, - }, - { - name: "Sandra Muller", - role: "Project Manager", - title: "Senior Project Manager", - department: "operations", - capabilities: "Project planning and timeline creation, resource allocation across concurrent client projects, Asana/Monday/ClickUp workflow management, sprint facilitation, budget tracking and profitability analysis, risk identification and mitigation, client status reports, internal retrospectives, QA process coordination, scope-change documentation. Keeps every project on time, on budget, and on scope.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 150, - }, - ], - goals: [ - { - title: "Win and deliver flagship client projects", - description: "Pitch, scope, and deliver end-to-end digital projects (brand identity, website, marketing campaigns) for key clients. Measure success by client satisfaction, on-time delivery, and project profitability.", - }, - { - title: "Build repeatable creative and development processes", - description: "Establish standardized workflows for discovery, design, development, and launch. Create reusable templates, component libraries, and process documentation that improve quality and reduce delivery time.", - }, - { - title: "Grow retainer revenue through digital marketing results", - description: "Drive measurable marketing results (traffic, leads, revenue) for clients to convert project work into ongoing retainers. Track and report on SEO, paid media, and social KPIs monthly.", - }, - { - title: "Establish the agency as a thought leader", - description: "Publish case studies, blog posts, and social content showcasing the agency's work and expertise. Win industry awards, speak at conferences, and build a recognizable agency brand.", - }, - ], - }, - - { - id: "saas-company", - name: "B2B SaaS Company", - description: "A 20-agent B2B SaaS company with Engineering, Product, Sales, Marketing, Customer Success, and Finance teams. Built for the full SaaS lifecycle: build the product, acquire customers, drive adoption, and maximize net revenue retention.", - icon: "CloudArrowUp", - departments: [ - { id: "executive", name: "Executive Team", description: "Company strategy, fundraising, and cross-functional leadership." }, - { id: "engineering", name: "Engineering", description: "Product development, infrastructure, quality assurance, and data engineering." }, - { id: "product", name: "Product", description: "Product strategy, roadmap, feature specification, and analytics." }, - { id: "design", name: "Design", description: "User experience research, UI design, and design-system management." }, - { id: "marketing", name: "Marketing", description: "Demand generation, content, product marketing, and developer relations." }, - { id: "sales", name: "Sales", description: "Pipeline generation, enterprise sales, and partnerships." }, - { id: "customer_support", name: "Customer Success & Support", description: "Customer onboarding, retention, technical support, and expansion." }, - { id: "finance", name: "Finance", description: "SaaS metrics, billing operations, financial planning, and investor relations." }, - { id: "hr", name: "People", description: "Recruiting, onboarding, culture, and people operations." }, - ], - agents: [ - { - name: "Nathan Cross", - role: "CEO", - title: "Chief Executive Officer", - department: "executive", - capabilities: "Company vision and go-to-market strategy, fundraising and investor relations, board management, executive hiring, pricing and packaging decisions, strategic partnerships, company culture, crisis management. The ultimate owner of the company's trajectory from seed to scale.", - reportsToIndex: null, - connectorPreference: "claude_local", - budgetMonthlyUsd: 230, - }, - { - name: "Samira Rostami", - role: "CTO", - title: "Chief Technology Officer", - department: "executive", - capabilities: "Technical vision and platform architecture, build-vs-buy decisions, engineering hiring bar, security posture, infrastructure cost management, technology radar, API strategy, integration ecosystem design. Owns the technical foundation that the entire business runs on.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 240, - }, - { - name: "Derek Johansson", - role: "VP Engineering", - title: "Vice President of Engineering", - department: "engineering", - capabilities: "Engineering management across product and platform teams, sprint planning and delivery metrics, technical-debt prioritization, code-review standards, incident response, engineering hiring pipeline, on-call scheduling, engineering budget management, architectural-review facilitation. Ensures engineering ships reliably every sprint.", - reportsToIndex: 1, - connectorPreference: "claude_local", - budgetMonthlyUsd: 210, - }, - { - name: "Priya Venkatesh", - role: "Senior Backend Engineer", - title: "Senior Backend Engineer", - department: "engineering", - capabilities: "Multi-tenant SaaS backend development, REST and GraphQL API design, PostgreSQL and Redis, background-job processing (Sidekiq, BullMQ), webhook delivery systems, RBAC and team-management features, billing integration (Stripe), audit-logging, rate limiting, database migration management, comprehensive testing. Builds the core SaaS platform backend.", - reportsToIndex: 2, - connectorPreference: "codex_local", - budgetMonthlyUsd: 250, - }, - { - name: "Luis Mendoza", - role: "Senior Frontend Engineer", - title: "Senior Frontend Engineer", - department: "engineering", - capabilities: "React/Next.js SaaS application development, TypeScript, state management (Zustand, React Query), data-table and dashboard components, real-time collaboration features, onboarding flow implementation, settings and admin panels, design-system consumption, accessibility (WCAG 2.2), Playwright E2E testing, performance optimization. Builds the product interface customers use every day.", - reportsToIndex: 2, - connectorPreference: "codex_local", - budgetMonthlyUsd: 240, - }, - { - name: "Anika Bergman", - role: "Platform Engineer", - title: "Senior Platform Engineer", - department: "engineering", - capabilities: "CI/CD pipeline (GitHub Actions), Infrastructure-as-Code (Terraform), Kubernetes cluster management, observability (Datadog, Sentry), secrets management, zero-downtime deployment, auto-scaling configuration, database backup and disaster recovery, SOC2 infrastructure controls, cost-per-customer analysis. Keeps the SaaS platform available, secure, and cost-efficient.", - reportsToIndex: 2, - connectorPreference: "codex_local", - budgetMonthlyUsd: 230, - }, - { - name: "Tomoko Sato", - role: "QA Engineer", - title: "Quality Assurance Engineer", - department: "engineering", - capabilities: "Test strategy for SaaS (multi-tenant edge cases, billing scenarios, RBAC permutations), test-automation framework (Playwright, Jest), CI quality gates, regression-test suites, performance load testing, accessibility testing, release-readiness checklists, bug-triage, cross-browser testing matrix. Quality gatekeeper for every release.", - reportsToIndex: 2, - connectorPreference: "codex_local", - budgetMonthlyUsd: 180, - }, - { - name: "Rachel Abrams", - role: "VP Product", - title: "Vice President of Product", - department: "product", - capabilities: "Product vision and quarterly roadmap, pricing and packaging strategy, competitive analysis and feature benchmarking, PRD authorship, customer-interview synthesis, product analytics (Amplitude, Mixpanel), A/B test design, launch-readiness reviews, product-led growth strategy (free trial, onboarding, activation), integration-ecosystem prioritization. Decides what the product becomes next.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Jake Morrison", - role: "Product Manager", - title: "Product Manager", - department: "product", - capabilities: "Feature specification and user-story writing, sprint backlog grooming, customer-feedback triage (Productboard), data analysis for feature decisions, A/B test configuration, release-notes and changelog, beta-program management, competitive feature mapping, integration-partner coordination, go-to-market alignment with marketing. Turns user needs into shipped features.", - reportsToIndex: 7, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - { - name: "Chloe Laurent", - role: "Product Designer", - title: "Senior Product Designer", - department: "design", - capabilities: "User research (interviews, surveys, analytics review), wireframing and prototyping in Figma, UI design and design-system stewardship, usability testing, information architecture, onboarding-flow optimization, dashboard and data-visualization design, accessibility compliance, developer-handoff documentation. Ensures the product is intuitive and delightful to use.", - reportsToIndex: 7, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - { - name: "Ava Richardson", - role: "VP Marketing", - title: "Vice President of Marketing", - department: "marketing", - capabilities: "Integrated marketing strategy, demand-generation funnel design, marketing-budget allocation, brand positioning and messaging, marketing-ops tooling (HubSpot, Segment), campaign ROI analysis, product-marketing launch plans, competitive battlecard creation, analyst-relations. Drives pipeline and brand for the SaaS product.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 190, - }, - { - name: "Benjamin Hart", - role: "Content Marketing Manager", - title: "Content Marketing Manager", - department: "marketing", - capabilities: "Blog strategy and authorship, SEO keyword research and optimization, e-book and white-paper production, case-study writing, email-newsletter management, webinar content, social-media content calendar, thought-leadership ghostwriting, content-performance analytics. Produces content that drives organic traffic and educates buyers.", - reportsToIndex: 10, - connectorPreference: "claude_local", - budgetMonthlyUsd: 160, - }, - { - name: "Daria Volkov", - role: "Growth & Demand Gen Manager", - title: "Growth & Demand Generation Manager", - department: "marketing", - capabilities: "Paid-acquisition campaigns (Google, LinkedIn, Meta), marketing-automation workflows (lead scoring, nurture sequences), landing-page optimization, webinar and event marketing, referral-program management, product-led growth experiments (free-trial optimization, in-app messaging), attribution modeling, MQL-to-SQL conversion tracking. Fills the pipeline with qualified leads.", - reportsToIndex: 10, - connectorPreference: "claude_local", - budgetMonthlyUsd: 160, - }, - { - name: "Carter Ellison", - role: "VP Sales", - title: "Vice President of Sales", - department: "sales", - capabilities: "Sales strategy and annual quota planning, sales-team hiring and enablement, pipeline inspection and forecasting, deal-desk governance, discount-approval authority, territory planning, compensation-plan design, sales-engineering coordination, quarterly business reviews, competitive win/loss analysis. Owns the revenue number.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 200, - }, - { - name: "Nina Johansson", - role: "Enterprise Account Executive", - title: "Enterprise Account Executive", - department: "sales", - capabilities: "Enterprise sales cycle management, multi-stakeholder deal navigation, custom demo and POC coordination, value-engineering and ROI presentations, security-questionnaire completion, contract negotiation, CRM pipeline accuracy, champion-building within accounts, cross-sell and upsell in install base, reference-customer development. Closes large deals by understanding buyer pain.", - reportsToIndex: 13, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Travis Chen", - role: "SDR Lead", - title: "Sales Development Representative Lead", - department: "sales", - capabilities: "Outbound-prospecting strategy and sequence design, cold-email and cold-call cadences, lead-qualification framework (BANT/MEDDPICC), CRM data enrichment (ZoomInfo, Apollo), meeting-booked metrics, SDR-to-AE handoff process, intent-signal monitoring, account-based outreach coordination, objection-handling scripts. Fills the top of the pipeline with qualified opportunities.", - reportsToIndex: 13, - connectorPreference: "claude_local", - budgetMonthlyUsd: 130, - }, - { - name: "Miriam Okafor", - role: "VP Customer Success", - title: "Vice President of Customer Success", - department: "customer_support", - capabilities: "Customer-retention strategy, health-score model design, churn-risk intervention, QBR facilitation, NPS and CSAT program management, onboarding-program design, expansion-revenue targets, voice-of-customer reporting to product, support-team management, customer-advisory-board coordination. Ensures customers adopt the product and renew.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 180, - }, - { - name: "Alex Petridis", - role: "Customer Success Manager", - title: "Customer Success Manager", - department: "customer_support", - capabilities: "Post-sale onboarding and implementation support, product-adoption tracking, proactive check-ins with assigned accounts, renewal forecasting, expansion and upsell identification, customer-training sessions, internal-champion cultivation, feature-request aggregation, success-plan creation, reference-customer development. The customer's partner inside the company.", - reportsToIndex: 16, - connectorPreference: "claude_local", - budgetMonthlyUsd: 140, - }, - { - name: "Rina Tanaka", - role: "Support Engineer", - title: "Technical Support Engineer", - department: "customer_support", - capabilities: "Tier-1 and Tier-2 technical support, bug reproduction and engineering escalation, knowledge-base article creation, Zendesk/Intercom workflow management, SLA-adherence tracking, API-integration troubleshooting, customer-facing incident communication, internal runbook creation, canned-response optimization. Resolves customer issues quickly and feeds patterns back to engineering.", - reportsToIndex: 16, - connectorPreference: "codex_local", - budgetMonthlyUsd: 140, - }, - { - name: "Henrik Larsen", - role: "Head of Finance", - title: "Head of Finance", - department: "finance", - capabilities: "SaaS-metrics tracking (ARR, MRR, NRR, churn, CAC, LTV), financial planning and modeling, billing-operations management (Stripe, invoicing), budget-vs-actual reporting, fundraising data-room preparation, revenue recognition (ASC 606), expense-policy enforcement, vendor-contract negotiation, board-financial reporting, cash-runway monitoring. The financial engine of the SaaS business.", - reportsToIndex: 0, - connectorPreference: "claude_local", - budgetMonthlyUsd: 170, - }, - ], - goals: [ - { - title: "Ship a product customers love", - description: "Engineering, Product, and Design collaborate to build, ship, and iterate on the core SaaS product. Measure success by product adoption, feature usage, and customer satisfaction (NPS > 50).", - }, - { - title: "Build a repeatable sales engine", - description: "Sales, Marketing, and Customer Success create a predictable pipeline-to-revenue machine. Track CAC payback period, MQL-to-SQL conversion, and sales-cycle length. Achieve predictable quarterly revenue growth.", - }, - { - title: "Maximize net revenue retention", - description: "Customer Success drives onboarding, adoption, and expansion. Target net revenue retention above 120% by reducing churn, increasing product adoption, and identifying upsell opportunities within the install base.", - }, - { - title: "Drive efficient demand generation", - description: "Marketing generates qualified pipeline through content, paid acquisition, and product-led growth. Track marketing-sourced pipeline, content engagement, and free-trial-to-paid conversion rates.", - }, - { - title: "Build scalable engineering infrastructure", - description: "Platform engineering delivers CI/CD, monitoring, auto-scaling, and SOC2 compliance. Target 99.95% uptime, sub-200ms API latency at p95, and deployment frequency of multiple times per day.", - }, - { - title: "Achieve efficient growth economics", - description: "Finance and leadership monitor unit economics (CAC:LTV > 3:1, CAC payback < 18 months, gross margin > 75%). Maintain financial discipline while investing aggressively in growth.", - }, - ], - }, -]; - -export function getTemplate(id: string): CompanyTemplate | undefined { - return COMPANY_TEMPLATES.find(t => t.id === id); -} diff --git a/src/main/updater-service.ts b/src/main/updater-service.ts new file mode 100644 index 00000000..b23c4c9c --- /dev/null +++ b/src/main/updater-service.ts @@ -0,0 +1,61 @@ +import electronUpdater from "electron-updater"; +import type { AppDatabase } from "./database"; + +const { autoUpdater } = electronUpdater; + +interface UpdaterServiceDependencies { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + notify: (title: string, body: string) => void; +} + +export function createUpdaterService({ + db, + logger, + notify, +}: UpdaterServiceDependencies) { + function attachListeners() { + autoUpdater.on("checking-for-update", () => { + logger.info("[updater] checking for updates"); + }); + autoUpdater.on("update-available", (info) => { + notify("Update available", `AgentCompany ${info.version} is available to download.`); + }); + autoUpdater.on("update-not-available", () => { + notify("No updates found", "AgentCompany is already on the latest configured version."); + }); + autoUpdater.on("error", (error) => { + logger.error("[updater] error", error); + notify("Update check failed", error instanceof Error ? error.message : String(error)); + }); + } + + async function checkForUpdates() { + const snapshot = db.listSnapshot(); + if (!snapshot.updatesEnabled || process.env.AGENTCOMPANY_DISABLE_UPDATES === "true") { + return false; + } + const updateUrl = process.env.AGENTCOMPANY_UPDATE_URL?.trim(); + if (!updateUrl || updateUrl.includes("example.invalid")) { + logger.info("[updater] Skipping update check because no real update URL is configured."); + return false; + } + try { + autoUpdater.logger = logger as any; + autoUpdater.autoDownload = false; + await autoUpdater.checkForUpdates(); + return true; + } catch (error) { + logger.error(error); + return false; + } + } + + return { + attachListeners, + checkForUpdates, + }; +} diff --git a/src/main/window-state.ts b/src/main/window-state.ts new file mode 100644 index 00000000..24d07f68 --- /dev/null +++ b/src/main/window-state.ts @@ -0,0 +1,82 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { BrowserWindow, Rectangle } from "electron"; + +interface StoredWindowState { + bounds: Rectangle; + maximized: boolean; +} + +const DEFAULT_BOUNDS: Rectangle = { + width: 1480, + height: 980, + x: 120, + y: 80, +}; + +function safeParseState(filePath: string): StoredWindowState | null { + try { + const raw = readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (!parsed.bounds || typeof parsed.bounds !== "object") return null; + const { width, height, x, y } = parsed.bounds as Partial; + if (![width, height].every((value) => typeof value === "number" && Number.isFinite(value))) { + return null; + } + return { + bounds: { + width: width!, + height: height!, + x: typeof x === "number" && Number.isFinite(x) ? x : DEFAULT_BOUNDS.x, + y: typeof y === "number" && Number.isFinite(y) ? y : DEFAULT_BOUNDS.y, + }, + maximized: parsed.maximized === true, + }; + } catch { + return null; + } +} + +function writeState(filePath: string, state: StoredWindowState) { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +} + +export function loadWindowState(filePath: string) { + return safeParseState(filePath) ?? { + bounds: DEFAULT_BOUNDS, + maximized: false, + }; +} + +export function attachWindowStatePersistence(win: BrowserWindow, filePath: string) { + let saveTimer: ReturnType | null = null; + + const persist = () => { + if (saveTimer) { + clearTimeout(saveTimer); + } + saveTimer = setTimeout(() => { + saveTimer = null; + writeState(filePath, { + bounds: win.getBounds(), + maximized: win.isMaximized(), + }); + }, 250); + }; + + win.on("resize", persist); + win.on("move", persist); + win.on("maximize", persist); + win.on("unmaximize", persist); + win.on("close", () => { + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = null; + } + writeState(filePath, { + bounds: win.getBounds(), + maximized: win.isMaximized(), + }); + }); +} diff --git a/src/main/worker-service.ts b/src/main/worker-service.ts new file mode 100644 index 00000000..33304f69 --- /dev/null +++ b/src/main/worker-service.ts @@ -0,0 +1,337 @@ +import { utilityProcess } from "electron"; +import { z } from "zod"; +import { getConnectorDefinition } from "./connectors"; +import type { AppDatabase } from "./database"; +import type { RunRecord } from "@shared/types"; + +const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]); +export const workerMessageSchema = z.union([ + z.object({ + type: z.literal("run-log"), + runId: z.string().uuid(), + stream: z.enum(["stdout", "stderr"]), + chunk: z.string(), + }), + z.object({ + type: z.literal("run-status"), + runId: z.string().uuid(), + status: runStatusSchema, + message: z.string(), + }), + z.object({ + type: z.literal("run-finished"), + runId: z.string().uuid(), + status: runStatusSchema, + summary: z.string().nullable(), + errorMessage: z.string().nullable(), + exitCode: z.number().int().nullable(), + signal: z.string().nullable(), + model: z.string().nullable(), + sessionParams: z.record(z.string(), z.unknown()).nullable(), + sessionDisplayId: z.string().nullable(), + costUsd: z.number().nullable(), + unattributedCost: z.boolean(), + }), +]); +type WorkerUiEvent = Extract, { type: "run-log" | "run-status" }>; + +interface WorkerServiceDependencies { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + workerPath: string; + isQuitting: () => boolean; + publishDomainChanged: () => void; + emitEvent: (event: WorkerUiEvent) => void; + wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; + dispatchRunToWorker: (params: { + runId: string; + companyId: string; + agent: ReturnType; + task: ReturnType; + connector: ReturnType; + workspace: ReturnType; + workspaceMode: "exclusive" | "shared" | "none"; + companyName: string; + companyDescription: string; + snapshot: ReturnType; + trigger: string; + }) => void; + processRunCompletion: (run: RunRecord, event: z.infer & { type: "run-finished" }) => void; + notify: (title: string, body: string) => void; +} + +export function createWorkerService({ + db, + logger, + workerPath, + isQuitting, + publishDomainChanged, + emitEvent, + wakeAgentIfPossible, + dispatchRunToWorker, + processRunCompletion, + notify, +}: WorkerServiceDependencies) { + let worker: ReturnType | null = null; + + function getWorker() { + return worker; + } + + function createWorker() { + worker = utilityProcess.fork(workerPath, [], { + stdio: "pipe", + serviceName: "AgentCompany Orchestrator", + }); + + worker.stdout?.on("data", (chunk) => logger.info(`[worker] ${String(chunk).trim()}`)); + worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`)); + + worker.on("exit", (code) => { + if (isQuitting()) return; + + logger.warn(`[worker] Worker exited with code ${code}, recovering...`); + + const snapshot = db.listSnapshot(); + const affectedAgentIds = new Set(); + const requeueCandidates: Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: string }> = []; + + for (const run of snapshot.runs) { + if (run.status === "running") { + db.finishRun({ + runId: run.id, + status: "interrupted", + summary: "Worker process crashed.", + errorMessage: "Worker process exited unexpectedly.", + exitCode: code, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: null, + unattributedCost: true, + }); + if (run.workspaceId) { + db.releaseWorkspaceLock(run.workspaceId, run.id); + } + affectedAgentIds.add(run.agentId); + + const chain = db.getChainOfCommand(run.agentId); + if (chain.length > 0) { + wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); + } + } else if (run.status === "queued") { + requeueCandidates.push({ + id: run.id, + taskId: run.taskId, + agentId: run.agentId, + companyId: run.companyId, + workspaceId: run.workspaceId, + connectorId: run.connectorId, + }); + } + } + + for (const agentId of affectedAgentIds) { + const pendingWakes = db.consumePendingWakes(agentId); + for (const wake of pendingWakes) { + wakeAgentIfPossible(agentId, wake.companyId, wake.trigger); + } + } + + publishDomainChanged(); + + createWorker(); + + if (requeueCandidates.length > 0 && worker) { + logger.info(`[worker] Re-enqueuing ${requeueCandidates.length} queued run(s) after worker restart.`); + const recoverySnapshot = db.listSnapshot(); + for (const candidate of requeueCandidates) { + try { + const task = db.getTask(candidate.taskId); + const agent = db.getAgent(candidate.agentId); + const connector = db.getConnector(agent.connectorId); + const workspaceResolution = { + taskWorkspace: db.getWorkspace(task.workspaceId), + agentWorkspace: db.getWorkspace(agent.workspaceId), + projectWorkspace: db.getProjectPrimaryWorkspace(task.projectId ?? null, candidate.companyId), + }; + const workspace = workspaceResolution.taskWorkspace + ?? workspaceResolution.agentWorkspace + ?? workspaceResolution.projectWorkspace + ?? db.getAnyCompanyWorkspace(candidate.companyId); + const workspaceMode = workspaceResolution.taskWorkspace || workspaceResolution.agentWorkspace || workspaceResolution.projectWorkspace + ? "exclusive" + : workspace + ? "shared" + : "none"; + const recoveryCompany = recoverySnapshot.companies.find((company) => company.id === candidate.companyId); + + dispatchRunToWorker({ + runId: candidate.id, + companyId: candidate.companyId, + agent, + task, + connector, + workspace, + workspaceMode, + companyName: recoveryCompany?.name ?? "Company", + companyDescription: recoveryCompany?.description ?? "", + snapshot: recoverySnapshot, + trigger: "recovery", + }); + } catch (error) { + logger.warn(`[worker] Failed to re-enqueue run ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`); + db.finishRun({ + runId: candidate.id, + status: "interrupted", + summary: "Could not re-enqueue after worker restart.", + errorMessage: error instanceof Error ? error.message : String(error), + exitCode: null, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: null, + unattributedCost: true, + }); + if (candidate.workspaceId) { + db.releaseWorkspaceLock(candidate.workspaceId, candidate.id); + } + } + } + } + }); + + worker.on("message", async (message) => { + let event: z.infer; + try { + event = workerMessageSchema.parse(message); + } catch (error) { + logger.error("[worker] invalid message payload", error); + return; + } + + if (event.type === "run-log") { + await db.appendRunLog(event.runId, event.stream, event.chunk); + emitEvent(event); + return; + } + + if (event.type === "run-status") { + db.markRunStatus(event.runId, event.status, event.message); + if (event.status === "running") { + const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId); + if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) { + logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`); + worker?.postMessage({ type: "cancel-run", runId: run.id }); + } + } + emitEvent(event); + publishDomainChanged(); + return; + } + + const snapshot = db.listSnapshot(); + const run = snapshot.runs.find((entry) => entry.id === event.runId); + if (!run) { + return; + } + + const connector = getConnectorDefinition(run.connectorId); + + let task: ReturnType | null = null; + try { + task = db.getTask(run.taskId); + } catch { + task = null; + } + + if (task) { + if (event.sessionParams && connector.module.sessionCodec) { + const serialized = connector.module.sessionCodec.serialize(event.sessionParams); + db.updateTaskSession(task.id, serialized, connector.module.sessionCodec.getDisplayId?.(serialized ?? null) ?? event.sessionDisplayId ?? null); + } + if (!event.sessionParams) { + db.updateTaskSession(task.id, null, null); + } + } + + const runOutcome = db.finishRun(event); + if (run.workspaceId) { + db.releaseWorkspaceLock(run.workspaceId, run.id); + } + db.addActivity({ + companyId: run.companyId, + actor: run.agentId, + action: `run.${event.status}`, + entityType: "run", + entityId: run.id, + detail: event.summary ?? event.errorMessage ?? "", + }); + publishDomainChanged(); + emitEvent({ + type: "run-status", + runId: run.id, + status: event.status, + message: event.summary ?? event.errorMessage ?? event.status, + }); + if ((event.status === "failed" || event.status === "timed_out" || event.status === "interrupted") && task) { + notify("Run attention required", task.title); + } + + let updatedAgent: ReturnType | null = null; + try { + updatedAgent = db.getAgent(run.agentId); + } catch { + updatedAgent = null; + } + + if (updatedAgent && runOutcome.budgetHardStopped) { + db.addActivity({ + companyId: updatedAgent.companyId, + actor: "system", + action: "agent.auto_paused", + entityType: "agent", + entityId: updatedAgent.id, + detail: `Budget exhausted at ${updatedAgent.spentMonthlyUsd.toFixed(2)} / ${updatedAgent.budgetMonthlyUsd.toFixed(2)} USD.`, + }); + notify("Agent paused for budget limit", updatedAgent.name); + publishDomainChanged(); + } + + if (event.status === "failed" || event.status === "timed_out") { + try { + const chain = db.getChainOfCommand(run.agentId); + if (chain.length > 0) { + wakeAgentIfPossible(chain[0].id, run.companyId, "report_failed"); + } + } catch { + // Agent may have been deleted while resolving the escalation chain. + } + } + + const pendingWakes = db.consumePendingWakes(run.agentId); + if (pendingWakes.length > 0) { + for (const wake of pendingWakes) { + wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger); + } + } + + processRunCompletion(run, event as z.infer & { type: "run-finished" }); + }); + } + + function destroy() { + worker?.kill(); + worker = null; + } + + return { + createWorker, + getWorker, + destroy, + }; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 526fde2c..399dd930 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ const api: DesktopApi = { startTaskRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.startTaskRun, input), cancelRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelRun, input), getRunLog: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRunLog, input), + getRunLogChunk: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRunLogChunk, input), backupProfile: () => ipcRenderer.invoke(IPC_CHANNELS.backupProfile), restoreProfile: () => ipcRenderer.invoke(IPC_CHANNELS.restoreProfile), updateSettings: (input) => ipcRenderer.invoke(IPC_CHANNELS.updateSettings, input), @@ -38,6 +39,7 @@ const api: DesktopApi = { deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input), getSidebarBadges: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getSidebarBadges, companyId), listConnectorModels: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.listConnectorModels, connectorId), + openConnectorAuthTerminal: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.openConnectorAuthTerminal, connectorId), addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input), listComments: (input) => ipcRenderer.invoke(IPC_CHANNELS.listComments, input), setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input), @@ -47,13 +49,12 @@ const api: DesktopApi = { getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input), getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input), getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId), - applyCompanyTemplate: (input) => ipcRenderer.invoke(IPC_CHANNELS.applyCompanyTemplate, input), - listCompanyTemplates: () => ipcRenderer.invoke(IPC_CHANNELS.listCompanyTemplates), + bootstrapOnboarding: (input) => ipcRenderer.invoke(IPC_CHANNELS.bootstrapOnboarding, input), saveSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSocialAccount, input), deleteSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSocialAccount, input), listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input), cancelBrowserAction: (input) => ipcRenderer.invoke(IPC_CHANNELS.cancelBrowserAction, input), - triggerBrowserLogin: (id) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, id), + triggerBrowserLogin: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerBrowserLogin, input), saveMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveMeeting, input), deleteMeeting: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteMeeting, input), saveDocument: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveDocument, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0410ab7d..998ef507 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -34,7 +34,6 @@ import { SidebarSimple, TerminalWindow, WarningCircle, - Wrench, X, } from "@phosphor-icons/react"; import type { @@ -42,6 +41,7 @@ import type { CompanyMetrics, DesktopEvent, InboxItem, + OnboardingBootstrapResult, ProfileSnapshot, RunRecord, SectionId, @@ -55,6 +55,7 @@ import { Inbox, StandupView, PerformanceView } from "./components/inbox"; import { ConnectorEditor } from "./components/connectors"; import { Dashboard, KanbanBoard } from "./components/dashboard"; import { EntityWorkspace } from "./components/EntityWorkspace"; +import { RunCenter } from "./components/runs"; import { ActivityList, ApprovalInspector, @@ -94,6 +95,11 @@ import { formatMoney, formatTime } from "./lib/formatters"; type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour }; type NavGroup = { label: string; items: NavItem[] }; +type PaletteAction = { + key: string; + section: SectionId; + entityId?: string; +}; const navGroups: NavGroup[] = [ { @@ -103,6 +109,7 @@ const navGroups: NavGroup[] = [ { id: "inbox", label: "Inbox", icon: Bell }, { id: "standup", label: "Standup", icon: Heartbeat }, { id: "performance", label: "Performance", icon: ChartBar }, + { id: "runs", label: "Runs", icon: TerminalWindow }, ], }, { @@ -180,6 +187,7 @@ export function App() { const [, setLogRevision] = useState(0); const [badges, setBadges] = useState(null); const [commandPaletteSearch, setCommandPaletteSearch] = useState(""); + const [selectedPaletteIndex, setSelectedPaletteIndex] = useState(0); const [showOnboarding, setShowOnboarding] = useState(false); const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false); const [inboxItems, setInboxItems] = useState([]); @@ -188,6 +196,23 @@ export function App() { const [socialAccounts, setSocialAccounts] = useState([]); const [browserActions, setBrowserActions] = useState([]); + const clearCompanyScopedUiState = useCallback(() => { + setSelectedTaskId(null); + setSelectedApprovalId(null); + setSelectedRunId(null); + setSelectedAgentId(null); + setSelectedGoalId(null); + setSelectedProjectId(null); + setSelectedWorkspaceId(null); + setSelectedCommunicationEntityId(null); + setInboxItems([]); + setCompanyMetrics(null); + setStandupReport(null); + setSocialAccounts([]); + setBrowserActions([]); + setBadges(null); + }, []); + const refresh = useCallback(async () => { try { const next = unwrap(await window.agentCompany.loadSnapshot()); @@ -200,6 +225,8 @@ export function App() { if (next.currentCompanyId) { const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId); if (badgeResult.ok) setBadges(badgeResult.data); + const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId); + if (inboxResult.ok) setInboxItems(inboxResult.data); setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []); const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 }); if (browserActionResult.ok) { @@ -234,10 +261,19 @@ export function App() { void refreshRef.current(); const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => { if (event.type === "run-log") { - logBufferRef.current = { - ...logBufferRef.current, - [event.runId]: `${logBufferRef.current[event.runId] ?? ""}${event.chunk}`, - }; + const MAX_LOG_BYTES_PER_RUN = 512_000; + const MAX_TRACKED_RUNS = 50; + const existing = logBufferRef.current[event.runId] ?? ""; + const updated = existing.length < MAX_LOG_BYTES_PER_RUN + ? existing + event.chunk + : existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk; + logBufferRef.current[event.runId] = updated; + const runIds = Object.keys(logBufferRef.current); + if (runIds.length > MAX_TRACKED_RUNS) { + for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) { + delete logBufferRef.current[oldId]; + } + } setLogRevision((r) => r + 1); return; } @@ -257,19 +293,6 @@ export function App() { }; }, [debouncedRefresh]); - useEffect(() => { - if (!selectedRunId) return; - void (async () => { - const result = await window.agentCompany.getRunLog({ runId: selectedRunId }); - if (result.ok) { - logBufferRef.current = { ...logBufferRef.current, [selectedRunId]: result.data }; - setLogRevision((r) => r + 1); - return; - } - setError(result.error.message); - })(); - }, [selectedRunId]); - useEffect(() => { if (!snapshot) return; const root = document.documentElement; @@ -280,41 +303,50 @@ export function App() { useEffect(() => { if (!snapshot?.currentCompanyId) return; const companyId = snapshot.currentCompanyId; + let cancelled = false; + + if (section === "inbox") { + setInboxItems([]); + } + + if (section === "performance") { + setCompanyMetrics(null); + } + + if (section === "standup") { + setStandupReport(null); + } + + if (section === "social") { + setSocialAccounts([]); + } if (section === "inbox") { void window.agentCompany.getInbox(companyId).then((result) => { - if (result.ok) setInboxItems(result.data); + if (!cancelled && result.ok) setInboxItems(result.data); }); } if (section === "performance") { void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => { - if (result.ok) setCompanyMetrics(result.data); + if (!cancelled && result.ok) setCompanyMetrics(result.data); }); } if (section === "standup") { void window.agentCompany.getStandupReport(companyId).then((result) => { - if (result.ok) setStandupReport(result.data); + if (!cancelled && result.ok) setStandupReport(result.data); }); } if (section === "social") { - setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []); + setSocialAccounts(snapshot.socialAccounts?.filter((a) => a.companyId === companyId) ?? []); } - }, [section, snapshot]); - useEffect(() => { - if (!commandPaletteOpen) return; - function onKey(event: KeyboardEvent) { - if (event.key === "Escape") { - setCommandPaletteOpen(false); - setCommandPaletteSearch(""); - } - } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [commandPaletteOpen]); + return () => { + cancelled = true; + }; + }, [section, snapshot]); const paletteTrapRef = useFocusTrap(commandPaletteOpen); @@ -327,10 +359,36 @@ export function App() { const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null; const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null; const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); + const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]); const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null; const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null; + useEffect(() => { + if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) { + setSelectedRunId(runs[0]?.id ?? null); + } + }, [runs, selectedRunId]); + + const activeRunId = selectedRun?.id ?? null; + + useEffect(() => { + if (!activeRunId) return; + void (async () => { + const activeRun = runs.find((run) => run.id === activeRunId); + if (!activeRun) { + return; + } + const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId }); + if (result.ok) { + logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data }; + setLogRevision((r) => r + 1); + return; + } + setError(result.error.message); + })(); + }, [activeRunId, runs]); + const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null; const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); @@ -341,6 +399,12 @@ export function App() { const activeInboxCount = inboxItems.length; const navBadges = useMemo>>(() => badges ? { inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined, + runs: + badges.failedRuns > 0 + ? { count: badges.failedRuns, tone: "danger" } + : badges.activeRuns > 0 + ? { count: badges.activeRuns, tone: "default" } + : undefined, tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined, approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined, costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined, @@ -363,24 +427,99 @@ export function App() { : navItems; const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; + const matchedRuns = paletteQuery ? runs.filter((run) => { + const matchingTask = tasks.find((task) => task.id === run.taskId); + return [ + run.id, + run.summary ?? "", + run.errorMessage ?? "", + matchingTask?.title ?? "", + run.connectorId, + ].some((value) => value.toLowerCase().includes(paletteQuery)); + }).slice(0, 5) : []; const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : []; - const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0; - return { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults }; - }, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]); - const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults; + const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0; + return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults }; + }, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks]); + const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults; + const paletteActions = useMemo(() => ([ + ...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })), + ...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })), + ...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })), + ...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })), + ...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })), + ...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })), + ]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]); + const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]); const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => { setSection(targetSection); if (entityId) { if (targetSection === "agents") setSelectedAgentId(entityId); - else if (targetSection === "tasks") setSelectedTaskId(entityId); + else if (targetSection === "connectors") setSelectedConnectorId(entityId); + else if (targetSection === "runs") { + setSelectedRunId(entityId); + setConsoleOpen(true); + } + else if (targetSection === "tasks") { + const matchingTask = tasks.find((task) => task.id === entityId) ?? null; + if (matchingTask) { + setSelectedTaskId(matchingTask.id); + } else { + const matchingRun = runs.find((run) => run.id === entityId) ?? null; + if (matchingRun) { + setSelectedRunId(matchingRun.id); + setSelectedTaskId(matchingRun.taskId); + setConsoleOpen(true); + } + } + } else if (targetSection === "approvals") setSelectedApprovalId(entityId); else if (targetSection === "goals") setSelectedGoalId(entityId); else if (targetSection === "projects") setSelectedProjectId(entityId); else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId); } - }, []); + }, [runs, tasks]); + + useEffect(() => { + if (!commandPaletteOpen) return; + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") { + setCommandPaletteOpen(false); + setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); + return; + } + if (paletteActions.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedPaletteIndex((current) => (current + 1) % paletteActions.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedPaletteIndex((current) => (current - 1 + paletteActions.length) % paletteActions.length); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + const selectedAction = paletteActions[Math.min(selectedPaletteIndex, paletteActions.length - 1)]; + if (!selectedAction) return; + navigateTo(selectedAction.section, selectedAction.entityId); + setCommandPaletteOpen(false); + setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [commandPaletteOpen, navigateTo, paletteActions, selectedPaletteIndex]); + + useEffect(() => { + if (!commandPaletteOpen) return; + setSelectedPaletteIndex(0); + }, [commandPaletteOpen, commandPaletteSearch]); const setCompany = useCallback(async (companyId: string) => { if (companyId === "__new__") { @@ -389,12 +528,13 @@ export function App() { } try { unwrap(await window.agentCompany.setCurrentCompany(companyId)); + clearCompanyScopedUiState(); await refresh(); setError(null); } catch (error) { setError(error instanceof Error ? error.message : String(error)); } - }, [refresh]); + }, [clearCompanyScopedUiState, refresh]); const deleteCompany = useCallback(async (companyId: string) => { try { @@ -408,7 +548,11 @@ export function App() { }, [refresh]); const startRun = useCallback(async (taskId: string) => { - const result = await window.agentCompany.startTaskRun({ taskId }); + if (!currentCompany) { + setError("Select a company before starting a run."); + return; + } + const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId }); if (!result.ok) { setError(result.error.message); return; @@ -416,29 +560,49 @@ export function App() { setSelectedRunId(result.data); setConsoleOpen(true); await refresh(); - }, [refresh]); + }, [currentCompany, refresh]); const cancelRun = useCallback(async (runId: string) => { try { - unwrap(await window.agentCompany.cancelRun({ runId })); + const run = runs.find((entry) => entry.id === runId); + if (!run) { + throw new Error("Selected run is no longer available."); + } + unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId })); await refresh(); setError(null); } catch (error) { setError(error instanceof Error ? error.message : String(error)); } + }, [refresh, runs]); + + const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => { + setShowOnboarding(false); + setInitialOnboardingLocked(false); + await refresh(); + setError(null); + if (summary.runId) { + setSection("runs"); + setSelectedRunId(summary.runId); + setConsoleOpen(true); + return; + } + if (summary.taskId) { + setSection("tasks"); + setSelectedTaskId(summary.taskId); + return; + } + if (summary.projectId) { + setSection("projects"); + setSelectedProjectId(summary.projectId); + return; + } + if (summary.leadAgentId) { + setSection("agents"); + setSelectedAgentId(summary.leadAgentId); + } }, [refresh]); - const quickConnectorCheck = useCallback(async () => { - if (!selectedConnector) return; - try { - unwrap(await window.agentCompany.testConnector(selectedConnector.id)); - await refresh(); - setError(null); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } - }, [selectedConnector, refresh]); - if (!snapshot) { return (
@@ -448,10 +612,10 @@ export function App() { } if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) { - return { + return 0 && !initialOnboardingLocked} onCancel={() => { setShowOnboarding(false); setInitialOnboardingLocked(false); - await refresh(); + setError(null); }} />; } @@ -688,6 +852,27 @@ export function App() { /> ) : null} + {section === "runs" ? ( + { + setSelectedRunId(runId); + }} + onOpenLogPath={async (path: string) => { + unwrap(await window.agentCompany.openPath(path)); + }} + onCancelRun={cancelRun} + onOpenConsole={(runId: string) => { + setSelectedRunId(runId); + setConsoleOpen(true); + }} + /> + ) : null} + {section === "connectors" && selectedConnector ? (
@@ -715,8 +900,6 @@ export function App() { void quickConnectorCheck()} icon={Wrench} tone="accent" />} > @@ -746,7 +929,7 @@ export function App() { workspaces={companyWorkspaces} agents={companyAgents} companyId={currentCompany!.id} - onSaved={async () => { setSelectedAgentId(null); await refresh(); }} + onSaved={refresh} onDeleted={async () => { setSelectedAgentId(null); await refresh(); }} /> ) : null} @@ -861,6 +1044,8 @@ export function App() { projects={companyProjects} workspaces={companyWorkspaces} companyId={currentCompany!.id} + comments={(snapshot.comments ?? []).filter((comment) => comment.taskId === selectedTask.id)} + onRefresh={refresh} onRun={startRun} onCancelRun={cancelRun} onCreateApproval={async () => { @@ -880,6 +1065,11 @@ export function App() { setSelectedApprovalId(approvalId); await refresh(); }} + onOpenDiscussion={(taskId) => { + setSelectedTaskId(null); + setSection("communication"); + setSelectedCommunicationEntityId(taskId); + }} onDeleted={async () => { setSelectedTaskId(null); await refresh(); }} /> ) : null} @@ -893,7 +1083,7 @@ export function App() { - + setSelectedApprovalId(null)}> {selectedApproval ? ( @@ -943,33 +1133,60 @@ export function App() { agents={companyAgents.map(a => ({ id: a.id, name: a.name }))} companyId={snapshot.currentCompanyId} onSave={async (input) => { - await window.agentCompany.saveSocialAccount({ - companyId: input.companyId, - platform: input.platform, - accountName: input.accountName, - displayName: input.displayName, - requireApproval: input.requireApproval, - profileUrl: "", - status: "login_required", - metadataJson: "{}", - }); - await refresh(); + try { + unwrap(await window.agentCompany.saveSocialAccount({ + companyId: input.companyId, + platform: input.platform, + accountName: input.accountName, + displayName: input.displayName, + requireApproval: input.requireApproval, + profileUrl: "", + status: "login_required", + metadataJson: "{}", + })); + await refresh(); + setError(null); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } }} onDelete={async (id) => { if (!snapshot?.currentCompanyId) return; - await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId }); - await refresh(); + try { + unwrap(await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId })); + await refresh(); + setError(null); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } }} onLogin={async (accountId) => { - await window.agentCompany.triggerBrowserLogin(accountId); - await refresh(); + if (!snapshot?.currentCompanyId) return; + try { + unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId })); + await refresh(); + setError(null); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } }} onOpenPath={async (path) => { - await window.agentCompany.openPath(path); + try { + unwrap(await window.agentCompany.openPath(path)); + setError(null); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } }} onCancelAction={async (actionId) => { - await window.agentCompany.cancelBrowserAction({ runId: actionId }); - await refresh(); + if (!snapshot?.currentCompanyId) return; + try { + unwrap(await window.agentCompany.cancelBrowserAction({ actionId, companyId: snapshot.currentCompanyId })); + await refresh(); + setError(null); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } }} onNavigate={(sectionName, entityId) => { setSection(sectionName); @@ -996,6 +1213,11 @@ export function App() { agents={companyAgents} companyId={currentCompany.id} selectedEntityId={selectedCommunicationEntityId} + onEntityConsumed={() => setSelectedCommunicationEntityId(null)} + onNavigateToTask={(taskId) => { + setSection("tasks"); + setSelectedTaskId(taskId); + }} onRefresh={refresh} /> ) : null} @@ -1202,11 +1424,12 @@ export function App() { {filteredNavItems.map((item) => ( { - setSection(item.id); + navigateTo(item.id); setCommandPaletteOpen(false); setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); }} >
@@ -1231,12 +1454,12 @@ export function App() { {matchedAgents.map((agent) => ( { - setSection("agents"); - setSelectedAgentId(agent.id); + navigateTo("agents", agent.id); setCommandPaletteOpen(false); setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); }} >
@@ -1260,12 +1483,12 @@ export function App() { {matchedTasks.map((task) => ( { - setSection("tasks"); - setSelectedTaskId(task.id); + navigateTo("tasks", task.id); setCommandPaletteOpen(false); setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); }} >
@@ -1281,6 +1504,36 @@ export function App() { ) : null} + {matchedRuns.length > 0 ? ( + <> +
Runs
+ + {matchedRuns.map((run) => ( + { + navigateTo("runs", run.id); + setCommandPaletteOpen(false); + setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); + }} + > +
+
+ + + {run.summary || runs.find((entry) => entry.id === run.id)?.id.slice(0, 8) || run.id.slice(0, 8)} + +
+ +
+
+ ))} +
+ + ) : null} + {matchedGoals.length > 0 ? ( <>
Goals
@@ -1288,12 +1541,12 @@ export function App() { {matchedGoals.map((goal) => ( { - setSection("goals"); - setSelectedGoalId(goal.id); + navigateTo("goals", goal.id); setCommandPaletteOpen(false); setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); }} >
@@ -1316,12 +1569,12 @@ export function App() { {matchedProjects.map((project) => ( { - setSection("projects"); - setSelectedProjectId(project.id); + navigateTo("projects", project.id); setCommandPaletteOpen(false); setCommandPaletteSearch(""); + setSelectedPaletteIndex(0); }} >
diff --git a/src/renderer/components/CompanyOnboarding.tsx b/src/renderer/components/CompanyOnboarding.tsx index 788a383c..37baeecb 100644 --- a/src/renderer/components/CompanyOnboarding.tsx +++ b/src/renderer/components/CompanyOnboarding.tsx @@ -1,69 +1,70 @@ -import { useCallback, useEffect, useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { Buildings, CheckCircle, - Code, Cpu, - Flask, FolderOpen, Lightning, - Megaphone, - Robot, Rocket, - Stack, Target, - TreeStructure, - Users, WarningCircle, XCircle, } from "@phosphor-icons/react"; -import type { CompanyTemplate, ProfileSnapshot } from "@shared/types"; +import type { OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types"; import { unwrap } from "../lib/desktop"; -import { getAgentInitials } from "../lib/formatters"; -import { ActionButton, Input, TextArea } from "./ui"; +import { ActionButton, Input, Select, TextArea } from "./ui"; -type Step = "choose" | "template" | "company" | "goal" | "launch"; - -const templateIcons: Record = { - Cpu: Cpu, - Megaphone: Megaphone, - Flask: Flask, - Stack: Stack, - Code: Code, -}; +type Step = "company" | "mission" | "launch"; export function CompanyOnboarding({ connectors, onDone, + canCancel = false, + onCancel, }: { connectors: ProfileSnapshot["connectors"]; - onDone: () => Promise; + onDone: (summary: OnboardingBootstrapResult) => Promise; + canCancel?: boolean; + onCancel?: () => void; }) { - const [step, setStep] = useState("choose"); + const [step, setStep] = useState("company"); const [companyName, setCompanyName] = useState(""); + const [companyDescription, setCompanyDescription] = useState(""); const [workspacePath, setWorkspacePath] = useState(""); const [autoApproveHires, setAutoApproveHires] = useState(true); const [goalTitle, setGoalTitle] = useState(""); const [goalDescription, setGoalDescription] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [createdCompanyId, setCreatedCompanyId] = useState(null); - const [selectedTemplateId, setSelectedTemplateId] = useState(null); - const [templates, setTemplates] = useState([]); + const [launchSummary, setLaunchSummary] = useState(null); - const hasReadyConnector = connectors.some((c) => c.status === "ready" || c.status === "degraded"); - const readyConnectors = connectors.filter((c) => c.status === "ready" || c.status === "degraded"); - const bestConnector = readyConnectors[0] ?? null; - const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null; + const readyConnectors = useMemo( + () => connectors.filter((connector) => connector.status === "ready" || connector.status === "degraded"), + [connectors], + ); + const [selectedConnectorId, setSelectedConnectorId] = useState(readyConnectors[0]?.id ?? ""); + const hasRunnableConnector = readyConnectors.length > 0; + const selectedConnector = readyConnectors.find((connector) => connector.id === selectedConnectorId) ?? readyConnectors[0] ?? null; useEffect(() => { - window.agentCompany.listCompanyTemplates().then((result) => { - if (result.ok) setTemplates(result.data); - }); - }, []); + if (!selectedConnectorId && readyConnectors[0]) { + setSelectedConnectorId(readyConnectors[0].id); + return; + } + if (selectedConnectorId && !readyConnectors.some((connector) => connector.id === selectedConnectorId)) { + setSelectedConnectorId(readyConnectors[0]?.id ?? ""); + } + }, [readyConnectors, selectedConnectorId]); - async function pickDirectory() { + const stepLabels = [ + { label: "Company", icon: Buildings }, + { label: "Mission", icon: Target }, + { label: "Launch", icon: Rocket }, + ] as const; + const stepIndex = step === "company" ? 0 : step === "mission" ? 1 : 2; + + const pickDirectory = useCallback(async () => { setError(null); const result = await window.agentCompany.pickDirectory(); if (!result.ok) { @@ -73,612 +74,407 @@ export function CompanyOnboarding({ if (result.data) { setWorkspacePath(result.data); } - } + }, []); - const launchFromTemplate = useCallback(async () => { - if (loading || !selectedTemplate) return; - if (!companyName.trim()) { - setError("Enter a company name."); - return; - } - if (!hasReadyConnector) { - setError("At least one connector must be ready."); - return; - } - setLoading(true); - setError(null); - try { - const companyId = unwrap( - await window.agentCompany.applyCompanyTemplate({ - templateId: selectedTemplate.id, - companyName: companyName.trim(), - companyDescription: "", - connectorId: bestConnector?.id, - }), - ); + const bootstrap = useCallback(async () => { + const result = unwrap(await window.agentCompany.bootstrapOnboarding({ + companyName: companyName.trim(), + companyDescription: companyDescription.trim(), + workspacePath: workspacePath.trim(), + autoApproveHires, + goalTitle: goalTitle.trim(), + goalDescription: goalDescription.trim(), + connectorId: selectedConnector?.id, + })); + setLaunchSummary(result); + setStep("launch"); + }, [ + autoApproveHires, + companyDescription, + companyName, + goalDescription, + goalTitle, + selectedConnector?.id, + workspacePath, + ]); - if (workspacePath) { - unwrap( - await window.agentCompany.saveWorkspace({ - companyId, - name: "Default Workspace", - localPath: workspacePath, - repoUrl: "", - repoRef: "", - isPrimary: true, - }), - ); - } - - setCreatedCompanyId(companyId); - setStep("launch"); - await onDone(); - } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : "Unable to create the company."); - } finally { - setLoading(false); - } - }, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]); - - const createCompany = useCallback(async () => { + const continueToMission = useCallback(async () => { if (loading) return; if (!companyName.trim()) { setError("Enter a company name."); return; } - if (!hasReadyConnector) { - setError("At least one connector must be detected before creating a company."); + if (!selectedConnector) { + setError("At least one ready connector is required before you can continue."); + return; + } + setError(null); + setStep("mission"); + }, [companyName, loading, selectedConnector]); + + const launchCompany = useCallback(async () => { + if (loading) return; + if (!goalTitle.trim()) { + setError("Describe the first real company goal."); return; } setLoading(true); setError(null); try { - const companyId = unwrap( - await window.agentCompany.saveCompany({ - name: companyName.trim(), - description: "", - status: "active", - autoApproveHires, - }), - ); - if (workspacePath) { - unwrap( - await window.agentCompany.saveWorkspace({ - companyId, - name: "Default Workspace", - localPath: workspacePath, - repoUrl: "", - repoRef: "", - isPrimary: true, - }), - ); - } - unwrap(await window.agentCompany.setCurrentCompany(companyId)); - setCreatedCompanyId(companyId); - setStep("goal"); + await bootstrap(); } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : "Unable to create the company."); + setError(submitError instanceof Error ? submitError.message : "Unable to bootstrap the company."); } finally { setLoading(false); } - }, [loading, companyName, hasReadyConnector, autoApproveHires, workspacePath]); + }, [bootstrap, goalTitle, loading]); - const bootstrapInitialCompany = useCallback(async (companyId: string, seedGoal?: { title?: string; description?: string }) => { - if (seedGoal?.title?.trim()) { - unwrap( - await window.agentCompany.saveGoal({ - companyId, - title: seedGoal.title.trim(), - description: seedGoal.description?.trim() || `Achieve: ${seedGoal.title.trim()}`, - status: "active", - parentId: null, - ownerAgentId: null, - }), - ); - } - - if (bestConnector) { - const ceoId = unwrap( - await window.agentCompany.saveAgent({ - companyId, - name: "CEO", - role: "CEO", - title: "Chief Executive Officer", - reportsTo: null, - connectorId: bestConnector.id, - workspaceId: null, - model: null, - capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.", - budgetMonthlyUsd: 250, - status: "idle", - }), - ); - unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId, enabled: true, intervalSec: 120 })); - setStep("launch"); - setTimeout(async () => { - try { - await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId, trigger: "assignment" }); - } catch { - // Non-fatal. The CEO will wake on the next scheduled heartbeat. - } - }, 1500); - } - - await onDone(); - }, [bestConnector, onDone]); - - const createGoalAndLaunch = useCallback(async () => { - if (loading || !createdCompanyId) return; + const finishLaunch = useCallback(async () => { + if (!launchSummary) return; setLoading(true); setError(null); try { - await bootstrapInitialCompany(createdCompanyId, { - title: goalTitle, - description: goalDescription, - }); + await onDone(launchSummary); } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : "Unable to set up the company."); - setLoading(false); - } - }, [loading, createdCompanyId, goalTitle, goalDescription, bootstrapInitialCompany]); - - const skipGoalAndFinish = useCallback(async () => { - if (loading || !createdCompanyId) return; - setLoading(true); - setError(null); - try { - await bootstrapInitialCompany(createdCompanyId); - } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : "Unable to continue."); + setError(submitError instanceof Error ? submitError.message : "Unable to open the control center."); } finally { setLoading(false); } - }, [loading, createdCompanyId, bootstrapInitialCompany]); - - const stepLabels = step === "choose" || step === "template" - ? [ - { label: "Choose Setup", icon: Lightning }, - { label: "Configure", icon: Buildings }, - { label: "Launch", icon: Rocket }, - ] - : [ - { label: "Create Company", icon: Buildings }, - { label: "Set Mission", icon: Target }, - { label: "Launch", icon: Rocket }, - ]; - - const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2; + }, [launchSummary, onDone]); return ( -
+
-
AgentCompany Desktop
+
+ AgentCompany Desktop +

- Build your
AI company. + Start a real local-first +
+ agent company.

-

- Set up a fully autonomous AI-powered organization. Your agents will hire, delegate, execute, review, and coordinate — just like a real company. +

+ This flow creates one company, binds a real connector, links an optional workspace, writes the first goal, + creates the operating project, and queues the first execution task.

-
- {stepLabels.map((s, i) => ( -
- {i > 0 ?
: null} -
- {i < stepIndex ? : } - {s.label} + {stepLabels.map((entry, index) => ( +
+ {index > 0 ? ( +
+ ) : null} +
+ {index < stepIndex ? : } + {entry.label}
))}
-
-
Detected agents
+
+ Connector readiness +
- {connectors.map((c) => { - const isOk = c.status === "ready" || c.status === "degraded"; - const isFail = c.status === "not_installed"; + {connectors.map((connector) => { + const isRunnable = connector.status === "ready" || connector.status === "degraded"; + const isMissing = connector.status === "not_installed"; return ( -
- {isOk ? ( +
+ {isRunnable ? ( - ) : isFail ? ( + ) : isMissing ? ( ) : ( )} - - {c.label} + + {connector.label} - {c.status.replaceAll("_", " ")} + {connector.status.replaceAll("_", " ")}
); })}
- {!hasReadyConnector && ( + {!hasRunnableConnector ? (
- No connector detected. Install at least one supported CLI tool (Claude Code, Codex, Gemini CLI, OpenCode, or Kimi Code) and restart. + Install and authenticate at least one supported CLI runtime before creating the company.
- )} + ) : null}
-
- - {step === "choose" ? ( - + {canCancel && onCancel ? ( + + ) : null} - -
-
Quick Start — Choose a template
-
- {templates.map((template) => { - const Icon = templateIcons[template.icon] ?? Cpu; - return ( - - ); - })} -
-
- -
-
- OR -
-
- - - - - ) : step === "template" && selectedTemplate ? ( - -
Template: {selectedTemplate.name}
-
Configure & Launch
-
+
-
- -
- -
- - void pickDirectory()} icon={FolderOpen} /> -
-
-
- - -
-
Team that will be created
-
- {selectedTemplate.agents.map((agent, i) => ( -
-
- {getAgentInitials(agent.name)} -
- {agent.name} - — {agent.title} +
+ +