diff --git a/.env.example b/.env.example index eddadb61..7dbd2106 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ AGENTCOMPANY_UPDATE_URL= AGENTCOMPANY_DISABLE_UPDATES=true +AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS=false CSC_LINK= CSC_KEY_PASSWORD= WIN_CSC_LINK= diff --git a/docs/2026-03-18-adversarial-review-verdict.md b/docs/2026-03-18-adversarial-review-verdict.md new file mode 100644 index 00000000..188bf1f1 --- /dev/null +++ b/docs/2026-03-18-adversarial-review-verdict.md @@ -0,0 +1,49 @@ +## Intent +Unify and harden cross-process message flow in AgentCompany so API/IPC/automation/renderer behavior is consistent, secure, and production-safe. + +## Verdict: CONTESTED (pre-fix) -> PASS (post-fix) +Pre-fix review surfaced multiple high-severity issues; after remediation in this cycle, no remaining high-severity blocker is accepted. + +## Findings +1. **[high] Unsafe connector bypass inconsistency** (`src/main/orchestrator/worker.ts:114-119`) + - Lens: Skeptic, Architect + - Principle: `fix-root-causes`, `boundary-discipline` + - Recommendation: gate `dangerouslySkipPermissions` by explicit env flag. + +2. **[high] API message path missing real-time message event parity** (`src/main/api-server.ts:1456-1465`) + - Lens: Architect, Minimalist + - Principle: `prove-it-works`, `foundational-thinking` + - Recommendation: wire `emitEvent` through API callbacks and dispatch dependencies. + +3. **[high] Mark-read visibility check false negatives under message volume** (`src/main/ipc-agent-coordination-handlers.ts:82-87`) + - Lens: Skeptic + - Principle: `prove-it-works` + - Recommendation: replace bounded list scan with targeted lookup by `messageId`. + +4. **[medium] Message validation drift risk (schema vs dispatch normalization)** (`src/shared/contracts.ts:413-436`, `src/main/message-dispatch.ts:99-110`) + - Lens: Skeptic, Architect, Minimalist + - Principle: `foundational-thinking` + - Recommendation: enforce same recipient/channel invariants in dispatch layer (implemented). + +5. **[medium] Wake target resolution snapshot scaling risk** (`src/main/message-dispatch.ts:177-223`) + - Lens: Skeptic, Architect + - Principle: `cost-aware-delegation` + - Recommendation: follow-up optimization to avoid full snapshot traversal per message. + +6. **[low] Direct-channel avatar identity ambiguity** (`src/renderer/components/communication.tsx:134-140`, `:288-304`) + - Lens: Skeptic, Minimalist + - Principle: `outcome-oriented-execution` + - Recommendation: bind avatar identity to active viewer context (deferred). + +## What Went Well +- Message flow now has one dispatch path shared by API, IPC, and automation. +- Quality gates (typecheck/lint/unit/integration/e2e/build) all pass after remediation. +- Build pipeline now emits cleaned release artifacts to `/build/release` with explicit version. + +## Lead Judgment +1. Accept. Fixed in this cycle. +2. Accept. Fixed in this cycle. +3. Accept. Fixed in this cycle. +4. Accept. Partially fixed by dispatch-layer invariant enforcement; future refactor can further reduce duplication. +5. Accept as valid risk. Deferred to next optimization wave because correctness/security was prioritized first. +6. Accept as low-priority UX consistency issue. Deferred. diff --git a/docs/2026-03-18-full-system-audit-remediation.md b/docs/2026-03-18-full-system-audit-remediation.md new file mode 100644 index 00000000..fc1c4931 --- /dev/null +++ b/docs/2026-03-18-full-system-audit-remediation.md @@ -0,0 +1,167 @@ +# AgentCompany Full System Audit + Cross-Feature Remediation (2026-03-18) + +## Scope and Method + +- Repository scan scope: `src/main`, `src/preload`, `src/renderer`, `src/shared`, `tests`, `scripts`, build config. +- File inventory baseline: `173` tracked files (`rg --files | wc -l`). +- Validation gates executed after remediation: + - `pnpm typecheck` + - `pnpm lint` + - `pnpm typecheck:all` + - `pnpm test:unit` + - `pnpm test:integration` + - `pnpm test:packages` + - `pnpm test:e2e` + - `AGENTCOMPANY_BUILD_VERSION=0.4.28 pnpm build` + +## Mandatory Pipeline Mapping + +### Phase 1 (ProjectManager): Scope + Milestones + +- Current working milestone interpreted as: stabilize autonomous coordination + message workflow + desktop shell release readiness. +- Release artifact target confirmed: macOS arm64 zip output in `/build/release`. + +### Phase 2 (ResearchAnalyst): Electron + Desktop Constraints + +- Security baseline validated against Electron guidance: + - renderer with `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` in desktop shell and browser windows. + - webview attach blocked; external navigation constrained in shell manager. +- Packaging/update baseline validated against `electron-builder.yml` and runtime build script. + +### Phase 3 (TechnicalImplementationResearcher): Integration Model + +- High-friction subsystem identified: message pipeline drift across API/IPC/automation/renderer. +- Root cause: duplicated validation + duplicated wake/emit logic + per-entrypoint special behavior. +- Strategy used: unify dispatch semantics in one `main` module and route all entrypoints through it. + +### Phase 4 (EnterpriseArchitect): Boundary Enforcement + +- Main/preload/renderer boundary retained. +- IPC contract hardened for message semantics (`direct`/`department`/`project` requirements). +- Connector execution defaults tightened to avoid implicit unsafe permission bypass. + +### Phase 5-8 (Implementation + Verification) + +Implemented and validated in this cycle (details below). + +## Cross-Feature Coupling Audit (Before Fix) + +### Critical coupling defects + +1. Message contract drift between API / IPC / automation + - Different validation, wake-up targeting, and event emission behavior. +2. Direct-message send path in renderer could lose recipient context outside reply mode. +3. Mark-read visibility check used bounded list (`limit=500`), causing false negatives at scale. +4. API message write path did not emit `new-message` event, unlike IPC/automation. +5. Connector execution safety policy inconsistent (`claude_local` bypass always on). +6. Console visibility control removed from header, breaking e2e contract and operator UX. + +## Remediation Implemented + +### 1) Unified message dispatch (single source of truth) + +- Added: `src/main/message-dispatch.ts` +- Centralized: + - input normalization + - company/agent/project/parent validation + - send + event emission + - domain refresh publication + - wake target resolution by channel + +### 2) API, IPC, automation now use same dispatch behavior + +- API send route now uses `dispatchAgentMessage` and shared schema parse. +- IPC send handler now uses `dispatchAgentMessage`. +- Automation actions (`send_message`, escalation, cross-department notify) now use `dispatchAgentMessage`. + +### 3) IPC + API mark-read visibility correction + +- Added `messageId` filter support to `db.listAgentMessages(...)`. +- Replaced broad visibility scans with targeted message visibility lookup (`limit: 1` + `messageId`). +- Removed over-restrictive direct-recipient-only mark-read branch to avoid false denial when sender marks visible thread entries. + +### 4) API event parity restored + +- `AgentApiServer` now receives and uses `emitEvent` callback. +- API-originated message writes now emit `new-message` event consistently with IPC/automation. + +### 5) Connector security tightening + +- `claude_local` execution config now ties `dangerouslySkipPermissions` to `AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS` (default false). +- `.env.example` documents this guard variable. + +### 6) Renderer communication channel correctness + +- DM channels now store participant sets and resolve recipient deterministically for composer send path. +- Compose modal enforces target requirements per channel (department/project require target; non-target channels clear target state). + +### 7) Contract hardening + +- `agentMessageInputSchema.superRefine(...)` enforces: + - direct must provide `toAgentId` + - department/project must provide `channelTargetId` + - non-direct must not provide `toAgentId` +- Dispatch layer now enforces the same non-direct recipient prohibition, eliminating API/automation semantic mismatch. + +### 8) Desktop UX/e2e compatibility restore + +- Restored header `Show console / Hide console` control in `App.tsx` with stable ARIA/title semantics used by e2e. + +## Adversarial Review (Opposite Model CLI) + +- Ran `claude -p` in three lenses: `skeptic`, `architect`, `minimalist`. +- Output artifacts stored under `/tmp/adversarial-review-agentcompany-20260318-120225/`. +- Key accepted findings addressed in this cycle: + - unsafe connector bypass inconsistency + - API event parity gap + - bounded mark-read visibility check correctness gap +- Remaining contested/medium findings (deferred) tracked in “Open Risks”. + +## Open Risks / Remaining Work + +1. `resolveMessageWakeTargets` is snapshot-based and can be heavy at larger org scale. +2. Incident/company channel wake fan-out can cause high wake volume without throttling. +3. Message attachment JSON is stored as raw string; no structural JSON validation yet. +4. Some renderer DM channel avatar semantics still depend on ambiguous channel-level `agentId` for two-party threads. +5. Update/notarization production credential flow still depends on release environment material availability. + +## Verification Evidence + +- Type safety: pass (`pnpm typecheck`, `pnpm typecheck:all`). +- Lint: pass (`pnpm lint`). +- Unit/integration/package tests: pass. +- E2E smoke: pass. +- Build packaging: + - build folder cleaned by script + - version used: `0.4.28` + - artifact produced: `build/release/AgentCompany-0.4.28-arm64-mac.zip` + +## External Research References + +- Electron security checklist and architecture guidance: + - https://www.electronjs.org/docs/latest/tutorial/security +- Preload and contextBridge guidance: + - https://www.electronjs.org/docs/latest/tutorial/tutorial-preload + - https://www.electronjs.org/docs/latest/api/context-bridge +- BrowserWindow/webPreferences hardening: + - https://www.electronjs.org/docs/latest/api/browser-window +- Utility/background process model: + - https://www.electronjs.org/docs/latest/api/utility-process +- Packaging and auto-update config: + - https://www.electron.build/ + - https://www.electron.build/auto-update + +## Changed Files (this remediation wave) + +- `src/main/message-dispatch.ts` (new) +- `src/main/api-server.ts` +- `src/main/ipc-agent-coordination-handlers.ts` +- `src/main/database.ts` +- `src/main/automation-service.ts` +- `src/main/bootstrap-application.ts` +- `src/main/orchestrator/worker.ts` +- `src/shared/contracts.ts` +- `src/renderer/components/communication.tsx` +- `src/renderer/App.tsx` +- `.env.example` +- `package.json` diff --git a/docs/2026-03-18-integrated-audit-matrix.md b/docs/2026-03-18-integrated-audit-matrix.md new file mode 100644 index 00000000..a0e23e89 --- /dev/null +++ b/docs/2026-03-18-integrated-audit-matrix.md @@ -0,0 +1,333 @@ +# AgentCompany Integrated Audit Matrix (2026-03-18) + +## Scope + +This audit covered: + +- `PRD.md` +- Electron main / preload / renderer source under `src/main`, `src/preload`, `src/renderer`, `src/shared` +- Packaging and runtime config: `electron-builder.yml`, `electron.vite.config.ts`, `scripts/build-desktop.mjs` +- Existing validation assets under `tests/unit`, `tests/integration`, `tests/e2e` +- Runtime validation through a live Electron launch with real IPC, onboarding, org creation, approvals, and run orchestration + +## Evidence Collected + +Validation commands executed in this session: + +- `pnpm typecheck` +- `pnpm lint` +- `pnpm test:unit` +- `pnpm test:integration` +- `pnpm test:e2e` +- `pnpm electron-vite build` + +Additional runtime scenarios executed against the built Electron app: + +- full first-launch onboarding to desktop shell +- navigation sweep across desktop sections +- live hire request flow with `autoApproveHires = true` +- live hire request flow with `autoApproveHires = false` +- negative-path validation that legacy `saveAgent` creation and `pending_approval -> idle` status bypasses are rejected + +## Current Architecture Reality + +What already exists and is materially real: + +- main / preload / renderer separation is implemented +- renderer uses typed preload APIs instead of raw Node access +- `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` are used for the main desktop shell +- orchestration worker runs in an Electron utility process +- SQLite-backed local-first state model exists +- connectors for `codex_local`, `claude_local`, `gemini_local`, `opencode_local`, `kimi_local` exist +- approvals, runs, costs, activity, automation, meetings, knowledge, documents, sprints, social, messages, workspaces, and onboarding all exist in code and UI + +What is still structurally weak: + +- several modules are still too large to be considered stable long-term coordination boundaries +- cross-feature side effects still live in a few orchestration-heavy modules instead of a smaller explicit event-policy layer +- some release/security/product rules exist in PRD and code, but not all are enforced uniformly in every write path + +Largest modules by line count at audit time: + +- `src/main/database.ts`: 3348 +- `src/renderer/components/execution.tsx`: 1726 +- `src/renderer/components/communication.tsx`: 1726 +- `src/main/api-server.ts`: 1697 +- `src/renderer/components/automation.tsx`: 1693 +- `src/renderer/App.tsx`: 1606 +- `src/main/prompt-builder.ts`: 1541 + +## PRD Matrix + +### 1. Local Profile / Company + +Status: implemented, with one policy caveat + +Evidence: + +- local profile bootstrap, backup/restore, company switching, settings persistence all exist +- onboarding successfully creates company, workspace, goal, initial run, and opens desktop shell + +Remaining risk: + +- company policy toggles are present, but some governance behavior is still distributed across UI, IPC, API, and automation rather than centralized in a single policy service + +### 2. Connector Management + +Status: implemented + +Evidence: + +- connector registry UI exists +- connector health service exists +- model listing and auth terminal support exist +- command/env binding handling exists + +Remaining risk: + +- connector execution policy is still sensitive to environment-based behavior and deserves a dedicated policy surface, not scattered guard clauses + +### 3. Agent Registry / Org Structure + +Status: implemented, previously inconsistent, partially hardened in this wave + +Evidence: + +- org chart, agent roster, hiring pipeline, manager/report relationships, and budget fields exist +- pending-approval lifecycle exists in data model and approval resolution logic + +Critical finding fixed in this wave: + +- renderer previously allowed direct agent creation from the Agents page, bypassing `hire_agent` approval and breaking synchronization between Hiring, Approvals, onboarding, and org history +- renderer also allowed direct status edits that could move a pending hire into an active state without an approval decision + +Remediation added: + +- dedicated `requestHire` IPC contract +- transactional `createHireRequest(...)` persistence path +- renderer Agent creation now submits hire requests instead of active agents +- IPC `saveAgent` now rejects new-agent creation and rejects direct pending-hire activation + +Runtime proof: + +- with `autoApproveHires = false`, a new hire lands in `pending_approval` +- direct `saveAgent` creation returns `AGENT_CREATION_REQUIRES_HIRE_REQUEST` +- direct status bypass returns `AGENT_APPROVAL_REQUIRED` +- formal approval transitions the same agent record to `idle` + +### 4. Goals / Projects / Tasks + +Status: implemented + +Evidence: + +- onboarding creates goal and project structure +- task creation, comments, assignment, review states, kanban/list views, and run linkage exist +- task status changes feed automation/orchestration paths + +Remaining risk: + +- feature is functionally present, but task + review + message + automation interactions are still distributed across large renderer and main modules + +### 5. Run Orchestration / Session Continuity + +Status: implemented, with scaling caveats + +Evidence: + +- run creation, queueing, worker dispatch, recovery, logs, cancellation, and run status events exist +- utility-process worker is used instead of running orchestration in the renderer +- integration tests cover autonomous chain and recovery cases + +Remaining risk: + +- orchestration and wake logic remain heavily centralized +- cross-feature triggers are correct at current scope, but scaling behavior and event fan-out still need a slimmer event-policy boundary + +### 6. Approvals / Governance + +Status: implemented, materially improved in this wave + +Evidence: + +- approvals UI, decision handling, related task linkage, and automation callbacks exist +- board-side approval decisions update state and trigger follow-up automations + +Critical finding fixed in this wave: + +- agent creation governance could be bypassed from the desktop UI and IPC write path + +Remaining risk: + +- other approval-governed domains, especially termination and production-deploy style flows, still deserve the same hard policy treatment across all entrypoints + +### 7. Cost / Budget + +Status: implemented + +Evidence: + +- budget fields, cost entries, cost dashboard, thresholds, and budget-related automation logic exist + +Remaining risk: + +- budgets are enforced operationally, but not all escalation policy is centralized; some cost behavior is still advisory rather than hard-stopping privileged actions + +### 8. Activity / Audit / Logs + +Status: implemented + +Evidence: + +- activity timeline exists +- many core mutations add activity entries +- run logs exist and are streamable into renderer console panels + +Remaining risk: + +- structured logging exists, but the audit trail is still assembled from multiple write sites instead of passing through a smaller domain-event backbone + +### 9. Secrets / Config / Environment + +Status: implemented, hardened in this wave + +Evidence: + +- Electron `safeStorage` is used where available +- encrypted fallback vault exists behind explicit env opt-in +- env binding resolution exists for connector runs + +Security hardening added in this wave: + +- `NODE_OPTIONS` was removed from the runtime environment allowlist so spawned agent runtimes do not inherit host-side Node injection flags by default + +Remaining risk: + +- preload API surface is still large, even if typed +- env policy should eventually be expressed as explicit runtime capability profiles per connector, not only a static allowlist + +### 10. Workspace / Repo / Artifact Management + +Status: implemented + +Evidence: + +- workspace registry, path selection, repo URL/ref fields, primary workspace logic, and lock metadata exist +- orchestration resolves workspace context into runtime env + +Remaining risk: + +- workspace locking and stale-lock cleanup exist, but deserve more explicit operator-facing conflict states in UI for complex multi-run contention + +### 11. Notifications / State Awareness + +Status: implemented + +Evidence: + +- desktop notifications exist +- tray/menu/window controls exist +- sidebar badges, inbox, and console surfacing exist + +Remaining risk: + +- notification strategy is present but not yet tuned as a full release-quality desktop attention model across macOS, Windows, and Linux + +## Electron / Security / Release Audit + +### Security Baseline + +Status: mostly aligned + +Confirmed: + +- secure BrowserWindow defaults are in place +- renderer does not receive raw Node primitives +- preload uses typed IPC contract calls +- utility process is used for background orchestration work +- vault prefers OS-backed secret storage + +New hardening applied: + +- runtime env no longer forwards host `NODE_OPTIONS` +- pending-hire governance cannot be bypassed from renderer IPC + +Still open: + +- preload surface is broader than ideal for a production app of this size +- some domain rules are enforced in handlers rather than at a smaller dedicated policy layer + +### Packaging / Release + +Status: partially complete + +Confirmed: + +- `electron-builder.yml` targets macOS, Windows, Linux +- build script cleans `/build`, stages a non-mutating build version, and outputs into `/build/release` +- e2e and build use the current Electron/Vite packaging path correctly + +Still open: + +- this audit validated runtime on the current local platform only +- cross-platform runtime differences and signing/notarization behavior still need explicit validation evidence per target platform +- updater flow currently checks availability but does not yet provide a fully operator-visible download/install lifecycle + +### Performance + +Status: acceptable for current prototype depth, not yet release-optimized + +Evidence: + +- app launches and is usable in live runtime checks +- worker isolation prevents heavy agent runs from living in renderer + +Remaining risk: + +- renderer build currently produces an approximately `1.7 MB` main JS chunk, which is too large for a polished long-term desktop shell +- mega-components still make fine-grained performance work harder than it should be + +## External Research Used + +Primary references used for this audit: + +- Electron security checklist: https://www.electronjs.org/docs/latest/tutorial/security +- Electron process model / utility process guidance: https://www.electronjs.org/docs/latest/tutorial/process-model +- Electron safe storage API: https://www.electronjs.org/docs/latest/api/safe-storage +- electron-builder auto-update guidance: https://www.electron.build/auto-update + +These references were used to validate: + +- secure BrowserWindow / preload expectations +- utility-process suitability for background work +- safe secret storage expectations +- updater/build configuration direction + +## Remediation Implemented In This Wave + +Files changed in this wave: + +- `src/shared/contracts.ts` +- `src/preload/index.ts` +- `src/main/runtime-env.ts` +- `src/main/database.ts` +- `src/main/ipc-core-work-graph-handlers.ts` +- `src/renderer/components/organization.tsx` +- `src/renderer/App.tsx` + +Concrete changes: + +- added typed `requestHire` contract and preload API +- added transactional `createHireRequest(...)` database path +- rerouted renderer new-agent flow into hiring / approvals pipeline +- blocked direct renderer IPC creation of active agents +- blocked direct renderer status promotion of pending hires +- tightened runtime env inheritance by removing `NODE_OPTIONS` + +## Remaining High-Value Follow-Ups + +1. Split `App.tsx`, `database.ts`, `api-server.ts`, `automation-service.ts`, `communication.tsx`, and `execution.tsx` into smaller domain slices. +2. Pull wake / publish / automation side effects into an explicit domain-event policy layer instead of leaving them distributed in orchestration-heavy modules. +3. Shrink the renderer entry chunk with section-level lazy loading for heavyweight desktop panels. +4. Make updater lifecycle operator-visible beyond availability checks. +5. Validate runtime behavior on Windows and Linux rather than relying only on configured targets. diff --git a/electron-builder.yml b/electron-builder.yml index 0b5bd139..a55c7f01 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -27,6 +27,3 @@ nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true -publish: - provider: generic - url: ${AGENTCOMPANY_UPDATE_URL:-https://updates.example.invalid/agentcompany} diff --git a/package.json b/package.json index 2ccd573f..572da0e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.25", + "version": "0.4.29", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs index 1a913d29..54ed83c7 100644 --- a/scripts/build-desktop.mjs +++ b/scripts/build-desktop.mjs @@ -11,11 +11,14 @@ const runtimeConfigPath = join(buildDir, "electron-builder.runtime.json"); const extraArgs = process.argv.slice(2); const env = { ...process.env }; +const requestedUpdateUrl = env.AGENTCOMPANY_UPDATE_URL?.trim() ?? ""; +const strictRelease = env.AGENTCOMPANY_STRICT_RELEASE === "true"; const hasMacSigningMaterial = Boolean( env.CSC_LINK?.trim() || env.CSC_NAME?.trim() || env.APPLE_API_KEY?.trim(), ); +const isDirBuild = extraArgs.includes("--dir"); function bumpPatchVersion(currentVersion) { const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion); @@ -43,11 +46,24 @@ async function writeRuntimeConfig(buildVersion) { version: buildVersion, }, }; + if (requestedUpdateUrl) { + runtimeConfig.publish = [ + { + provider: "generic", + url: requestedUpdateUrl, + }, + ]; + } if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true") { + if (strictRelease) { + throw new Error("[build-desktop] Strict release mode requires macOS signing/notarization material."); + } runtimeConfig.mac = { - target: ["zip"], hardenedRuntime: false, }; + if (!isDirBuild) { + runtimeConfig.mac.target = ["zip"]; + } } else if (process.platform === "darwin") { runtimeConfig.mac = { hardenedRuntime: true, @@ -91,18 +107,33 @@ 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) { +if ( + process.platform === "darwin" && + !hasMacSigningMaterial && + env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" && + !explicitPlatformArg && + !isDirBuild +) { builderArgs.push("--mac", "zip"); } builderArgs.push(...extraArgs); +if (strictRelease && !requestedUpdateUrl) { + throw new Error("[build-desktop] Strict release mode requires AGENTCOMPANY_UPDATE_URL to be configured."); +} + await rm(buildDir, { recursive: true, force: true }); await mkdir(buildDir, { recursive: true }); const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await resolveBuildVersion(); +const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); +if (packageJson.version !== buildVersion) { + packageJson.version = buildVersion; + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); +} await writeRuntimeConfig(buildVersion); env.AGENTCOMPANY_BUILD_VERSION = buildVersion; env.npm_package_version = buildVersion; -console.log(`[build-desktop] Cleaned ${buildDir} and staged non-mutating build version ${buildVersion}.`); +console.log(`[build-desktop] Cleaned ${buildDir} and staged build version ${buildVersion}.`); const child = spawn("pnpm", builderArgs, { stdio: "inherit", diff --git a/src/main/agent-permissions.ts b/src/main/agent-permissions.ts index bd898c86..4f307fd1 100644 --- a/src/main/agent-permissions.ts +++ b/src/main/agent-permissions.ts @@ -16,6 +16,24 @@ function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string) return db.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId); } +export function canControlAgent(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string): boolean { + const actor = safeGetAgent(db, actorAgentId); + const target = safeGetAgent(db, targetAgentId); + if (!actor || !target) { + return false; + } + if (actor.companyId !== companyId || target.companyId !== companyId) { + return false; + } + if (actor.id === target.id) { + return true; + } + if (canManageOrganization(db, companyId, actorAgentId)) { + return true; + } + return managesAgent(db, actorAgentId, targetAgentId); +} + export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean { const agent = safeGetAgent(db, agentId); if (!agent || agent.companyId !== companyId) { diff --git a/src/main/api-server.ts b/src/main/api-server.ts index 795f2ea7..6b87715c 100644 --- a/src/main/api-server.ts +++ b/src/main/api-server.ts @@ -1,10 +1,13 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import log from "electron-log/main.js"; import { z } from "zod"; +import { agentMessageInputSchema, approvalInputSchema, goalInputSchema, hireRequestSchema, projectInputSchema, taskInputSchema } from "@shared/contracts"; import { validateAgentToken, type TokenPayload } from "./agent-auth"; -import { canAssignTask, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions"; +import { canAssignTask, canControlAgent, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions"; +import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; import type { AppDatabase } from "./database"; -import { agentInputSchema, approvalInputSchema, goalInputSchema, projectInputSchema, taskInputSchema } from "@shared/contracts"; +import { dispatchAgentMessage } from "./message-dispatch"; +import type { DesktopEvent } from "@shared/types"; type RequestHandler = ( req: IncomingMessage, @@ -49,6 +52,7 @@ export interface ApiServerEventCallbacks { 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; + emitEvent: (event: DesktopEvent) => void; } export class AgentApiServer { @@ -58,6 +62,7 @@ export class AgentApiServer { private db: AppDatabase; private onDomainChanged: () => void; private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; + private emitEvent: (event: DesktopEvent) => void; private callbacks: ApiServerEventCallbacks; private allowedOrigins: Set; private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null = null; @@ -72,6 +77,7 @@ export class AgentApiServer { this.db = db; this.onDomainChanged = onDomainChanged; this.onWakeAgent = onWakeAgent; + this.emitEvent = callbacks?.emitEvent ?? (() => {}); this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); this.callbacks = { onDomainChanged, @@ -82,6 +88,7 @@ export class AgentApiServer { onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}), onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}), onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null), + emitEvent: callbacks?.emitEvent ?? (() => {}), }; this.registerRoutes(); } @@ -202,7 +209,7 @@ export class AgentApiServer { } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error(`[api] ${method} ${pathname}: ${message}`); - this.json(res, 500, { error: message }); + this.json(res, 500, { error: "Internal server error" }); } return; } @@ -728,9 +735,9 @@ export class AgentApiServer { this.json(res, 403, { error: "Current agent is not allowed to request hires" }); return; } - const parsed = agentInputSchema.safeParse({ + const parsed = hireRequestSchema.safeParse({ companyId, - status: "pending_approval", + requestedByAgentId: token.agentId, ...(typeof body === "object" && body ? body : {}), reportsTo: typeof body === "object" && body && "reportsTo" in body @@ -743,85 +750,87 @@ export class AgentApiServer { } const input = parsed.data; if (this.companyRefError(res, companyId, [ + { id: input.requestedByAgentId, table: "agents", label: "requesting agent" }, { id: input.reportsTo, table: "agents", label: "manager" }, { id: input.workspaceId, table: "workspaces", label: "workspace" }, ])) { return; } - let agentId: string; + const managerLabel = input.reportsTo + ? (() => { + try { + return this.db.getAgent(input.reportsTo).name; + } catch { + return input.reportsTo; + } + })() + : "the company board"; + + let result: { agentId: string; approvalId: string }; try { - agentId = this.db.saveAgent({ - id: input.id ?? null, + result = this.db.createHireRequest({ companyId, + requestedByAgentId: input.requestedByAgentId ?? token.agentId, name: input.name, role: input.role, title: input.title, department: input.department ?? null, - status: "pending_approval", reportsTo: input.reportsTo ?? token.agentId, connectorId: input.connectorId, workspaceId: input.workspaceId ?? null, model: input.model ?? null, - metadataJson: input.metadataJson, + metadataJson: input.metadataJson ?? "{}", capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd, + payloadSummary: `Hire ${input.name} as ${input.role}`, + impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`, + payloadJson: JSON.stringify({ + requestedByAgentId: input.requestedByAgentId ?? token.agentId, + requestedConfigurationSnapshot: { + connectorId: input.connectorId, + workspaceId: input.workspaceId ?? null, + model: input.model ?? null, + metadataJson: input.metadataJson ?? "{}", + capabilities: input.capabilities, + budgetMonthlyUsd: input.budgetMonthlyUsd, + reportsTo: input.reportsTo ?? token.agentId, + department: input.department ?? null, + title: input.title, + }, + }), }); } catch (error) { this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" }); return; } - const approvalId = this.db.requestApproval({ - companyId, - requestedByAgentId: token.agentId, - relatedAgentId: agentId, - type: "hire_agent", - payloadSummary: `Hire ${input.name} as ${input.role}`, - impactSummary: input.capabilities || `${input.name} will report to ${input.reportsTo ?? token.agentId}.`, - payloadJson: JSON.stringify({ - agentId, - requestedByAgentId: token.agentId, - requestedConfigurationSnapshot: { - connectorId: input.connectorId, - workspaceId: input.workspaceId ?? null, - model: input.model ?? null, - metadataJson: input.metadataJson, - capabilities: input.capabilities, - budgetMonthlyUsd: input.budgetMonthlyUsd, - reportsTo: input.reportsTo ?? token.agentId, - }, - }), - }); - this.db.addActivity({ companyId, actor: token.agentId, action: "agent.hire_requested", entityType: "agent", - entityId: agentId, + entityId: result.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({ + let autoApproved = false; + if (shouldAutoApproveApproval(this.db, companyId, "hire_agent")) { + resolveApprovalDecision({ + db: this.db, + handleApprovalResolved: this.callbacks.onApprovalResolved, companyId, - actor: "board", - action: "approval.approved", - entityType: "approval", - entityId: approvalId, + approvalId: result.approvalId, + state: "approved", + decisionNote: "Auto-approved by company policy", detail: `Auto-approved hire: ${input.name}`, }); + autoApproved = true; log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`); } this.onDomainChanged(); - this.json(res, 201, { agentId, approvalId }); + this.json(res, 201, { ...result, autoApproved }); }); this.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => { @@ -904,18 +913,27 @@ export class AgentApiServer { if (this.companyRefError(res, companyId, [ { id: input.relatedTaskId, table: "tasks", label: "related task" }, { id: token.agentId, table: "agents", label: "requesting agent" }, + { id: input.relatedAgentId, table: "agents", label: "related agent" }, ])) { return; } - const id = this.db.requestApproval({ - companyId, - type: input.type, - relatedTaskId: input.relatedTaskId ?? null, - requestedByAgentId: token.agentId, - payloadSummary: input.payloadSummary, - impactSummary: input.impactSummary, - }); + let id: string; + try { + id = this.db.requestApproval({ + companyId, + type: input.type, + relatedTaskId: input.relatedTaskId ?? null, + requestedByAgentId: token.agentId, + relatedAgentId: input.relatedAgentId ?? null, + payloadSummary: input.payloadSummary, + impactSummary: input.impactSummary, + payloadJson: input.payloadJson ?? null, + }); + } catch (error) { + this.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" }); + return; + } this.db.addActivity({ companyId, @@ -926,20 +944,17 @@ export class AgentApiServer { detail: input.payloadSummary, }); - const snapshot = this.db.listSnapshot(); - const company = snapshot.companies.find((c) => c.id === companyId); - if (company?.autoApproveHires && (input.type === "hire_agent" || input.type === "approve_ceo_strategy")) { - this.db.decideApproval({ companyId, approvalId: id, state: "approved", decisionNote: "Auto-approved by company policy" }); - this.callbacks.onApprovalResolved(id, companyId, "approved"); - this.db.addActivity({ + if (shouldAutoApproveApproval(this.db, companyId, input.type)) { + resolveApprovalDecision({ + db: this.db, + handleApprovalResolved: this.callbacks.onApprovalResolved, companyId, - actor: "board", - action: "approval.approved", - entityType: "approval", - entityId: id, + approvalId: id, + state: "approved", + decisionNote: "Auto-approved by company policy", detail: "Auto-approved by company policy", }); - log.info(`[auto-approve] Auto-approved hire request: ${input.payloadSummary}`); + log.info(`[auto-approve] Auto-approved approval ${id}: ${input.payloadSummary}`); } this.onDomainChanged(); @@ -1437,74 +1452,35 @@ export class AgentApiServer { return; } - const messageSchema = z.object({ - toAgentId: z.string().uuid().nullable().optional(), - channel: z.enum(["direct", "department", "company", "project", "incident"]).default("direct"), - channelTargetId: z.string().nullable().optional(), - subject: z.string().min(1).max(200), - body: z.string().min(1).max(8000), - priority: z.enum(["urgent", "normal", "low"]).default("normal"), - parentMessageId: z.string().uuid().nullable().optional(), - attachmentsJson: z.string().default("[]"), + const parsed = agentMessageInputSchema.safeParse({ + ...(body as Record), + companyId, + fromAgentId: token.agentId, }); - - const parsed = messageSchema.safeParse(body); if (!parsed.success) { this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } - const msg = parsed.data; - if (msg.channel === "direct" && !msg.toAgentId) { - this.json(res, 400, { error: "Direct messages require a recipient" }); + const dispatchResult = dispatchAgentMessage( + { + db: this.db, + emitEvent: this.emitEvent, + publishDomainChanged: this.onDomainChanged, + wakeAgentIfPossible: (agentId, companyId, trigger) => { + if (!agentId) return null; + return this.onWakeAgent(agentId, companyId, trigger); + }, + }, + parsed.data, + "message", + ); + if (!dispatchResult.ok) { + this.json(res, 400, { error: dispatchResult.error.message, code: dispatchResult.error.code }); return; } - if (msg.toAgentId && !this.db.belongsToCompany("agents", msg.toAgentId, companyId)) { - this.json(res, 400, { error: "Direct-message recipient does not belong to the current company" }); - return; - } - if (msg.channel === "project" && msg.channelTargetId && !this.db.belongsToCompany("projects", msg.channelTargetId, companyId)) { - this.json(res, 400, { error: "Project message target does not belong to the current company" }); - return; - } - const messageId = this.db.sendAgentMessage({ - companyId, - fromAgentId: token.agentId, - toAgentId: msg.toAgentId, - channel: msg.channel, - channelTargetId: msg.channelTargetId, - subject: msg.subject, - body: msg.body, - priority: msg.priority, - parentMessageId: msg.parentMessageId, - attachmentsJson: msg.attachmentsJson, - }); - if (msg.toAgentId && msg.channel === "direct") { - this.onWakeAgent(msg.toAgentId, companyId, "message"); - } - - if (msg.channel === "department" && msg.channelTargetId) { - const snapshot = this.db.listSnapshot(); - const deptAgents = snapshot.agents.filter( - (a) => a.department === msg.channelTargetId && a.companyId === companyId && a.id !== token.agentId && a.status !== "terminated", - ); - for (const a of deptAgents) { - this.onWakeAgent(a.id, companyId, "message"); - } - } - - // For company-wide messages, wake the CEO so leadership responds - if (msg.channel === "company") { - const snapshot = this.db.listSnapshot(); - const ceo = snapshot.agents.find((a) => a.companyId === companyId && !a.reportsTo && a.status !== "terminated"); - if (ceo && ceo.id !== token.agentId) { - this.onWakeAgent(ceo.id, companyId, "message"); - } - } - - this.onDomainChanged(); - this.json(res, 201, { id: messageId }); + this.json(res, 201, { id: dispatchResult.data.id }); }); this.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => { @@ -1515,14 +1491,21 @@ export class AgentApiServer { } const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`); - const channel = url.searchParams.get("channel") ?? undefined; + const channelParam = url.searchParams.get("channel"); + const channelTargetId = url.searchParams.get("channelTargetId"); const unreadOnly = url.searchParams.get("unreadOnly") === "true"; const limitStr = url.searchParams.get("limit"); const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 200) : 50; + const allowedChannels = new Set(["direct", "department", "company", "project", "incident"]); + if (channelParam && !allowedChannels.has(channelParam)) { + this.json(res, 400, { error: `Invalid channel filter: ${channelParam}` }); + return; + } const messages = this.db.listAgentMessages(companyId, { agentId: token.agentId, - channel, + channel: channelParam ?? undefined, + channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined, unreadOnly, limit, }); @@ -1540,16 +1523,17 @@ export class AgentApiServer { this.json(res, 404, { error: "Message not found" }); return; } - const visibleToAgent = this.db.listAgentMessages(companyId, { agentId: token.agentId }).some((entry) => entry.id === message.id); + const visibleToAgent = this.db.listAgentMessages(companyId, { + agentId: token.agentId, + messageId: message.id, + limit: 1, + }).some((entry) => entry.id === message.id); if (!visibleToAgent) { this.json(res, 403, { error: "This message is not visible to the current agent" }); return; } - if (message.channel === "direct" && message.toAgentId !== token.agentId) { - this.json(res, 403, { error: "Only the direct recipient can mark this message as read" }); - return; - } this.db.markAgentMessageRead(params.messageId!, companyId, token.agentId); + this.onDomainChanged(); this.json(res, 200, { ok: true }); }); @@ -1634,6 +1618,21 @@ export class AgentApiServer { this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => { const targetAgentId = params.agentId!; + const targetAgent = (() => { + try { + return this.db.getAgent(targetAgentId); + } catch { + return null; + } + })(); + if (!targetAgent || targetAgent.companyId !== token.companyId) { + this.json(res, 404, { error: "Agent not found" }); + return; + } + if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) { + this.json(res, 403, { error: "Current agent is not allowed to wake this agent" }); + return; + } const wakeSchema = z.object({ reason: z.string().min(1).max(200).default("peer_request"), }); @@ -1666,6 +1665,10 @@ export class AgentApiServer { this.json(res, 404, { error: "Agent not found or not in the same company" }); return; } + if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) { + this.json(res, 403, { error: "Current agent is not allowed to trigger heartbeat for this agent" }); + return; + } const alreadyActive = this.db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId }); const runId = this.onWakeAgent(targetAgentId, token.companyId, "peer_request"); if (runId) { diff --git a/src/main/approval-policy.ts b/src/main/approval-policy.ts new file mode 100644 index 00000000..f4b16594 --- /dev/null +++ b/src/main/approval-policy.ts @@ -0,0 +1,54 @@ +import type { AppDatabase } from "./database"; + +const AUTO_APPROVAL_TYPES = new Set(["hire_agent", "approve_ceo_strategy"]); + +export function shouldAutoApproveApproval( + db: AppDatabase, + companyId: string, + approvalType: string, +): boolean { + if (!AUTO_APPROVAL_TYPES.has(approvalType)) { + return false; + } + + return db + .listSnapshot() + .companies + .some((company) => company.id === companyId && company.autoApproveHires); +} + +export function resolveApprovalDecision({ + db, + handleApprovalResolved, + companyId, + approvalId, + state, + decisionNote, + detail, + actor = "board", +}: { + db: AppDatabase; + handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; + companyId: string; + approvalId: string; + state: "approved" | "rejected" | "revision_requested"; + decisionNote?: string; + detail?: string | null; + actor?: string; +}) { + db.decideApproval({ + companyId, + approvalId, + state, + decisionNote: decisionNote ?? "", + }); + db.addActivity({ + companyId, + actor, + action: `approval.${state}`, + entityType: "approval", + entityId: approvalId, + detail: detail ?? decisionNote ?? "", + }); + handleApprovalResolved(approvalId, companyId, state); +} diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index 3a2fc7d3..f124d430 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -7,6 +7,7 @@ import type { RunRecord, RunStatus, } from "@shared/types"; +import { dispatchAgentMessage } from "./message-dispatch"; const WORKFLOW_ACTIONS: ReadonlySet = new Set([ "assign_task", @@ -198,7 +199,14 @@ export function createAutomationService({ } function executeAutomationAction( - rule: { action: string; actionConfigJson: string; targetDepartment: string | null; trigger?: string }, + rule: { + id?: string; + name?: string; + action: string; + actionConfigJson: string; + targetDepartment: string | null; + trigger?: string; + }, ctx: AutomationEventContext, ) { const config = (() => { @@ -268,7 +276,11 @@ export function createAutomationService({ 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"); + const defaultChannel = toAgentId ? "direct" : "company"; + const requestedChannel = (config.channel as string) ?? defaultChannel; + const channel = ["direct", "department", "company", "project", "incident"].includes(requestedChannel) + ? requestedChannel + : defaultChannel; let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null; if (channel === "department" && !channelTargetId && fromAgentId) { try { @@ -278,23 +290,29 @@ export function createAutomationService({ 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"); + const dispatchResult = dispatchAgentMessage( + { + db, + emitEvent, + publishDomainChanged, + wakeAgentIfPossible, + }, + { + companyId: ctx.companyId, + fromAgentId, + toAgentId: channel === "direct" ? toAgentId : null, + channel: channel as "direct" | "department" | "company" | "project" | "incident", + 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 "urgent" | "normal" | "low") ?? "normal", + }, + "message", + ); + if (!dispatchResult.ok) { + logger.warn( + `[automation] Failed to dispatch message for rule ${rule.name} (${rule.id}): ${dispatchResult.error.code} ${dispatchResult.error.message}`, + ); } break; } @@ -374,30 +392,55 @@ export function createAutomationService({ ? "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); + const escalationResult = dispatchAgentMessage( + { + db, + emitEvent, + publishDomainChanged, + wakeAgentIfPossible, + }, + { + companyId: ctx.companyId, + fromAgentId: targetAgentId, + toAgentId: manager.id, + channel: "direct", + subject: `Escalation from ${agentName}: ${taskTitle}`, + body: escalationBody, + priority: "urgent", + }, + wakeTrigger, + ); + if (!escalationResult.ok) { + logger.warn( + `[automation] Failed to escalate "${taskTitle}" to manager: ${escalationResult.error.code} ${escalationResult.error.message}`, + ); + } } 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(); + const escalationResult = dispatchAgentMessage( + { + db, + emitEvent, + publishDomainChanged, + wakeAgentIfPossible, + }, + { + companyId: ctx.companyId, + fromAgentId: targetAgentId, + toAgentId: null, + channel: "company", + subject: `Escalation: ${taskTitle}`, + body: escalationBody, + priority: "urgent", + }, + wakeTrigger, + ); + if (!escalationResult.ok) { + logger.warn( + `[automation] Failed to escalate "${taskTitle}" to company channel: ${escalationResult.error.code} ${escalationResult.error.message}`, + ); + } } break; } @@ -407,20 +450,30 @@ export function createAutomationService({ 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"); + const dispatchResult = dispatchAgentMessage( + { + db, + emitEvent, + publishDomainChanged, + wakeAgentIfPossible, + }, + { + 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", + }, + "message", + ); + if (!dispatchResult.ok) { + logger.warn( + `[automation] Failed cross-department notification for ${targetDepartment}: ${dispatchResult.error.code} ${dispatchResult.error.message}`, + ); + } } break; } diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index 6dd70f28..f711cb7a 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -30,6 +30,7 @@ interface BootstrapApplicationDependencies { 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; + emitEvent: (event: DesktopEvent) => void; }, allowedOrigins: string[]) => AgentApiServer; createRunToken: (agentId: string, companyId: string, runId: string) => string; seedDefaultAutomationRules: (companyId: string) => void; @@ -155,6 +156,7 @@ export async function bootstrapApplication({ dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined }); }, onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId), + emitEvent, }, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin))); apiServerRef.current = apiServer; diff --git a/src/main/browser-action-service.ts b/src/main/browser-action-service.ts index 51986549..f10e3631 100644 --- a/src/main/browser-action-service.ts +++ b/src/main/browser-action-service.ts @@ -25,6 +25,30 @@ export function createBrowserActionService({ if (!action) { throw new Error(`Browser action not found: ${actionId}`); } + if (action.status !== "queued") { + throw new Error(`Browser action ${actionId} is ${action.status} and cannot be dispatched.`); + } + if (action.approvalId) { + const approval = (() => { + try { + return db.getApprovalRecord(action.approvalId); + } catch { + return null; + } + })(); + if (!approval || approval.companyId !== companyId) { + throw new Error(`Browser action approval ${action.approvalId} does not belong to company ${companyId}.`); + } + if (approval.state !== "approved") { + db.updateBrowserAction(actionId, { + status: "approval_required", + resultSummary: "Browser action is still waiting for approval.", + errorMessage: "Approval must be granted before execution.", + }); + publishDomainChanged(); + return; + } + } const account = db.getSocialAccount(action.socialAccountId, companyId); if (!account) { diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 26c98c5c..f78becc8 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -327,45 +327,63 @@ export class BrowserManager { assertNotCancelled(); if (platform === "twitter") { + // Use clipboard-based approach since Twitter's React editor ignores execCommand await win.webContents.executeJavaScript(` (async () => { - const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]'); - if (editor) { - editor.focus(); - document.execCommand('insertText', false, ${JSON.stringify(content)}); + const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]'); + if (!editor) return; + editor.focus(); + // Write content to clipboard and paste it — this triggers React's input handlers + const dt = new DataTransfer(); + dt.setData('text/plain', ${JSON.stringify(content)}); + const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + editor.dispatchEvent(pasteEvent); + // Fallback: if paste didn't work, set innerHTML and dispatch input + await new Promise(r => setTimeout(r, 500)); + if (!editor.textContent || editor.textContent.trim().length === 0) { + editor.textContent = ${JSON.stringify(content)}; + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(content)} })); } })() `); - await this.delay(1000); + await this.delay(1500); await win.webContents.executeJavaScript(` (() => { const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'); - if (btn) btn.click(); + if (btn && !btn.disabled) btn.click(); })() `); await this.delay(3000); } else if (platform === "linkedin") { await win.webContents.executeJavaScript(` (() => { - const btn = document.querySelector('button.share-box-feed-entry__trigger'); + const btn = document.querySelector('button.share-box-feed-entry__trigger, button.artdeco-button--muted'); if (btn) btn.click(); })() `); await this.delay(2000); await win.webContents.executeJavaScript(` (async () => { - const editor = document.querySelector('[role="textbox"][contenteditable="true"]'); + const editor = document.querySelector('[role="textbox"][contenteditable="true"], .ql-editor[contenteditable="true"]'); if (editor) { editor.focus(); - document.execCommand('insertText', false, ${JSON.stringify(content)}); + const dt = new DataTransfer(); + dt.setData('text/plain', ${JSON.stringify(content)}); + const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + editor.dispatchEvent(pasteEvent); + await new Promise(r => setTimeout(r, 500)); + if (!editor.textContent || editor.textContent.trim().length === 0) { + editor.textContent = ${JSON.stringify(content)}; + editor.dispatchEvent(new InputEvent('input', { bubbles: true })); + } } })() `); - await this.delay(1000); + await this.delay(1500); await win.webContents.executeJavaScript(` (() => { - const btn = document.querySelector('button.share-actions__primary-action'); - if (btn) btn.click(); + const btn = document.querySelector('button.share-actions__primary-action, button.artdeco-button--primary[type="submit"]'); + if (btn && !btn.disabled) btn.click(); })() `); await this.delay(3000); @@ -386,18 +404,26 @@ export class BrowserManager { await win.webContents.executeJavaScript(` (async () => { - const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]'); + const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]'); if (editor) { editor.focus(); - document.execCommand('insertText', false, ${JSON.stringify(replyContent)}); + const dt = new DataTransfer(); + dt.setData('text/plain', ${JSON.stringify(replyContent)}); + const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + editor.dispatchEvent(pasteEvent); + await new Promise(r => setTimeout(r, 500)); + if (!editor.textContent || editor.textContent.trim().length === 0) { + editor.textContent = ${JSON.stringify(replyContent)}; + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(replyContent)} })); + } } })() `); - await this.delay(1000); + await this.delay(1500); await win.webContents.executeJavaScript(` (() => { const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]'); - if (btn) btn.click(); + if (btn && !btn.disabled) btn.click(); })() `); await this.delay(3000); @@ -675,7 +701,7 @@ export class BrowserManager { private getComposeUrl(platform: string): string { const urls: Record = { - twitter: "https://x.com/compose/post", + twitter: "https://x.com/compose/tweet", linkedin: "https://www.linkedin.com/feed/", instagram: "https://www.instagram.com/", facebook: "https://www.facebook.com/", diff --git a/src/main/database.ts b/src/main/database.ts index a6fd034f..8ad8f630 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -787,6 +787,53 @@ export class AppDatabase { return id; } + createHireRequest(input: { + companyId: string; + requestedByAgentId?: string | null; + name: string; + role: string; + title: string; + department?: string | null; + reportsTo?: string | null; + connectorId: ConnectorId; + workspaceId?: string | null; + model?: string | null; + metadataJson?: string; + capabilities: string; + budgetMonthlyUsd: number; + payloadSummary: string; + impactSummary: string; + payloadJson?: string | null; + }) { + return this.transaction(() => { + const agentId = this.saveAgent({ + companyId: input.companyId, + name: input.name, + role: input.role, + title: input.title, + department: input.department ?? null, + status: "pending_approval", + reportsTo: input.reportsTo ?? null, + connectorId: input.connectorId, + workspaceId: input.workspaceId ?? null, + model: input.model ?? null, + metadataJson: input.metadataJson ?? "{}", + capabilities: input.capabilities, + budgetMonthlyUsd: input.budgetMonthlyUsd, + }); + const approvalId = this.requestApproval({ + companyId: input.companyId, + requestedByAgentId: input.requestedByAgentId ?? null, + relatedAgentId: agentId, + type: "hire_agent", + payloadSummary: input.payloadSummary, + impactSummary: input.impactSummary, + payloadJson: input.payloadJson ?? null, + }); + return { agentId, approvalId }; + }); + } + saveGoal(input: { id?: string | null; companyId: string; title: string; description: string; parentId?: string | null; ownerAgentId?: string | null; status: string }) { const id = ensureId(input.id ?? null); const ts = nowIso(); @@ -882,6 +929,18 @@ export class AppDatabase { relatedAgentId = payload.agentId ?? null; } catch { /* ignore malformed payload */ } } + if (input.type === "hire_agent") { + if (!relatedAgentId) { + throw new Error("hire_agent approvals require a linked pending agent."); + } + const agent = this.getAgent(relatedAgentId); + if (agent.companyId !== input.companyId) { + throw new Error(`Agent ${relatedAgentId} does not belong to company ${input.companyId}.`); + } + if (agent.status !== "pending_approval") { + throw new Error(`hire_agent approvals can only target agents pending approval. Current status: ${agent.status}.`); + } + } this.db .prepare( "insert into approvals (id, company_id, related_task_id, requested_by_agent_id, related_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'pending', ?, ?)", @@ -923,14 +982,20 @@ export class AppDatabase { throw new Error(`hire_agent approval ${input.approvalId} has no linked agent — cannot resolve hire target. Ensure relatedAgentId or payloadJson.agentId is set.`); } if (input.state === "approved") { - this.db + const result = this.db .prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") .run(ts, agentId, approval.companyId); + if (result.changes === 0) { + throw new Error(`hire_agent approval ${input.approvalId} could not activate agent ${agentId}; the agent is missing or no longer pending approval.`); + } } if (input.state === "rejected") { - this.db + const result = this.db .prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'") .run(ts, agentId, approval.companyId); + if (result.changes === 0) { + throw new Error(`hire_agent approval ${input.approvalId} could not reject agent ${agentId}; the agent is missing or no longer pending approval.`); + } } } }); @@ -2349,9 +2414,20 @@ export class AppDatabase { return row ?? null; } - listAgentMessages(companyId: string, opts?: { agentId?: string; channel?: string; channelTargetId?: string | null; unreadOnly?: boolean; limit?: number }) { + listAgentMessages(companyId: string, opts?: { + agentId?: string; + messageId?: string; + channel?: string; + channelTargetId?: string | null; + unreadOnly?: boolean; + limit?: number; + }) { let sql = "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where company_id = ?"; const params: SQLInputValue[] = [companyId]; + if (opts?.messageId) { + sql += " and id = ?"; + params.push(opts.messageId); + } if (opts?.channel) { sql += " and channel = ?"; params.push(opts.channel); } if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { sql += " and channel_target_id = ?"; params.push(opts.channelTargetId); diff --git a/src/main/ipc-agent-coordination-handlers.ts b/src/main/ipc-agent-coordination-handlers.ts index 0004c804..eb6ab625 100644 --- a/src/main/ipc-agent-coordination-handlers.ts +++ b/src/main/ipc-agent-coordination-handlers.ts @@ -6,6 +6,7 @@ import { messageQuerySchema, } from "@shared/contracts"; import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; +import { dispatchAgentMessage } from "./message-dispatch"; export function registerAgentCoordinationHandlers({ registerHandle, @@ -29,41 +30,20 @@ export function registerAgentCoordinationHandlers({ 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 dispatchResult = dispatchAgentMessage( + { + db, + emitEvent, + publishDomainChanged, + wakeAgentIfPossible, + }, + parsed, + "message", + ); + if (!dispatchResult.ok) { + return fail(dispatchResult.error.code, dispatchResult.error.message); } - 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); + return ok(dispatchResult.data.id); }); registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => { @@ -90,13 +70,27 @@ export function registerAgentCoordinationHandlers({ const parsed = markMessageReadSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; - if (!db.getAgentMessage(parsed.messageId, parsed.companyId)) { + const message = db.getAgentMessage(parsed.messageId, parsed.companyId); + if (!message) { 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."); } + if (parsed.readerAgentId) { + const visibleToReader = db + .listAgentMessages(parsed.companyId, { + agentId: parsed.readerAgentId, + messageId: parsed.messageId, + limit: 1, + }) + .some((entry) => entry.id === parsed.messageId); + if (!visibleToReader) { + return fail("MESSAGE_NOT_VISIBLE", "This message is not visible to the selected reader."); + } + } db.markMessageReadForReader(parsed.messageId, parsed.companyId, parsed.readerAgentId ?? null); + publishDomainChanged(); return ok(true); }); diff --git a/src/main/ipc-core-operations-handlers.ts b/src/main/ipc-core-operations-handlers.ts index 62ac0247..5d8baae5 100644 --- a/src/main/ipc-core-operations-handlers.ts +++ b/src/main/ipc-core-operations-handlers.ts @@ -11,6 +11,7 @@ import { startRunSchema, triggerHeartbeatSchema, } from "@shared/contracts"; +import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; export function registerCoreOperationHandlers({ @@ -43,10 +44,16 @@ export function registerCoreOperationHandlers({ 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) { + const invalidRelatedAgent = parsed.relatedAgentId && !db.belongsToCompany("agents", parsed.relatedAgentId, parsed.companyId); + if (invalidTask || invalidAgent || invalidRelatedAgent) { return fail("INVALID_COMPANY_REFERENCE", "The approval references data outside the current company."); } - const id = db.requestApproval(parsed); + let id: string; + try { + id = db.requestApproval(parsed); + } catch (error) { + return fail("APPROVAL_INVALID", error instanceof Error ? error.message : "Approval could not be created."); + } db.addActivity({ companyId: parsed.companyId, actor: parsed.requestedByAgentId ?? "board", @@ -55,6 +62,20 @@ export function registerCoreOperationHandlers({ entityId: id, detail: parsed.payloadSummary, }); + if (shouldAutoApproveApproval(db, parsed.companyId, parsed.type)) { + resolveApprovalDecision({ + db, + handleApprovalResolved, + companyId: parsed.companyId, + approvalId: id, + state: "approved", + decisionNote: "Auto-approved by company policy", + detail: "Auto-approved by company policy", + }); + publishDomainChanged(); + return ok(id); + } + publishDomainChanged(); return ok(id); }); @@ -69,16 +90,15 @@ export function registerCoreOperationHandlers({ if (!approval || approval.companyId !== parsed.companyId) { return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); } - db.decideApproval(parsed); - db.addActivity({ + resolveApprovalDecision({ + db, + handleApprovalResolved, companyId: approval.companyId, - actor: "board", - action: `approval.${parsed.state}`, - entityType: "approval", - entityId: approval.id, + approvalId: approval.id, + state: parsed.state, + decisionNote: parsed.decisionNote, detail: parsed.decisionNote, }); - handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state); publishDomainChanged(); return ok(true); }); diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts index b3bcc8f3..fff3cd4a 100644 --- a/src/main/ipc-core-read-handlers.ts +++ b/src/main/ipc-core-read-handlers.ts @@ -86,8 +86,7 @@ export function registerCoreReadHandlers({ }); registerHandle(IPC_CHANNELS.checkForUpdates, async () => { - await checkForUpdates(); - return ok(true); + return ok(await checkForUpdates()); }); registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => { diff --git a/src/main/ipc-core-work-graph-handlers.ts b/src/main/ipc-core-work-graph-handlers.ts index 4049fc93..ba7e7c25 100644 --- a/src/main/ipc-core-work-graph-handlers.ts +++ b/src/main/ipc-core-work-graph-handlers.ts @@ -3,10 +3,12 @@ import { agentInputSchema, deleteEntitySchema, goalInputSchema, + hireRequestSchema, projectInputSchema, taskInputSchema, workspaceInputSchema, } from "@shared/contracts"; +import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; export function registerCoreWorkGraphHandlers({ @@ -16,6 +18,7 @@ export function registerCoreWorkGraphHandlers({ findInvalidReference, publishDomainChanged, wakeAgentIfPossible, + handleApprovalResolved, handleTaskCreated, handleTaskStatusChange, }: Pick< @@ -26,6 +29,7 @@ export function registerCoreWorkGraphHandlers({ | "findInvalidReference" | "publishDomainChanged" | "wakeAgentIfPossible" + | "handleApprovalResolved" | "handleTaskCreated" | "handleTaskStatusChange" >) { @@ -56,6 +60,35 @@ export function registerCoreWorkGraphHandlers({ const parsed = agentInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); if (companyError) return companyError; + const isNew = !parsed.id; + if (isNew) { + return fail( + "AGENT_CREATION_REQUIRES_HIRE_REQUEST", + "Create new agents through the hiring workflow so approval, onboarding, and org state stay synchronized.", + ); + } + const existingAgent = (() => { + try { + return db.getAgent(parsed.id!); + } catch { + return null; + } + })(); + if (!existingAgent || existingAgent.companyId !== parsed.companyId) { + return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); + } + if (existingAgent.status === "pending_approval") { + return fail( + "AGENT_APPROVAL_REQUIRED", + "Pending hires must be resolved from Hiring or Approvals. Direct edits, deletes, and activation changes are blocked here.", + ); + } + if (parsed.status === "pending_approval") { + return fail( + "AGENT_STATUS_LOCKED", + "Active agents cannot be moved back to pending approval by editing status directly.", + ); + } const invalidReference = findInvalidReference(parsed.companyId, [ { id: parsed.reportsTo, table: "agents", label: "manager" }, { id: parsed.workspaceId, table: "workspaces", label: "workspace" }, @@ -96,6 +129,93 @@ export function registerCoreWorkGraphHandlers({ return ok(id); }); + registerHandle(IPC_CHANNELS.requestHire, async (_event, payload) => { + const parsed = hireRequestSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const invalidReference = findInvalidReference(parsed.companyId, [ + { id: parsed.requestedByAgentId, table: "agents", label: "requesting agent" }, + { 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.`); + } + const managerLabel = parsed.reportsTo + ? (() => { + try { + return db.getAgent(parsed.reportsTo).name; + } catch { + return parsed.reportsTo; + } + })() + : "the company board"; + const payloadSummary = `Hire ${parsed.name} as ${parsed.role}`; + const impactSummary = parsed.capabilities || `${parsed.name} will report to ${managerLabel}.`; + let result: { agentId: string; approvalId: string }; + try { + result = db.createHireRequest({ + companyId: parsed.companyId, + requestedByAgentId: parsed.requestedByAgentId ?? null, + name: parsed.name, + role: parsed.role, + title: parsed.title, + department: parsed.department ?? null, + reportsTo: parsed.reportsTo ?? null, + connectorId: parsed.connectorId, + workspaceId: parsed.workspaceId ?? null, + model: parsed.model ?? null, + metadataJson: parsed.metadataJson, + capabilities: parsed.capabilities, + budgetMonthlyUsd: parsed.budgetMonthlyUsd, + payloadSummary, + impactSummary, + payloadJson: JSON.stringify({ + requestedByAgentId: parsed.requestedByAgentId ?? null, + requestedConfigurationSnapshot: { + connectorId: parsed.connectorId, + workspaceId: parsed.workspaceId ?? null, + model: parsed.model ?? null, + metadataJson: parsed.metadataJson ?? "{}", + capabilities: parsed.capabilities, + budgetMonthlyUsd: parsed.budgetMonthlyUsd, + reportsTo: parsed.reportsTo ?? null, + department: parsed.department ?? null, + title: parsed.title, + }, + }), + }); + } catch (error) { + return fail("HIRE_REQUEST_FAILED", error instanceof Error ? error.message : "Hire request could not be created."); + } + + db.addActivity({ + companyId: parsed.companyId, + actor: parsed.requestedByAgentId ?? "board", + action: "agent.hire_requested", + entityType: "agent", + entityId: result.agentId, + detail: parsed.name, + }); + + let autoApproved = false; + if (shouldAutoApproveApproval(db, parsed.companyId, "hire_agent")) { + resolveApprovalDecision({ + db, + handleApprovalResolved, + companyId: parsed.companyId, + approvalId: result.approvalId, + state: "approved", + decisionNote: "Auto-approved by company policy", + detail: "Auto-approved by company policy", + }); + autoApproved = true; + } + + publishDomainChanged(); + return ok({ ...result, autoApproved }); + }); + registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => { const parsed = goalInputSchema.parse(payload); const companyError = ensureCompanyExists(parsed.companyId); @@ -196,13 +316,65 @@ export function registerCoreWorkGraphHandlers({ return ok(id); }); + registerHandle(IPC_CHANNELS.deleteAgent, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("agents", parsed.id, parsed.companyId)) { + return fail("AGENT_NOT_FOUND", "Agent not found in the current company."); + } + const agent = db.getAgent(parsed.id); + if (agent.status === "pending_approval") { + return fail( + "AGENT_APPROVAL_REQUIRED", + "Pending hires cannot be deleted from the agent editor. Reject the hire request from Hiring or Approvals instead.", + ); + } + db.deleteAgent(parsed.id, parsed.companyId); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: "agent.deleted", + entityType: "agent", + entityId: parsed.id, + detail: "", + }); + publishDomainChanged(); + return ok(true); + }); + + registerHandle(IPC_CHANNELS.deleteApproval, async (_event, payload) => { + const parsed = deleteEntitySchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + if (!db.belongsToCompany("approvals", parsed.id, parsed.companyId)) { + return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); + } + const approval = db.getApprovalRecord(parsed.id); + if (approval.state === "pending" && approval.type === "hire_agent") { + return fail( + "APPROVAL_DELETE_BLOCKED", + "Pending hire approvals cannot be deleted. Reject the hire request instead so the linked agent record stays consistent.", + ); + } + db.deleteApproval(parsed.id, parsed.companyId); + db.addActivity({ + companyId: parsed.companyId, + actor: "board", + action: "approval.deleted", + entityType: "approval", + entityId: parsed.id, + detail: "", + }); + publishDomainChanged(); + return ok(true); + }); + 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) { diff --git a/src/main/message-dispatch.ts b/src/main/message-dispatch.ts new file mode 100644 index 00000000..1766c81f --- /dev/null +++ b/src/main/message-dispatch.ts @@ -0,0 +1,264 @@ +import type { AppDatabase } from "./database"; +import type { AgentMessageRecord, DesktopEvent, MessageChannel } from "@shared/types"; + +export interface AgentMessageDispatchInput { + companyId: string; + fromAgentId: string; + toAgentId?: string | null; + channel?: MessageChannel; + channelTargetId?: string | null; + subject?: string | null; + body: string; + priority?: "urgent" | "normal" | "low"; + parentMessageId?: string | null; + attachmentsJson?: string; +} + +export interface NormalizedAgentMessageInput { + companyId: string; + fromAgentId: string; + toAgentId: string | null; + channel: MessageChannel; + channelTargetId: string | null; + subject: string; + body: string; + priority: "urgent" | "normal" | "low"; + parentMessageId: string | null; + attachmentsJson: string; +} + +export interface MessageValidationError { + code: + | "DIRECT_MESSAGE_RECIPIENT_REQUIRED" + | "NON_DIRECT_MESSAGE_RECIPIENT_FORBIDDEN" + | "INVALID_COMPANY_REFERENCE" + | "CHANNEL_TARGET_REQUIRED" + | "PARENT_MESSAGE_NOT_FOUND"; + message: string; +} + +export interface DispatchAgentMessageDependencies { + db: AppDatabase; + publishDomainChanged?: () => void; + wakeAgentIfPossible?: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; + emitEvent?: (event: DesktopEvent) => void; +} + +export interface DispatchAgentMessageResult { + id: string; + message: AgentMessageRecord | null; + wakeTargets: string[]; +} + +function normalizeString(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +function normalizeChannelTargetId(channel: MessageChannel, channelTargetId: string | null | undefined): string | null { + const target = normalizeString(channelTargetId); + if (channel === "department" || channel === "project") { + return target || null; + } + return null; +} + +export function normalizeAgentMessageInput(input: AgentMessageDispatchInput): NormalizedAgentMessageInput { + const channel = input.channel ?? "direct"; + const subject = normalizeString(input.subject) || "(no subject)"; + const priority = input.priority ?? "normal"; + const toAgentId = normalizeString(input.toAgentId) || null; + const channelTargetId = normalizeChannelTargetId(channel, input.channelTargetId); + const parentMessageId = normalizeString(input.parentMessageId) || null; + const attachmentsJson = normalizeString(input.attachmentsJson) || "[]"; + + return { + companyId: input.companyId, + fromAgentId: input.fromAgentId, + toAgentId, + channel, + channelTargetId, + subject, + body: input.body, + priority, + parentMessageId, + attachmentsJson, + }; +} + +export function validateAgentMessageInput( + db: AppDatabase, + input: NormalizedAgentMessageInput, +): MessageValidationError | null { + if (!db.belongsToCompany("agents", input.fromAgentId, input.companyId)) { + return { + code: "INVALID_COMPANY_REFERENCE", + message: "The message sender does not belong to the current company.", + }; + } + + if (input.channel === "direct" && !input.toAgentId) { + return { + code: "DIRECT_MESSAGE_RECIPIENT_REQUIRED", + message: "Direct messages require a recipient agent.", + }; + } + if (input.channel !== "direct" && input.toAgentId) { + return { + code: "NON_DIRECT_MESSAGE_RECIPIENT_FORBIDDEN", + message: "Only direct messages may set a recipient agent.", + }; + } + + if (input.toAgentId && !db.belongsToCompany("agents", input.toAgentId, input.companyId)) { + return { + code: "INVALID_COMPANY_REFERENCE", + message: "The message recipient does not belong to the current company.", + }; + } + + if (input.channel === "project") { + if (!input.channelTargetId) { + return { + code: "CHANNEL_TARGET_REQUIRED", + message: "Project messages require a project channel target.", + }; + } + if (!db.belongsToCompany("projects", input.channelTargetId, input.companyId)) { + return { + code: "INVALID_COMPANY_REFERENCE", + message: "The selected project channel does not belong to the current company.", + }; + } + } + + if (input.channel === "department" && !input.channelTargetId) { + return { + code: "CHANNEL_TARGET_REQUIRED", + message: "Department messages require a department channel target.", + }; + } + + if (input.parentMessageId) { + const parentMessage = db.getAgentMessage(input.parentMessageId, input.companyId); + if (!parentMessage) { + return { + code: "PARENT_MESSAGE_NOT_FOUND", + message: "The parent message was not found in the current company.", + }; + } + } + + return null; +} + +function resolveProjectWakeTargets( + input: NormalizedAgentMessageInput, + snapshot: ReturnType, +): string[] { + if (!input.channelTargetId) return []; + const project = snapshot.projects.find( + (entry) => entry.id === input.channelTargetId && entry.companyId === input.companyId, + ); + const participantIds = new Set(); + if (project?.leadAgentId) participantIds.add(project.leadAgentId); + for (const task of snapshot.tasks) { + if (task.companyId !== input.companyId) continue; + if (task.projectId !== input.channelTargetId) continue; + if (task.assigneeAgentId) participantIds.add(task.assigneeAgentId); + } + + return Array.from(participantIds).filter((agentId) => { + if (agentId === input.fromAgentId) return false; + const agent = snapshot.agents.find((entry) => entry.id === agentId); + return Boolean(agent && agent.companyId === input.companyId && agent.status !== "terminated"); + }); +} + +export function resolveMessageWakeTargets( + db: AppDatabase, + input: NormalizedAgentMessageInput, +): string[] { + const snapshot = db.listSnapshot(); + const wakeTargets = new Set(); + + if (input.channel === "direct" && input.toAgentId && input.toAgentId !== input.fromAgentId) { + wakeTargets.add(input.toAgentId); + } + + if (input.channel === "department" && input.channelTargetId) { + for (const agent of snapshot.agents) { + if (agent.companyId !== input.companyId) continue; + if (agent.status === "terminated") continue; + if (agent.id === input.fromAgentId) continue; + if (agent.department === input.channelTargetId) { + wakeTargets.add(agent.id); + } + } + } + + if (input.channel === "company") { + const topLevel = snapshot.agents.filter( + (agent) => agent.companyId === input.companyId && !agent.reportsTo && agent.status !== "terminated", + ); + for (const agent of topLevel) { + if (agent.id !== input.fromAgentId) { + wakeTargets.add(agent.id); + } + } + } + + if (input.channel === "project") { + for (const agentId of resolveProjectWakeTargets(input, snapshot)) { + wakeTargets.add(agentId); + } + } + + if (input.channel === "incident") { + for (const agent of snapshot.agents) { + if (agent.companyId !== input.companyId) continue; + if (agent.status === "terminated" || agent.status === "pending_approval") continue; + if (agent.id === input.fromAgentId) continue; + wakeTargets.add(agent.id); + } + } + + return Array.from(wakeTargets); +} + +export function dispatchAgentMessage( + deps: DispatchAgentMessageDependencies, + rawInput: AgentMessageDispatchInput, + wakeTrigger = "message", +): { ok: true; data: DispatchAgentMessageResult } | { ok: false; error: MessageValidationError } { + const normalized = normalizeAgentMessageInput(rawInput); + const validationError = validateAgentMessageInput(deps.db, normalized); + if (validationError) { + return { ok: false, error: validationError }; + } + + const persistedMessage = normalized.channel === "direct" + ? normalized + : { ...normalized, toAgentId: null }; + const id = deps.db.sendAgentMessage(persistedMessage); + const message = deps.db.getAgentMessage(id, normalized.companyId); + if (message && deps.emitEvent) { + deps.emitEvent({ type: "new-message", message }); + } + deps.publishDomainChanged?.(); + + const wakeTargets = resolveMessageWakeTargets(deps.db, normalized); + if (deps.wakeAgentIfPossible) { + for (const wakeTarget of wakeTargets) { + deps.wakeAgentIfPossible(wakeTarget, normalized.companyId, wakeTrigger); + } + } + + return { + ok: true, + data: { + id, + message, + wakeTargets, + }, + }; +} diff --git a/src/main/orchestrator/worker.ts b/src/main/orchestrator/worker.ts index 9e2468b9..c22c79fd 100644 --- a/src/main/orchestrator/worker.ts +++ b/src/main/orchestrator/worker.ts @@ -76,6 +76,7 @@ const activeByAgent = new Map(); const activeByWorkspace = new Map(); const queue: RunJob[] = []; const cancelledRuns = new Set(); +const allowUnsafeConnectorBypass = process.env.AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS === "true"; function post(message: ChildMessage) { parentPort.postMessage(message); @@ -101,11 +102,11 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record = new Set([ "DEVELOPER_DIR", "SDKROOT", "NODE_ENV", - "NODE_OPTIONS", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", diff --git a/src/preload/index.ts b/src/preload/index.ts index 399dd930..5f9d7a79 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,7 @@ const api: DesktopApi = { testConnector: (id) => ipcRenderer.invoke(IPC_CHANNELS.testConnector, id), saveWorkspace: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkspace, input), saveAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAgent, input), + requestHire: (input) => ipcRenderer.invoke(IPC_CHANNELS.requestHire, input), saveGoal: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveGoal, input), saveProject: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveProject, input), saveTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveTask, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 998ef507..50e32975 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -239,6 +239,15 @@ export function App() { } }, [selectedConnectorId, selectedRunId]); + const openPathWithFeedback = useCallback(async (path: string) => { + try { + unwrap(await window.agentCompany.openPath(path)); + setError(null); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } + }, []); + // Stable ref so the subscribe callback always calls the latest refresh // without tearing down the subscription when refresh identity changes. const refreshRef = useRef(refresh); @@ -863,7 +872,7 @@ export function App() { setSelectedRunId(runId); }} onOpenLogPath={async (path: string) => { - unwrap(await window.agentCompany.openPath(path)); + await openPathWithFeedback(path); }} onCancelRun={cancelRun} onOpenConsole={(runId: string) => { @@ -908,7 +917,7 @@ export function App() { {section === "agents" ? ( <> - setSelectedAgentId("__new__")} />}> + setSelectedAgentId("__new__")} />}> - setSelectedAgentId(null)}> - { setSelectedAgentId(null); await refresh(); }} /> + setSelectedAgentId(null)}> + { + setSelectedAgentId(null); + setSection("hiring"); + await refresh(); + }} + /> setSelectedAgentId(null)}> {selectedAgent ? ( @@ -1198,6 +1217,7 @@ export function App() { {section === "automation" && currentCompany ? ( r.companyId === currentCompany.id) ?? []} workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []} companyId={currentCompany.id} @@ -1211,6 +1231,7 @@ export function App() { comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []} tasks={tasks} agents={companyAgents} + projects={companyProjects} companyId={currentCompany.id} selectedEntityId={selectedCommunicationEntityId} onEntityConsumed={() => setSelectedCommunicationEntityId(null)} @@ -1257,7 +1278,7 @@ export function App() { companyId={currentCompany!.id} selectedWorkspaceId={selectedWorkspace?.id ?? null} onSelect={setSelectedWorkspaceId} - onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)} + onOpen={openPathWithFeedback} onDeleted={refresh} /> @@ -1272,7 +1293,7 @@ export function App() { runs={runs} documents={companyDocuments} project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null} - onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)} + onOpen={openPathWithFeedback} /> @@ -1356,7 +1377,7 @@ export function App() { {selectedRun.logPath ? (
- void window.agentCompany.openPath(selectedRun.logPath!)} icon={FolderOpen} /> + void openPathWithFeedback(selectedRun.logPath!)} icon={FolderOpen} />
) : null} diff --git a/src/renderer/components/communication.tsx b/src/renderer/components/communication.tsx index 3b7722ff..b68d1b87 100644 --- a/src/renderer/components/communication.tsx +++ b/src/renderer/components/communication.tsx @@ -27,6 +27,7 @@ import type { DepartmentId, MessageChannel, MessagePriority, + ProjectRecord, TaskRecord, } from "@shared/types" import { getAgentInitials, formatMessageTime } from "../lib/formatters" @@ -64,6 +65,8 @@ interface ChannelDescriptor { targetId: string | null /** Agent ID for DM channels */ agentId: string | null + /** Participant IDs for DM channels */ + participantIds: string[] } interface TaskDiscussionDescriptor { @@ -76,9 +79,11 @@ interface TaskDiscussionDescriptor { function deriveChannels( messages: AgentMessageRecord[], agents: AgentRecord[], + projects?: ProjectRecord[], ): ChannelDescriptor[] { const channelMap = new Map() const agentMap = new Map(agents.map((a) => [a.id, a])) + const projectMap = new Map((projects ?? []).map((project) => [project.id, project])) // Always create company-wide channel channelMap.set("company-all", { @@ -88,6 +93,7 @@ function deriveChannels( label: "#company-wide", targetId: null, agentId: null, + participantIds: [], }) // Create permanent department channels for all departments that have agents @@ -107,6 +113,7 @@ function deriveChannels( label: `#${deptLabel}`, targetId: dept, agentId: null, + participantIds: [], }) } @@ -122,12 +129,14 @@ function deriveChannels( if (!otherId) { key = `direct-broadcast` label = "Broadcast DMs" + agentId = null } else { const ids = [msg.fromAgentId, otherId].sort() key = `direct-${ids.join("-")}` - const other = agentMap.get(otherId) - label = other ? other.name : "Unknown Agent" - agentId = otherId + const first = agentMap.get(ids[0] ?? "") + const second = agentMap.get(ids[1] ?? "") + label = `${first?.name ?? (ids[0] ? ids[0].slice(0, 8) : "Unknown")} ↔ ${second?.name ?? (ids[1] ? ids[1].slice(0, 8) : "Unknown")}` + agentId = ids[1] ?? null } break } @@ -145,13 +154,13 @@ function deriveChannels( case "project": { const projId = msg.channelTargetId ?? "general" key = `project-${projId}` - label = `#project-${projId.slice(0, 8)}` + label = projectMap.get(projId)?.name ?? `#project-${projId.slice(0, 8)}` break } case "incident": { - const incId = msg.channelTargetId ?? "general" + const incId = msg.channelTargetId ?? "room" key = `incident-${incId}` - label = `#incident-${incId.slice(0, 8)}` + label = incId === "room" ? "#incident-room" : `#incident-${incId.slice(0, 8)}` break } } @@ -164,6 +173,9 @@ function deriveChannels( label, targetId: msg.channelTargetId, agentId, + participantIds: msg.channel === "direct" && msg.toAgentId + ? [msg.fromAgentId, msg.toAgentId].sort() + : [], }) } } @@ -180,12 +192,17 @@ function deriveChannels( function deriveTaskDiscussionChannels( comments: CommentRecord[], tasks: TaskRecord[], + forcedTaskId?: string | null, ): TaskDiscussionDescriptor[] { const taskMap = new Map(tasks.map((task) => [task.id, task])) const activeTaskIds = tasks .filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status)) .map((task) => task.id) - const discussionTaskIds = new Set([...comments.map((comment) => comment.taskId), ...activeTaskIds]) + const discussionTaskIds = new Set([ + ...comments.map((comment) => comment.taskId), + ...activeTaskIds, + ...(forcedTaskId ? [forcedTaskId] : []), + ]) return Array.from(discussionTaskIds) .map((taskId) => { @@ -227,9 +244,9 @@ function getChannelMessages( } if (channel.channel === "direct" && msg.channel === "direct") { if (channel.key === "direct-broadcast") return !msg.toAgentId - if (!channel.agentId) return false - const ids = [msg.fromAgentId, msg.toAgentId ?? ""].sort() - return channel.key === `direct-${ids.join("-")}` + if (!msg.toAgentId || channel.participantIds.length < 2) return false + const ids = [msg.fromAgentId, msg.toAgentId].sort() + return ids[0] === channel.participantIds[0] && ids[1] === channel.participantIds[1] } return false }) @@ -355,22 +372,12 @@ function MessageBubble({ isThread ? "ml-11" : "" } ${ isUnread - ? "border-l-[3px] bg-[color:var(--panel)]" - : "border-l-[3px] border-l-transparent hover:bg-[color:var(--panel-soft)]/40" + ? "bg-[color:var(--panel)]" + : "hover:bg-[color:var(--panel-soft)]/40" } ${ - isUrgent - ? "border-l-[color:var(--danger)]" - : isUnread - ? `border-l-[${TERRACOTTA}]` - : "" + isUrgent ? "border-l-[3px]" : "" }`} - style={ - isUnread && !isUrgent - ? { borderLeftColor: TERRACOTTA } - : isUrgent - ? { borderLeftColor: "var(--danger)" } - : undefined - } + style={isUrgent ? { borderLeftColor: "var(--danger)" } : undefined} > @@ -610,6 +617,8 @@ interface MessageComposerProps { /** Pre-fill channel context */ channel: MessageChannel channelTargetId: string | null + /** Participant IDs for a direct channel */ + directParticipantIds?: string[] /** Pre-fill as a reply to a message */ replyTo: AgentMessageRecord | null onSent: () => void @@ -621,6 +630,7 @@ function MessageComposer({ companyId, channel, channelTargetId, + directParticipantIds = [], replyTo, onSent, onCancelReply, @@ -638,15 +648,41 @@ function MessageComposer({ } }, [replyTo]) + useEffect(() => { + if (agents.length === 0) { + if (fromAgentId) setFromAgentId("") + return + } + if (!agents.some((agent) => agent.id === fromAgentId)) { + setFromAgentId(agents[0]?.id ?? "") + } + }, [agents, fromAgentId]) + + const directRecipientId = useMemo(() => { + if (channel !== "direct") return null + if (replyTo?.fromAgentId) return replyTo.fromAgentId + if (directParticipantIds.length === 0) return null + if (directParticipantIds.length === 1) return directParticipantIds[0] ?? null + if (fromAgentId && directParticipantIds.includes(fromAgentId)) { + return directParticipantIds.find((agentId) => agentId !== fromAgentId) ?? null + } + return directParticipantIds[0] ?? null + }, [channel, replyTo, directParticipantIds, fromAgentId]) + const handleSend = useCallback(async () => { if (!body.trim() || !fromAgentId) return + if (channel === "direct" && !directRecipientId) return + if (!agents.some((agent) => agent.id === fromAgentId)) { + setSendError("Select a valid sender from the current company before sending.") + return + } setSending(true) setSendError(null) try { unwrap(await window.agentCompany.sendAgentMessage({ companyId, fromAgentId, - toAgentId: replyTo ? replyTo.fromAgentId : null, + toAgentId: channel === "direct" ? directRecipientId : null, channel, channelTargetId, subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), @@ -663,7 +699,7 @@ function MessageComposer({ } finally { setSending(false) } - }, [body, fromAgentId, companyId, channel, channelTargetId, replyTo, subject, onSent]) + }, [agents, body, channel, channelTargetId, companyId, directRecipientId, fromAgentId, onSent, replyTo, subject]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -680,6 +716,11 @@ function MessageComposer({ return (
{sendError ?
: null} + {channel === "direct" && !directRecipientId ? ( +
+ +
+ ) : null} {replyTo ? ( void handleSend()} - disabled={!body.trim() || !fromAgentId || sending} + disabled={!body.trim() || !fromAgentId || sending || (channel === "direct" && !directRecipientId)} className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-[8px] text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" style={{ backgroundColor: TERRACOTTA }} aria-label="Send message" @@ -768,11 +809,12 @@ interface ComposeModalProps { open: boolean onClose: () => void agents: AgentRecord[] + projects?: ProjectRecord[] companyId: string onSent: () => void } -function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModalProps) { +function ComposeModal({ open, onClose, agents, projects, companyId, onSent }: ComposeModalProps) { const [fromAgentId, setFromAgentId] = useState(agents[0]?.id ?? "") const [toAgentId, setToAgentId] = useState("") const [channel, setChannel] = useState("company") @@ -791,8 +833,38 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal return Array.from(depts).sort() }, [agents]) + useEffect(() => { + if (channel !== "direct") { + setToAgentId("") + } + if (channel !== "department" && channel !== "project") { + setChannelTargetId("") + } + }, [channel]) + + useEffect(() => { + if (agents.length === 0) { + if (fromAgentId) setFromAgentId("") + return + } + if (!agents.some((agent) => agent.id === fromAgentId)) { + setFromAgentId(agents[0]?.id ?? "") + } + }, [agents, fromAgentId]) + const handleSend = useCallback(async () => { - if (!body.trim() || !fromAgentId || !subject.trim() || (channel === "direct" && !toAgentId)) return + const requiresTarget = channel === "department" || channel === "project" + if ( + !body.trim() + || !fromAgentId + || !subject.trim() + || (channel === "direct" && !toAgentId) + || (requiresTarget && !channelTargetId) + ) return + if (!agents.some((agent) => agent.id === fromAgentId)) { + setSendError("Select a valid sender from the current company before sending.") + return + } setSending(true) setSendError(null) try { @@ -801,7 +873,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal fromAgentId, toAgentId: channel === "direct" && toAgentId ? toAgentId : null, channel, - channelTargetId: channelTargetId || null, + channelTargetId: channel === "department" || channel === "project" ? channelTargetId || null : null, subject: subject.trim(), body: body.trim(), priority, @@ -818,7 +890,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal } finally { setSending(false) } - }, [body, fromAgentId, toAgentId, channel, channelTargetId, subject, priority, companyId, onSent, onClose]) + }, [agents, body, channel, channelTargetId, companyId, fromAgentId, onClose, onSent, priority, subject, toAgentId]) return ( @@ -875,12 +947,18 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal })), ]} /> - ) : channel === "project" || channel === "incident" ? ( - ({ + value: p.id, + label: p.name, + })), + ]} /> ) : null} @@ -921,7 +999,14 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
); } @@ -292,6 +300,7 @@ export function AgentEditor({ const existingMeta = useMemo(() => { try { return JSON.parse(agent.metadataJson || "{}"); } catch { return {}; } }, [agent.metadataJson]); const [thinkingEffort, setThinkingEffort] = useState(existingMeta.thinkingEffort ?? ""); const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local"; + const pendingApproval = agent.status === "pending_approval"; useEffect(() => { void (async () => { @@ -314,27 +323,35 @@ export function AgentEditor({ return (
- - - + + + setConnectorId(v as ConnectorRecord["id"])} + disabled={pendingApproval} options={connectors.map((c) => ({ value: c.id, label: c.label }))} /> {availableModels.length > 0 ? ( @@ -342,6 +359,7 @@ export function AgentEditor({ label="Model" value={model} onChange={setModel} + disabled={pendingApproval} options={availableModels.map((m) => ({ value: m.id, label: m.label }))} /> ) : null} @@ -350,6 +368,7 @@ export function AgentEditor({ label="Thinking effort" value={thinkingEffort} onChange={setThinkingEffort} + disabled={pendingApproval} options={[ { value: "", label: "Default" }, { value: "low", label: "Low" }, @@ -362,6 +381,7 @@ export function AgentEditor({ label="Workspace" value={workspaceId} onChange={setWorkspaceId} + disabled={pendingApproval} options={[ { value: "", label: "No workspace" }, ...workspaces.map((w) => ({ value: w.id, label: w.name })), @@ -371,13 +391,14 @@ export function AgentEditor({ label="Reports to" value={reportsTo} onChange={setReportsTo} + disabled={pendingApproval} options={[ { value: "", label: "No manager" }, ...managerCandidates.map((a) => ({ value: a.id, label: a.name })), ]} /> - -