update
This commit is contained in:
1 parent
1ba3147ed2
commit
0cd65521b8
32 files changed
+1853
-342
No files matched your search
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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>>;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user