This commit is contained in:
ZacharyZhang-NY committed 2026-03-18 14:42:14 -04:00
1 parent 1ba3147ed2
commit 0cd65521b8
32 files changed
+1853 -342

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
AGENTCOMPANY_UPDATE_URL= AGENTCOMPANY_UPDATE_URL=
AGENTCOMPANY_DISABLE_UPDATES=true AGENTCOMPANY_DISABLE_UPDATES=true
AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS=false
CSC_LINK= CSC_LINK=
CSC_KEY_PASSWORD= CSC_KEY_PASSWORD=
WIN_CSC_LINK= WIN_CSC_LINK=
@@ -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.
@@ -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`
+333
View File
@@ -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.
-3
View File
@@ -27,6 +27,3 @@ nsis:
oneClick: false oneClick: false
perMachine: false perMachine: false
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
publish:
provider: generic
url: ${AGENTCOMPANY_UPDATE_URL:-https://updates.example.invalid/agentcompany}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-company", "name": "agent-company",
"version": "0.4.25", "version": "0.4.29",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
+34 -3
View File
@@ -11,11 +11,14 @@ const runtimeConfigPath = join(buildDir, "electron-builder.runtime.json");
const extraArgs = process.argv.slice(2); const extraArgs = process.argv.slice(2);
const env = { ...process.env }; const env = { ...process.env };
const requestedUpdateUrl = env.AGENTCOMPANY_UPDATE_URL?.trim() ?? "";
const strictRelease = env.AGENTCOMPANY_STRICT_RELEASE === "true";
const hasMacSigningMaterial = Boolean( const hasMacSigningMaterial = Boolean(
env.CSC_LINK?.trim() || env.CSC_LINK?.trim() ||
env.CSC_NAME?.trim() || env.CSC_NAME?.trim() ||
env.APPLE_API_KEY?.trim(), env.APPLE_API_KEY?.trim(),
); );
const isDirBuild = extraArgs.includes("--dir");
function bumpPatchVersion(currentVersion) { function bumpPatchVersion(currentVersion) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion); const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion);
@@ -43,11 +46,24 @@ async function writeRuntimeConfig(buildVersion) {
version: buildVersion, version: buildVersion,
}, },
}; };
if (requestedUpdateUrl) {
runtimeConfig.publish = [
{
provider: "generic",
url: requestedUpdateUrl,
},
];
}
if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true") { 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 = { runtimeConfig.mac = {
target: ["zip"],
hardenedRuntime: false, hardenedRuntime: false,
}; };
if (!isDirBuild) {
runtimeConfig.mac.target = ["zip"];
}
} else if (process.platform === "darwin") { } else if (process.platform === "darwin") {
runtimeConfig.mac = { runtimeConfig.mac = {
hardenedRuntime: true, hardenedRuntime: true,
@@ -91,18 +107,33 @@ const builderArgs = ["exec", "electron-builder", "--config", runtimeConfigPath];
if (!explicitPublishArg) { if (!explicitPublishArg) {
builderArgs.push("--publish", publishMode); 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("--mac", "zip");
} }
builderArgs.push(...extraArgs); 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 rm(buildDir, { recursive: true, force: true });
await mkdir(buildDir, { recursive: true }); await mkdir(buildDir, { recursive: true });
const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await resolveBuildVersion(); 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); await writeRuntimeConfig(buildVersion);
env.AGENTCOMPANY_BUILD_VERSION = buildVersion; env.AGENTCOMPANY_BUILD_VERSION = buildVersion;
env.npm_package_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, { const child = spawn("pnpm", builderArgs, {
stdio: "inherit", stdio: "inherit",
+18
View File
@@ -16,6 +16,24 @@ function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string)
return db.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId); 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 { export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean {
const agent = safeGetAgent(db, agentId); const agent = safeGetAgent(db, agentId);
if (!agent || agent.companyId !== companyId) { if (!agent || agent.companyId !== companyId) {
+133 -130
View File
@@ -1,10 +1,13 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import log from "electron-log/main.js"; import log from "electron-log/main.js";
import { z } from "zod"; import { z } from "zod";
import { agentMessageInputSchema, approvalInputSchema, goalInputSchema, hireRequestSchema, projectInputSchema, taskInputSchema } from "@shared/contracts";
import { validateAgentToken, type TokenPayload } from "./agent-auth"; 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 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 = ( type RequestHandler = (
req: IncomingMessage, req: IncomingMessage,
@@ -49,6 +52,7 @@ export interface ApiServerEventCallbacks {
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void; onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void;
onAutoAssignTask: (taskId: string, companyId: string) => string | null; onAutoAssignTask: (taskId: string, companyId: string) => string | null;
emitEvent: (event: DesktopEvent) => void;
} }
export class AgentApiServer { export class AgentApiServer {
@@ -58,6 +62,7 @@ export class AgentApiServer {
private db: AppDatabase; private db: AppDatabase;
private onDomainChanged: () => void; private onDomainChanged: () => void;
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
private emitEvent: (event: DesktopEvent) => void;
private callbacks: ApiServerEventCallbacks; private callbacks: ApiServerEventCallbacks;
private allowedOrigins: Set<string>; private allowedOrigins: Set<string>;
private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null; private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
@@ -72,6 +77,7 @@ export class AgentApiServer {
this.db = db; this.db = db;
this.onDomainChanged = onDomainChanged; this.onDomainChanged = onDomainChanged;
this.onWakeAgent = onWakeAgent; this.onWakeAgent = onWakeAgent;
this.emitEvent = callbacks?.emitEvent ?? (() => {});
this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean));
this.callbacks = { this.callbacks = {
onDomainChanged, onDomainChanged,
@@ -82,6 +88,7 @@ export class AgentApiServer {
onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}), onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}),
onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}), onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}),
onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null), onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null),
emitEvent: callbacks?.emitEvent ?? (() => {}),
}; };
this.registerRoutes(); this.registerRoutes();
} }
@@ -202,7 +209,7 @@ export class AgentApiServer {
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
log.error(`[api] ${method} ${pathname}: ${message}`); log.error(`[api] ${method} ${pathname}: ${message}`);
this.json(res, 500, { error: message }); this.json(res, 500, { error: "Internal server error" });
} }
return; return;
} }
@@ -728,9 +735,9 @@ export class AgentApiServer {
this.json(res, 403, { error: "Current agent is not allowed to request hires" }); this.json(res, 403, { error: "Current agent is not allowed to request hires" });
return; return;
} }
const parsed = agentInputSchema.safeParse({ const parsed = hireRequestSchema.safeParse({
companyId, companyId,
status: "pending_approval", requestedByAgentId: token.agentId,
...(typeof body === "object" && body ? body : {}), ...(typeof body === "object" && body ? body : {}),
reportsTo: reportsTo:
typeof body === "object" && body && "reportsTo" in body typeof body === "object" && body && "reportsTo" in body
@@ -743,85 +750,87 @@ export class AgentApiServer {
} }
const input = parsed.data; const input = parsed.data;
if (this.companyRefError(res, companyId, [ if (this.companyRefError(res, companyId, [
{ id: input.requestedByAgentId, table: "agents", label: "requesting agent" },
{ id: input.reportsTo, table: "agents", label: "manager" }, { id: input.reportsTo, table: "agents", label: "manager" },
{ id: input.workspaceId, table: "workspaces", label: "workspace" }, { id: input.workspaceId, table: "workspaces", label: "workspace" },
])) { ])) {
return; 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 { try {
agentId = this.db.saveAgent({ result = this.db.createHireRequest({
id: input.id ?? null,
companyId, companyId,
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
name: input.name, name: input.name,
role: input.role, role: input.role,
title: input.title, title: input.title,
department: input.department ?? null, department: input.department ?? null,
status: "pending_approval",
reportsTo: input.reportsTo ?? token.agentId, reportsTo: input.reportsTo ?? token.agentId,
connectorId: input.connectorId, connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null, workspaceId: input.workspaceId ?? null,
model: input.model ?? null, model: input.model ?? null,
metadataJson: input.metadataJson, metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities, capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd, 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) { } catch (error) {
this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" }); this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" });
return; 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({ this.db.addActivity({
companyId, companyId,
actor: token.agentId, actor: token.agentId,
action: "agent.hire_requested", action: "agent.hire_requested",
entityType: "agent", entityType: "agent",
entityId: agentId, entityId: result.agentId,
detail: input.name, detail: input.name,
}); });
// Auto-approve hire if company policy allows it let autoApproved = false;
const snapshot = this.db.listSnapshot(); if (shouldAutoApproveApproval(this.db, companyId, "hire_agent")) {
const company = snapshot.companies.find((c) => c.id === companyId); resolveApprovalDecision({
if (company?.autoApproveHires) { db: this.db,
this.db.decideApproval({ companyId, approvalId, state: "approved", decisionNote: "Auto-approved by company policy" }); handleApprovalResolved: this.callbacks.onApprovalResolved,
this.callbacks.onApprovalResolved(approvalId, companyId, "approved");
this.db.addActivity({
companyId, companyId,
actor: "board", approvalId: result.approvalId,
action: "approval.approved", state: "approved",
entityType: "approval", decisionNote: "Auto-approved by company policy",
entityId: approvalId,
detail: `Auto-approved hire: ${input.name}`, detail: `Auto-approved hire: ${input.name}`,
}); });
autoApproved = true;
log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`); log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`);
} }
this.onDomainChanged(); 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) => { this.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => {
@@ -904,18 +913,27 @@ export class AgentApiServer {
if (this.companyRefError(res, companyId, [ if (this.companyRefError(res, companyId, [
{ id: input.relatedTaskId, table: "tasks", label: "related task" }, { id: input.relatedTaskId, table: "tasks", label: "related task" },
{ id: token.agentId, table: "agents", label: "requesting agent" }, { id: token.agentId, table: "agents", label: "requesting agent" },
{ id: input.relatedAgentId, table: "agents", label: "related agent" },
])) { ])) {
return; return;
} }
const id = this.db.requestApproval({ let id: string;
companyId, try {
type: input.type, id = this.db.requestApproval({
relatedTaskId: input.relatedTaskId ?? null, companyId,
requestedByAgentId: token.agentId, type: input.type,
payloadSummary: input.payloadSummary, relatedTaskId: input.relatedTaskId ?? null,
impactSummary: input.impactSummary, 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({ this.db.addActivity({
companyId, companyId,
@@ -926,20 +944,17 @@ export class AgentApiServer {
detail: input.payloadSummary, detail: input.payloadSummary,
}); });
const snapshot = this.db.listSnapshot(); if (shouldAutoApproveApproval(this.db, companyId, input.type)) {
const company = snapshot.companies.find((c) => c.id === companyId); resolveApprovalDecision({
if (company?.autoApproveHires && (input.type === "hire_agent" || input.type === "approve_ceo_strategy")) { db: this.db,
this.db.decideApproval({ companyId, approvalId: id, state: "approved", decisionNote: "Auto-approved by company policy" }); handleApprovalResolved: this.callbacks.onApprovalResolved,
this.callbacks.onApprovalResolved(id, companyId, "approved");
this.db.addActivity({
companyId, companyId,
actor: "board", approvalId: id,
action: "approval.approved", state: "approved",
entityType: "approval", decisionNote: "Auto-approved by company policy",
entityId: id,
detail: "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(); this.onDomainChanged();
@@ -1437,74 +1452,35 @@ export class AgentApiServer {
return; return;
} }
const messageSchema = z.object({ const parsed = agentMessageInputSchema.safeParse({
toAgentId: z.string().uuid().nullable().optional(), ...(body as Record<string, unknown>),
channel: z.enum(["direct", "department", "company", "project", "incident"]).default("direct"), companyId,
channelTargetId: z.string().nullable().optional(), fromAgentId: token.agentId,
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 = messageSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return; return;
} }
const msg = parsed.data; const dispatchResult = dispatchAgentMessage(
if (msg.channel === "direct" && !msg.toAgentId) { {
this.json(res, 400, { error: "Direct messages require a recipient" }); 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; 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.json(res, 201, { id: dispatchResult.data.id });
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.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => { 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 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 unreadOnly = url.searchParams.get("unreadOnly") === "true";
const limitStr = url.searchParams.get("limit"); const limitStr = url.searchParams.get("limit");
const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 200) : 50; 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, { const messages = this.db.listAgentMessages(companyId, {
agentId: token.agentId, agentId: token.agentId,
channel, channel: channelParam ?? undefined,
channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined,
unreadOnly, unreadOnly,
limit, limit,
}); });
@@ -1540,16 +1523,17 @@ export class AgentApiServer {
this.json(res, 404, { error: "Message not found" }); this.json(res, 404, { error: "Message not found" });
return; 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) { if (!visibleToAgent) {
this.json(res, 403, { error: "This message is not visible to the current agent" }); this.json(res, 403, { error: "This message is not visible to the current agent" });
return; 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.db.markAgentMessageRead(params.messageId!, companyId, token.agentId);
this.onDomainChanged();
this.json(res, 200, { ok: true }); 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) => { this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => {
const targetAgentId = params.agentId!; 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({ const wakeSchema = z.object({
reason: z.string().min(1).max(200).default("peer_request"), 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" }); this.json(res, 404, { error: "Agent not found or not in the same company" });
return; 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 alreadyActive = this.db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
const runId = this.onWakeAgent(targetAgentId, token.companyId, "peer_request"); const runId = this.onWakeAgent(targetAgentId, token.companyId, "peer_request");
if (runId) { if (runId) {
+54
View File
@@ -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);
}
+107 -54
View File
@@ -7,6 +7,7 @@ import type {
RunRecord, RunRecord,
RunStatus, RunStatus,
} from "@shared/types"; } from "@shared/types";
import { dispatchAgentMessage } from "./message-dispatch";
const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set([ const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set([
"assign_task", "assign_task",
@@ -198,7 +199,14 @@ export function createAutomationService({
} }
function executeAutomationAction( 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, ctx: AutomationEventContext,
) { ) {
const config = (() => { const config = (() => {
@@ -268,7 +276,11 @@ export function createAutomationService({
const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null; const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null;
const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId; const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId;
if (!fromAgentId) break; 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; let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null;
if (channel === "department" && !channelTargetId && fromAgentId) { if (channel === "department" && !channelTargetId && fromAgentId) {
try { try {
@@ -278,23 +290,29 @@ export function createAutomationService({
channelTargetId = null; channelTargetId = null;
} }
} }
const messageId = db.sendAgentMessage({ const dispatchResult = dispatchAgentMessage(
companyId: ctx.companyId, {
fromAgentId, db,
toAgentId: channel === "direct" ? toAgentId : null, emitEvent,
channel, publishDomainChanged,
channelTargetId, wakeAgentIfPossible,
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", companyId: ctx.companyId,
}); fromAgentId,
const message = db.getAgentMessage(messageId, ctx.companyId); toAgentId: channel === "direct" ? toAgentId : null,
if (message) { channel: channel as "direct" | "department" | "company" | "project" | "incident",
emitEvent({ type: "new-message", message }); channelTargetId,
} subject: (config.subject as string) ?? "Automation notification",
publishDomainChanged(); body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`,
if (toAgentId && channel === "direct") { priority: (config.priority as "urgent" | "normal" | "low") ?? "normal",
wakeAgentIfPossible(toAgentId, ctx.companyId, "message"); },
"message",
);
if (!dispatchResult.ok) {
logger.warn(
`[automation] Failed to dispatch message for rule ${rule.name} (${rule.id}): ${dispatchResult.error.code} ${dispatchResult.error.message}`,
);
} }
break; break;
} }
@@ -374,30 +392,55 @@ export function createAutomationService({
? "report_failed" ? "report_failed"
: "report_blocked"; : "report_blocked";
if (manager) { if (manager) {
db.sendAgentMessage({ const escalationResult = dispatchAgentMessage(
companyId: ctx.companyId, {
fromAgentId: targetAgentId, db,
toAgentId: manager.id, emitEvent,
channel: "direct", publishDomainChanged,
subject: `Escalation from ${agentName}: ${taskTitle}`, wakeAgentIfPossible,
body: escalationBody, },
priority: "urgent", {
}); companyId: ctx.companyId,
publishDomainChanged(); fromAgentId: targetAgentId,
wakeAgentIfPossible(manager.id, ctx.companyId, wakeTrigger); 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 { } else {
// No manager found — agent is likely the CEO or top-level. // No manager found — agent is likely the CEO or top-level.
// Post as company-wide message so the board and other leaders see it. // Post as company-wide message so the board and other leaders see it.
db.sendAgentMessage({ const escalationResult = dispatchAgentMessage(
companyId: ctx.companyId, {
fromAgentId: targetAgentId, db,
toAgentId: null, emitEvent,
channel: "company", publishDomainChanged,
subject: `Escalation: ${taskTitle}`, wakeAgentIfPossible,
body: escalationBody, },
priority: "urgent", {
}); companyId: ctx.companyId,
publishDomainChanged(); 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; break;
} }
@@ -407,20 +450,30 @@ export function createAutomationService({
const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment); const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment);
const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id; const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id;
if (senderAgentId) { if (senderAgentId) {
db.sendAgentMessage({ const dispatchResult = dispatchAgentMessage(
companyId: ctx.companyId, {
fromAgentId: senderAgentId, db,
toAgentId: null, emitEvent,
channel: "department", publishDomainChanged,
channelTargetId: targetDepartment, wakeAgentIfPossible,
subject: (config.subject as string) ?? "Cross-department notification", },
body: (config.body as string) ?? "Automation triggered a cross-department alert.", {
priority: "normal", companyId: ctx.companyId,
}); fromAgentId: senderAgentId,
publishDomainChanged(); toAgentId: null,
} channel: "department",
for (const agent of departmentAgents.slice(0, 5)) { channelTargetId: targetDepartment,
wakeAgentIfPossible(agent.id, ctx.companyId, "message"); 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; break;
} }
+2
View File
@@ -30,6 +30,7 @@ interface BootstrapApplicationDependencies {
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void; onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void;
onAutoAssignTask: (taskId: string, companyId: string) => string | null; onAutoAssignTask: (taskId: string, companyId: string) => string | null;
emitEvent: (event: DesktopEvent) => void;
}, allowedOrigins: string[]) => AgentApiServer; }, allowedOrigins: string[]) => AgentApiServer;
createRunToken: (agentId: string, companyId: string, runId: string) => string; createRunToken: (agentId: string, companyId: string, runId: string) => string;
seedDefaultAutomationRules: (companyId: string) => void; seedDefaultAutomationRules: (companyId: string) => void;
@@ -155,6 +156,7 @@ export async function bootstrapApplication({
dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined }); dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined });
}, },
onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId), onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId),
emitEvent,
}, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin))); }, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin)));
apiServerRef.current = apiServer; apiServerRef.current = apiServer;
+24
View File
@@ -25,6 +25,30 @@ export function createBrowserActionService({
if (!action) { if (!action) {
throw new Error(`Browser action not found: ${actionId}`); 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); const account = db.getSocialAccount(action.socialAccountId, companyId);
if (!account) { if (!account) {
+43 -17
View File
@@ -327,45 +327,63 @@ export class BrowserManager {
assertNotCancelled(); assertNotCancelled();
if (platform === "twitter") { if (platform === "twitter") {
// Use clipboard-based approach since Twitter's React editor ignores execCommand
await win.webContents.executeJavaScript(` await win.webContents.executeJavaScript(`
(async () => { (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) { if (!editor) return;
editor.focus(); editor.focus();
document.execCommand('insertText', false, ${JSON.stringify(content)}); // 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(` await win.webContents.executeJavaScript(`
(() => { (() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'); const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
if (btn) btn.click(); if (btn && !btn.disabled) btn.click();
})() })()
`); `);
await this.delay(3000); await this.delay(3000);
} else if (platform === "linkedin") { } else if (platform === "linkedin") {
await win.webContents.executeJavaScript(` 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(); if (btn) btn.click();
})() })()
`); `);
await this.delay(2000); await this.delay(2000);
await win.webContents.executeJavaScript(` await win.webContents.executeJavaScript(`
(async () => { (async () => {
const editor = document.querySelector('[role="textbox"][contenteditable="true"]'); const editor = document.querySelector('[role="textbox"][contenteditable="true"], .ql-editor[contenteditable="true"]');
if (editor) { if (editor) {
editor.focus(); 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(` await win.webContents.executeJavaScript(`
(() => { (() => {
const btn = document.querySelector('button.share-actions__primary-action'); const btn = document.querySelector('button.share-actions__primary-action, button.artdeco-button--primary[type="submit"]');
if (btn) btn.click(); if (btn && !btn.disabled) btn.click();
})() })()
`); `);
await this.delay(3000); await this.delay(3000);
@@ -386,18 +404,26 @@ export class BrowserManager {
await win.webContents.executeJavaScript(` await win.webContents.executeJavaScript(`
(async () => { (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) { if (editor) {
editor.focus(); 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(` await win.webContents.executeJavaScript(`
(() => { (() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]'); 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); await this.delay(3000);
@@ -675,7 +701,7 @@ export class BrowserManager {
private getComposeUrl(platform: string): string { private getComposeUrl(platform: string): string {
const urls: Record<string, string> = { const urls: Record<string, string> = {
twitter: "https://x.com/compose/post", twitter: "https://x.com/compose/tweet",
linkedin: "https://www.linkedin.com/feed/", linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/", instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/", facebook: "https://www.facebook.com/",
+79 -3
View File
@@ -787,6 +787,53 @@ export class AppDatabase {
return id; 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 }) { 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 id = ensureId(input.id ?? null);
const ts = nowIso(); const ts = nowIso();
@@ -882,6 +929,18 @@ export class AppDatabase {
relatedAgentId = payload.agentId ?? null; relatedAgentId = payload.agentId ?? null;
} catch { /* ignore malformed payload */ } } 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 this.db
.prepare( .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', ?, ?)", "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.`); 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") { 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'") .prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'")
.run(ts, agentId, approval.companyId); .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") { 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'") .prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'")
.run(ts, agentId, approval.companyId); .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; 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 = ?"; 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]; 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?.channel) { sql += " and channel = ?"; params.push(opts.channel); }
if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) {
sql += " and channel_target_id = ?"; params.push(opts.channelTargetId); sql += " and channel_target_id = ?"; params.push(opts.channelTargetId);
+29 -35
View File
@@ -6,6 +6,7 @@ import {
messageQuerySchema, messageQuerySchema,
} from "@shared/contracts"; } from "@shared/contracts";
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
import { dispatchAgentMessage } from "./message-dispatch";
export function registerAgentCoordinationHandlers({ export function registerAgentCoordinationHandlers({
registerHandle, registerHandle,
@@ -29,41 +30,20 @@ export function registerAgentCoordinationHandlers({
const parsed = agentMessageInputSchema.parse(payload); const parsed = agentMessageInputSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId); const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError; if (companyError) return companyError;
if (parsed.channel === "direct" && !parsed.toAgentId) { const dispatchResult = dispatchAgentMessage(
return fail("DIRECT_MESSAGE_RECIPIENT_REQUIRED", "Direct messages require a recipient agent."); {
db,
emitEvent,
publishDomainChanged,
wakeAgentIfPossible,
},
parsed,
"message",
);
if (!dispatchResult.ok) {
return fail(dispatchResult.error.code, dispatchResult.error.message);
} }
const invalidReference = findInvalidReference(parsed.companyId, [ return ok(dispatchResult.data.id);
{ id: parsed.fromAgentId, table: "agents", label: "message sender" },
{ id: parsed.toAgentId, table: "agents", label: "message recipient" },
{ id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" },
]);
if (invalidReference) {
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
}
const id = db.sendAgentMessage(parsed);
const message = db.getAgentMessage(id, parsed.companyId);
if (message) {
emitEvent({ type: "new-message", message });
}
publishDomainChanged();
if (parsed.toAgentId) {
wakeAgentIfPossible(parsed.toAgentId, parsed.companyId, "message");
}
if (parsed.channel === "department" && parsed.channelTargetId) {
const departmentAgents = db.getIdleAgentsForDepartment(parsed.companyId, parsed.channelTargetId);
for (const agent of departmentAgents.filter((entry: { id: string }) => entry.id !== parsed.fromAgentId)) {
wakeAgentIfPossible(agent.id, parsed.companyId, "message");
}
}
if (parsed.channel === "company") {
const ceo = db.listSnapshot().agents.find((agent: { companyId: string; reportsTo: string | null; status: string }) =>
agent.companyId === parsed.companyId && !agent.reportsTo && agent.status !== "terminated",
);
if (ceo && ceo.id !== parsed.fromAgentId) {
wakeAgentIfPossible(ceo.id, parsed.companyId, "message");
}
}
return ok(id);
}); });
registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => { registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => {
@@ -90,13 +70,27 @@ export function registerAgentCoordinationHandlers({
const parsed = markMessageReadSchema.parse(payload); const parsed = markMessageReadSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId); const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError; 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."); return fail("MESSAGE_NOT_FOUND", "Message not found in the current company.");
} }
if (parsed.readerAgentId && !db.belongsToCompany("agents", parsed.readerAgentId, parsed.companyId)) { 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."); 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); db.markMessageReadForReader(parsed.messageId, parsed.companyId, parsed.readerAgentId ?? null);
publishDomainChanged();
return ok(true); return ok(true);
}); });
+29 -9
View File
@@ -11,6 +11,7 @@ import {
startRunSchema, startRunSchema,
triggerHeartbeatSchema, triggerHeartbeatSchema,
} from "@shared/contracts"; } from "@shared/contracts";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
export function registerCoreOperationHandlers({ export function registerCoreOperationHandlers({
@@ -43,10 +44,16 @@ export function registerCoreOperationHandlers({
if (companyError) return companyError; if (companyError) return companyError;
const invalidTask = parsed.relatedTaskId && !db.belongsToCompany("tasks", parsed.relatedTaskId, parsed.companyId); const invalidTask = parsed.relatedTaskId && !db.belongsToCompany("tasks", parsed.relatedTaskId, parsed.companyId);
const invalidAgent = parsed.requestedByAgentId && !db.belongsToCompany("agents", parsed.requestedByAgentId, 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."); 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({ db.addActivity({
companyId: parsed.companyId, companyId: parsed.companyId,
actor: parsed.requestedByAgentId ?? "board", actor: parsed.requestedByAgentId ?? "board",
@@ -55,6 +62,20 @@ export function registerCoreOperationHandlers({
entityId: id, entityId: id,
detail: parsed.payloadSummary, 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); return ok(id);
}); });
@@ -69,16 +90,15 @@ export function registerCoreOperationHandlers({
if (!approval || approval.companyId !== parsed.companyId) { if (!approval || approval.companyId !== parsed.companyId) {
return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company."); return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company.");
} }
db.decideApproval(parsed); resolveApprovalDecision({
db.addActivity({ db,
handleApprovalResolved,
companyId: approval.companyId, companyId: approval.companyId,
actor: "board", approvalId: approval.id,
action: `approval.${parsed.state}`, state: parsed.state,
entityType: "approval", decisionNote: parsed.decisionNote,
entityId: approval.id,
detail: parsed.decisionNote, detail: parsed.decisionNote,
}); });
handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state);
publishDomainChanged(); publishDomainChanged();
return ok(true); return ok(true);
}); });
+1 -2
View File
@@ -86,8 +86,7 @@ export function registerCoreReadHandlers({
}); });
registerHandle(IPC_CHANNELS.checkForUpdates, async () => { registerHandle(IPC_CHANNELS.checkForUpdates, async () => {
await checkForUpdates(); return ok(await checkForUpdates());
return ok(true);
}); });
registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => { registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => {
+174 -2
View File
@@ -3,10 +3,12 @@ import {
agentInputSchema, agentInputSchema,
deleteEntitySchema, deleteEntitySchema,
goalInputSchema, goalInputSchema,
hireRequestSchema,
projectInputSchema, projectInputSchema,
taskInputSchema, taskInputSchema,
workspaceInputSchema, workspaceInputSchema,
} from "@shared/contracts"; } from "@shared/contracts";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
export function registerCoreWorkGraphHandlers({ export function registerCoreWorkGraphHandlers({
@@ -16,6 +18,7 @@ export function registerCoreWorkGraphHandlers({
findInvalidReference, findInvalidReference,
publishDomainChanged, publishDomainChanged,
wakeAgentIfPossible, wakeAgentIfPossible,
handleApprovalResolved,
handleTaskCreated, handleTaskCreated,
handleTaskStatusChange, handleTaskStatusChange,
}: Pick< }: Pick<
@@ -26,6 +29,7 @@ export function registerCoreWorkGraphHandlers({
| "findInvalidReference" | "findInvalidReference"
| "publishDomainChanged" | "publishDomainChanged"
| "wakeAgentIfPossible" | "wakeAgentIfPossible"
| "handleApprovalResolved"
| "handleTaskCreated" | "handleTaskCreated"
| "handleTaskStatusChange" | "handleTaskStatusChange"
>) { >) {
@@ -56,6 +60,35 @@ export function registerCoreWorkGraphHandlers({
const parsed = agentInputSchema.parse(payload); const parsed = agentInputSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId); const companyError = ensureCompanyExists(parsed.companyId);
if (companyError) return companyError; 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, [ const invalidReference = findInvalidReference(parsed.companyId, [
{ id: parsed.reportsTo, table: "agents", label: "manager" }, { id: parsed.reportsTo, table: "agents", label: "manager" },
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" }, { id: parsed.workspaceId, table: "workspaces", label: "workspace" },
@@ -96,6 +129,93 @@ export function registerCoreWorkGraphHandlers({
return ok(id); 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) => { registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => {
const parsed = goalInputSchema.parse(payload); const parsed = goalInputSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId); const companyError = ensureCompanyExists(parsed.companyId);
@@ -196,13 +316,65 @@ export function registerCoreWorkGraphHandlers({
return ok(id); 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 = [ 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.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.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.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.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; ] as const;
for (const { channel, table, entity, deleteFn } of deleteHandlers) { for (const { channel, table, entity, deleteFn } of deleteHandlers) {
+264
View File
@@ -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<AppDatabase["listSnapshot"]>,
): string[] {
if (!input.channelTargetId) return [];
const project = snapshot.projects.find(
(entry) => entry.id === input.channelTargetId && entry.companyId === input.companyId,
);
const participantIds = new Set<string>();
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<string>();
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,
},
};
}
+5 -4
View File
@@ -76,6 +76,7 @@ const activeByAgent = new Map<string, string>();
const activeByWorkspace = new Map<string, string>(); const activeByWorkspace = new Map<string, string>();
const queue: RunJob[] = []; const queue: RunJob[] = [];
const cancelledRuns = new Set<string>(); const cancelledRuns = new Set<string>();
const allowUnsafeConnectorBypass = process.env.AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS === "true";
function post(message: ChildMessage) { function post(message: ChildMessage) {
parentPort.postMessage(message); parentPort.postMessage(message);
@@ -101,11 +102,11 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
return { return {
...baseConfig, ...baseConfig,
modelReasoningEffort: job.thinkingEffort ?? "", modelReasoningEffort: job.thinkingEffort ?? "",
dangerouslyBypassApprovalsAndSandbox: true, dangerouslyBypassApprovalsAndSandbox: allowUnsafeConnectorBypass,
extraArgs: [ extraArgs: [
"--skip-git-repo-check", "--skip-git-repo-check",
"--sandbox", "--sandbox",
"danger-full-access", allowUnsafeConnectorBypass ? "danger-full-access" : "workspace-write",
"--ask-for-approval", "--ask-for-approval",
"never", "never",
], ],
@@ -114,14 +115,14 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
return { return {
...baseConfig, ...baseConfig,
effort: job.thinkingEffort ?? "", effort: job.thinkingEffort ?? "",
dangerouslySkipPermissions: true, dangerouslySkipPermissions: allowUnsafeConnectorBypass,
}; };
case "gemini_local": case "gemini_local":
return { return {
...baseConfig, ...baseConfig,
yolo: true, yolo: true,
approvalMode: "yolo", approvalMode: "yolo",
sandbox: false, sandbox: true,
}; };
case "kimi_local": case "kimi_local":
return { return {
+30 -5
View File
@@ -300,15 +300,19 @@ You have TWO communication channels — use both strategically:
**Agent Messaging** (for coordination, announcements, and requests): **Agent Messaging** (for coordination, announcements, and requests):
- Use POST /api/companies/{companyId}/messages to send messages - Use POST /api/companies/{companyId}/messages to send messages
- Direct messages (channel: "direct") WAKE the recipient immediately - Direct messages (channel: "direct", toAgentId: "{agentId}") WAKE the recipient immediately
- Department messages (channel: "department", channelTargetId: "engineering") reach all dept members - Department messages (channel: "department", channelTargetId: "{departmentName}") reach all dept members
- Company-wide messages (channel: "company") reach everyone - Company-wide messages (channel: "company") reach everyone and wake the CEO
- Project messages (channel: "project", channelTargetId: "{projectId}") reach all agents assigned to tasks in that project
- Incident messages (channel: "incident") are high-visibility alerts visible to all agents company-wide
- USE MESSAGING WHEN: - USE MESSAGING WHEN:
* You need another agent to take action NOW (send direct message) * You need another agent to take action NOW (send direct message)
* You're announcing something department-wide (send to department channel) * You're announcing something department-wide (send to department channel)
* You're sharing a status update or decision (send to company channel) * You're sharing a status update or decision (send to company channel)
* You need cross-department coordination (send to target department) * You need cross-department coordination (send to target department)
* You want to discuss strategy or priorities (send to your manager directly) * You want to discuss strategy or priorities (send to your manager directly)
* You're coordinating within a specific project (send to project channel with projectId)
* There is a critical outage or urgent issue (send to incident channel)
**IMPORTANT**: Check your messages EVERY wake cycle. Read unread messages first: **IMPORTANT**: Check your messages EVERY wake cycle. Read unread messages first:
GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20 GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20
@@ -603,16 +607,23 @@ GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.age
### Send a Message to Another Agent ### Send a Message to Another Agent
POST /api/companies/${ctx.agent.companyId}/messages POST /api/companies/${ctx.agent.companyId}/messages
Body: { Body: {
"toAgentId": "{targetAgentId}", "toAgentId": "{targetAgentId or null for non-direct channels}",
"channel": "direct|department|company|project|incident", "channel": "direct|department|company|project|incident",
"channelTargetId": "{departmentName or null for direct}", "channelTargetId": "{see below}",
"subject": "Message subject — clear and actionable", "subject": "Message subject — clear and actionable",
"body": "Your message content — include context so recipient can act", "body": "Your message content — include context so recipient can act",
"priority": "urgent|normal|low", "priority": "urgent|normal|low",
"parentMessageId": "{optionalReplyToMessageId}" "parentMessageId": "{optionalReplyToMessageId}"
} }
channelTargetId rules:
- channel "direct": set channelTargetId to null, set toAgentId to recipient
- channel "department": set channelTargetId to department name (e.g. "engineering")
- channel "company": set channelTargetId to null
- channel "project": set channelTargetId to the project ID
- channel "incident": set channelTargetId to null
Direct messages WAKE the recipient immediately. Use this when you need another agent to act. Direct messages WAKE the recipient immediately. Use this when you need another agent to act.
Department messages wake all members of that department. Department messages wake all members of that department.
Company-wide messages wake the CEO. Incident messages are visible company-wide.
Example — send urgent message to a peer: Example — send urgent message to a peer:
curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/messages" \\ curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/messages" \\
-H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\ -H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\
@@ -773,12 +784,26 @@ Body: {
### Check Action Status ### Check Action Status
GET /api/companies/{companyId}/browser/actions?socialAccountId={id}&status=succeeded,failed GET /api/companies/{companyId}/browser/actions?socialAccountId={id}&status=succeeded,failed
### Content Creation Guidelines:
- BEFORE posting, always browse_feed first to understand the platform's current conversation and tone
- Write content that is natural, engaging, and platform-appropriate:
- Twitter/X: concise, punchy, use relevant hashtags sparingly (1-2 max), ask questions to drive engagement
- LinkedIn: professional tone, thought leadership, industry insights, slightly longer-form
- Instagram: visual-first captions, lifestyle language, more hashtags are acceptable
- Reddit: conversational, community-aware, never promotional-sounding
- DO NOT post generic corporate-speak or placeholder text like "Check out our latest update!"
- Each post must have a clear purpose: inform, engage, promote, or respond
- Vary content types: questions, insights, announcements, responses to trends, tips
- Include a call-to-action when appropriate (reply, share, visit link)
- Match the voice and brand of the company — review company goals and documents for brand guidance
### Important Notes: ### Important Notes:
- Posts on accounts with "requires approval" will be held for Board review before publishing - Posts on accounts with "requires approval" will be held for Board review before publishing
- Always browse the feed first to understand the current conversation before posting - Always browse the feed first to understand the current conversation before posting
- Keep posts professional and on-brand - Keep posts professional and on-brand
- Check action results for success/failure before proceeding - Check action results for success/failure before proceeding
- Rate limit yourself: don't submit more than 5 actions per session - Rate limit yourself: don't submit more than 5 actions per session
- If an action fails, try again with a different approach — do not keep retrying the same action
`); `);
} }
-1
View File
@@ -11,7 +11,6 @@ const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
"DEVELOPER_DIR", "DEVELOPER_DIR",
"SDKROOT", "SDKROOT",
"NODE_ENV", "NODE_ENV",
"NODE_OPTIONS",
"GIT_AUTHOR_NAME", "GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL", "GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME", "GIT_COMMITTER_NAME",
+1
View File
@@ -15,6 +15,7 @@ const api: DesktopApi = {
testConnector: (id) => ipcRenderer.invoke(IPC_CHANNELS.testConnector, id), testConnector: (id) => ipcRenderer.invoke(IPC_CHANNELS.testConnector, id),
saveWorkspace: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkspace, input), saveWorkspace: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkspace, input),
saveAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveAgent, 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), saveGoal: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveGoal, input),
saveProject: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveProject, input), saveProject: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveProject, input),
saveTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveTask, input), saveTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveTask, input),
+28 -7
View File
@@ -239,6 +239,15 @@ export function App() {
} }
}, [selectedConnectorId, selectedRunId]); }, [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 // Stable ref so the subscribe callback always calls the latest refresh
// without tearing down the subscription when refresh identity changes. // without tearing down the subscription when refresh identity changes.
const refreshRef = useRef(refresh); const refreshRef = useRef(refresh);
@@ -863,7 +872,7 @@ export function App() {
setSelectedRunId(runId); setSelectedRunId(runId);
}} }}
onOpenLogPath={async (path: string) => { onOpenLogPath={async (path: string) => {
unwrap(await window.agentCompany.openPath(path)); await openPathWithFeedback(path);
}} }}
onCancelRun={cancelRun} onCancelRun={cancelRun}
onOpenConsole={(runId: string) => { onOpenConsole={(runId: string) => {
@@ -908,7 +917,7 @@ export function App() {
{section === "agents" ? ( {section === "agents" ? (
<> <>
<Panel title="Agent roster" action={<ActionButton label="New agent" icon={Robot} tone="accent" onClick={() => setSelectedAgentId("__new__")} />}> <Panel title="Agent roster" action={<ActionButton label="New hire request" icon={UserPlus} tone="accent" onClick={() => setSelectedAgentId("__new__")} />}>
<AgentRoster <AgentRoster
agents={companyAgents} agents={companyAgents}
connectors={snapshot.connectors} connectors={snapshot.connectors}
@@ -917,8 +926,18 @@ export function App() {
onSelect={setSelectedAgentId} onSelect={setSelectedAgentId}
/> />
</Panel> </Panel>
<Modal open={selectedAgentId === "__new__"} title="New agent" onClose={() => setSelectedAgentId(null)}> <Modal open={selectedAgentId === "__new__"} title="Request new hire" onClose={() => setSelectedAgentId(null)}>
<AgentCreator companyId={currentCompany!.id} connectors={snapshot.connectors} workspaces={companyWorkspaces} agents={companyAgents} onSaved={async () => { setSelectedAgentId(null); await refresh(); }} /> <AgentCreator
companyId={currentCompany!.id}
connectors={snapshot.connectors}
workspaces={companyWorkspaces}
agents={companyAgents}
onSaved={async () => {
setSelectedAgentId(null);
setSection("hiring");
await refresh();
}}
/>
</Modal> </Modal>
<Modal open={!!selectedAgent} title={selectedAgent ? `Edit: ${selectedAgent.name}` : ""} onClose={() => setSelectedAgentId(null)}> <Modal open={!!selectedAgent} title={selectedAgent ? `Edit: ${selectedAgent.name}` : ""} onClose={() => setSelectedAgentId(null)}>
{selectedAgent ? ( {selectedAgent ? (
@@ -1198,6 +1217,7 @@ export function App() {
{section === "automation" && currentCompany ? ( {section === "automation" && currentCompany ? (
<AutomationPanel <AutomationPanel
key={currentCompany.id}
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []} rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []} workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
companyId={currentCompany.id} companyId={currentCompany.id}
@@ -1211,6 +1231,7 @@ export function App() {
comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []} comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []}
tasks={tasks} tasks={tasks}
agents={companyAgents} agents={companyAgents}
projects={companyProjects}
companyId={currentCompany.id} companyId={currentCompany.id}
selectedEntityId={selectedCommunicationEntityId} selectedEntityId={selectedCommunicationEntityId}
onEntityConsumed={() => setSelectedCommunicationEntityId(null)} onEntityConsumed={() => setSelectedCommunicationEntityId(null)}
@@ -1257,7 +1278,7 @@ export function App() {
companyId={currentCompany!.id} companyId={currentCompany!.id}
selectedWorkspaceId={selectedWorkspace?.id ?? null} selectedWorkspaceId={selectedWorkspace?.id ?? null}
onSelect={setSelectedWorkspaceId} onSelect={setSelectedWorkspaceId}
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)} onOpen={openPathWithFeedback}
onDeleted={refresh} onDeleted={refresh}
/> />
</Panel> </Panel>
@@ -1272,7 +1293,7 @@ export function App() {
runs={runs} runs={runs}
documents={companyDocuments} documents={companyDocuments}
project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null} project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null}
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)} onOpen={openPathWithFeedback}
/> />
</Panel> </Panel>
<Panel title="Add workspace"> <Panel title="Add workspace">
@@ -1356,7 +1377,7 @@ export function App() {
</div> </div>
{selectedRun.logPath ? ( {selectedRun.logPath ? (
<div className="pt-1"> <div className="pt-1">
<ActionButton label="Open log" onClick={() => void window.agentCompany.openPath(selectedRun.logPath!)} icon={FolderOpen} /> <ActionButton label="Open log" onClick={() => void openPathWithFeedback(selectedRun.logPath!)} icon={FolderOpen} />
</div> </div>
) : null} ) : null}
</div> </div>
+134 -38
View File
@@ -27,6 +27,7 @@ import type {
DepartmentId, DepartmentId,
MessageChannel, MessageChannel,
MessagePriority, MessagePriority,
ProjectRecord,
TaskRecord, TaskRecord,
} from "@shared/types" } from "@shared/types"
import { getAgentInitials, formatMessageTime } from "../lib/formatters" import { getAgentInitials, formatMessageTime } from "../lib/formatters"
@@ -64,6 +65,8 @@ interface ChannelDescriptor {
targetId: string | null targetId: string | null
/** Agent ID for DM channels */ /** Agent ID for DM channels */
agentId: string | null agentId: string | null
/** Participant IDs for DM channels */
participantIds: string[]
} }
interface TaskDiscussionDescriptor { interface TaskDiscussionDescriptor {
@@ -76,9 +79,11 @@ interface TaskDiscussionDescriptor {
function deriveChannels( function deriveChannels(
messages: AgentMessageRecord[], messages: AgentMessageRecord[],
agents: AgentRecord[], agents: AgentRecord[],
projects?: ProjectRecord[],
): ChannelDescriptor[] { ): ChannelDescriptor[] {
const channelMap = new Map<string, ChannelDescriptor>() const channelMap = new Map<string, ChannelDescriptor>()
const agentMap = new Map(agents.map((a) => [a.id, a])) 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 // Always create company-wide channel
channelMap.set("company-all", { channelMap.set("company-all", {
@@ -88,6 +93,7 @@ function deriveChannels(
label: "#company-wide", label: "#company-wide",
targetId: null, targetId: null,
agentId: null, agentId: null,
participantIds: [],
}) })
// Create permanent department channels for all departments that have agents // Create permanent department channels for all departments that have agents
@@ -107,6 +113,7 @@ function deriveChannels(
label: `#${deptLabel}`, label: `#${deptLabel}`,
targetId: dept, targetId: dept,
agentId: null, agentId: null,
participantIds: [],
}) })
} }
@@ -122,12 +129,14 @@ function deriveChannels(
if (!otherId) { if (!otherId) {
key = `direct-broadcast` key = `direct-broadcast`
label = "Broadcast DMs" label = "Broadcast DMs"
agentId = null
} else { } else {
const ids = [msg.fromAgentId, otherId].sort() const ids = [msg.fromAgentId, otherId].sort()
key = `direct-${ids.join("-")}` key = `direct-${ids.join("-")}`
const other = agentMap.get(otherId) const first = agentMap.get(ids[0] ?? "")
label = other ? other.name : "Unknown Agent" const second = agentMap.get(ids[1] ?? "")
agentId = otherId 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 break
} }
@@ -145,13 +154,13 @@ function deriveChannels(
case "project": { case "project": {
const projId = msg.channelTargetId ?? "general" const projId = msg.channelTargetId ?? "general"
key = `project-${projId}` key = `project-${projId}`
label = `#project-${projId.slice(0, 8)}` label = projectMap.get(projId)?.name ?? `#project-${projId.slice(0, 8)}`
break break
} }
case "incident": { case "incident": {
const incId = msg.channelTargetId ?? "general" const incId = msg.channelTargetId ?? "room"
key = `incident-${incId}` key = `incident-${incId}`
label = `#incident-${incId.slice(0, 8)}` label = incId === "room" ? "#incident-room" : `#incident-${incId.slice(0, 8)}`
break break
} }
} }
@@ -164,6 +173,9 @@ function deriveChannels(
label, label,
targetId: msg.channelTargetId, targetId: msg.channelTargetId,
agentId, agentId,
participantIds: msg.channel === "direct" && msg.toAgentId
? [msg.fromAgentId, msg.toAgentId].sort()
: [],
}) })
} }
} }
@@ -180,12 +192,17 @@ function deriveChannels(
function deriveTaskDiscussionChannels( function deriveTaskDiscussionChannels(
comments: CommentRecord[], comments: CommentRecord[],
tasks: TaskRecord[], tasks: TaskRecord[],
forcedTaskId?: string | null,
): TaskDiscussionDescriptor[] { ): TaskDiscussionDescriptor[] {
const taskMap = new Map(tasks.map((task) => [task.id, task])) const taskMap = new Map(tasks.map((task) => [task.id, task]))
const activeTaskIds = tasks const activeTaskIds = tasks
.filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status)) .filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status))
.map((task) => task.id) .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) return Array.from(discussionTaskIds)
.map((taskId) => { .map((taskId) => {
@@ -227,9 +244,9 @@ function getChannelMessages(
} }
if (channel.channel === "direct" && msg.channel === "direct") { if (channel.channel === "direct" && msg.channel === "direct") {
if (channel.key === "direct-broadcast") return !msg.toAgentId if (channel.key === "direct-broadcast") return !msg.toAgentId
if (!channel.agentId) return false if (!msg.toAgentId || channel.participantIds.length < 2) return false
const ids = [msg.fromAgentId, msg.toAgentId ?? ""].sort() const ids = [msg.fromAgentId, msg.toAgentId].sort()
return channel.key === `direct-${ids.join("-")}` return ids[0] === channel.participantIds[0] && ids[1] === channel.participantIds[1]
} }
return false return false
}) })
@@ -355,22 +372,12 @@ function MessageBubble({
isThread ? "ml-11" : "" isThread ? "ml-11" : ""
} ${ } ${
isUnread isUnread
? "border-l-[3px] bg-[color:var(--panel)]" ? "bg-[color:var(--panel)]"
: "border-l-[3px] border-l-transparent hover:bg-[color:var(--panel-soft)]/40" : "hover:bg-[color:var(--panel-soft)]/40"
} ${ } ${
isUrgent isUrgent ? "border-l-[3px]" : ""
? "border-l-[color:var(--danger)]"
: isUnread
? `border-l-[${TERRACOTTA}]`
: ""
}`} }`}
style={ style={isUrgent ? { borderLeftColor: "var(--danger)" } : undefined}
isUnread && !isUrgent
? { borderLeftColor: TERRACOTTA }
: isUrgent
? { borderLeftColor: "var(--danger)" }
: undefined
}
> >
<AgentAvatar agent={agent} /> <AgentAvatar agent={agent} />
@@ -610,6 +617,8 @@ interface MessageComposerProps {
/** Pre-fill channel context */ /** Pre-fill channel context */
channel: MessageChannel channel: MessageChannel
channelTargetId: string | null channelTargetId: string | null
/** Participant IDs for a direct channel */
directParticipantIds?: string[]
/** Pre-fill as a reply to a message */ /** Pre-fill as a reply to a message */
replyTo: AgentMessageRecord | null replyTo: AgentMessageRecord | null
onSent: () => void onSent: () => void
@@ -621,6 +630,7 @@ function MessageComposer({
companyId, companyId,
channel, channel,
channelTargetId, channelTargetId,
directParticipantIds = [],
replyTo, replyTo,
onSent, onSent,
onCancelReply, onCancelReply,
@@ -638,15 +648,41 @@ function MessageComposer({
} }
}, [replyTo]) }, [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 () => { const handleSend = useCallback(async () => {
if (!body.trim() || !fromAgentId) return 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) setSending(true)
setSendError(null) setSendError(null)
try { try {
unwrap(await window.agentCompany.sendAgentMessage({ unwrap(await window.agentCompany.sendAgentMessage({
companyId, companyId,
fromAgentId, fromAgentId,
toAgentId: replyTo ? replyTo.fromAgentId : null, toAgentId: channel === "direct" ? directRecipientId : null,
channel, channel,
channelTargetId, channelTargetId,
subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""),
@@ -663,7 +699,7 @@ function MessageComposer({
} finally { } finally {
setSending(false) setSending(false)
} }
}, [body, fromAgentId, companyId, channel, channelTargetId, replyTo, subject, onSent]) }, [agents, body, channel, channelTargetId, companyId, directRecipientId, fromAgentId, onSent, replyTo, subject])
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@@ -680,6 +716,11 @@ function MessageComposer({
return ( return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3"> <div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null} {sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
{channel === "direct" && !directRecipientId ? (
<div className="mb-2">
<InlineNotice message="Select a direct conversation partner before sending a DM." />
</div>
) : null}
<AnimatePresence> <AnimatePresence>
{replyTo ? ( {replyTo ? (
<motion.div <motion.div
@@ -742,7 +783,7 @@ function MessageComposer({
<button <button
type="button" type="button"
onClick={() => void handleSend()} onClick={() => 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" 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 }} style={{ backgroundColor: TERRACOTTA }}
aria-label="Send message" aria-label="Send message"
@@ -768,11 +809,12 @@ interface ComposeModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
agents: AgentRecord[] agents: AgentRecord[]
projects?: ProjectRecord[]
companyId: string companyId: string
onSent: () => void 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 [fromAgentId, setFromAgentId] = useState(agents[0]?.id ?? "")
const [toAgentId, setToAgentId] = useState("") const [toAgentId, setToAgentId] = useState("")
const [channel, setChannel] = useState<MessageChannel>("company") const [channel, setChannel] = useState<MessageChannel>("company")
@@ -791,8 +833,38 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
return Array.from(depts).sort() return Array.from(depts).sort()
}, [agents]) }, [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 () => { 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) setSending(true)
setSendError(null) setSendError(null)
try { try {
@@ -801,7 +873,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
fromAgentId, fromAgentId,
toAgentId: channel === "direct" && toAgentId ? toAgentId : null, toAgentId: channel === "direct" && toAgentId ? toAgentId : null,
channel, channel,
channelTargetId: channelTargetId || null, channelTargetId: channel === "department" || channel === "project" ? channelTargetId || null : null,
subject: subject.trim(), subject: subject.trim(),
body: body.trim(), body: body.trim(),
priority, priority,
@@ -818,7 +890,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
} finally { } finally {
setSending(false) setSending(false)
} }
}, [body, fromAgentId, toAgentId, channel, channelTargetId, subject, priority, companyId, onSent, onClose]) }, [agents, body, channel, channelTargetId, companyId, fromAgentId, onClose, onSent, priority, subject, toAgentId])
return ( return (
<Modal open={open} title="Compose Message" onClose={onClose} width="560px"> <Modal open={open} title="Compose Message" onClose={onClose} width="560px">
@@ -875,12 +947,18 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
})), })),
]} ]}
/> />
) : channel === "project" || channel === "incident" ? ( ) : channel === "project" ? (
<Input <Select
label={channel === "project" ? "Project ID" : "Incident ID"} label="Project"
value={channelTargetId} value={channelTargetId}
onChange={setChannelTargetId} onChange={setChannelTargetId}
placeholder={`Enter ${channel} identifier`} options={[
{ value: "", label: "Select project..." },
...(projects ?? []).map((p) => ({
value: p.id,
label: p.name,
})),
]}
/> />
) : null} ) : null}
@@ -921,7 +999,14 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
<button <button
type="button" type="button"
onClick={() => void handleSend()} onClick={() => void handleSend()}
disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || sending} disabled={
!body.trim()
|| !subject.trim()
|| !fromAgentId
|| (channel === "direct" && !toAgentId)
|| ((channel === "department" || channel === "project") && !channelTargetId)
|| sending
}
className="inline-flex items-center gap-2 rounded-[8px] px-4 py-2 text-[12px] font-semibold text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" className="inline-flex items-center gap-2 rounded-[8px] px-4 py-2 text-[12px] font-semibold text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
style={{ backgroundColor: TERRACOTTA }} style={{ backgroundColor: TERRACOTTA }}
> >
@@ -1084,6 +1169,7 @@ interface CommunicationHubProps {
comments: CommentRecord[] comments: CommentRecord[]
tasks: TaskRecord[] tasks: TaskRecord[]
agents: AgentRecord[] agents: AgentRecord[]
projects?: ProjectRecord[]
companyId: string companyId: string
selectedEntityId?: string | null selectedEntityId?: string | null
onEntityConsumed?: () => void onEntityConsumed?: () => void
@@ -1096,6 +1182,7 @@ export function CommunicationHub({
comments, comments,
tasks, tasks,
agents, agents,
projects,
companyId, companyId,
selectedEntityId, selectedEntityId,
onEntityConsumed, onEntityConsumed,
@@ -1136,8 +1223,15 @@ export function CommunicationHub({
}, [effectiveMessages]) }, [effectiveMessages])
// Derive channels from messages // Derive channels from messages
const messageChannels = useMemo(() => deriveChannels(effectiveMessages, agents), [effectiveMessages, agents]) const messageChannels = useMemo(() => deriveChannels(effectiveMessages, agents, projects), [effectiveMessages, agents, projects])
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks]) const forcedTaskDiscussionId = useMemo(
() => (selectedEntityId && tasks.some((task) => task.id === selectedEntityId) ? selectedEntityId : null),
[selectedEntityId, tasks],
)
const taskDiscussionChannels = useMemo(
() => deriveTaskDiscussionChannels(comments, tasks, forcedTaskDiscussionId),
[comments, forcedTaskDiscussionId, tasks],
)
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels]) const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
// Auto-select first valid channel // Auto-select first valid channel
@@ -1633,6 +1727,7 @@ export function CommunicationHub({
companyId={companyId} companyId={companyId}
channel={activeChannel.channel} channel={activeChannel.channel}
channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId}
directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []}
replyTo={replyTo} replyTo={replyTo}
onSent={() => { onSent={() => {
setReplyTo(null) setReplyTo(null)
@@ -1662,6 +1757,7 @@ export function CommunicationHub({
open={showCompose} open={showCompose}
onClose={() => setShowCompose(false)} onClose={() => setShowCompose(false)}
agents={agents} agents={agents}
projects={projects}
companyId={companyId} companyId={companyId}
onSent={onRefresh} onSent={onRefresh}
/> />
+2 -2
View File
@@ -641,7 +641,7 @@ export function Dashboard(props: DashboardProps) {
value={liveRuns.length} value={liveRuns.length}
icon={Heartbeat} icon={Heartbeat}
tone={liveRuns.length > 0 ? "success" : "default"} tone={liveRuns.length > 0 ? "success" : "default"}
onClick={() => onNavigate("tasks")} onClick={() => onNavigate("runs")}
subtitle={liveRuns.length > 0 ? `${liveRuns.filter((r) => r.status === "running").length} running` : "All quiet"} subtitle={liveRuns.length > 0 ? `${liveRuns.filter((r) => r.status === "running").length} running` : "All quiet"}
/> />
<MetricCard <MetricCard
@@ -665,7 +665,7 @@ export function Dashboard(props: DashboardProps) {
value={failedRuns.length} value={failedRuns.length}
icon={Warning} icon={Warning}
tone={failedRuns.length > 0 ? "danger" : "default"} tone={failedRuns.length > 0 ? "danger" : "default"}
onClick={() => onNavigate("tasks")} onClick={() => onNavigate("runs")}
/> />
<MetricCard <MetricCard
label="Total Spend" label="Total Spend"
+2 -2
View File
@@ -1486,8 +1486,8 @@ export function SettingsPane({
setActionNotice(null); setActionNotice(null);
setLoadingAction("check"); setLoadingAction("check");
try { try {
unwrap(await window.agentCompany.checkForUpdates()); const started = unwrap(await window.agentCompany.checkForUpdates());
setActionNotice("Update check started."); setActionNotice(started ? "Update check started." : "Update check skipped because no release update feed is configured.");
} catch (error) { } catch (error) {
setActionError(describeActionError(error)); setActionError(describeActionError(error));
} finally { } finally {
+50 -19
View File
@@ -148,6 +148,7 @@ export function AgentCreator({
const [capabilities, setCapabilities] = useState(""); const [capabilities, setCapabilities] = useState("");
const [reportsTo, setReportsTo] = useState(""); const [reportsTo, setReportsTo] = useState("");
const [thinkingEffort, setThinkingEffort] = useState(""); const [thinkingEffort, setThinkingEffort] = useState("");
const [requestError, setRequestError] = useState<string | null>(null);
const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local"; const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local";
useEffect(() => { useEffect(() => {
@@ -220,19 +221,25 @@ export function AgentCreator({
onChange={setCapabilities} onChange={setCapabilities}
placeholder="What this agent is expected to do and when it becomes relevant." placeholder="What this agent is expected to do and when it becomes relevant."
/> />
<p className="text-[12px] text-[color:var(--muted)]">
New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record.
</p>
<ActionButton <ActionButton
label="Create agent" label="Submit hire request"
tone="accent" tone="accent"
onClick={() => onClick={() =>
void (async () => { void (async () => {
try { try {
setRequestError(null);
const meta: Record<string, string> = {}; const meta: Record<string, string> = {};
if (thinkingEffort) meta.thinkingEffort = thinkingEffort; if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
await window.agentCompany.saveAgent({ unwrap(await window.agentCompany.requestHire({
companyId, companyId,
requestedByAgentId: null,
name, name,
role, role,
title, title,
department: null,
connectorId: connectorId as ConnectorRecord["id"], connectorId: connectorId as ConnectorRecord["id"],
workspaceId: workspaceId || null, workspaceId: workspaceId || null,
reportsTo: reportsTo || null, reportsTo: reportsTo || null,
@@ -240,21 +247,22 @@ export function AgentCreator({
metadataJson: JSON.stringify(meta), metadataJson: JSON.stringify(meta),
capabilities, capabilities,
budgetMonthlyUsd: Number(budget || 0), budgetMonthlyUsd: Number(budget || 0),
status: "idle", }));
});
setName(""); setName("");
setRole(""); setRole("");
setTitle(""); setTitle("");
setCapabilities(""); setCapabilities("");
setReportsTo(""); setReportsTo("");
setRequestError(null);
await onSaved(); await onSaved();
} catch (error) { } catch (error) {
void error; setRequestError(error instanceof Error ? error.message : "Hire request failed.");
} }
})() })()
} }
icon={Robot} icon={Robot}
/> />
{requestError ? <p className="text-[12px] text-[color:var(--danger)]">{requestError}</p> : null}
</div> </div>
); );
} }
@@ -292,6 +300,7 @@ export function AgentEditor({
const existingMeta = useMemo(() => { try { return JSON.parse(agent.metadataJson || "{}"); } catch { return {}; } }, [agent.metadataJson]); const existingMeta = useMemo(() => { try { return JSON.parse(agent.metadataJson || "{}"); } catch { return {}; } }, [agent.metadataJson]);
const [thinkingEffort, setThinkingEffort] = useState<string>(existingMeta.thinkingEffort ?? ""); const [thinkingEffort, setThinkingEffort] = useState<string>(existingMeta.thinkingEffort ?? "");
const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local"; const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local";
const pendingApproval = agent.status === "pending_approval";
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@@ -314,27 +323,35 @@ export function AgentEditor({
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" /> <Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" readOnly={pendingApproval} disabled={pendingApproval} />
<Input label="Role" value={role} onChange={setRole} placeholder="Role" /> <Input label="Role" value={role} onChange={setRole} placeholder="Role" readOnly={pendingApproval} disabled={pendingApproval} />
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" /> <Input label="Title" value={title} onChange={setTitle} placeholder="Title" readOnly={pendingApproval} disabled={pendingApproval} />
<Select <Select
label="Status" label="Status"
value={status} value={status}
onChange={(v) => setStatus(v as AgentRecord["status"])} onChange={(v) => setStatus(v as AgentRecord["status"])}
options={[ disabled={pendingApproval}
{ value: "idle", label: "Idle" }, options={pendingApproval
{ value: "active", label: "Active" }, ? [{ value: "pending_approval", label: "Pending Approval" }]
{ value: "running", label: "Running" }, : [
{ value: "paused", label: "Paused" }, { value: "idle", label: "Idle" },
{ value: "error", label: "Error" }, { value: "active", label: "Active" },
{ value: "pending_approval", label: "Pending Approval" }, { value: "running", label: "Running" },
{ value: "terminated", label: "Terminated" }, { value: "paused", label: "Paused" },
]} { value: "error", label: "Error" },
{ value: "terminated", label: "Terminated" },
]}
/> />
{pendingApproval ? (
<p className="text-[12px] text-[color:var(--muted)]">
Pending hires must be approved or rejected from Hiring or Approvals. Status changes are intentionally locked here.
</p>
) : null}
<Select <Select
label="Connector" label="Connector"
value={connectorId} value={connectorId}
onChange={(v) => setConnectorId(v as ConnectorRecord["id"])} onChange={(v) => setConnectorId(v as ConnectorRecord["id"])}
disabled={pendingApproval}
options={connectors.map((c) => ({ value: c.id, label: c.label }))} options={connectors.map((c) => ({ value: c.id, label: c.label }))}
/> />
{availableModels.length > 0 ? ( {availableModels.length > 0 ? (
@@ -342,6 +359,7 @@ export function AgentEditor({
label="Model" label="Model"
value={model} value={model}
onChange={setModel} onChange={setModel}
disabled={pendingApproval}
options={availableModels.map((m) => ({ value: m.id, label: m.label }))} options={availableModels.map((m) => ({ value: m.id, label: m.label }))}
/> />
) : null} ) : null}
@@ -350,6 +368,7 @@ export function AgentEditor({
label="Thinking effort" label="Thinking effort"
value={thinkingEffort} value={thinkingEffort}
onChange={setThinkingEffort} onChange={setThinkingEffort}
disabled={pendingApproval}
options={[ options={[
{ value: "", label: "Default" }, { value: "", label: "Default" },
{ value: "low", label: "Low" }, { value: "low", label: "Low" },
@@ -362,6 +381,7 @@ export function AgentEditor({
label="Workspace" label="Workspace"
value={workspaceId} value={workspaceId}
onChange={setWorkspaceId} onChange={setWorkspaceId}
disabled={pendingApproval}
options={[ options={[
{ value: "", label: "No workspace" }, { value: "", label: "No workspace" },
...workspaces.map((w) => ({ value: w.id, label: w.name })), ...workspaces.map((w) => ({ value: w.id, label: w.name })),
@@ -371,13 +391,14 @@ export function AgentEditor({
label="Reports to" label="Reports to"
value={reportsTo} value={reportsTo}
onChange={setReportsTo} onChange={setReportsTo}
disabled={pendingApproval}
options={[ options={[
{ value: "", label: "No manager" }, { value: "", label: "No manager" },
...managerCandidates.map((a) => ({ value: a.id, label: a.name })), ...managerCandidates.map((a) => ({ value: a.id, label: a.name })),
]} ]}
/> />
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" /> <Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" readOnly={pendingApproval} disabled={pendingApproval} />
<TextArea label="Capabilities" value={capabilities} onChange={setCapabilities} placeholder="Capabilities" /> <TextArea label="Capabilities" value={capabilities} onChange={setCapabilities} placeholder="Capabilities" readOnly={pendingApproval} disabled={pendingApproval} />
{/* Heartbeat / Continuous Execution */} {/* Heartbeat / Continuous Execution */}
<div className="rounded-lg border border-[color:var(--line)] p-3"> <div className="rounded-lg border border-[color:var(--line)] p-3">
@@ -390,6 +411,7 @@ export function AgentEditor({
<input <input
type="checkbox" type="checkbox"
checked={agent.heartbeatEnabled} checked={agent.heartbeatEnabled}
disabled={pendingApproval}
onChange={(e) => onChange={(e) =>
void (async () => { void (async () => {
await window.agentCompany.setHeartbeat({ await window.agentCompany.setHeartbeat({
@@ -427,6 +449,7 @@ export function AgentEditor({
await onSaved(); await onSaved();
})() })()
} }
disabled={pendingApproval}
options={[ options={[
{ value: "30", label: "30 seconds" }, { value: "30", label: "30 seconds" },
{ value: "60", label: "1 minute" }, { value: "60", label: "1 minute" },
@@ -443,6 +466,7 @@ export function AgentEditor({
label="Wake now" label="Wake now"
tone="accent" tone="accent"
icon={Lightning} icon={Lightning}
disabled={pendingApproval}
onClick={() => onClick={() =>
void (async () => { void (async () => {
await window.agentCompany.triggerHeartbeat({ await window.agentCompany.triggerHeartbeat({
@@ -462,6 +486,7 @@ export function AgentEditor({
label="Save changes" label="Save changes"
tone="accent" tone="accent"
icon={Pencil} icon={Pencil}
disabled={pendingApproval}
onClick={() => onClick={() =>
void (async () => { void (async () => {
setSaveError(null); setSaveError(null);
@@ -507,10 +532,16 @@ export function AgentEditor({
label="Delete" label="Delete"
tone="danger" tone="danger"
icon={Trash} icon={Trash}
disabled={pendingApproval}
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
/> />
)} )}
</div> </div>
{pendingApproval ? (
<p className="text-[12px] text-[color:var(--muted)]">
Pending hires are read-only here. Use Hiring or Approvals to approve or reject the request before editing the agent profile.
</p>
) : null}
{saveError ? <p className="text-[12px] text-[color:var(--danger)]">{saveError}</p> : null} {saveError ? <p className="text-[12px] text-[color:var(--danger)]">{saveError}</p> : null}
</div> </div>
); );
+11 -2
View File
@@ -208,19 +208,25 @@ export function TextArea({
placeholder, placeholder,
rows = 4, rows = 4,
label, label,
readOnly = false,
disabled = false,
}: { }: {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
label?: string; label?: string;
readOnly?: boolean;
disabled?: boolean;
}) { }) {
const textarea = ( const textarea = (
<textarea <textarea
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]" className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] disabled:cursor-not-allowed disabled:opacity-60"
value={value} value={value}
rows={rows} rows={rows}
placeholder={placeholder} placeholder={placeholder}
readOnly={readOnly}
disabled={disabled}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
/> />
); );
@@ -241,12 +247,14 @@ export function Select({
options, options,
compact = false, compact = false,
label, label,
disabled = false,
}: { }: {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
options: Array<{ value: string; label: string }>; options: Array<{ value: string; label: string }>;
compact?: boolean; compact?: boolean;
label?: string; label?: string;
disabled?: boolean;
}) { }) {
const select = ( const select = (
<select <select
@@ -254,8 +262,9 @@ export function Select({
compact compact
? "rounded-[6px] border-[color:var(--line)] px-2.5 py-1.5 text-[13px] font-medium" ? "rounded-[6px] border-[color:var(--line)] px-2.5 py-1.5 text-[13px] font-medium"
: "rounded-[8px] border-[color:var(--line)] px-3.5 py-2.5 text-[14px]" : "rounded-[8px] border-[color:var(--line)] px-3.5 py-2.5 text-[14px]"
}`} } disabled:cursor-not-allowed disabled:opacity-60`}
value={value} value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
> >
{options.map((option) => ( {options.map((option) => (
+45 -1
View File
@@ -11,6 +11,7 @@ export const IPC_CHANNELS = {
testConnector: "agent-company:test-connector", testConnector: "agent-company:test-connector",
saveWorkspace: "agent-company:save-workspace", saveWorkspace: "agent-company:save-workspace",
saveAgent: "agent-company:save-agent", saveAgent: "agent-company:save-agent",
requestHire: "agent-company:request-hire",
saveGoal: "agent-company:save-goal", saveGoal: "agent-company:save-goal",
saveProject: "agent-company:save-project", saveProject: "agent-company:save-project",
saveTask: "agent-company:save-task", saveTask: "agent-company:save-task",
@@ -122,6 +123,22 @@ export const agentInputSchema = z.object({
status: z.enum(["idle", "active", "running", "paused", "error", "pending_approval", "terminated"]).default("idle"), status: z.enum(["idle", "active", "running", "paused", "error", "pending_approval", "terminated"]).default("idle"),
}); });
export const hireRequestSchema = z.object({
companyId: z.string().uuid(),
requestedByAgentId: optionalId,
name: z.string().min(2).max(80),
role: z.string().min(2).max(80),
title: z.string().default(""),
department: z.enum(["executive", "engineering", "product", "marketing", "sales", "hr", "finance", "legal", "operations", "customer_support", "research", "design"]).nullable().optional(),
reportsTo: optionalId,
connectorId: connectorInputSchema.shape.id,
workspaceId: optionalId,
model: z.string().nullable().optional(),
metadataJson: z.string().default("{}").optional(),
capabilities: z.string().default(""),
budgetMonthlyUsd: z.number().min(0).default(0),
});
export const goalInputSchema = z.object({ export const goalInputSchema = z.object({
id: optionalId, id: optionalId,
companyId: z.string().uuid(), companyId: z.string().uuid(),
@@ -161,9 +178,11 @@ export const approvalInputSchema = z.object({
companyId: z.string().uuid(), companyId: z.string().uuid(),
relatedTaskId: optionalId, relatedTaskId: optionalId,
requestedByAgentId: optionalId, requestedByAgentId: optionalId,
relatedAgentId: optionalId,
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation"]), type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation"]),
payloadSummary: z.string().min(2).max(280), payloadSummary: z.string().min(2).max(280),
impactSummary: z.string().min(2).max(400), impactSummary: z.string().min(2).max(400),
payloadJson: z.string().nullable().optional(),
}); });
export const approvalDecisionSchema = z.object({ export const approvalDecisionSchema = z.object({
@@ -405,11 +424,35 @@ export const agentMessageInputSchema = z.object({
toAgentId: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()), toAgentId: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
channel: z.enum(["direct", "department", "company", "project", "incident"]).default("direct"), channel: z.enum(["direct", "department", "company", "project", "incident"]).default("direct"),
channelTargetId: z.string().nullable().optional(), channelTargetId: z.string().nullable().optional(),
subject: z.string().min(1).max(200), subject: z.preprocess((v) => (v === "" ? "(no subject)" : v), z.string().min(1).max(200)),
body: z.string().min(1).max(8000), body: z.string().min(1).max(8000),
priority: z.enum(["urgent", "normal", "low"]).default("normal"), priority: z.enum(["urgent", "normal", "low"]).default("normal"),
parentMessageId: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()), parentMessageId: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
attachmentsJson: z.string().default("[]"), attachmentsJson: z.string().default("[]"),
}).superRefine((value, ctx) => {
if (value.channel === "direct" && !value.toAgentId) {
ctx.addIssue({
path: ["toAgentId"],
code: z.ZodIssueCode.custom,
message: "Direct messages require toAgentId.",
});
}
if ((value.channel === "department" || value.channel === "project") && !value.channelTargetId) {
ctx.addIssue({
path: ["channelTargetId"],
code: z.ZodIssueCode.custom,
message: `${value.channel} messages require channelTargetId.`,
});
}
if (value.channel !== "direct" && value.toAgentId) {
ctx.addIssue({
path: ["toAgentId"],
code: z.ZodIssueCode.custom,
message: "Only direct messages may set toAgentId.",
});
}
}); });
export const messageQuerySchema = z.object({ export const messageQuerySchema = z.object({
@@ -473,6 +516,7 @@ export type DesktopApi = {
testConnector: (id: z.infer<typeof connectorInputSchema.shape.id>) => Promise<DesktopResult<boolean>>; testConnector: (id: z.infer<typeof connectorInputSchema.shape.id>) => Promise<DesktopResult<boolean>>;
saveWorkspace: (input: z.infer<typeof workspaceInputSchema>) => Promise<DesktopResult<string>>; saveWorkspace: (input: z.infer<typeof workspaceInputSchema>) => Promise<DesktopResult<string>>;
saveAgent: (input: z.infer<typeof agentInputSchema>) => Promise<DesktopResult<string>>; saveAgent: (input: z.infer<typeof agentInputSchema>) => Promise<DesktopResult<string>>;
requestHire: (input: z.infer<typeof hireRequestSchema>) => Promise<DesktopResult<{ agentId: string; approvalId: string; autoApproved: boolean }>>;
saveGoal: (input: z.infer<typeof goalInputSchema>) => Promise<DesktopResult<string>>; saveGoal: (input: z.infer<typeof goalInputSchema>) => Promise<DesktopResult<string>>;
saveProject: (input: z.infer<typeof projectInputSchema>) => Promise<DesktopResult<string>>; saveProject: (input: z.infer<typeof projectInputSchema>) => Promise<DesktopResult<string>>;
saveTask: (input: z.infer<typeof taskInputSchema>) => Promise<DesktopResult<string>>; saveTask: (input: z.infer<typeof taskInputSchema>) => Promise<DesktopResult<string>>;
+3 -2
View File
@@ -410,19 +410,20 @@ describe("Step 4: Hire approval + decideApproval", () => {
it("getApprovalRecord includes payloadJson", () => { it("getApprovalRecord includes payloadJson", () => {
const ceoId = createAgent({ name: "CEO", role: "CEO" }); const ceoId = createAgent({ name: "CEO", role: "CEO" });
const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" });
const approvalId = db.requestApproval({ const approvalId = db.requestApproval({
companyId, companyId,
requestedByAgentId: ceoId, requestedByAgentId: ceoId,
type: "hire_agent", type: "hire_agent",
payloadSummary: "Hire CTO", payloadSummary: "Hire CTO",
impactSummary: "Test", impactSummary: "Test",
payloadJson: JSON.stringify({ agentId: "some-agent-id", config: { connector: "codex_local" } }), payloadJson: JSON.stringify({ agentId: hireId, config: { connector: "codex_local" } }),
}); });
const approval = db.getApprovalRecord(approvalId); const approval = db.getApprovalRecord(approvalId);
expect(approval.payloadJson).toBeTruthy(); expect(approval.payloadJson).toBeTruthy();
const payload = JSON.parse(approval.payloadJson!); const payload = JSON.parse(approval.payloadJson!);
expect(payload.agentId).toBe("some-agent-id"); expect(payload.agentId).toBe(hireId);
}); });
}); });