This commit is contained in:
ZacharyZhang-NY committed 2026-04-04 12:03:50 -04:00
1 parent 66158e222e
commit 67c757f481
314 files changed
+37945 -22962

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
# Cloudflare R2 release hosting (Wrangler OAuth is used for bucket/bootstrap/upload)
# If left empty, release-local will discover/fill what it can via `npx wrangler`.
R2_ACCOUNT_ID=
R2_BUCKET=agentcompany-updates
R2_PUBLIC_BASE_URL=
AGENTCOMPANY_RELEASE_PREFIX=AC
# Optional override. When left empty, release-local derives:
# ${R2_PUBLIC_BASE_URL}/${AGENTCOMPANY_RELEASE_PREFIX}
AGENTCOMPANY_UPDATE_URL=
# Public-release macOS signing / notarization
# Supply either:
# - CSC_LINK=<path-or-url-to-Developer-ID-Application.p12>
# - CSC_KEY_PASSWORD=<p12 password>
# or, if you already imported the cert+private-key into Keychain:
# - CSC_NAME="<Legal Name> (<TEAMID>)"
CSC_LINK=
CSC_KEY_PASSWORD=
CSC_NAME=
# Choose one notarization method:
# 1) Apple ID + app-specific password
APPLE_ID=
APPLE_APP_SPECIFIC_PASSWORD=
APPLE_TEAM_ID=
# 2) App Store Connect API key
APPLE_API_KEY=
APPLE_API_KEY_ID=
APPLE_API_ISSUER=
# Optional Windows signing
WIN_CSC_LINK=
WIN_CSC_KEY_PASSWORD=
+65 -32
View File
@@ -19,6 +19,7 @@ concurrency:
env:
PNPM_VERSION: '10.24.0'
NODE_VERSION: '22'
CI_RELEASE_TAG: ci-build-${{ github.run_id }}
jobs:
# ─────────────────────────────────────────────
@@ -37,8 +38,9 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm exec tsc --noEmit --project tsconfig.check.json
- run: pnpm typecheck:all
# ─────────────────────────────────────────────
# Stage 2: Unit Tests
@@ -57,7 +59,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm test:unit
- run: pnpm test:integration
# ─────────────────────────────────────────────
# Stage 3: Build matrix (parallel per platform)
@@ -67,6 +70,8 @@ jobs:
name: macOS (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs: test
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@@ -103,26 +108,36 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- uses: actions/upload-artifact@v4
with:
name: macos-${{ matrix.arch }}
path: |
build/release/*.dmg
build/release/*.zip
build/release/*.blockmap
build/release/latest-mac.yml
if-no-files-found: error
- name: Upload release files
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "$CI_RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$CI_RELEASE_TAG" \
--draft \
--prerelease \
--title "CI Build $GITHUB_RUN_ID" \
--notes "Temporary build artifact transport for Cloudflare publication." \
--target "$GITHUB_SHA"
gh release upload "$CI_RELEASE_TAG" \
build/release/*.dmg \
build/release/*.zip \
build/release/*.blockmap \
build/release/latest-mac.yml \
--clobber
build-windows:
name: Windows (${{ matrix.arch }})
runs-on: windows-latest
needs: test
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- arch: x64
builder_args: --win --x64
- arch: x64-ia32
builder_args: --win --x64 --ia32
- arch: arm64
builder_args: --win --arm64
steps:
@@ -142,19 +157,30 @@ jobs:
env:
CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- uses: actions/upload-artifact@v4
with:
name: windows-${{ matrix.arch }}
path: |
build/release/*.exe
build/release/*.blockmap
build/release/latest.yml
if-no-files-found: error
- name: Upload release files
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "$CI_RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$CI_RELEASE_TAG" \
--draft \
--prerelease \
--title "CI Build $GITHUB_RUN_ID" \
--notes "Temporary build artifact transport for Cloudflare publication." \
--target "$GITHUB_SHA"
gh release upload "$CI_RELEASE_TAG" \
build/release/*.exe \
build/release/*.blockmap \
build/release/latest.yml \
--clobber
build-linux:
name: Linux (${{ matrix.arch }})
runs-on: ubuntu-latest
needs: test
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@@ -180,17 +206,24 @@ jobs:
run: pnpm exec electron-vite build
- name: Package
run: pnpm exec electron-builder ${{ matrix.builder_args }} --config electron-builder.yml --publish never
- uses: actions/upload-artifact@v4
with:
name: linux-${{ matrix.arch }}
path: |
build/release/*.AppImage
build/release/*.deb
build/release/*.rpm
build/release/*.snap
build/release/*.blockmap
build/release/latest-linux.yml
if-no-files-found: warn
- name: Upload release files
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "$CI_RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$CI_RELEASE_TAG" \
--draft \
--prerelease \
--title "CI Build $GITHUB_RUN_ID" \
--notes "Temporary build artifact transport for Cloudflare publication." \
--target "$GITHUB_SHA"
gh release upload "$CI_RELEASE_TAG" \
build/release/*.AppImage \
build/release/*.deb \
build/release/*.rpm \
build/release/*.blockmap \
build/release/latest-linux.yml \
--clobber
# ─────────────────────────────────────────────
# Stage 4: Publish to GitHub Releases
+12 -4
View File
@@ -12,9 +12,11 @@ win-unpacked
.claude
build
.omc
.omx
.planning
paperclip-reference
Reference
.pnpm-store
# Test artifacts
test-results/
playwright-report/
@@ -30,8 +32,11 @@ playwright-report/
# Environment files
.env
.env.*
.env.local
.env.*.local
!.env.example
!.env.release.example
# Editor and OS files
.DS_Store
@@ -55,8 +60,10 @@ oldreference/
# Local design scratch files
homepagedesign.html
# Landing page (deployed separately to Cloudflare Pages)
landing/
# Landing page is part of the repo and should be versioned
# Boss edition build (contains credentials — never commit)
boss-build/
# Package manager logs
npm-debug.log*
@@ -65,4 +72,5 @@ yarn-debug.log*
yarn-error.log*
build/release/AgentCompany-0.4.61-arm64.dmg
CLAUDE.md
build/release/AgentCompany-0.4.90-arm64.dmg
build/release/AgentCompany-0.4.90-arm64.dmg
local-release/
+2
View File
@@ -126,12 +126,14 @@ pnpm build:verify
pnpm build # Compile + package for current platform → /build/release
pnpm build:dir # Compile + unpackaged directory
pnpm build:all # Build for all platforms
pnpm release:local # Signed/uploading release pipeline (see docs/release/local-r2.md)
```
- **Targets:** macOS (`.dmg`), Windows (`.exe` / NSIS), Linux (`.AppImage`)
- Packaging uses `electron-builder`
- Local builds use ad-hoc signing (`CSC_IDENTITY_AUTO_DISCOVERY=false`)
- Defaults to `--publish never`; override with `AGENTCOMPANY_ELECTRON_PUBLISH`
- Cloudflare R2 release hosting + auto-update pipeline: `docs/release/local-r2.md`
---
+10 -9
View File
@@ -1,15 +1,16 @@
{
"extends": "/Users/zacharyzhang/Documents/Github/AgentCompany/electron-builder.yml",
"extends": "/project/electron-builder.yml",
"directories": {
"output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release"
"output": "/project/build/release"
},
"extraMetadata": {
"version": "0.4.136"
"version": "0.4.150"
},
"mac": {
"hardenedRuntime": false,
"target": [
"zip"
]
}
"electronDist": "/root/.cache/electron",
"publish": [
{
"provider": "generic",
"url": "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC"
}
]
}
-22
View File
@@ -1,22 +0,0 @@
arm64:
firstOrDefaultFilePatterns:
- '!**/node_modules/**'
- '!assets{,/**/*}'
- '!build/release{,/**/*}'
- out/**
- package.json
- assets/icon.png
- assets/tray-icon*.png
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
- '!**/._*'
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
- '!.yarn{,/**/*}'
- '!.editorconfig'
- '!.yarnrc.yml'
nodeModuleFilePatterns:
- '**/*'
- out/**
- package.json
- assets/icon.png
- assets/tray-icon*.png
Binary file not shown.
Whitespace-only changes.
+2 -11
View File
@@ -39,10 +39,8 @@ win:
- target: nsis
arch:
- x64
- ia32
- arm64
- target: portable
arch:
- x64
linux:
icon: assets/icon.png
@@ -58,9 +56,7 @@ linux:
- target: rpm
arch:
- x64
- target: snap
arch:
- x64
- arm64
category: Development
nsis:
@@ -79,8 +75,3 @@ deb:
- libatspi2.0-0
- libuuid1
- libsecret-1-0
snap:
plugs:
- default
- removable-media
+9
View File
@@ -8,10 +8,17 @@ export default tseslint.config(
{
ignores: [
"out/**",
"**/out/**",
"build/**",
"dist/**",
"oldreference/**",
"Reference/**",
"boss-build/**",
"builtin-skills/**",
"node_modules/**",
"**/node_modules/**",
".next/**",
"**/.next/**",
"test-results/**",
"playwright-report/**",
"coverage/**",
@@ -52,8 +59,10 @@ export default tseslint.config(
],
"@typescript-eslint/no-explicit-any": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/incompatible-library": "off",
"react-hooks/set-state-in-effect": "off",
"react-hooks/refs": "off",
"react-hooks/preserve-manual-memoization": "off",
},
},
{
+5
View File
@@ -0,0 +1,5 @@
.next/
.wrangler/
out/
node_modules/
npm-debug.log*
+21
View File
@@ -0,0 +1,21 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Permissions-Policy: camera=(), microphone=(), geolocation=()
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
/screenshots/*
Cache-Control: public, max-age=31536000, immutable
/assets/*
Cache-Control: public, max-age=31536000, immutable
/_next/static/*
Cache-Control: public, max-age=31536000, immutable
/favicon.ico
Cache-Control: public, max-age=31536000, immutable
/icon.png
Cache-Control: public, max-age=31536000, immutable
+3
View File
@@ -0,0 +1,3 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246/0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246/0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*
! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com
*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,SF Mono,Roboto Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-4{bottom:1rem}.left-0{left:0}.left-\[15px\]{left:15px}.right-0{right:0}.top-0{top:0}.top-10{top:2.5rem}.z-10{z-index:10}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.mx-auto{margin-left:auto;margin-right:auto}.-mb-10{margin-bottom:-2.5rem}.mb-1\.5{margin-bottom:.375rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-20{margin-bottom:5rem}.mb-24{margin-bottom:6rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-2{margin-left:.5rem}.mt-12{margin-top:3rem}.mt-14{margin-top:3.5rem}.mt-2{margin-top:.5rem}.mt-20{margin-top:5rem}.mt-24{margin-top:6rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-11{height:2.75rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-8{height:2rem}.h-\[340px\]{height:340px}.h-auto{height:auto}.h-full{height:100%}.max-h-0{max-height:0}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.w-1\.5{width:.375rem}.w-11{width:2.75rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-8{width:2rem}.w-full{width:100%}.w-px{width:1px}.min-w-\[120px\]{min-width:120px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.rotate-45{--tw-rotate:45deg}.rotate-45,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--twLine truncated
+222
View File
@@ -0,0 +1,222 @@
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "../components/theme-provider";
const description =
"Local-first desktop control plane for autonomous AI companies. Set a goal, let an AI CEO assemble specialists, delegate work, and deliver code, research, content, and operations from your desktop.";
export const metadata: Metadata = {
metadataBase: new URL("https://agent-company.dev"),
title: "AgentCompany — Launch an Autonomous AI Company in Minutes",
description,
keywords: [
"AI company",
"autonomous AI agents",
"AI automation",
"multi-agent system",
"AI CEO",
"AI agent orchestration",
"autonomous agents desktop app",
"AI task delegation",
"AI team management",
"AI code generation",
"AI content generation",
"AI research automation",
"self-organizing AI",
"AI workforce",
"Claude Code",
"Codex CLI",
"Gemini CLI",
"multi-agent framework",
"AI company builder",
],
openGraph: {
title: "AgentCompany — Launch an Autonomous AI Company in Minutes",
description,
url: "https://agent-company.dev/",
siteName: "AgentCompany",
locale: "en_US",
images: [
{
url: "/screenshots/Dashboard.jpeg",
width: 1920,
height: 1080,
alt: "AgentCompany dashboard showing autonomous AI agents managing a company",
},
],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "AgentCompany — Launch an Autonomous AI Company in Minutes",
description,
images: ["/screenshots/Dashboard.jpeg"],
},
robots: {
index: true,
follow: true,
},
icons: {
icon: "/favicon.ico",
apple: "/icon.png",
},
};
const themeScript = `
(function(){try{var t=localStorage.getItem("agentcompany-theme");if(t==="dark"||(!t&&window.matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.classList.add("dark")}}catch(e){}})();
`;
const softwareJsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "AgentCompany",
applicationCategory: "BusinessApplication",
operatingSystem: "macOS, Windows, Linux",
url: "https://agent-company.dev",
downloadUrl: "https://agent-company.dev/#download",
screenshot: "https://agent-company.dev/screenshots/Dashboard.jpeg",
datePublished: "2026-03-01",
softwareVersion: "1.0",
description,
featureList: [
"One-click AI company creation",
"Self-organizing AI agent teams",
"Autonomous task management",
"Real-time agent communication",
"Human-in-the-loop approvals",
"AI document generation",
"Multi-connector support (Claude, Codex, Gemini)",
"Cross-platform (macOS, Windows, Linux)",
],
author: { "@type": "Organization", name: "AgentCompany", url: "https://agent-company.dev" },
};
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "AgentCompany",
url: "https://agent-company.dev",
logo: "https://agent-company.dev/icon.png",
};
const howToJsonLd = {
"@context": "https://schema.org",
"@type": "HowTo",
name: "How to Launch an Autonomous AI Company with AgentCompany",
description: "Create a fully autonomous AI company in three steps using the AgentCompany desktop app.",
step: [
{
"@type": "HowToStep",
position: 1,
name: "Set Your Goal",
text: "Describe the company, its mission, and what you want delivered. Connect your preferred AI runtime.",
},
{
"@type": "HowToStep",
position: 2,
name: "Agents Self-Organize",
text: "A CEO agent analyzes your goal, hires specialists, creates departments, and delegates the work autonomously.",
},
{
"@type": "HowToStep",
position: 3,
name: "Collect Results",
text: "Review deliverables, approve critical decisions, and download finished work including code, content, and research.",
},
],
};
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: [
{
"@type": "Question",
name: "What is AgentCompany?",
acceptedAnswer: {
"@type": "Answer",
text: "AgentCompany is a local-first desktop application for launching and operating autonomous AI companies. You define the mission, an AI CEO organizes the team, delegates work, and coordinates delivery across engineering, design, research, and operations.",
},
},
{
"@type": "Question",
name: "How do I download AgentCompany?",
acceptedAnswer: {
"@type": "Answer",
text: "Use the download section on the website to choose the installer for your platform. macOS ships as a signed and notarized DMG. Windows and Linux builds are distributed directly from our Cloudflare release host.",
},
},
{
"@type": "Question",
name: "What platforms does AgentCompany support?",
acceptedAnswer: {
"@type": "Answer",
text: "AgentCompany is available on macOS, Windows, and Linux. It is built with Electron and runs natively on all three platforms with full feature parity.",
},
},
{
"@type": "Question",
name: "What AI models can AgentCompany use?",
acceptedAnswer: {
"@type": "Answer",
text: "AgentCompany supports multiple AI runtimes as first-class connectors: Claude Code, Codex CLI, and Gemini CLI. You can connect your preferred provider during setup.",
},
},
{
"@type": "Question",
name: "How does the AI CEO work?",
acceptedAnswer: {
"@type": "Answer",
text: "When you create a company with a goal, the AI CEO agent analyzes the objective, creates the necessary departments, hires specialized agents for each role, builds an org chart, and delegates tasks. The CEO monitors progress, handles blockers, and coordinates cross-department work.",
},
},
{
"@type": "Question",
name: "Is my data private? Does it leave my machine?",
acceptedAnswer: {
"@type": "Answer",
text: "AgentCompany is local-first. All data is stored in a local SQLite database on your machine. Secrets are encrypted via an OS-level keychain. The only external communication is between your machine and your AI provider's API.",
},
},
{
"@type": "Question",
name: "What can an AI company actually deliver?",
acceptedAnswer: {
"@type": "Answer",
text: "AI companies built with AgentCompany can produce code, content, research, business documents, and more. The deliverables depend on the goal you set and the AI runtime you connect.",
},
},
{
"@type": "Question",
name: "How are updates delivered?",
acceptedAnswer: {
"@type": "Answer",
text: "Desktop updates are published to a Cloudflare-hosted release lane. macOS builds include the signed update metadata required for in-app update checks, while Windows and Linux builds are delivered from the same release host for manual installation.",
},
},
],
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="googlebot" content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(howToJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
</head>
<body className="bg-[var(--bg)] text-[var(--text)] antialiased transition-colors duration-300">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { DownloadSection } from "../components/download-section";
import { FAQ } from "../components/faq";
import { Features } from "../components/features";
import { Footer } from "../components/footer";
import { Hero } from "../components/hero";
import { HowItWorks } from "../components/how-it-works";
import { Navbar } from "../components/navbar";
export default function Page() {
return (
<>
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-[var(--accent)] focus:text-[var(--text-on-accent)] focus:text-sm focus:font-medium"
>
Skip to content
</a>
<Navbar />
<main id="main">
<Hero />
<Features />
<HowItWorks />
<FAQ />
<DownloadSection />
</main>
<Footer />
</>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useEffect, useRef } from "react";
const glyphs = " .'`^,:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
function parseRgbHex(hex: string): [number, number, number] {
const value = hex.replace("#", "").trim();
if (value.length !== 6) return [128, 128, 128];
return [
Number.parseInt(value.slice(0, 2), 16),
Number.parseInt(value.slice(2, 4), 16),
Number.parseInt(value.slice(4, 6), 16),
];
}
export function AsciiBackground({
cssVar = "--accent",
baseOpacity = 0.08,
speed = 1,
}: {
cssVar?: string;
baseOpacity?: number;
speed?: number;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const context = canvas.getContext("2d");
if (!context) return;
let width = 0;
let height = 0;
let rgb: [number, number, number] = [128, 128, 128];
let phase = 0;
let frame = 0;
let isVisible = true;
const pointer = { x: -9999, y: -9999 };
const updateColor = () => {
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
if (value.startsWith("#")) {
rgb = parseRgbHex(value);
}
};
updateColor();
const mutationObserver = new MutationObserver(updateColor);
mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const intersectionObserver = new IntersectionObserver(
(entries) => {
const [entry] = entries;
isVisible = entry?.isIntersecting ?? true;
},
{ threshold: 0 },
);
intersectionObserver.observe(container);
const resize = () => {
width = container.clientWidth;
height = container.clientHeight;
const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
};
const handlePointer = (event: MouseEvent) => {
const rect = container.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
};
const render = () => {
frame = requestAnimationFrame(render);
if (!isVisible) return;
context.clearRect(0, 0, width, height);
context.font = "14px monospace";
context.textAlign = "center";
context.textBaseline = "middle";
const columns = Math.ceil(width / 14);
const rows = Math.ceil(height / 14);
const [red, green, blue] = rgb;
const pointerX = pointer.x;
const pointerY = pointer.y;
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const centerX = column * 14 + 7;
const centerY = row * 14 + 7;
const value =
Math.sin(0.05 * column + phase) * Math.cos(0.05 * row + phase) +
Math.sin(0.012 * column - 0.7 * phase) * Math.cos(0.1 * row) * 0.5;
if (Math.abs(value) < 0.3) continue;
const index = Math.floor(Math.abs(value) * glyphs.length);
let glyph = glyphs[index % glyphs.length];
if (!glyph.trim()) continue;
let alpha = Math.abs(value) * baseOpacity;
const deltaX = centerX - pointerX;
const deltaY = centerY - pointerY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance < 110) {
const intensity = 1 - distance / 110;
glyph = Math.random() > 0.5 ? "0" : "1";
alpha = 0.35 * intensity;
const drift = distance > 1 ? 7 * intensity : 0;
const offsetX = (deltaX / distance) * drift;
const offsetY = (deltaY / distance) * drift;
context.fillStyle = `rgba(${red},${green},${blue},${alpha})`;
context.fillText(glyph, centerX - offsetX, centerY - offsetY);
} else {
context.fillStyle = `rgba(${red},${green},${blue},${alpha})`;
context.fillText(glyph, centerX, centerY);
}
}
}
phase += 0.005 * speed;
};
resize();
window.addEventListener("resize", resize);
window.addEventListener("mousemove", handlePointer);
render();
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", handlePointer);
mutationObserver.disconnect();
intersectionObserver.disconnect();
};
}, [baseOpacity, cssVar, speed]);
return (
<div ref={containerRef} className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<canvas ref={canvasRef} className="block w-full h-full" />
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { motion } from "framer-motion";
import { AsciiBackground } from "./ascii-background";
import { platformDownloads, releaseVersion } from "./release-links.generated";
export function DownloadSection() {
return (
<section id="download" className="py-24 lg:py-40 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
<motion.div
className="relative mx-auto max-w-5xl rounded-3xl p-14 lg:p-24 overflow-hidden"
style={{
background: "linear-gradient(135deg, var(--cta) 0%, color-mix(in srgb, var(--cta) 85%, #000) 100%)",
}}
initial={{ opacity: 0, scale: 0.92 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
>
<AsciiBackground cssVar="--text-on-cta" baseOpacity={0.1} speed={0.8} />
<div
className="absolute inset-0 pointer-events-none opacity-[0.03]"
aria-hidden="true"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E\")",
backgroundRepeat: "repeat",
backgroundSize: "128px 128px",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
aria-hidden="true"
style={{
background:
"radial-gradient(ellipse at top right, rgba(255,255,255,0.12) 0%, transparent 50%), radial-gradient(ellipse at bottom left, rgba(255,255,255,0.06) 0%, transparent 50%)",
}}
/>
<div className="relative z-10 flex flex-col items-center text-center">
<h2 className="font-serif italic text-[clamp(2rem,1.5rem+2.5vw,3.25rem)] tracking-[-0.01em] text-[var(--text-on-cta)] mb-6">
Install the Right Build for Your Machine
</h2>
<p className="text-[var(--text-on-cta-muted)] text-lg max-w-xl mx-auto mb-12 leading-relaxed">
Full release matrix hosted on our Cloudflare release lane. Version {releaseVersion}.
</p>
<div className="w-full mt-2 grid grid-cols-1 md:grid-cols-3 gap-6">
{platformDownloads.map((download, index) => (
<motion.div
key={download.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.5, delay: 0.08 * index, ease: [0.16, 1, 0.3, 1] }}
className="rounded-2xl border border-white/10 bg-white/6 backdrop-blur-xl shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] p-6 lg:p-7 text-left"
>
<p className="font-mono text-xs uppercase tracking-[0.25em] text-[var(--text-on-cta-faint)] mb-3">
{download.title}
</p>
<p className="text-[var(--text-on-cta-muted)] text-sm leading-relaxed mb-4">{download.note}</p>
<div className="space-y-2.5">
{download.assets.map((asset) => (
<a
key={asset.id}
href={asset.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-3 rounded-xl border border-white/12 bg-black/10 px-4 py-3 text-sm text-[var(--text-on-cta)] transition-colors duration-200 hover:bg-black/18"
>
<span className="font-semibold tracking-tight">{asset.label}</span>
<span className="font-mono text-xs uppercase tracking-[0.18em] text-[var(--text-on-cta-faint)]">
{asset.note}
</span>
</a>
))}
</div>
</motion.div>
))}
</div>
</div>
</motion.div>
</section>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { motion } from "framer-motion";
import { useState } from "react";
const faqs = [
{
question: "What is AgentCompany?",
answer:
"AgentCompany is a local-first desktop application for launching and operating autonomous AI companies. You define the mission, an AI CEO organizes the team, delegates work, and coordinates delivery across engineering, design, research, and operations.",
},
{
question: "How do I download AgentCompany?",
answer:
"Use the download section on this page to choose the build that matches your architecture and package format. macOS ships as signed and notarized DMGs, Windows ships as a universal installer covering ARM64, x64, and x86, and Linux ships as AppImage, DEB, and RPM builds from our Cloudflare release host.",
},
{
question: "What platforms does AgentCompany support?",
answer:
"AgentCompany is available on macOS arm64 and x64, Windows ARM64/x64/x86, and Linux x64 and ARM64. It is built with Electron and the desktop feature set stays aligned across all supported targets.",
},
{
question: "What AI models can AgentCompany use?",
answer:
"AgentCompany supports multiple AI runtimes as first-class connectors: Claude Code (Anthropic), Codex CLI (OpenAI), and Gemini CLI (Google). You can connect your preferred provider during setup.",
},
{
question: "How does the AI CEO work?",
answer:
"When you create a company with a goal, the AI CEO agent analyzes the objective, creates the necessary departments (Engineering, Design, Marketing, etc.), hires specialized agents for each role, builds an org chart, and delegates tasks. The CEO monitors progress, handles blockers, and coordinates cross-department work — all autonomously.",
},
{
question: "Is my data private? Does it leave my machine?",
answer:
"AgentCompany is local-first. All data is stored in a local SQLite database on your machine. Secrets are encrypted via an OS-level keychain. The only external communication is between your machine and your AI provider's API — no data is sent to AgentCompany servers.",
},
{
question: "What can an AI company actually deliver?",
answer:
"AI companies built with AgentCompany can produce code (full applications, scripts, APIs), content (blog posts, marketing copy, social media), research (competitive analysis, market research), business documents (business plans, technical specs, strategies), and more. The deliverables depend on the goal you set and the AI runtime you connect.",
},
{
question: "How are updates delivered?",
answer:
"Desktop updates are published to a Cloudflare-hosted release lane. macOS builds include the signed update metadata required for in-app update checks, while the Windows and Linux installers and packages are distributed from the same release host.",
},
];
function FaqItem({ question, answer, index }: { question: string; answer: string; index: number }) {
const [open, setOpen] = useState(false);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.5, delay: 0.06 * index, ease: [0.16, 1, 0.3, 1] }}
className="border-b border-[var(--border)] last:border-b-0"
>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
className="flex w-full items-center justify-between gap-4 py-6 text-left transition-colors duration-200 hover:text-[var(--accent)]"
>
<h3 className="text-base lg:text-lg font-semibold text-[var(--text)] pr-4">{question}</h3>
<span
className={`shrink-0 text-[var(--muted)] transition-transform duration-300 ${open ? "rotate-45" : ""}`}
aria-hidden="true"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</span>
</button>
<div className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${open ? "max-h-96 pb-6" : "max-h-0"}`}>
<p className="text-[var(--muted-strong)] leading-relaxed text-base pr-12">{answer}</p>
</div>
</motion.div>
);
}
export function FAQ() {
return (
<section id="faq" className="py-24 lg:py-40 px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="text-center mb-16 lg:mb-20"
>
<p className="font-mono text-xs font-medium uppercase tracking-[0.25em] text-[var(--accent)] mb-5">FAQ</p>
<h2 className="font-serif text-[clamp(2rem,1.5rem+2.5vw,3.25rem)] tracking-[-0.01em] text-[var(--text)]">
Frequently Asked Questions
</h2>
</motion.div>
<div className="border-t border-[var(--border)]">
{faqs.map((item, index) => (
<FaqItem key={item.question} question={item.question} answer={item.answer} index={index} />
))}
</div>
</div>
</section>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { motion } from "framer-motion";
const features = [
{
number: "01",
title: "One-Click Company Creation",
description:
"Name your company, describe your mission, connect your AI runtime, and launch. Your first agents start working in under a minute.",
image: "/screenshots/Onboarding.jpeg",
imageAlt: "Company creation onboarding flow with mission and runtime setup",
imageLeft: true,
},
{
number: "02",
title: "Self-Organizing Teams",
description:
"Your AI CEO builds the org chart. It identifies the roles needed, hires specialists, and creates reporting lines — all from a single goal.",
image: "/screenshots/Auto-OrgChart-Team.jpeg",
imageAlt: "AI-generated org chart showing self-organizing team structure",
imageLeft: false,
},
{
number: "03",
title: "Autonomous Task Management",
description:
"Goals break down into tasks automatically. Agents pick up work, report progress, and escalate blockers — you decide when to step in.",
image: "/screenshots/Auto-Task.jpeg",
imageAlt: "Autonomous task board with agents managing work items",
imageLeft: true,
},
{
number: "04",
title: "Real-Time Agent Communication",
description:
"Agents communicate through department channels and direct messages. Watch strategic decisions happen in real time, like a Slack workspace you built.",
image: "/screenshots/AI-Internal-Realtime-Message.jpeg",
imageAlt: "Real-time messaging between AI agents in department channels",
},
{
number: "05",
title: "Human-in-the-Loop Approvals",
description:
"Stay in control. Critical decisions surface for your review. Approve, reject, or redirect — your company respects the chain of command.",
image: "/screenshots/Approval-by-Human.jpeg",
imageAlt: "Approval interface showing human review of agent decisions",
},
{
number: "06",
title: "AI Document Generation",
description:
"Business plans, marketing strategies, technical specs — agents draft and iterate on real deliverables, stored in your document center.",
image: "/screenshots/Document-Center.jpeg",
imageAlt: "Document center with AI-generated business deliverables",
},
];
function FeaturePair({ feature }: { feature: (typeof features)[number] }) {
const { number, title, description, image, imageAlt, imageLeft } = feature;
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];
const media = (
<motion.div
variants={{
hidden: { opacity: 0, x: imageLeft ? -60 : 60 },
visible: { opacity: 1, x: 0, transition: { duration: 0.7, ease } },
}}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.3 }}
className={`order-2 ${imageLeft ? "lg:order-1" : "lg:order-2"}`}
>
<div className="rounded-2xl overflow-hidden shadow-[0_4px_32px_-8px_rgba(0,0,0,0.1)] border border-[var(--border)] bg-[var(--panel)] transition-shadow duration-500 hover:shadow-[0_8px_48px_-12px_rgba(0,0,0,0.15)]">
<img src={image} alt={imageAlt} width="1920" height="1080" className="w-full h-auto block" loading="lazy" />
</div>
</motion.div>
);
const copy = (
<motion.div
variants={{
hidden: { opacity: 0, x: imageLeft ? 60 : -60 },
visible: { opacity: 1, x: 0, transition: { duration: 0.7, ease, delay: 0.1 } },
}}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.3 }}
className={`flex flex-col justify-center order-1 ${imageLeft ? "lg:order-2" : "lg:order-1"}`}
>
<span className="font-mono text-[5rem] lg:text-[6rem] font-bold leading-none text-[var(--accent)] opacity-[0.07] select-none -mb-10 lg:-mb-12">
{number}
</span>
<div className="relative">
<div className="flex items-center gap-3 mb-4">
<span className="block w-2 h-2 rounded-full bg-[var(--accent)]" />
<span className="font-mono text-sm text-[var(--muted)]">{number}</span>
</div>
<h2 className="text-2xl lg:text-[1.875rem] font-bold text-[var(--text)] mb-4 tracking-tight">{title}</h2>
<p className="text-[var(--muted-strong)] leading-relaxed text-base lg:text-lg">{description}</p>
</div>
</motion.div>
);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
{media}
{copy}
</div>
);
}
export function Features() {
return (
<section
id="features"
className="py-24 lg:py-40"
style={{ background: "linear-gradient(180deg, transparent 0%, var(--section-alt) 30%, var(--section-alt) 70%, transparent 100%)" }}
>
<div className="max-w-6xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="text-center mb-24 lg:mb-36"
>
<span className="font-mono text-xs uppercase tracking-[0.25em] text-[var(--accent)] mb-5 block font-medium">
Features
</span>
<h2 className="font-serif text-[clamp(2rem,1.5rem+2.5vw,3.25rem)] tracking-[-0.01em] text-[var(--text)]">
Your Company, Under the Hood
</h2>
</motion.div>
<div className="space-y-24 lg:space-y-40">
{features.slice(0, 3).map((feature) => (
<FeaturePair key={feature.number} feature={feature} />
))}
</div>
<motion.div
className="mt-24 lg:mt-40 grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={{
hidden: {},
visible: {
transition: { staggerChildren: 0.12 },
},
}}
>
{features.slice(3).map((feature) => (
<motion.div
key={feature.number}
variants={{
hidden: { opacity: 0, y: 32 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] },
},
}}
className="group bg-[var(--panel)] rounded-2xl border border-[var(--border)] overflow-hidden transition-all duration-300 hover:shadow-[0_8px_40px_-12px_rgba(0,0,0,0.12)] hover:-translate-y-1.5"
>
<div className="overflow-hidden">
<img
src={feature.image}
alt={feature.imageAlt}
width="640"
height="360"
className="w-full h-auto block transition-transform duration-500 group-hover:scale-[1.03]"
loading="lazy"
/>
</div>
<div className="p-6 lg:p-7">
<div className="flex items-center gap-3 mb-3">
<span className="block w-2 h-2 rounded-full bg-[var(--accent)]" />
<span className="font-mono text-sm text-[var(--muted)]">{feature.number}</span>
</div>
<h2 className="text-lg font-bold text-[var(--text)] mb-2 tracking-tight">{feature.title}</h2>
<p className="text-sm text-[var(--muted-strong)] leading-relaxed">{feature.description}</p>
</div>
</motion.div>
))}
</motion.div>
</div>
</section>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { AsciiBackground } from "./ascii-background";
import { IconMark } from "./icon-mark";
import { CONTACT_EMAIL, WEBSITE_URL } from "./site-constants";
import { platformDownloads } from "./release-links.generated";
import { ThemeToggle } from "./theme-toggle";
function findAsset(platformId: string, assetId: string) {
const platform = platformDownloads.find((entry) => entry.id === platformId);
if (!platform) {
throw new Error(`Missing platform download entry for ${platformId}`);
}
const asset = platform.assets.find((entry) => entry.id === assetId);
if (!asset) {
throw new Error(`Missing asset ${assetId} for ${platformId}`);
}
return asset.href;
}
const footerGroups = [
{
title: "Product",
links: [
{ label: "Features", href: "#features" },
{ label: "How It Works", href: "#how-it-works" },
{ label: "FAQ", href: "#faq" },
{ label: "Download", href: "#download" },
],
},
{
title: "Downloads",
links: [
{ label: "Apple Silicon", href: findAsset("macos", "macos-arm64-dmg"), external: true },
{ label: "Intel Mac", href: findAsset("macos", "macos-x64-dmg"), external: true },
{ label: "Windows Installer", href: findAsset("windows", "windows-universal-installer"), external: true },
{ label: "Linux x64 AppImage", href: findAsset("linux", "linux-x64-appimage"), external: true },
],
},
{
title: "Company",
links: [
{ label: "Website", href: WEBSITE_URL, external: true },
{ label: "Contact", href: `mailto:${CONTACT_EMAIL}`, external: true },
{ label: "Download Center", href: "#download" },
],
},
];
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="relative border-t border-[var(--border)] overflow-hidden">
<AsciiBackground cssVar="--accent" baseOpacity={0.04} speed={0.5} />
<div className="relative z-10 mx-auto max-w-6xl px-6 lg:px-8 pt-16 lg:pt-20 pb-10 lg:pb-12">
<div className="flex flex-col lg:flex-row gap-y-12 gap-x-8">
<div className="shrink-0 lg:w-64 mb-4 lg:mb-0">
<a href="#" className="inline-flex items-center gap-2.5" aria-label="AgentCompany home">
<IconMark size={28} />
<span className="text-[var(--text)] font-semibold text-lg tracking-tight">AgentCompany</span>
</a>
<p className="mt-4 text-sm leading-relaxed text-[var(--muted)] max-w-xs">
Local-first desktop control plane for autonomous AI companies.
</p>
</div>
<div className="flex-1 flex flex-wrap gap-y-12 gap-x-8 justify-between">
{footerGroups.map((group) => (
<div key={group.title} className="min-w-[120px]">
<h3 className="text-sm font-semibold text-[var(--text)] tracking-tight mb-4">{group.title}</h3>
<ul className="space-y-3">
{group.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="text-sm text-[var(--muted)] hover:text-[var(--text)] transition-colors duration-200"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-14 lg:mt-16 pt-6 border-t border-[var(--border)]" />
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4 text-xs text-[var(--muted)]">
<span>© {year} AgentCompany</span>
<span className="hidden sm:inline-flex items-center gap-1.5">
Cloudflare-hosted desktop builds
</span>
</div>
<div className="flex items-center gap-1">
<ThemeToggle />
</div>
</div>
</div>
</footer>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { motion } from "framer-motion";
import { AsciiBackground } from "./ascii-background";
const sharedEase: [number, number, number, number] = [0.16, 1, 0.3, 1];
function heroMotion(delay: number) {
return {
initial: { opacity: 0, y: 24 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, delay, ease: sharedEase },
};
}
function AppleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
}
function WindowsIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 12V6.5l8-1.1V12H3zm0 .5h8v6.6l-8-1.1V12.5zM11.5 5.3l9.5-1.3v8h-9.5V5.3zm0 7.2h9.5v8l-9.5-1.3V12.5z" />
</svg>
);
}
function LinuxIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.5 2C10 2 8.2 4.2 8.2 7c0 1.5.3 2.2.7 3.2.3.8.7 1.7.7 3 0 .3 0 .6-.1.9-.2 1-.6 1.8-1.2 2.3-.8.7-1.8 1.1-2.8 1.6-.7.3-1.4.7-1.9 1.2-.4.4-.6.9-.6 1.4 0 .5.2.9.6 1.2.7.6 2 .7 3.1.7.8 0 1.6-.1 2.2-.2.6-.1 1.2-.3 1.9-.3.7 0 1.3.2 1.9.3.6.1 1.4.2 2.2.2 1.1 0 2.4-.1 3.1-.7.4-.3.6-.7.6-1.2 0-.5-.2-1-.6-1.4-.5-.5-1.2-.9-1.9-1.2-1-.5-2-1-2.8-1.6-.6-.5-1-1.3-1.2-2.3-.1-.3-.1-.6-.1-.9 0-1.3.4-2.2.7-3 .4-1 .7-1.7.7-3.2 0-2.8-1.8-5-4.3-5h-.2zm-1 5c0-.8.5-1.5 1.2-1.5s1.2.7 1.2 1.5-.5 1.5-1.2 1.5S11.5 7.8 11.5 7z" />
</svg>
);
}
export function Hero() {
return (
<section className="relative overflow-hidden pt-32 pb-24 lg:pt-44 lg:pb-36">
<AsciiBackground cssVar="--accent" baseOpacity={0.06} speed={1} />
<div
className="absolute inset-0 pointer-events-none"
aria-hidden="true"
style={{
background:
"radial-gradient(ellipse 80% 50% at 50% 0%, var(--glow-warm) 0%, transparent 70%), radial-gradient(ellipse 60% 40% at 50% 20%, var(--glow-accent) 0%, transparent 60%)",
}}
/>
<div className="relative mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-col items-center text-center">
<motion.div {...heroMotion(0.05)}>
<span className="inline-flex items-center px-4 py-1.5 rounded-full text-xs font-semibold tracking-wide bg-[var(--accent-soft)] text-[var(--accent)] dark:text-[var(--muted-strong)] border border-[var(--border)]">
Local-First · Desktop App
</span>
</motion.div>
<motion.h1
{...heroMotion(0.15)}
className="mt-8 font-serif italic text-[clamp(2.75rem,2rem+3.8vw,5rem)] tracking-[-0.02em] text-[var(--text)] leading-[1.08]"
>
Launch an AI Company <br className="hidden sm:block" />
<span className="text-[var(--cta)]">in Minutes</span>
</motion.h1>
<motion.p
{...heroMotion(0.25)}
className="mt-7 max-w-2xl text-lg sm:text-xl leading-relaxed text-[var(--muted)]"
>
Describe your company&apos;s mission. A CEO agent recruits the team, delegates work, and delivers real
results — code, content, research, and more. You&apos;re the founder.
</motion.p>
<motion.div {...heroMotion(0.35)} className="mt-12">
<a
href="#download"
className="group inline-flex items-center justify-center px-9 py-4 rounded-xl text-[17px] font-semibold bg-[var(--cta)] text-[var(--text-on-cta)] hover:bg-[var(--cta-hover)] hover:scale-[1.03] active:scale-[0.98] transition-all duration-200 will-change-transform shadow-[0_4px_24px_-4px_var(--cta),0_8px_40px_-12px_var(--cta)]"
>
See Downloads
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="ml-2 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
>
<path d="m7 7 10 10" />
<path d="M17 7v10H7" />
</svg>
</a>
</motion.div>
<motion.div {...heroMotion(0.45)} className="mt-5 flex items-center gap-3 text-xs text-[var(--muted)]">
<div className="flex items-center gap-1.5 opacity-70">
<AppleIcon />
<WindowsIcon />
<LinuxIcon />
</div>
<span>Available on macOS, Windows & Linux</span>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.9, delay: 0.5, ease: sharedEase }}
className="mt-20 lg:mt-24 mx-auto max-w-5xl"
>
<img
src="/screenshots/Dashboard.jpeg"
alt="AgentCompany dashboard showing autonomous AI agents managing a company"
width="1920"
height="1080"
className="w-full h-auto block rounded-2xl border border-[var(--border)] shadow-[0_8px_40px_-8px_rgba(0,0,0,0.12),0_20px_80px_-20px_var(--glow-warm)] transition-shadow duration-500"
loading="eager"
/>
</motion.div>
</div>
</section>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
const demoMessages = [
{ agent: "You", role: "system", text: "Build a SaaS landing page with pricing, blog, and auth.", delay: 0 },
{ agent: "CEO", role: "ceo", text: "Analyzing goal... Creating departments: Engineering, Design, Content.", delay: 800 },
{ agent: "CEO", role: "ceo", text: "Hiring 4 specialists for this mission.", delay: 1600 },
{ agent: "Lead Engineer", role: "engineer", text: "Picked up task: Set up Next.js project with auth routes.", delay: 2400 },
{ agent: "UI Designer", role: "designer", text: "Picked up task: Design landing page and pricing components.", delay: 3000 },
{ agent: "Researcher", role: "researcher", text: "Picked up task: Competitive analysis of SaaS pricing models.", delay: 3600 },
{ agent: "Lead Engineer", role: "engineer", text: "Completed: Auth scaffolding with JWT + session middleware.", delay: 4400 },
{ agent: "UI Designer", role: "designer", text: "Completed: Responsive landing page with 3-tier pricing grid.", delay: 5200 },
{ agent: "CEO", role: "ceo", text: "All tasks delivered. 3 documents ready for review.", delay: 6000 },
];
const logTone: Record<(typeof demoMessages)[number]["role"], string> = {
system: "text-[var(--muted-strong)]",
ceo: "text-[#ca8a04]",
engineer: "text-[var(--accent)]",
designer: "text-[#8b5cf6]",
researcher: "text-[#059669]",
};
const steps = [
{ number: 1, title: "Set Your Goal", description: "Describe the company, its mission, and what you want delivered." },
{ number: 2, title: "Agents Self-Organize", description: "A CEO analyzes your goal, hires specialists, and delegates the work." },
{ number: 3, title: "Collect Results", description: "Review deliverables, approve decisions, and download finished work." },
];
function DemoPanel() {
const anchorRef = useRef<HTMLDivElement | null>(null);
const isVisible = useInView(anchorRef, { once: true, amount: 0.4 });
const [visibleCount, setVisibleCount] = useState(0);
const logRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isVisible) return;
const timers = demoMessages.map((message, index) =>
window.setTimeout(() => setVisibleCount(index + 1), message.delay),
);
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [isVisible]);
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [visibleCount]);
return (
<div ref={anchorRef} className="relative">
<div className="rounded-2xl overflow-hidden border border-[var(--border)] bg-[var(--panel)] shadow-[0_8px_40px_-8px_rgba(0,0,0,0.1),0_24px_80px_-24px_var(--glow-warm)]">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--panel-soft)] border-b border-[var(--border)]">
<span className="w-3 h-3 rounded-full bg-[#ff5f57]" aria-hidden="true" />
<span className="w-3 h-3 rounded-full bg-[#febc2e]" aria-hidden="true" />
<span className="w-3 h-3 rounded-full bg-[#28c840]" aria-hidden="true" />
<span className="ml-2 text-xs text-[var(--muted)] font-mono">AgentCompany</span>
</div>
<div ref={logRef} role="log" aria-label="Agent activity demonstration" className="p-5 lg:p-6 font-mono text-sm leading-relaxed h-[340px] overflow-y-auto">
{demoMessages.slice(0, visibleCount).map((message, index) => (
<motion.div
key={`${message.agent}-${index}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="flex gap-3 mb-3 last:mb-0"
>
<span className={`shrink-0 font-semibold inline-flex items-center gap-1.5 ${logTone[message.role]}`}>
<span
className={`w-1.5 h-1.5 rounded-full ${
message.role === "system"
? "bg-[var(--muted-strong)]"
: message.role === "ceo"
? "bg-[#ca8a04]"
: message.role === "engineer"
? "bg-[var(--accent)]"
: message.role === "designer"
? "bg-[#8b5cf6]"
: "bg-[#059669]"
}`}
aria-hidden="true"
/>
{message.agent}
</span>
<span className="text-[var(--text)]">{message.text}</span>
</motion.div>
))}
{visibleCount < demoMessages.length && isVisible && (
<span className="inline-block w-2 h-4 bg-[var(--accent)] animate-pulse rounded-sm" aria-hidden="true" />
)}
{visibleCount >= demoMessages.length && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-4 pt-4 border-t border-[var(--border)] flex items-center gap-2 text-[#059669]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m9 12 2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
<span className="font-semibold">Company run complete. All deliverables ready.</span>
</motion.div>
)}
<div />
</div>
</div>
</div>
);
}
export function HowItWorks() {
return (
<section id="how-it-works" className="py-24 lg:py-40 px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-6xl">
<div className="text-center mb-20 lg:mb-24">
<p className="font-mono text-xs font-medium uppercase tracking-[0.25em] text-[var(--accent)] mb-5">
How It Works
</p>
<h2 className="font-serif text-[clamp(2rem,1.5rem+2.5vw,3.25rem)] tracking-[-0.01em] text-[var(--text)]">
Watch a Company Come to Life
</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-12 lg:gap-16 items-start">
<div className="lg:col-span-2 relative">
<div className="absolute left-[15px] top-10 bottom-4 w-px bg-[var(--border)] hidden lg:block" aria-hidden="true" />
<div className="space-y-10">
{steps.map((step, index) => (
<motion.div
key={step.number}
initial={{ opacity: 0, x: -24 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 0.5, delay: 0.12 * index, ease: [0.16, 1, 0.3, 1] }}
className="flex gap-5"
>
<div className="relative z-10 shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-[var(--accent)] text-[var(--text-on-accent)] text-sm font-bold shadow-[0_2px_12px_-2px_var(--accent)]">
{step.number}
</div>
<div className="pt-0.5">
<h3 className="text-base font-bold text-[var(--text)] mb-1.5 tracking-tight">{step.title}</h3>
<p className="text-sm leading-relaxed text-[var(--muted)]">{step.description}</p>
</div>
</motion.div>
))}
</div>
</div>
<motion.div
className="lg:col-span-3"
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
>
<DemoPanel />
</motion.div>
</div>
</div>
</section>
);
}
+24
View File
@@ -0,0 +1,24 @@
export function IconMark({ size = 32 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle cx="512" cy="512" r="480" fill="#191919" />
<g transform="translate(268 255) scale(4)">
<path
fill="#FBFAF5"
d="M 76.604813 111.661926 C 65.070183 111.662621 53.987053 111.662621 42.757744 111.662621 C 45.2239 101.998177 45.297546 101.949265 53.830105 102.036659 C 57.279823 102.071999 60.730202 102.04248 64.259201 102.04248 C 62.159458 93.316338 61.799103 93.009819 51.340881 91.341682 C 59.312901 73.847687 67.193756 56.553749 75.587578 38.134155 C 86.933441 62.965954 97.804642 86.758881 108.711754 110.630394 C 100.707413 111.871689 92.355965 107.246559 88.107536 98.852119 C 84.207748 91.146538 80.800591 83.191696 77.172813 75.348343 C 76.340195 73.54818 75.492958 71.754791 74.614624 69.877441 C 69.107384 75.691788 68.008286 81.475128 71.005882 88.088753 C 74.413452 95.606903 77.813919 103.128281 81.452705 111.167984 C 79.55114 111.381325 78.303734 111.521271 76.604813 111.661926 Z"
/>
<path
fill="#FBFAF5"
d="M 26.805992 87.743256 C 38.616821 61.830872 50.261688 36.260269 62.470131 9.452141 C 65.732315 17.818909 71.145126 23.524567 66.258224 33.086182 C 55.518253 54.099815 46.339973 75.911873 36.533691 97.402336 C 32.699284 105.805443 26.495394 110.823158 16.012421 111.410957 C 19.72117 103.270836 23.180601 95.67794 26.805992 87.743256 Z"
/>
</g>
</svg>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import { IconMark } from "./icon-mark";
import { ThemeToggle } from "./theme-toggle";
const navLinks = [
{ label: "Features", href: "#features" },
{ label: "How It Works", href: "#how-it-works" },
{ label: "FAQ", href: "#faq" },
{ label: "Download", href: "#download" },
];
function MenuIcon({ open }: { open: boolean }) {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{open ? (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
);
}
export function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 768) {
setMobileOpen(false);
}
};
window.addEventListener("resize", handleResize, { passive: true });
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<nav
className={[
"fixed top-0 left-0 right-0 z-50 transition-all duration-300 backdrop-blur-xl",
scrolled ? "bg-[var(--panel)]/80 border-b border-[var(--border)] shadow-sm" : "bg-transparent border-b border-transparent",
].join(" ")}
>
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<a href="#" className="flex items-center gap-2.5 shrink-0" aria-label="AgentCompany home">
<IconMark />
<span className="text-[var(--text)] font-semibold text-lg tracking-tight">AgentCompany</span>
</a>
<div className="hidden md:flex items-center gap-8">
{navLinks.map(({ label, href }) => (
<a key={href} href={href} className="text-sm text-[var(--muted)] hover:text-[var(--text)] transition-colors duration-200">
{label}
</a>
))}
</div>
<div className="hidden md:flex items-center gap-3">
<ThemeToggle />
<a
href="#download"
className="inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium bg-[var(--cta)] text-[var(--text-on-cta)] hover:bg-[var(--cta-hover)] hover:scale-[1.03] active:scale-[0.98] transition-all duration-200"
>
Download
</a>
</div>
<div className="flex md:hidden items-center gap-2">
<ThemeToggle />
<button
type="button"
onClick={() => setMobileOpen((current) => !current)}
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
aria-expanded={mobileOpen}
aria-controls="mobile-nav-menu"
className="inline-flex items-center justify-center w-11 h-11 rounded-lg text-[var(--muted)] hover:text-[var(--text)] hover:bg-[var(--panel-soft)] transition-colors duration-200"
>
<MenuIcon open={mobileOpen} />
</button>
</div>
</div>
</div>
<div
id="mobile-nav-menu"
className={[
"md:hidden overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
mobileOpen ? "max-h-64 opacity-100" : "max-h-0 opacity-0",
].join(" ")}
>
<div className="px-4 pb-4 pt-1 space-y-1 bg-[var(--panel)]/90 backdrop-blur-xl border-b border-[var(--border)]">
{navLinks.map(({ label, href }) => (
<a
key={href}
href={href}
onClick={() => setMobileOpen(false)}
className="block px-3 py-2.5 rounded-lg text-sm text-[var(--muted)] hover:text-[var(--text)] hover:bg-[var(--panel-soft)] transition-colors duration-200"
>
{label}
</a>
))}
<a
href="#download"
onClick={() => setMobileOpen(false)}
className="block px-3 py-2.5 rounded-lg text-sm font-medium text-center bg-[var(--cta)] text-[var(--text-on-cta)] hover:bg-[var(--cta-hover)] transition-colors duration-200 mt-2"
>
Download
</a>
</div>
</div>
</nav>
);
}
@@ -0,0 +1,93 @@
export type PlatformDownloadAsset = {
id: string;
label: string;
href: string;
note: string;
};
export type PlatformDownload = {
id: "macos" | "windows" | "linux";
title: string;
note: string;
assets: PlatformDownloadAsset[];
};
export const releaseVersion = "0.4.150";
export const platformDownloads: PlatformDownload[] = [
{
id: "macos",
title: "macOS",
note: "Signed + notarized DMG builds",
assets: [
{
id: "macos-arm64-dmg",
label: "Apple Silicon DMG",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150-arm64.dmg",
note: "arm64",
},
{
id: "macos-x64-dmg",
label: "Intel Mac DMG",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150.dmg",
note: "x64",
},
],
},
{
id: "windows",
title: "Windows",
note: "NSIS installer builds",
assets: [
{
id: "windows-universal-installer",
label: "Windows Universal Installer",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.150.exe",
note: "arm64 + x64 + ia32",
},
],
},
{
id: "linux",
title: "Linux",
note: "AppImage, DEB, and RPM builds",
assets: [
{
id: "linux-x64-appimage",
label: "Linux x64 AppImage",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150.AppImage",
note: "x64",
},
{
id: "linux-x64-deb",
label: "Linux x64 DEB",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.150_amd64.deb",
note: "x64",
},
{
id: "linux-x64-rpm",
label: "Linux x64 RPM",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.150.x86_64.rpm",
note: "x64",
},
{
id: "linux-arm64-appimage",
label: "Linux ARM64 AppImage",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150-arm64.AppImage",
note: "arm64",
},
{
id: "linux-arm64-deb",
label: "Linux ARM64 DEB",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.150_arm64.deb",
note: "arm64",
},
{
id: "linux-arm64-rpm",
label: "Linux ARM64 RPM",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.150.aarch64.rpm",
note: "arm64",
},
],
},
];
+2
View File
@@ -0,0 +1,2 @@
export const WEBSITE_URL = "https://agent-company.dev";
export const CONTACT_EMAIL = "dev@agentcompany.ai";
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
const storageKey = "agentcompany-theme";
type ThemeMode = "light" | "dark";
type ThemeContextValue = {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function resolveTheme(): ThemeMode {
if (typeof window === "undefined") {
return "light";
}
const stored = localStorage.getItem(storageKey);
if (stored === "light" || stored === "dark") {
return stored;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(resolveTheme);
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem(storageKey, theme);
}, [theme]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (event: MediaQueryListEvent) => {
if (!localStorage.getItem(storageKey)) {
setThemeState(event.matches ? "dark" : "light");
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const setTheme = useCallback((nextTheme: ThemeMode) => {
setThemeState(nextTheme);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((currentTheme) => (currentTheme === "light" ? "dark" : "light"));
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useTheme } from "./theme-provider";
function SunIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
);
}
function MoonIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
}
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
type="button"
onClick={toggleTheme}
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
className="relative inline-flex items-center justify-center w-11 h-11 rounded-lg transition-colors duration-200 cursor-pointer hover:bg-[var(--panel-soft)] text-[var(--muted)] hover:text-[var(--text)]"
>
{theme === "light" ? <MoonIcon /> : <SunIcon />}
</button>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
turbopack: {
root: path.join(__dirname),
},
};
export default nextConfig;
+1079
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "agent-company-landing",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"preview": "wrangler pages dev out"
},
"dependencies": {
"framer-motion": "12.38.0",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Permissions-Policy: camera=(), microphone=(), geolocation=()
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
/screenshots/*
Cache-Control: public, max-age=31536000, immutable
/assets/*
Cache-Control: public, max-age=31536000, immutable
/_next/static/*
Cache-Control: public, max-age=31536000, immutable
/favicon.ico
Cache-Control: public, max-age=31536000, immutable
/icon.png
Cache-Control: public, max-age=31536000, immutable
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
<circle cx="512" cy="512" r="480" fill="#191919"/>
<g transform="translate(268 255) scale(4)">
<path fill="#FBFAF5" d="M 76.604813 111.661926 C 65.070183 111.662621 53.987053 111.662621 42.757744 111.662621 C 45.2239 101.998177 45.297546 101.949265 53.830105 102.036659 C 57.279823 102.071999 60.730202 102.04248 64.259201 102.04248 C 62.159458 93.316338 61.799103 93.009819 51.340881 91.341682 C 59.312901 73.847687 67.193756 56.553749 75.587578 38.134155 C 86.933441 62.965954 97.804642 86.758881 108.711754 110.630394 C 100.707413 111.871689 92.355965 107.246559 88.107536 98.852119 C 84.207748 91.146538 80.800591 83.191696 77.172813 75.348343 C 76.340195 73.54818 75.492958 71.754791 74.614624 69.877441 C 69.107384 75.691788 68.008286 81.475128 71.005882 88.088753 C 74.413452 95.606903 77.813919 103.128281 81.452705 111.167984 C 79.55114 111.381325 78.303734 111.521271 76.604813 111.661926 Z"/>
<path fill="#FBFAF5" d="M 26.805992 87.743256 C 38.616821 61.830872 50.261688 36.260269 62.470131 9.452141 C 65.732315 17.818909 71.145126 23.524567 66.258224 33.086182 C 55.518253 54.099815 46.339973 75.911873 36.533691 97.402336 C 32.699284 105.805443 26.495394 110.823158 16.012421 111.410957 C 19.72117 103.270836 23.180601 95.67794 26.805992 87.743256 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+3
View File
@@ -0,0 +1,3 @@
name = "agentcompany"
compatibility_date = "2026-04-03"
pages_build_output_dir = "out"
+6 -9
View File
@@ -1,10 +1,6 @@
{
"name": "agent-company",
<<<<<<< Updated upstream
"version": "0.4.148",
=======
"version": "0.4.136",
>>>>>>> Stashed changes
"version": "0.4.150",
"private": true,
"type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
@@ -20,15 +16,16 @@
"build": "electron-vite build && node scripts/build-desktop.mjs",
"build:dir": "electron-vite build && node scripts/build-desktop.mjs --dir",
"build:all": "node scripts/build-all-platforms.mjs",
"release:local": "node scripts/release-local.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test": "node scripts/run-vitest.mjs run",
"typecheck": "tsc --noEmit",
"typecheck:all": "pnpm -r typecheck",
"test:unit": "vitest run --config vitest.config.ts tests/unit",
"test:integration": "vitest run --config vitest.config.ts tests/integration",
"test:unit": "node scripts/run-vitest.mjs run --config vitest.config.ts tests/unit",
"test:integration": "node scripts/run-vitest.mjs run --config vitest.config.ts tests/integration",
"test:packages": "echo 'no standalone package tests'",
"test:e2e": "playwright test -c tests/e2e/playwright.config.ts",
"test:e2e": "node --no-warnings ./node_modules/@playwright/test/cli.js test -c tests/e2e/playwright.config.ts",
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e",
"verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:packages && pnpm test:e2e",
"verify:release": "pnpm verify && pnpm build:dir",
@@ -0,0 +1,261 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
import {
asString,
asNumber,
asStringArray,
parseObject,
buildAgentCompanyEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
export const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
export async function resolveAgentCompanySkillsDir(): Promise<string | null> {
for (const candidate of SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
/**
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
* managed skill entries (preferred) or the repo's static `skills/` directory
* (backward compatibility fallback), so `--add-dir` makes Claude Code discover
* them as proper registered skills.
*/
export async function buildSkillsDir(
managedSkills: ManagedSkillRef[],
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agent-company-skills-"));
const target = path.join(tmp, ".claude", "skills");
await fs.mkdir(target, { recursive: true });
// Use managed skills when available; fall back to static repo scan
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, target, onLog);
return tmp;
}
const skillsDir = await resolveAgentCompanySkillsDir();
if (!skillsDir) return tmp;
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
await fs.symlink(
path.join(skillsDir, entry.name),
path.join(target, entry.name),
);
}
}
return tmp;
}
export interface ClaudeExecutionInput {
runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context: Record<string, unknown>;
authToken?: string;
}
export interface ClaudeRuntimeConfig {
command: string;
cwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
extraArgs: string[];
}
export function buildLoginResult(input: {
proc: RunProcessResult;
loginUrl: string | null;
}) {
return {
exitCode: input.proc.exitCode,
signal: input.proc.signal,
timedOut: input.proc.timedOut,
stdout: input.proc.stdout,
stderr: input.proc.stderr,
loginUrl: input.loginUrl,
};
}
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
export function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
}
export async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
const { runId, agent, config, context, authToken } = input;
const command = asString(config.command, "claude");
const workspaceContext = parseObject(context.agentCompanyWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
? context.agentCompanyWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
? context.agentCompanyRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
? context.agentCompanyRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
env.AGENT_COMPANY_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (wakeTaskId) {
env.AGENT_COMPANY_TASK_ID = wakeTaskId;
}
if (wakeReason) {
env.AGENT_COMPANY_WAKE_REASON = wakeReason;
}
if (wakeCommentId) {
env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
}
if (approvalId) {
env.AGENT_COMPANY_APPROVAL_ID = approvalId;
}
if (approvalStatus) {
env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
}
if (linkedIssueIds.length > 0) {
env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (effectiveWorkspaceCwd) {
env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
}
if (workspaceSource) {
env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceStrategy) {
env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) {
env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
}
if (workspaceRepoUrl) {
env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) {
env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.AGENT_COMPANY_API_KEY = authToken;
}
const runtimeEnv = ensurePathInEnv(env);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
return {
command,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
timeoutSec,
graceSec,
extraArgs,
};
}
@@ -1,7 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
import type { ManagedSkillRef } from "@agentcompany/adapter-utils";
@@ -9,18 +7,12 @@ import {
asString,
asNumber,
asBoolean,
asStringArray,
parseObject,
parseJson,
buildAgentCompanyEnv,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
filterConflictingArgs,
renderTemplate,
runChildProcess,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import {
parseClaudeStreamJson,
@@ -29,250 +21,12 @@ import {
isClaudeMaxTurnsResult,
isClaudeUnknownSessionError,
} from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
for (const candidate of SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
/**
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
* managed skill entries (preferred) or the repo's static `skills/` directory
* (backward compatibility fallback), so `--add-dir` makes Claude Code discover
* them as proper registered skills.
*/
async function buildSkillsDir(
managedSkills: ManagedSkillRef[],
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agent-company-skills-"));
const target = path.join(tmp, ".claude", "skills");
await fs.mkdir(target, { recursive: true });
// Use managed skills when available; fall back to static repo scan
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, target, onLog);
return tmp;
}
const skillsDir = await resolveAgentCompanySkillsDir();
if (!skillsDir) return tmp;
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
await fs.symlink(
path.join(skillsDir, entry.name),
path.join(target, entry.name),
);
}
}
return tmp;
}
interface ClaudeExecutionInput {
runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context: Record<string, unknown>;
authToken?: string;
}
interface ClaudeRuntimeConfig {
command: string;
cwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
extraArgs: string[];
}
function buildLoginResult(input: {
proc: RunProcessResult;
loginUrl: string | null;
}) {
return {
exitCode: input.proc.exitCode,
signal: input.proc.signal,
timedOut: input.proc.timedOut,
stdout: input.proc.stdout,
stderr: input.proc.stderr,
loginUrl: input.loginUrl,
};
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
}
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
const { runId, agent, config, context, authToken } = input;
const command = asString(config.command, "claude");
const workspaceContext = parseObject(context.agentCompanyWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
? context.agentCompanyWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
? context.agentCompanyRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
? context.agentCompanyRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
env.AGENT_COMPANY_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (wakeTaskId) {
env.AGENT_COMPANY_TASK_ID = wakeTaskId;
}
if (wakeReason) {
env.AGENT_COMPANY_WAKE_REASON = wakeReason;
}
if (wakeCommentId) {
env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
}
if (approvalId) {
env.AGENT_COMPANY_APPROVAL_ID = approvalId;
}
if (approvalStatus) {
env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
}
if (linkedIssueIds.length > 0) {
env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (effectiveWorkspaceCwd) {
env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
}
if (workspaceSource) {
env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceStrategy) {
env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) {
env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
}
if (workspaceRepoUrl) {
env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) {
env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.AGENT_COMPANY_API_KEY = authToken;
}
const runtimeEnv = ensurePathInEnv(env);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
return {
command,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
timeoutSec,
graceSec,
extraArgs,
};
}
import {
buildSkillsDir,
buildLoginResult,
resolveClaudeBillingType,
buildClaudeRuntimeConfig,
} from "./execute-helpers.js";
export async function runClaudeLogin(input: {
runId: string;
@@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
export const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
export const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
export function stripCodexRolloutNoise(text: string): string {
const parts = text.split(/\r?\n/);
const kept: string[] = [];
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) {
kept.push(part);
continue;
}
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
kept.push(part);
}
return kept.join("\n");
}
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
export function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
export function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".codex");
}
export async function resolveAgentCompanySkillsDir(): Promise<string | null> {
for (const candidate of SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
export async function ensureCodexSkillsInjected(
managedSkills: ManagedSkillRef[],
onLog: AdapterExecutionContext["onLog"],
) {
const skillsHome = path.join(codexHomeDir(), "skills");
// Use managed skills when available; fall back to static repo scan
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
return;
}
const skillsDir = await resolveAgentCompanySkillsDir();
if (!skillsDir) return;
await fs.mkdir(skillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog(
"stderr",
`[agent-company] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
@@ -1,7 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
asString,
@@ -17,96 +15,14 @@ import {
filterConflictingArgs,
renderTemplate,
runChildProcess,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
function stripCodexRolloutNoise(text: string): string {
const parts = text.split(/\r?\n/);
const kept: string[] = [];
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) {
kept.push(part);
continue;
}
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
kept.push(part);
}
return kept.join("\n");
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".codex");
}
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
for (const candidate of SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function ensureCodexSkillsInjected(
managedSkills: ManagedSkillRef[],
onLog: AdapterExecutionContext["onLog"],
) {
const skillsHome = path.join(codexHomeDir(), "skills");
// Use managed skills when available; fall back to static repo scan
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
return;
}
const skillsDir = await resolveAgentCompanySkillsDir();
if (!skillsDir) return;
await fs.mkdir(skillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog(
"stderr",
`[agent-company] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
import {
stripCodexRolloutNoise,
resolveCodexBillingType,
ensureCodexSkillsInjected,
} from "./execute-helpers.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
@@ -0,0 +1,125 @@
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
export const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
export function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
? "api"
: "subscription";
}
export function renderEnvNote(env: Record<string, string>): string {
const agentCompanyKeys = Object.keys(env)
.filter((key) => key.startsWith("AGENT_COMPANY_"))
.sort();
if (agentCompanyKeys.length === 0) return "";
return [
"Runtime note:",
`The following AGENT_COMPANY_* environment variables are available in this run: ${agentCompanyKeys.join(", ")}`,
"Do not assume these variables are missing without checking your shell environment.",
"",
"",
].join("\n");
}
export function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_URL") || !hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_KEY")) return "";
return [
"API access note:",
"Use run_shell_command with curl to make API requests.",
"GET example:",
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" \\"$AGENT_COMPANY_API_URL/api/agents/me\\"" })`,
"POST/PATCH example:",
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Agent-Company-Run-Id: $AGENT_COMPANY_RUN_ID\\" -d '{...}' \\"$AGENT_COMPANY_API_URL/api/issues/{id}/checkout\\"" })`,
"",
"",
].join("\n");
}
export async function resolveAgentCompanySkillsDir(): Promise<string | null> {
for (const candidate of SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
export function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
/**
* Inject AgentCompany skills directly into `~/.gemini/skills/` via symlinks.
* Uses managed skills when available; falls back to static repo scan.
*/
export async function ensureGeminiSkillsInjected(
managedSkills: ManagedSkillRef[],
onLog: AdapterExecutionContext["onLog"],
): Promise<void> {
const skillsHome = geminiSkillsHome();
// Use managed skills when available; fall back to static repo scan
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
return;
}
const skillsDir = await resolveAgentCompanySkillsDir();
if (!skillsDir) return;
try {
await fs.mkdir(skillsHome, { recursive: true });
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to read AgentCompany skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog("stderr", `[agent-company] Linked Gemini skill: ${entry.name}\n`);
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
@@ -1,8 +1,6 @@
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
asBoolean,
@@ -18,7 +16,6 @@ import {
redactEnvForLogs,
renderTemplate,
runChildProcess,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import {
@@ -29,122 +26,12 @@ import {
parseGeminiJsonl,
} from "./parse.js";
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
? "api"
: "subscription";
}
function renderEnvNote(env: Record<string, string>): string {
const agentCompanyKeys = Object.keys(env)
.filter((key) => key.startsWith("AGENT_COMPANY_"))
.sort();
if (agentCompanyKeys.length === 0) return "";
return [
"Runtime note:",
`The following AGENT_COMPANY_* environment variables are available in this run: ${agentCompanyKeys.join(", ")}`,
"Do not assume these variables are missing without checking your shell environment.",
"",
"",
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_URL") || !hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_KEY")) return "";
return [
"API access note:",
"Use run_shell_command with curl to make API requests.",
"GET example:",
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" \\"$AGENT_COMPANY_API_URL/api/agents/me\\"" })`,
"POST/PATCH example:",
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Agent-Company-Run-Id: $AGENT_COMPANY_RUN_ID\\" -d '{...}' \\"$AGENT_COMPANY_API_URL/api/issues/{id}/checkout\\"" })`,
"",
"",
].join("\n");
}
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
for (const candidate of SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
/**
* Inject AgentCompany skills directly into `~/.gemini/skills/` via symlinks.
* Uses managed skills when available; falls back to static repo scan.
*/
async function ensureGeminiSkillsInjected(
managedSkills: ManagedSkillRef[],
onLog: AdapterExecutionContext["onLog"],
): Promise<void> {
const skillsHome = geminiSkillsHome();
// Use managed skills when available; fall back to static repo scan
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
return;
}
const skillsDir = await resolveAgentCompanySkillsDir();
if (!skillsDir) return;
try {
await fs.mkdir(skillsHome, { recursive: true });
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to read AgentCompany skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog("stderr", `[agent-company] Linked Gemini skill: ${entry.name}\n`);
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
import {
resolveGeminiBillingType,
renderEnvNote,
renderApiAccessNote,
ensureGeminiSkillsInjected,
} from "./execute-helpers.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
@@ -275,25 +162,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
}
const commandNotes = (() => {
const notes: string[] = [useStdin
? "Prompt passed via stdin (exceeds command-line length limit)."
: "Prompt is passed to Gemini as the final positional argument."];
notes.push("Added --approval-mode yolo for unattended execution.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
);
return notes;
}
notes.push(
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
);
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
@@ -313,6 +181,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// and pass the content via stdin to avoid ENAMETOOLONG.
const SAFE_ARG_LIMIT = 30_000;
const useStdin = prompt.length > SAFE_ARG_LIMIT;
const commandNotes = (() => {
const notes: string[] = [useStdin
? "Prompt passed via stdin (exceeds command-line length limit)."
: "Prompt is passed to Gemini as the final positional argument."];
notes.push("Added --approval-mode yolo for unattended execution.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
);
return notes;
}
notes.push(
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
);
return notes;
})();
let promptFilePath: string | null = null;
if (useStdin) {
promptFilePath = path.join(os.tmpdir(), `agentcompany-gemini-prompt-${runId}.txt`);
@@ -0,0 +1,138 @@
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
export function parseObject(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
export function asString(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
export function asNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
export function asBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
export function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
export function parseJson(value: string): Record<string, unknown> | null {
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
return null;
}
}
export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
const combined = prev + chunk;
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
}
export function resolvePathValue(obj: Record<string, unknown>, dottedPath: string) {
const parts = dottedPath.split(".");
let cursor: unknown = obj;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) {
return "";
}
cursor = (cursor as Record<string, unknown>)[part];
}
if (cursor === null || cursor === undefined) return "";
if (typeof cursor === "string") return cursor;
if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor);
try {
return JSON.stringify(cursor);
} catch {
return "";
}
}
export function renderTemplate(template: string, data: Record<string, unknown>) {
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
}
/**
* Remove flags from `extraArgs` that the adapter will already add itself.
* Handles both `--flag value` pairs and `--flag=value` forms.
* This prevents duplicate/conflicting CLI arguments when users have shell
* aliases or wrapper functions that inject the same flags.
*/
export function filterConflictingArgs(extraArgs: string[], adapterFlags: string[]): string[] {
const flagSet = new Set(adapterFlags.map((f) => f.replace(/=.*$/, "")));
const filtered: string[] = [];
for (let i = 0; i < extraArgs.length; i++) {
const arg = extraArgs[i];
const bareFlag = arg.replace(/=.*$/, "");
if (flagSet.has(bareFlag)) {
// Skip this flag and its value if the value is a separate argument
if (!arg.includes("=") && i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith("-")) {
i++; // skip the value argument too
}
continue;
}
filtered.push(arg);
}
return filtered;
}
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
}
return redacted;
}
export function buildAgentCompanyEnv(agent: { id: string; companyId: string }): Record<string, string> {
const resolveHostForUrl = (rawHost: string): string => {
const host = rawHost.trim();
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
return host;
};
const vars: Record<string, string> = {
AGENT_COMPANY_AGENT_ID: agent.id,
AGENT_COMPANY_COMPANY_ID: agent.companyId,
};
const runtimeHost = resolveHostForUrl(
process.env.AGENT_COMPANY_LISTEN_HOST ?? process.env.HOST ?? "localhost",
);
const runtimePort = process.env.AGENT_COMPANY_LISTEN_PORT ?? process.env.PORT ?? "3100";
const apiUrl = process.env.AGENT_COMPANY_API_URL ?? `http://${runtimeHost}:${runtimePort}`;
vars.AGENT_COMPANY_API_URL = apiUrl;
return vars;
}
export function defaultPathForPlatform() {
if (process.platform === "win32") {
const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
const appData = process.env.APPDATA ?? (home ? `${home}\\AppData\\Roaming` : "");
const localAppData = process.env.LOCALAPPDATA ?? (home ? `${home}\\AppData\\Local` : "");
const parts = [
"C:\\Windows\\System32",
"C:\\Windows",
"C:\\Windows\\System32\\Wbem",
appData ? `${appData}\\npm` : "",
localAppData ? `${localAppData}\\pnpm` : "",
home ? `${home}\\.local\\bin` : "",
home ? `${home}\\.cargo\\bin` : "",
"C:\\Program Files\\nodejs",
].filter(Boolean);
return parts.join(";");
}
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
}
@@ -0,0 +1,380 @@
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { constants as fsConstants, promises as fs } from "node:fs";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
import { appendWithCap, defaultPathForPlatform } from "./server-utils-parsers.js";
/**
* Decode a Buffer as GBK (CP936). Used as a fallback when UTF-8 decoding
* produces replacement characters on Chinese Windows systems where the
* system ANSI code page is GBK and chcp 65001 is ineffective for piped I/O.
*/
function decodeGbkBuffer(buf: Buffer): string {
try {
return new TextDecoder("gbk").decode(buf);
} catch {
return buf.toString("utf8");
}
}
export interface RunProcessResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
}
interface RunningProcess {
child: ChildProcess;
graceSec: number;
}
interface SpawnTarget {
command: string;
args: string[];
}
type ChildProcessWithEvents = ChildProcess & {
on(event: "error", listener: (err: Error) => void): ChildProcess;
on(
event: "close",
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
): ChildProcess;
};
export const runningProcesses = new Map<string, RunningProcess>();
export function normalizeCommandValue(command: string): string {
let normalized = command.trim();
while (normalized.length >= 2) {
const quote = normalized[0];
if ((quote !== '"' && quote !== "'") || normalized.at(-1) !== quote) {
break;
}
normalized = normalized.slice(1, -1).trim();
}
return normalized;
}
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
}
async function pathExists(candidate: string) {
try {
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
return true;
} catch {
return false;
}
}
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
const normalizedCommand = normalizeCommandValue(command);
const hasPathSeparator = normalizedCommand.includes("/") || normalizedCommand.includes("\\");
if (hasPathSeparator) {
const absolute = path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd, normalizedCommand);
return (await pathExists(absolute)) ? absolute : null;
}
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : ":";
const dirs = pathValue.split(delimiter).filter(Boolean);
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
for (const dir of dirs) {
const candidates =
process.platform === "win32"
? hasExtension
? [path.join(dir, normalizedCommand)]
: exts.map((ext) => path.join(dir, `${normalizedCommand}${ext}`))
: [path.join(dir, normalizedCommand)];
for (const candidate of candidates) {
if (await pathExists(candidate)) return candidate;
}
}
return null;
}
function quoteForCmd(arg: string) {
if (!arg.length) return '""';
const escaped = arg.replace(/"/g, '""');
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
}
async function resolveSpawnTarget(
command: string,
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
): Promise<SpawnTarget> {
const normalizedCommand = normalizeCommandValue(command);
const resolved = await resolveCommandPath(normalizedCommand, cwd, env);
const executable = resolved ?? normalizedCommand;
if (process.platform !== "win32") {
return { command: executable, args };
}
// On Windows, ALL spawned processes need UTF-8 code page (65001) to prevent
// Chinese/CJK character corruption. The default Windows code page (e.g. 936/GBK)
// corrupts multi-byte UTF-8 characters passed through cmd.exe, curl, etc.
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
return {
command: shell,
args: ["/d", "/s", "/c", `chcp 65001 >nul && ${commandLine}`],
};
}
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
if (typeof env.Path === "string" && env.Path.length > 0) return env;
return { ...env, PATH: defaultPathForPlatform() };
}
export async function ensureAbsoluteDirectory(
cwd: string,
opts: { createIfMissing?: boolean } = {},
) {
if (!path.isAbsolute(cwd)) {
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
}
const assertDirectory = async () => {
const stats = await fs.stat(cwd);
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: "${cwd}"`);
}
};
try {
await assertDirectory();
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (!opts.createIfMissing || code !== "ENOENT") {
if (code === "ENOENT") {
throw new Error(`Working directory does not exist: "${cwd}"`);
}
throw err instanceof Error ? err : new Error(String(err));
}
}
try {
await fs.mkdir(cwd, { recursive: true });
await assertDirectory();
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Could not create working directory "${cwd}": ${reason}`);
}
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
const normalizedCommand = normalizeCommandValue(command);
const resolved = await resolveCommandPath(normalizedCommand, cwd, env);
if (resolved) return;
if (normalizedCommand.includes("/") || normalizedCommand.includes("\\")) {
const absolute = path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd, normalizedCommand);
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
}
throw new Error(`Command not found in PATH: "${command}"`);
}
export async function runChildProcess(
runId: string,
command: string,
args: string[],
opts: {
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onLogError?: (err: unknown, runId: string, message: string) => void;
stdin?: string;
},
): Promise<RunProcessResult> {
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
return new Promise<RunProcessResult>((resolve, reject) => {
const rawMerged: NodeJS.ProcessEnv = { ...opts.env };
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
// don't refuse to start with "cannot be launched inside another session".
// These vars leak in when the AgentCompany server itself is started from
// within a Claude Code session (e.g. `npx agentcompany run` in a terminal
// owned by Claude Code) or when cron inherits a contaminated shell env.
const CLAUDE_CODE_NESTING_VARS = [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION",
"CLAUDE_CODE_PARENT_SESSION",
] as const;
for (const key of CLAUDE_CODE_NESTING_VARS) {
delete rawMerged[key];
}
const mergedEnv = ensurePathInEnv(rawMerged);
// On Windows, force UTF-8 encoding in child processes to prevent Chinese/CJK
// character corruption caused by the default system code page (e.g. 936/GBK).
if (process.platform === "win32") {
mergedEnv.PYTHONUTF8 = "1";
mergedEnv.PYTHONIOENCODING = "utf-8";
if (!mergedEnv.LANG) mergedEnv.LANG = "en_US.UTF-8";
}
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
.then((target) => {
const child = spawn(target.command, target.args, {
cwd: opts.cwd,
env: mergedEnv,
shell: false,
windowsHide: true,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents;
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
}
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
let timedOut = false;
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
// Use StringDecoder to handle multi-byte characters split across chunks.
// On Windows, child process stdout may arrive in GBK (CP936) instead
// of UTF-8 when chcp 65001 is ineffective (piped stdout has no console).
// We detect this per-stream and fall back to GBK decoding if needed.
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let stdoutIsGbk = false;
let stderrIsGbk = false;
const timeout =
opts.timeoutSec > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, Math.max(1, opts.graceSec) * 1000);
}, opts.timeoutSec * 1000)
: null;
child.stdout?.on("data", (chunk: unknown) => {
let text: string;
if (chunk instanceof Buffer) {
if (stdoutIsGbk) {
text = decodeGbkBuffer(chunk);
} else {
text = stdoutDecoder.write(chunk);
if (text.includes("\ufffd") && chunk.length > 0) {
stdoutIsGbk = true;
text = decodeGbkBuffer(chunk);
}
}
} else {
text = String(chunk);
}
stdout = appendWithCap(stdout, text);
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
child.stderr?.on("data", (chunk: unknown) => {
let text: string;
if (chunk instanceof Buffer) {
if (stderrIsGbk) {
text = decodeGbkBuffer(chunk);
} else {
text = stderrDecoder.write(chunk);
if (text.includes("\ufffd") && chunk.length > 0) {
stderrIsGbk = true;
text = decodeGbkBuffer(chunk);
}
}
} else {
text = String(chunk);
}
stderr = appendWithCap(stderr, text);
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
const msg =
errno === "ENOENT"
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
reject(new Error(msg));
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
// Flush any remaining partial multi-byte characters from the decoders.
const stdoutTail = stdoutDecoder.end();
const stderrTail = stderrDecoder.end();
if (stdoutTail) stdout = appendWithCap(stdout, stdoutTail);
if (stderrTail) stderr = appendWithCap(stderr, stderrTail);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
});
});
});
})
.catch(reject);
});
}
/**
* Kill a process and all its descendants.
* Windows: `taskkill /T /F` kills the full process tree.
* Unix: SIGKILL the process directly (CLI tools are leaf processes).
*/
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
try {
execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 });
} catch { /* process already exited */ }
} else {
try {
process.kill(pid, "SIGKILL");
} catch { /* already dead */ }
}
}
/**
* Force-kill every child process tracked in `runningProcesses` and clear the map.
* Called by the worker during shutdown to ensure no CLI processes survive app quit.
*/
export function shutdownAllRunning(): void {
for (const [runId, entry] of runningProcesses) {
const pid = entry.child.pid;
if (pid != null) {
killProcessTree(pid);
}
runningProcesses.delete(runId);
}
}
@@ -0,0 +1,329 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import type { AdapterSkillEntry, AdapterSkillSnapshot } from "./types.js";
import type { AgentCompanySkillEntry, InstalledSkillTarget } from "./server-utils-skills.js";
interface PersistentSkillSnapshotOptions {
adapterType: string;
availableEntries: AgentCompanySkillEntry[];
desiredSkills: string[];
installed: Map<string, InstalledSkillTarget>;
skillsHome: string;
locationLabel?: string | null;
installedDetail?: string | null;
missingDetail: string;
externalConflictDetail: string;
externalDetail: string;
warnings?: string[];
}
function normalizePathSlashes(value: string): string {
return value.replaceAll("\\", "/");
}
function isMaintainerOnlySkillTarget(candidate: string): boolean {
return normalizePathSlashes(candidate).includes("/.agents/skills/");
}
function skillLocationLabel(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
AdapterSkillEntry,
"origin" | "originLabel" | "readOnly"
> {
if (entry.required) {
return {
origin: "paperclip_required",
originLabel: "Required by AgentCompany",
readOnly: false,
};
}
return {
origin: "company_managed",
originLabel: "Managed by AgentCompany",
readOnly: false,
};
}
function canonicalizeDesiredAgentCompanySkillReference(
reference: string,
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
): string {
const normalizedReference = reference.trim().toLowerCase();
if (!normalizedReference) return "";
const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference);
if (exactKey) return exactKey.key;
const byRuntimeName = availableEntries.filter((entry) =>
typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference,
);
if (byRuntimeName.length === 1) return byRuntimeName[0]!.key;
const slugMatches = availableEntries.filter((entry) =>
entry.key.trim().toLowerCase().split("/").pop() === normalizedReference,
);
if (slugMatches.length === 1) return slugMatches[0]!.key;
return normalizedReference;
}
/**
* Build an AdapterSkillSnapshot for persistent-mode adapters (Gemini, Codex).
* Compares available entries against installed state and desired skill list.
*/
export function buildPersistentSkillSnapshot(
options: PersistentSkillSnapshotOptions,
): AdapterSkillSnapshot {
const {
adapterType,
availableEntries,
desiredSkills,
installed,
skillsHome,
locationLabel,
installedDetail,
missingDetail,
externalConflictDetail,
externalDetail,
} = options;
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = [];
const warnings = [...(options.warnings ?? [])];
for (const available of availableEntries) {
const installedEntry = installed.get(available.runtimeName) ?? null;
const desired = desiredSet.has(available.key);
let state: AdapterSkillEntry["state"] = "available";
let managed = false;
let detail: string | null = null;
if (installedEntry?.targetPath === available.source) {
managed = true;
state = desired ? "installed" : "stale";
detail = installedDetail ?? null;
} else if (installedEntry) {
state = "external";
detail = desired ? externalConflictDetail : externalDetail;
} else if (desired) {
state = "missing";
detail = missingDetail;
}
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed,
state,
sourcePath: available.source,
targetPath: path.join(skillsHome, available.runtimeName),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
}
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the AgentCompany skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: null,
detail: "AgentCompany cannot find this skill in the local runtime skills directory.",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: skillLocationLabel(locationLabel),
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
detail: externalDetail,
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType,
supported: true,
mode: "persistent",
desiredSkills,
entries,
warnings,
};
}
/**
* Read the desired skill sync preference from adapter config.
*/
export function readAgentCompanySkillSyncPreference(config: Record<string, unknown>): {
explicit: boolean;
desiredSkills: string[];
} {
const raw = config.agentCompanySkillSync;
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return { explicit: false, desiredSkills: [] };
}
const syncConfig = raw as Record<string, unknown>;
const desiredValues = syncConfig.desiredSkills;
const desired = Array.isArray(desiredValues)
? desiredValues
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
: [];
return {
explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"),
desiredSkills: Array.from(new Set(desired)),
};
}
/**
* Resolve the canonical desired skill list from config and available entries.
* Required skills are always included; explicit preferences are canonicalized.
*/
export function resolveAgentCompanyDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>,
): string[] {
const preference = readAgentCompanySkillSyncPreference(config);
const requiredSkills = availableEntries
.filter((entry) => entry.required)
.map((entry) => entry.key);
if (!preference.explicit) {
return Array.from(new Set(requiredSkills));
}
const desiredSkills = preference.desiredSkills
.map((reference) => canonicalizeDesiredAgentCompanySkillReference(reference, availableEntries))
.filter(Boolean);
return Array.from(new Set([...requiredSkills, ...desiredSkills]));
}
/**
* Write desired skills back to adapter config.
*/
export function writeAgentCompanySkillSyncPreference(
config: Record<string, unknown>,
desiredSkills: string[],
): Record<string, unknown> {
const next = { ...config };
const raw = next.agentCompanySkillSync;
const current =
typeof raw === "object" && raw !== null && !Array.isArray(raw)
? { ...(raw as Record<string, unknown>) }
: {};
current.desiredSkills = Array.from(
new Set(
desiredSkills
.map((value) => value.trim())
.filter(Boolean),
),
);
next.agentCompanySkillSync = current;
return next;
}
/**
* Create or repair a symlink from source to target skill directory.
* Returns the action taken: "created", "repaired", or "skipped".
*/
export async function ensureAgentCompanySkillSymlink(
source: string,
target: string,
linkSkill: (source: string, target: string) => Promise<void> = (linkSource, linkTarget) =>
fs.symlink(linkSource, linkTarget),
): Promise<"created" | "repaired" | "skipped"> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await linkSkill(source, target);
return "created";
}
if (!existing.isSymbolicLink()) {
return "skipped";
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return "skipped";
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) {
return "skipped";
}
const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false);
if (linkedPathExists) {
return "skipped";
}
await fs.unlink(target);
await linkSkill(source, target);
return "repaired";
}
/**
* Remove symlinks in skillsHome that point to maintainer-only paths
* (.agents/skills/) and are not in the allowed set.
*/
export async function removeMaintainerOnlySkillSymlinks(
skillsHome: string,
allowedSkillNames: Iterable<string>,
): Promise<string[]> {
const allowed = new Set(Array.from(allowedSkillNames));
try {
const entries = await fs.readdir(skillsHome, { withFileTypes: true });
const removed: string[] = [];
for (const entry of entries) {
if (allowed.has(entry.name)) continue;
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (!existing?.isSymbolicLink()) continue;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) continue;
const resolvedLinkedPath = path.isAbsolute(linkedPath)
? linkedPath
: path.resolve(path.dirname(target), linkedPath);
if (
!isMaintainerOnlySkillTarget(linkedPath) &&
!isMaintainerOnlySkillTarget(resolvedLinkedPath)
) {
continue;
}
await fs.unlink(target);
removed.push(entry.name);
}
return removed;
} catch {
return [];
}
}
@@ -0,0 +1,176 @@
import { promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import type { ManagedSkillRef } from "./types.js";
import { parseObject, asString, asBoolean } from "./server-utils-parsers.js";
/**
* Create symlinks for managed skills in the target directory.
* Per D-13: uses 'junction' type on Windows to avoid Developer Mode requirement.
* Individual skill symlink failures are logged and reported but do not block other skills.
*/
export async function createManagedSkillSymlinks(
managedSkills: ManagedSkillRef[],
targetDir: string,
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
): Promise<void> {
await fs.mkdir(targetDir, { recursive: true });
const symlinkType = process.platform === "win32" ? ("junction" as const) : undefined;
for (const skill of managedSkills) {
const target = path.join(targetDir, skill.runtimeName);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
await fs.rm(target, { recursive: true, force: true });
}
try {
await fs.symlink(skill.sourcePath, target, symlinkType);
await onLog("stderr", `[agent-company] Injected managed skill "${skill.runtimeName}"\n`);
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to inject skill "${skill.runtimeName}": ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
// ---------------------------------------------------------------------------
// Skill resolution helpers -- ported from Paperclip reference with
// AgentCompany naming. These utilities let each adapter discover, list,
// resolve, and synchronize skills from a shared skills directory.
// ---------------------------------------------------------------------------
export const AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES = [
"../../skills",
"../../../../../skills",
];
export interface AgentCompanySkillEntry {
key: string;
runtimeName: string;
source: string;
required?: boolean;
requiredReason?: string | null;
}
export interface InstalledSkillTarget {
targetPath: string | null;
kind: "symlink" | "directory" | "file";
}
export function resolveInstalledEntryTarget(
skillsHome: string,
entryName: string,
dirent: Dirent,
linkedPath: string | null,
): InstalledSkillTarget {
const fullPath = path.join(skillsHome, entryName);
if (dirent.isSymbolicLink()) {
return {
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
kind: "symlink",
};
}
if (dirent.isDirectory()) {
return { targetPath: fullPath, kind: "directory" };
}
return { targetPath: fullPath, kind: "file" };
}
export function normalizeConfiguredAgentCompanyRuntimeSkills(value: unknown): AgentCompanySkillEntry[] {
if (!Array.isArray(value)) return [];
const out: AgentCompanySkillEntry[] = [];
for (const rawEntry of value) {
const entry = parseObject(rawEntry);
const key = asString(entry.key, asString(entry.name, "")).trim();
const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim();
const source = asString(entry.source, "").trim();
if (!key || !runtimeName || !source) continue;
out.push({
key,
runtimeName,
source,
required: asBoolean(entry.required, false),
requiredReason:
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
? entry.requiredReason.trim()
: null,
});
}
return out;
}
/**
* Resolve the AgentCompany skills root directory from relative path candidates.
*/
export async function resolveAgentCompanySkillsDir(
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<string | null> {
const candidates = [
...AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
...additionalCandidates.map((candidate) => path.resolve(candidate)),
];
const seenRoots = new Set<string>();
for (const root of candidates) {
if (seenRoots.has(root)) continue;
seenRoots.add(root);
const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false);
if (isDirectory) return root;
}
return null;
}
/**
* List skill entries from the AgentCompany skills directory.
*/
export async function listAgentCompanySkillEntries(
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<AgentCompanySkillEntry[]> {
const root = await resolveAgentCompanySkillsDir(moduleDir, additionalCandidates);
if (!root) return [];
try {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
key: `agentcompany/agentcompany/${entry.name}`,
runtimeName: entry.name,
source: path.join(root, entry.name),
required: true,
requiredReason: "Bundled AgentCompany skills are always available for local adapters.",
}));
} catch {
return [];
}
}
/**
* Read installed skill targets from a skill home directory (symlinks, dirs, files).
*/
export async function readInstalledSkillTargets(skillsHome: string): Promise<Map<string, InstalledSkillTarget>> {
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
const out = new Map<string, InstalledSkillTarget>();
for (const entry of entries) {
const fullPath = path.join(skillsHome, entry.name);
const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null;
out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath));
}
return out;
}
/**
* Read runtime skill entries from adapter config or discover from module directory.
*/
export async function readAgentCompanyRuntimeSkillEntries(
config: Record<string, unknown>,
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<AgentCompanySkillEntry[]> {
const configuredEntries = normalizeConfiguredAgentCompanyRuntimeSkills(config.agentCompanyRuntimeSkills);
if (configuredEntries.length > 0) return configuredEntries;
return listAgentCompanySkillEntries(moduleDir, additionalCandidates);
}
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -1,5 +1,6 @@
import path from "node:path";
import type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult } from "./types.js";
import { normalizeCommandValue } from "./server-utils-process.js";
export function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@@ -21,7 +22,8 @@ export function firstNonEmptyLine(text: string): string {
}
export function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
const normalized = normalizeCommandValue(command).replace(/\\/g, "/");
const base = path.posix.basename(normalized).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
+273 -47
View File
@@ -8,9 +8,13 @@ const packageJsonPath = join(__dirname, "..", "package.json");
const buildDir = join(__dirname, "..", "build");
const releaseDir = join(buildDir, "release");
const runtimeConfigPath = join(buildDir, "electron-builder.runtime.json");
const localElectronDistPath = join(__dirname, "..", "node_modules", "electron", "dist");
const customMacSignPath = join(__dirname, "electron-builder-mac-sign.mjs");
const extraArgs = process.argv.slice(2);
const env = { ...process.env };
const explicitElectronDistPath = env.AGENTCOMPANY_ELECTRON_DIST?.trim() || "";
const noDeprecationFlag = "--no-deprecation";
const requestedUpdateUrl = env.AGENTCOMPANY_UPDATE_URL?.trim() ?? "";
const strictRelease = env.AGENTCOMPANY_STRICT_RELEASE === "true";
const hasMacSigningMaterial = Boolean(
@@ -19,24 +23,128 @@ const hasMacSigningMaterial = Boolean(
env.APPLE_API_KEY?.trim(),
);
const isDirBuild = extraArgs.includes("--dir");
const r2AccountId = env.AGENTCOMPANY_R2_ACCOUNT_ID?.trim() ?? env.R2_ACCOUNT_ID?.trim() ?? "";
const r2Bucket = env.AGENTCOMPANY_R2_BUCKET?.trim() ?? env.R2_BUCKET?.trim() ?? "";
const r2PublicUrl = env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() ?? env.R2_PUBLIC_BASE_URL?.trim() ?? "";
const r2Path = env.AGENTCOMPANY_R2_PATH?.trim() ?? env.AGENTCOMPANY_RELEASE_PREFIX?.trim() ?? "desktop";
const r2Region = env.AGENTCOMPANY_R2_REGION?.trim() || "auto";
const r2Endpoint = env.AGENTCOMPANY_R2_ENDPOINT?.trim() || (r2AccountId ? `https://${r2AccountId}.r2.cloudflarestorage.com` : "");
const filteredUnsignedMacWarningSnippets = [
"skipped macOS code signing reason=identity explicitly is set to null",
"arm64 requires signing, but identity is set to null and signing is being skipped",
];
function bumpPatchVersion(currentVersion) {
function validateBuildVersion(currentVersion) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion);
if (!match) {
throw new Error(`[build-desktop] Unsupported package version format: ${currentVersion}`);
}
const [, major, minor, patch] = match;
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
return currentVersion;
}
function normalizeBaseUrl(url) {
return url.replace(/\/+$/, "");
}
function normalizePublishPath(targetPath) {
return targetPath.replace(/^\/+|\/+$/g, "");
}
function resolveUpdateUrl() {
if (requestedUpdateUrl && !requestedUpdateUrl.includes("example.invalid")) {
return normalizeBaseUrl(requestedUpdateUrl);
}
if (!r2PublicUrl) {
return "";
}
const baseUrl = normalizeBaseUrl(r2PublicUrl);
const publishPath = normalizePublishPath(r2Path);
return publishPath ? `${baseUrl}/${publishPath}` : baseUrl;
}
const resolvedUpdateUrl = resolveUpdateUrl();
const hasR2PublishConfig = Boolean(
r2Bucket &&
r2Endpoint &&
resolvedUpdateUrl &&
(env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim() || env.AWS_ACCESS_KEY_ID?.trim()) &&
(env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim() || env.AWS_SECRET_ACCESS_KEY?.trim()),
);
if (!env.AWS_ACCESS_KEY_ID && (env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim())) {
env.AWS_ACCESS_KEY_ID = env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim();
}
if (!env.AWS_SECRET_ACCESS_KEY && (env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim())) {
env.AWS_SECRET_ACCESS_KEY = env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim();
}
if (!env.AWS_REGION) {
env.AWS_REGION = r2Region;
}
if (!env.AWS_DEFAULT_REGION) {
env.AWS_DEFAULT_REGION = env.AWS_REGION;
}
if (!env.AWS_EC2_METADATA_DISABLED) {
env.AWS_EC2_METADATA_DISABLED = "true";
}
if (!env.AGENTCOMPANY_UPDATE_URL && resolvedUpdateUrl) {
env.AGENTCOMPANY_UPDATE_URL = resolvedUpdateUrl;
}
function resolveCurrentPlatformArg() {
switch (process.platform) {
case "darwin":
return "--mac";
case "win32":
return "--win";
default:
return "--linux";
}
}
function resolveCurrentArchArg() {
switch (process.arch) {
case "arm64":
return "--arm64";
case "x64":
return "--x64";
default:
return null;
}
}
function hasExplicitArchArg(args) {
return args.some((arg) =>
arg === "--arm64" ||
arg === "--x64" ||
arg === "--ia32" ||
arg === "--universal" ||
arg.startsWith("--arm64=") ||
arg.startsWith("--x64=") ||
arg.startsWith("--ia32=") ||
arg.startsWith("--universal="),
);
}
function resolveCurrentMacArch() {
if (process.platform !== "darwin" || env.AGENTCOMPANY_MAC_ALL_ARCHES === "true" || hasExplicitArchArg(extraArgs)) {
return null;
}
return process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null;
}
async function resolveBuildVersion() {
const raw = await readFile(packageJsonPath, "utf8");
const pkg = JSON.parse(raw);
const nextVersion = bumpPatchVersion(pkg.version);
return nextVersion;
return validateBuildVersion(pkg.version);
}
async function writeRuntimeConfig(buildVersion) {
const forcedMacArch = resolveCurrentMacArch();
const isUnsignedLocalMacBuild = (
process.platform === "darwin" &&
!hasMacSigningMaterial &&
env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true"
);
const runtimeConfig = {
extends: join(__dirname, "..", "electron-builder.yml"),
directories: {
@@ -46,34 +154,76 @@ async function writeRuntimeConfig(buildVersion) {
version: buildVersion,
},
};
if (requestedUpdateUrl) {
if (explicitElectronDistPath) {
runtimeConfig.electronDist = explicitElectronDistPath;
} else if (isDirBuild) {
runtimeConfig.electronDist = localElectronDistPath;
}
if (hasR2PublishConfig) {
runtimeConfig.publish = [
{
provider: "s3",
bucket: r2Bucket,
endpoint: r2Endpoint,
region: r2Region,
path: normalizePublishPath(r2Path) || undefined,
acl: null,
publishAutoUpdate: true,
},
];
} else if (resolvedUpdateUrl) {
runtimeConfig.publish = [
{
provider: "generic",
url: requestedUpdateUrl,
url: resolvedUpdateUrl,
},
];
}
if (process.platform === "darwin" && !hasMacSigningMaterial && env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true") {
if (isUnsignedLocalMacBuild) {
if (strictRelease) {
throw new Error("[build-desktop] Strict release mode requires macOS signing/notarization material.");
}
runtimeConfig.mac = {
hardenedRuntime: false,
identity: null,
};
if (!isDirBuild) {
runtimeConfig.mac.target = ["zip"];
runtimeConfig.mac.target = forcedMacArch
? [{ target: "zip", arch: [forcedMacArch] }]
: ["zip"];
}
} else if (process.platform === "darwin" && !hasMacSigningMaterial) {
runtimeConfig.mac = {
hardenedRuntime: false,
identity: null,
};
} else if (process.platform === "darwin") {
runtimeConfig.mac = {
hardenedRuntime: true,
};
if (hasMacSigningMaterial) {
runtimeConfig.mac.sign = customMacSignPath;
}
}
if (process.platform === "darwin" && !isDirBuild && forcedMacArch && !isUnsignedLocalMacBuild) {
runtimeConfig.mac ??= {};
runtimeConfig.mac.target = [
{ target: "dmg", arch: [forcedMacArch] },
{ target: "zip", arch: [forcedMacArch] },
];
}
await writeFile(runtimeConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
}
if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) {
env.CSC_IDENTITY_AUTO_DISCOVERY = "false";
env.CSC_IDENTITY_AUTO_DISCOVERY = hasMacSigningMaterial ? "true" : "false";
}
const existingNodeOptions = env.NODE_OPTIONS?.trim();
if (!existingNodeOptions) {
env.NODE_OPTIONS = noDeprecationFlag;
} else if (!existingNodeOptions.includes(noDeprecationFlag)) {
env.NODE_OPTIONS = `${existingNodeOptions} ${noDeprecationFlag}`;
}
const allowedPublishModes = new Set(["always", "never", "onTag", "onTagOrDraft"]);
@@ -89,6 +239,14 @@ const explicitPlatformArg = extraArgs.some((arg) =>
arg.startsWith("--win=") ||
arg.startsWith("--linux="),
);
const explicitArchArg = hasExplicitArchArg(extraArgs);
const shouldForceLocalMacZip = (
process.platform === "darwin" &&
!hasMacSigningMaterial &&
env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" &&
!explicitPlatformArg &&
!isDirBuild
);
const requestedPublishMode = env.AGENTCOMPANY_ELECTRON_PUBLISH?.trim();
let publishMode = "never";
@@ -107,31 +265,102 @@ const builderArgs = ["exec", "electron-builder", "--config", runtimeConfigPath];
if (!explicitPublishArg) {
builderArgs.push("--publish", publishMode);
}
if (
process.platform === "darwin" &&
!hasMacSigningMaterial &&
env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" &&
!explicitPlatformArg &&
!isDirBuild
) {
builderArgs.push("--mac", "zip");
if (!explicitPlatformArg) {
builderArgs.push(resolveCurrentPlatformArg());
}
if (!explicitArchArg) {
const currentArchArg = resolveCurrentArchArg();
if (currentArchArg) {
builderArgs.push(currentArchArg);
}
}
if (shouldForceLocalMacZip) {
builderArgs.push("zip");
}
builderArgs.push(...extraArgs);
if (strictRelease && !requestedUpdateUrl) {
throw new Error("[build-desktop] Strict release mode requires AGENTCOMPANY_UPDATE_URL to be configured.");
if (strictRelease && !resolvedUpdateUrl) {
throw new Error("[build-desktop] Strict release mode requires AGENTCOMPANY_UPDATE_URL or an R2 public URL to be configured.");
}
if (publishMode !== "never" && !hasR2PublishConfig && !resolvedUpdateUrl) {
throw new Error(
"[build-desktop] Publishing requires either AGENTCOMPANY_UPDATE_URL (generic feed only) " +
"or full Cloudflare R2 publish configuration.",
);
}
const transientBuildFailurePatterns = [
/chmod .*Electron Framework.*no such file or directory/i,
];
function shouldRetryElectronBuilder(output) {
return transientBuildFailurePatterns.some((pattern) => pattern.test(output));
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function shouldSuppressBuildLine(line) {
return filteredUnsignedMacWarningSnippets.some((snippet) => line.includes(snippet));
}
function forwardChildOutput(stream, writer, onLine) {
if (!stream) return;
let pending = "";
stream.on("data", (chunk) => {
pending += chunk.toString();
const lines = pending.split(/\r?\n/);
pending = lines.pop() ?? "";
for (const line of lines) {
onLine(line);
if (!shouldSuppressBuildLine(line)) {
writer.write(`${line}\n`);
}
}
});
stream.on("end", () => {
if (!pending) return;
onLine(pending);
if (!shouldSuppressBuildLine(pending)) {
writer.write(`${pending}\n`);
}
});
}
function runElectronBuilder() {
return new Promise((resolve, reject) => {
const child = spawn("pnpm", builderArgs, {
stdio: ["ignore", "pipe", "pipe"],
env,
shell: process.platform === "win32",
});
let output = "";
const appendOutputLine = (line) => {
output += `${line}\n`;
};
forwardChildOutput(child.stdout, process.stdout, appendOutputLine);
forwardChildOutput(child.stderr, process.stderr, appendOutputLine);
child.on("error", reject);
child.on("close", (code, signal) => {
resolve({ code: code ?? 1, signal, output });
});
});
}
// On Windows, antivirus scanners may hold file locks on .asar/.exe files.
// Retry the rm with increasing delays to give the OS time to release them.
for (let attempt = 0; attempt < 5; attempt++) {
try {
await rm(buildDir, { recursive: true, force: true });
break;
} catch (rmErr) {
if (attempt < 4 && rmErr.code === "EBUSY") {
const retryable = rmErr.code === "EBUSY" || rmErr.code === "ENOTEMPTY" || rmErr.code === "EPERM";
if (attempt < 4 && retryable) {
const delay = (attempt + 1) * 3000;
console.log(`[build-desktop] Build dir locked (EBUSY), retrying in ${delay / 1000}s... (attempt ${attempt + 1}/5)`);
console.log(
`[build-desktop] Build dir cleanup blocked (${rmErr.code}), retrying in ${delay / 1000}s... ` +
`(attempt ${attempt + 1}/5)`,
);
await new Promise((r) => setTimeout(r, delay));
} else {
throw rmErr;
@@ -140,31 +369,28 @@ for (let attempt = 0; attempt < 5; attempt++) {
}
await mkdir(buildDir, { recursive: true });
const buildVersion = env.AGENTCOMPANY_BUILD_VERSION?.trim() || await resolveBuildVersion();
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
if (packageJson.version !== buildVersion) {
packageJson.version = buildVersion;
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
}
await writeRuntimeConfig(buildVersion);
env.AGENTCOMPANY_BUILD_VERSION = buildVersion;
env.npm_package_version = buildVersion;
console.log(`[build-desktop] Cleaned ${buildDir} and staged build version ${buildVersion}.`);
const child = spawn("pnpm", builderArgs, {
stdio: "inherit",
env,
shell: process.platform === "win32",
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const result = await runElectronBuilder();
if (result.signal) {
process.kill(process.pid, result.signal);
break;
}
if (result.code === 0) {
process.exit(0);
}
if (attempt === 0 && shouldRetryElectronBuilder(result.output)) {
console.warn("[build-desktop] electron-builder hit a transient macOS framework extraction error; retrying once.");
await delay(1000);
continue;
}
process.exit(result.code);
} catch (error) {
console.error(error);
process.exit(1);
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
console.error(error);
process.exit(1);
});
}
+151
View File
@@ -0,0 +1,151 @@
import { spawn } from "node:child_process";
import { readdir } from "node:fs/promises";
import { extname } from "node:path";
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runCommand(command, args, capture = false) {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
});
let stdout = "";
let stderr = "";
if (capture) {
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
}
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new Error(stderr || stdout || `${command} failed with exit code ${code ?? 1}`));
});
});
}
async function runCodesign(args) {
await runCommand("/usr/bin/codesign", args, false);
}
function buildCodesignArgs(targetPath, identity, keychain, fileOptions = {}) {
const args = [
"--sign", identity,
"--force",
];
if (keychain) {
args.push("--keychain", keychain);
}
if (fileOptions.timestamp !== false) {
args.push("--timestamp");
}
if (fileOptions.hardenedRuntime) {
args.push("--options", "runtime");
}
if (fileOptions.entitlements) {
args.push("--entitlements", fileOptions.entitlements);
}
if (Array.isArray(fileOptions.additionalArguments) && fileOptions.additionalArguments.length > 0) {
args.push(...fileOptions.additionalArguments);
}
args.push(targetPath);
return args;
}
async function isMachBinary(filePath) {
const extension = extname(filePath);
if (extension === ".dylib" || extension === ".node") {
return true;
}
const { stdout } = await runCommand("/usr/bin/file", ["-b", filePath], true);
return stdout.includes("Mach-O");
}
async function collectSignTargets(rootPath) {
const bundleTargets = [];
const binaryTargets = [];
async function walk(currentPath) {
const entries = await readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = `${currentPath}/${entry.name}`;
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
if (
entry.name.endsWith(".app") ||
entry.name.endsWith(".framework") ||
entry.name.endsWith(".xpc") ||
entry.name.endsWith(".appex") ||
entry.name.endsWith(".plugin")
) {
bundleTargets.push(fullPath);
}
await walk(fullPath);
continue;
}
if (await isMachBinary(fullPath)) {
binaryTargets.push(fullPath);
}
}
}
await walk(rootPath);
bundleTargets.sort((left, right) => right.length - left.length);
binaryTargets.sort((left, right) => right.length - left.length);
return { bundleTargets, binaryTargets };
}
async function signTarget(targetPath, context) {
const fileOptions = context.optionsForFile?.(targetPath) ?? {};
const args = buildCodesignArgs(targetPath, context.identity, context.keychain, fileOptions);
let lastError;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await runCodesign(args);
return;
} catch (error) {
lastError = error;
if (attempt === 2) {
throw error;
}
await delay(5000);
}
}
throw lastError;
}
export default async function sign(context) {
const { bundleTargets, binaryTargets } = await collectSignTargets(context.app);
for (const target of binaryTargets) {
await signTarget(target, context);
}
for (const target of bundleTargets) {
await signTarget(target, context);
}
await signTarget(context.app, context);
}
+240
View File
@@ -0,0 +1,240 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
const rootDir = process.cwd();
const localReleaseDir = join(rootDir, "local-release");
const outputPath = join(rootDir, "landing", "components", "release-links.generated.ts");
async function loadEnvFile(filePath) {
if (!existsSync(filePath)) return;
const content = await readFile(filePath, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const separatorIndex = line.indexOf("=");
if (separatorIndex <= 0) continue;
const key = line.slice(0, separatorIndex).trim();
if (!key || process.env[key]) continue;
const rawValue = line.slice(separatorIndex + 1).trim();
if (!rawValue) continue;
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
}
}
function normalizeBaseUrl(url) {
return url.replace(/\/+$/, "");
}
function normalizePathSegment(pathValue) {
return pathValue.replace(/^\/+|\/+$/g, "");
}
function resolveUpdateBaseUrl() {
const explicit = process.env.AGENTCOMPANY_UPDATE_URL?.trim();
if (explicit) return normalizeBaseUrl(explicit);
const publicBaseUrl =
process.env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() ||
process.env.R2_PUBLIC_BASE_URL?.trim();
const prefix = process.env.AGENTCOMPANY_RELEASE_PREFIX?.trim() || "AC";
if (!publicBaseUrl) {
throw new Error("Missing AGENTCOMPANY_UPDATE_URL / R2 public URL while generating landing release links.");
}
const normalizedBase = normalizeBaseUrl(publicBaseUrl);
const normalizedPrefix = normalizePathSegment(prefix);
return `${normalizedBase}/${normalizedPrefix}`;
}
function ensureSingle(files, predicate, label) {
const matches = files.filter(predicate);
if (matches.length !== 1) {
throw new Error(`Expected exactly one ${label} artifact, found ${matches.length}: ${matches.join(", ")}`);
}
return matches[0];
}
function extractVersion(fileNames) {
const versions = new Set(
fileNames
.map((fileName) => fileName.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null)
.filter(Boolean),
);
if (versions.size !== 1) {
throw new Error(`Expected exactly one release version across artifacts, found: ${[...versions].join(", ")}`);
}
return [...versions][0];
}
function urlFor(baseUrl, fileName) {
return `${baseUrl}/${encodeURIComponent(fileName)}`;
}
async function main() {
await loadEnvFile(join(rootDir, ".env.release.local"));
await loadEnvFile(join(rootDir, ".env.release"));
if (!existsSync(localReleaseDir)) {
throw new Error(`Local release directory not found: ${localReleaseDir}`);
}
const baseUrl = resolveUpdateBaseUrl();
const macFiles = (await readdir(join(localReleaseDir, "mac"))).filter((name) => !name.startsWith("."));
const winFiles = (await readdir(join(localReleaseDir, "win"))).filter((name) => !name.startsWith("."));
const linuxFiles = (await readdir(join(localReleaseDir, "linux"))).filter((name) => !name.startsWith("."));
const macArm64Dmg = ensureSingle(macFiles, (name) => name.endsWith(".dmg") && name.includes("arm64"), "macOS Apple Silicon .dmg");
const macIntelDmg = ensureSingle(macFiles, (name) => name.endsWith(".dmg") && !name.includes("arm64"), "macOS Intel .dmg");
const windowsUniversalInstaller = ensureSingle(
winFiles,
(name) => name.endsWith(".exe") && name.includes("Setup"),
"Windows universal installer",
);
const linuxX64AppImage = ensureSingle(
linuxFiles,
(name) => name.endsWith(".AppImage") && !name.includes("arm64"),
"Linux x64 AppImage",
);
const linuxArm64AppImage = ensureSingle(
linuxFiles,
(name) => name.endsWith(".AppImage") && name.includes("arm64"),
"Linux arm64 AppImage",
);
const linuxX64Deb = ensureSingle(
linuxFiles,
(name) => name.endsWith(".deb") && name.includes("amd64"),
"Linux x64 .deb",
);
const linuxArm64Deb = ensureSingle(
linuxFiles,
(name) => name.endsWith(".deb") && name.includes("arm64"),
"Linux arm64 .deb",
);
const linuxX64Rpm = ensureSingle(
linuxFiles,
(name) => name.endsWith(".rpm") && (name.includes("x86_64") || name.includes("amd64")),
"Linux x64 .rpm",
);
const linuxArm64Rpm = ensureSingle(
linuxFiles,
(name) => name.endsWith(".rpm") && (name.includes("aarch64") || name.includes("arm64")),
"Linux arm64 .rpm",
);
const version = extractVersion([
macArm64Dmg,
macIntelDmg,
windowsUniversalInstaller,
linuxX64AppImage,
linuxArm64AppImage,
linuxX64Deb,
linuxArm64Deb,
linuxX64Rpm,
linuxArm64Rpm,
]);
const content = `export type PlatformDownloadAsset = {
id: string;
label: string;
href: string;
note: string;
};
export type PlatformDownload = {
id: "macos" | "windows" | "linux";
title: string;
note: string;
assets: PlatformDownloadAsset[];
};
export const releaseVersion = "${version}";
export const platformDownloads: PlatformDownload[] = [
{
id: "macos",
title: "macOS",
note: "Signed + notarized DMG builds",
assets: [
{
id: "macos-arm64-dmg",
label: "Apple Silicon DMG",
href: "${urlFor(baseUrl, macArm64Dmg)}",
note: "arm64",
},
{
id: "macos-x64-dmg",
label: "Intel Mac DMG",
href: "${urlFor(baseUrl, macIntelDmg)}",
note: "x64",
},
],
},
{
id: "windows",
title: "Windows",
note: "NSIS installer builds",
assets: [
{
id: "windows-universal-installer",
label: "Windows Universal Installer",
href: "${urlFor(baseUrl, windowsUniversalInstaller)}",
note: "arm64 + x64 + ia32",
},
],
},
{
id: "linux",
title: "Linux",
note: "AppImage, DEB, and RPM builds",
assets: [
{
id: "linux-x64-appimage",
label: "Linux x64 AppImage",
href: "${urlFor(baseUrl, linuxX64AppImage)}",
note: "x64",
},
{
id: "linux-x64-deb",
label: "Linux x64 DEB",
href: "${urlFor(baseUrl, linuxX64Deb)}",
note: "x64",
},
{
id: "linux-x64-rpm",
label: "Linux x64 RPM",
href: "${urlFor(baseUrl, linuxX64Rpm)}",
note: "x64",
},
{
id: "linux-arm64-appimage",
label: "Linux ARM64 AppImage",
href: "${urlFor(baseUrl, linuxArm64AppImage)}",
note: "arm64",
},
{
id: "linux-arm64-deb",
label: "Linux ARM64 DEB",
href: "${urlFor(baseUrl, linuxArm64Deb)}",
note: "arm64",
},
{
id: "linux-arm64-rpm",
label: "Linux ARM64 RPM",
href: "${urlFor(baseUrl, linuxArm64Rpm)}",
note: "arm64",
},
],
},
];
`;
await writeFile(outputPath, content, "utf8");
console.log(`[generate-landing-release-links] wrote ${outputPath}`);
console.log(`[generate-landing-release-links] version ${version}`);
}
await main();
+283
View File
@@ -0,0 +1,283 @@
import { spawn } from "node:child_process";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import os from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const buildReleaseDir = join(rootDir, "build", "release");
const localReleaseDir = join(rootDir, "local-release");
const pnpmVersion = "10.24.0";
const dockerNodeModulesVolume = `${process.cwd().split("/").filter(Boolean).at(-1) ?? "agentcompany"}-node-modules`;
function parseArgs(argv) {
const options = {
skipVerify: false,
upload: true,
platforms: ["mac", "win", "linux"],
};
for (const arg of argv) {
if (arg === "--skip-verify") {
options.skipVerify = true;
continue;
}
if (arg === "--no-upload") {
options.upload = false;
continue;
}
if (arg.startsWith("--platforms=")) {
options.platforms = arg.slice("--platforms=".length)
.split(",")
.map((platform) => platform.trim())
.filter(Boolean);
continue;
}
if (arg === "--help") {
printHelp();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printHelp() {
console.log(`Usage: node scripts/release-local.mjs [options]
Options:
--platforms=mac,win,linux Comma-separated platform list (default: all)
--skip-verify Skip pnpm verify before release builds
--no-upload Build artifacts locally without publishing
--help Show this help
Environment loading:
- .env.release
- .env.release.local
- existing shell environment (takes precedence)
`);
}
async function loadEnvFile(filePath) {
if (!existsSync(filePath)) return;
const content = await readFile(filePath, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const separatorIndex = line.indexOf("=");
if (separatorIndex <= 0) continue;
const key = line.slice(0, separatorIndex).trim();
if (!key || process.env[key]) continue;
const rawValue = line.slice(separatorIndex + 1).trim();
if (!rawValue) continue;
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
}
}
function hasDocker() {
return spawnSyncSuccess("docker", ["--version"]);
}
function spawnSyncSuccess(command, args) {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: "ignore" });
child.on("error", () => resolve(false));
child.on("close", (code) => resolve(code === 0));
});
}
function hasMacSigningCredential() {
return Boolean(process.env.CSC_LINK?.trim() || process.env.CSC_NAME?.trim());
}
function hasMacNotarizationCredential() {
return Boolean(
(process.env.APPLE_ID?.trim() &&
process.env.APPLE_APP_SPECIFIC_PASSWORD?.trim() &&
process.env.APPLE_TEAM_ID?.trim()) ||
(process.env.APPLE_API_KEY?.trim() &&
process.env.APPLE_API_KEY_ID?.trim() &&
process.env.APPLE_API_ISSUER?.trim()),
);
}
async function validatePreflight(platforms, upload) {
if (platforms.includes("mac") && process.platform !== "darwin") {
throw new Error("Signed macOS release builds must run on macOS.");
}
if (platforms.includes("mac") && (!hasMacSigningCredential() || !hasMacNotarizationCredential())) {
throw new Error(
"Signed macOS release requires signing credentials (CSC_LINK or CSC_NAME) and notarization credentials " +
"(APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID or APPLE_API_KEY trio).",
);
}
if ((platforms.includes("win") || platforms.includes("linux")) && !(await hasDocker())) {
throw new Error("Docker is required to build Windows/Linux locally from this macOS host.");
}
if (!upload) {
return;
}
}
async function run(command, args, env = process.env) {
await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: rootDir,
env,
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code ?? 1}`));
});
});
}
async function stageRelease(platform) {
const destination = join(localReleaseDir, platform);
await rm(destination, { recursive: true, force: true });
await mkdir(localReleaseDir, { recursive: true });
await cp(buildReleaseDir, destination, { recursive: true });
console.log(`[release-local] staged ${platform} artifacts in ${destination}`);
}
function makeDockerEnv(platform) {
const env = {
AGENTCOMPANY_ELECTRON_PUBLISH: "never",
AGENTCOMPANY_UPDATE_URL: process.env.AGENTCOMPANY_UPDATE_URL ?? "",
AGENTCOMPANY_ELECTRON_DIST: "/root/.cache/electron",
};
if (platform === "win") {
env.CSC_LINK = process.env.WIN_CSC_LINK ?? "";
env.CSC_KEY_PASSWORD = process.env.WIN_CSC_KEY_PASSWORD ?? "";
}
return env;
}
function getDockerBuildCommands(platform) {
if (platform === "win") {
return [
"node scripts/build-desktop.mjs --win nsis --x64 --ia32",
"node scripts/build-desktop.mjs --win nsis --arm64",
];
}
return [
"node scripts/build-desktop.mjs --linux AppImage deb rpm --x64",
"node scripts/build-desktop.mjs --linux AppImage deb rpm --arm64",
];
}
async function runDockerBuild(platform) {
const isWindows = platform === "win";
const image = isWindows ? "electronuserland/builder:wine" : "electronuserland/builder";
const env = makeDockerEnv(platform);
const cacheDir = join(os.homedir(), ".cache");
const command = [
"corepack enable",
`corepack prepare pnpm@${pnpmVersion} --activate`,
"pnpm install --frozen-lockfile",
"pnpm exec electron-vite build",
...getDockerBuildCommands(platform),
].join(" && ");
const dockerArgs = [
"run",
"--rm",
"--platform", "linux/amd64",
"-v", `${rootDir}:/project`,
"-v", `${dockerNodeModulesVolume}:/project/node_modules`,
"-v", `${join(cacheDir, "electron")}:/root/.cache/electron`,
"-v", `${join(cacheDir, "electron-builder")}:/root/.cache/electron-builder`,
"-w", "/project",
];
for (const [key, value] of Object.entries(env)) {
dockerArgs.push("-e", `${key}=${value}`);
}
dockerArgs.push(image, "/bin/bash", "-lc", command);
await run("docker", dockerArgs);
}
async function buildMac() {
const env = {
...process.env,
AGENTCOMPANY_ELECTRON_PUBLISH: "never",
AGENTCOMPANY_ENABLE_LOCAL_DMG: "true",
CSC_IDENTITY_AUTO_DISCOVERY: "true",
};
await run("pnpm", ["exec", "electron-vite", "build"], env);
await run("node", ["scripts/build-desktop.mjs", "--mac", "--arm64", "--x64"], env);
await stageRelease("mac");
}
async function main() {
const options = parseArgs(process.argv.slice(2));
await loadEnvFile(join(rootDir, ".env.release.local"));
await loadEnvFile(join(rootDir, ".env.release"));
process.env.AGENTCOMPANY_ELECTRON_PUBLISH = "never";
const r2 = options.upload ? await prepareR2ReleaseHost({ rootDir }) : null;
await validatePreflight(options.platforms, options.upload);
console.log(`[release-local] update URL: ${process.env.AGENTCOMPANY_UPDATE_URL}`);
console.log(`[release-local] upload mode: ${options.upload ? "enabled" : "disabled"}`);
console.log(`[release-local] platforms: ${options.platforms.join(", ")}`);
await rm(localReleaseDir, { recursive: true, force: true });
if (!options.skipVerify) {
await run("pnpm", ["verify"]);
}
if (options.platforms.includes("mac")) {
await buildMac();
if (r2) {
await uploadReleaseFiles({
rootDir,
releaseDir: buildReleaseDir,
bucket: r2.bucket,
releasePrefix: r2.releasePrefix,
});
}
}
if (options.platforms.includes("win")) {
await runDockerBuild("win");
await stageRelease("win");
if (r2) {
await uploadReleaseFiles({
rootDir,
releaseDir: buildReleaseDir,
bucket: r2.bucket,
releasePrefix: r2.releasePrefix,
});
}
}
if (options.platforms.includes("linux")) {
await runDockerBuild("linux");
await stageRelease("linux");
if (r2) {
await uploadReleaseFiles({
rootDir,
releaseDir: buildReleaseDir,
bucket: r2.bucket,
releasePrefix: r2.releasePrefix,
});
}
}
await mkdir(localReleaseDir, { recursive: true });
await writeFile(
join(localReleaseDir, "manifest.json"),
JSON.stringify({
generatedAt: new Date().toISOString(),
platforms: options.platforms,
uploadEnabled: options.upload,
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
}, null, 2),
"utf8",
);
console.log(`[release-local] complete. Local artifact staging: ${localReleaseDir}`);
}
await main();
+143
View File
@@ -0,0 +1,143 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { spawn } from "node:child_process";
const defaultBucket = "agentcompany-updates";
const defaultPrefix = "AC";
const ignoredReleaseFiles = new Set(["builder-debug.yml"]);
function normalizeBaseUrl(url) {
return url.replace(/\/+$/, "");
}
function normalizePathSegment(pathValue) {
return pathValue.replace(/^\/+|\/+$/g, "");
}
function guessContentType(fileName) {
if (fileName.endsWith(".yml")) return "text/yaml; charset=utf-8";
if (fileName.endsWith(".blockmap")) return "application/octet-stream";
if (fileName.endsWith(".zip")) return "application/zip";
if (fileName.endsWith(".dmg")) return "application/x-apple-diskimage";
if (fileName.endsWith(".exe")) return "application/vnd.microsoft.portable-executable";
if (fileName.endsWith(".AppImage")) return "application/octet-stream";
if (fileName.endsWith(".deb")) return "application/vnd.debian.binary-package";
if (fileName.endsWith(".rpm")) return "application/x-rpm";
if (fileName.endsWith(".snap")) return "application/vnd.snap";
return "application/octet-stream";
}
async function runWrangler(args, cwd, capture = false) {
return await new Promise((resolve, reject) => {
const child = spawn("npx", ["wrangler", ...args], {
cwd,
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
shell: process.platform === "win32",
});
let stdout = "";
let stderr = "";
if (capture) {
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
}
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new Error(stderr || stdout || `wrangler ${args.join(" ")} failed with exit code ${code ?? 1}`));
});
});
}
function resolveUpdateUrl(publicBaseUrl, releasePrefix) {
const baseUrl = normalizeBaseUrl(publicBaseUrl);
const prefix = normalizePathSegment(releasePrefix);
return prefix ? `${baseUrl}/${prefix}` : baseUrl;
}
export async function prepareR2ReleaseHost({ rootDir }) {
const prefix = normalizePathSegment(process.env.AGENTCOMPANY_RELEASE_PREFIX?.trim() || defaultPrefix);
const whoami = JSON.parse((await runWrangler(["whoami", "--json"], rootDir, true)).stdout);
const accountId = process.env.R2_ACCOUNT_ID?.trim()
|| process.env.AGENTCOMPANY_R2_ACCOUNT_ID?.trim()
|| whoami.accounts?.[0]?.id;
if (!accountId) {
throw new Error("Unable to resolve Cloudflare account ID from wrangler.");
}
const bucket = process.env.R2_BUCKET?.trim()
|| process.env.AGENTCOMPANY_R2_BUCKET?.trim()
|| defaultBucket;
try {
await runWrangler(["r2", "bucket", "info", bucket, "--json"], rootDir, true);
} catch (error) {
if (!String(error).includes("does not exist")) {
throw error;
}
await runWrangler(["r2", "bucket", "create", bucket], rootDir, false);
}
let publicBaseUrl = process.env.R2_PUBLIC_BASE_URL?.trim()
|| process.env.AGENTCOMPANY_R2_PUBLIC_URL?.trim()
|| "";
if (!publicBaseUrl) {
const devUrlState = await runWrangler(["r2", "bucket", "dev-url", "get", bucket], rootDir, true)
.then((result) => result.stdout)
.catch(() => "");
if (!devUrlState.includes("https://")) {
await runWrangler(["r2", "bucket", "dev-url", "enable", bucket], rootDir, false);
}
const enabledOutput = (await runWrangler(["r2", "bucket", "dev-url", "get", bucket], rootDir, true)).stdout;
const match = enabledOutput.match(/https:\/\/[^\s']+\.r2\.dev/);
if (!match) {
throw new Error("Unable to resolve R2 public dev URL from wrangler.");
}
publicBaseUrl = match[0];
}
process.env.R2_ACCOUNT_ID = accountId;
process.env.AGENTCOMPANY_R2_ACCOUNT_ID = accountId;
process.env.R2_BUCKET = bucket;
process.env.AGENTCOMPANY_R2_BUCKET = bucket;
process.env.R2_PUBLIC_BASE_URL = publicBaseUrl;
process.env.AGENTCOMPANY_R2_PUBLIC_URL = publicBaseUrl;
process.env.AGENTCOMPANY_RELEASE_PREFIX = prefix;
process.env.AGENTCOMPANY_R2_PATH = prefix;
process.env.AGENTCOMPANY_UPDATE_URL = process.env.AGENTCOMPANY_UPDATE_URL?.trim()
|| resolveUpdateUrl(publicBaseUrl, prefix);
return {
accountId,
bucket,
publicBaseUrl,
releasePrefix: prefix,
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
};
}
export async function uploadReleaseFiles({ rootDir, releaseDir, bucket, releasePrefix }) {
const files = (await readdir(releaseDir, { withFileTypes: true }))
.filter((entry) => entry.isFile() && !ignoredReleaseFiles.has(entry.name))
.map((entry) => entry.name);
for (const fileName of files) {
const objectPath = `${bucket}/${normalizePathSegment(releasePrefix)}/${fileName}`;
const filePath = join(releaseDir, fileName);
const args = [
"r2", "object", "put", objectPath,
"--remote",
"--file", filePath,
"--content-type", guessContentType(fileName),
"--cache-control", fileName.endsWith(".yml") ? "no-cache" : "public, max-age=31536000, immutable",
];
await runWrangler(args, rootDir, false);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { spawn } from "node:child_process";
import { resolve } from "node:path";
const vitestEntrypoint = resolve(process.cwd(), "node_modules/vitest/vitest.mjs");
const args = process.argv.slice(2);
const warningFlag = "--disable-warning=ExperimentalWarning";
const env = { ...process.env };
const existingNodeOptions = env.NODE_OPTIONS?.trim();
if (!existingNodeOptions) {
env.NODE_OPTIONS = warningFlag;
} else if (!existingNodeOptions.includes(warningFlag)) {
env.NODE_OPTIONS = `${existingNodeOptions} ${warningFlag}`;
}
const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", vitestEntrypoint, ...args], {
stdio: "inherit",
env,
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
console.error(error);
process.exit(1);
});
+45 -1
View File
@@ -9,6 +9,33 @@ function safeGetAgent(db: AppDatabase, agentId: string) {
}
}
const MANAGEMENT_KEYWORDS = [
"chief",
"cto",
"cmo",
"coo",
"cfo",
"vp",
"vice president",
"head",
"director",
"manager",
"lead",
];
function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) {
if (agent.department === "hr") {
return true;
}
const text = `${agent.role} ${agent.title}`.toLowerCase();
return MANAGEMENT_KEYWORDS.some((keyword) => text.includes(keyword));
}
function hasOrganizationPermission(db: AppDatabase, agentId: string) {
return db.hasAgentPermission(agentId, "organization_manage");
}
function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string): boolean {
if (managerId === targetAgentId) {
return true;
@@ -42,7 +69,24 @@ export function canManageOrganization(db: AppDatabase, companyId: string, agentI
if (!agent.reportsTo) {
return true;
}
return db.getDirectReports(agentId, companyId).length > 0 || agent.department === "hr";
if (hasOrganizationPermission(db, agentId)) {
return true;
}
return agent.department === "hr";
}
export function canRequestHire(db: AppDatabase, companyId: string, agentId: string): boolean {
const agent = safeGetAgent(db, agentId);
if (!agent || agent.companyId !== companyId) {
return false;
}
if (canManageOrganization(db, companyId, agentId)) {
return true;
}
if (db.getDirectReports(agentId, companyId).length > 0) {
return true;
}
return hasManagementAuthority(agent);
}
export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean {
+4 -2324
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
import { AgentApiServer, type ApiServerEventCallbacks } from "./server-core";
import { registerAgentRoutes } from "./routes-agents";
import { registerAgentControlRoutes } from "./routes-agent-control";
import { registerTaskRoutes } from "./routes-tasks";
import { registerWorkGraphRoutes } from "./routes-work-graph";
import { registerMessageRoutes } from "./routes-messages";
import { registerApprovalRoutes } from "./routes-approvals";
import { registerFileRoutes } from "./routes-files";
import { registerMiscRoutes } from "./routes-misc";
import { registerRunRoutes } from "./routes-runs";
import type { AppDatabase } from "../database";
export type { ApiServerEventCallbacks };
export { AgentApiServer };
/**
* Factory that creates and returns a fully-wired AgentApiServer with all
* domain route modules registered.
*/
export function createApiServer(
db: AppDatabase,
onDomainChanged: () => void,
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null,
callbacks?: Partial<ApiServerEventCallbacks>,
allowedOrigins?: Iterable<string>,
): AgentApiServer {
const server = new AgentApiServer(db, onDomainChanged, onWakeAgent, callbacks, allowedOrigins);
registerAgentRoutes(server);
registerAgentControlRoutes(server);
registerTaskRoutes(server);
registerWorkGraphRoutes(server);
registerMessageRoutes(server);
registerApprovalRoutes(server);
registerFileRoutes(server);
registerMiscRoutes(server);
registerRunRoutes(server);
return server;
}
+136
View File
@@ -0,0 +1,136 @@
import { z } from "zod";
import { canControlAgent } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
export function registerAgentControlRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => {
const targetAgentId = params.agentId!;
const targetAgent = (() => {
try {
return db.getAgent(targetAgentId);
} catch {
return null;
}
})();
if (!targetAgent || targetAgent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
if (!canControlAgent(db, token.companyId, token.agentId, targetAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to wake this agent" });
return;
}
const wakeSchema = z.object({
reason: z.string().min(1).max(200).default("peer_request"),
});
const parsed = wakeSchema.safeParse(body);
const reason = parsed.success ? parsed.data.reason : "peer_request";
const alreadyActive = db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
const runId = onWakeAgent(targetAgentId, token.companyId, reason);
if (runId) {
server.json(res, 200, { ok: true, runId, message: `Wake started for agent ${targetAgentId}` });
return;
}
if (alreadyActive) {
server.json(res, 202, {
ok: true,
queued: true,
message: `Agent ${targetAgentId} is already running. Wake will be retried after the active run completes.`,
});
return;
}
server.json(res, 409, { ok: false, error: "Agent could not be woken right now" });
});
server.addRoute("PATCH", "/api/agents/:agentId/heartbeat", (_req, res, params, token) => {
const targetAgentId = params.agentId!;
const snapshot = db.listSnapshot();
const targetAgent = snapshot.agents.find(
(a) => a.id === targetAgentId && a.companyId === token.companyId,
);
if (!targetAgent) {
server.json(res, 404, { error: "Agent not found or not in the same company" });
return;
}
if (!canControlAgent(db, token.companyId, token.agentId, targetAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to trigger heartbeat for this agent" });
return;
}
const alreadyActive = db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
const runId = onWakeAgent(targetAgentId, token.companyId, "peer_request");
if (runId) {
server.json(res, 200, { ok: true, runId, message: `Heartbeat triggered for agent ${targetAgentId}` });
return;
}
if (alreadyActive) {
server.json(res, 202, {
ok: true,
queued: true,
message: `Agent ${targetAgentId} is already running. Heartbeat will retry after the current run.`,
});
return;
}
server.json(res, 409, { ok: false, error: "Heartbeat could not be started for this agent" });
});
server.addRoute("GET", "/api/agents/:agentId/skills", (_req, res, params, token) => {
const agentId = params.agentId!;
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
const skillIds = db.listAgentSkillAssignments(agentId);
const skills = skillIds.map((sid) => db.getCompanySkill(token.companyId, sid)).filter(Boolean);
server.json(res, 200, skills.map((s) => ({
id: s!.id,
name: s!.name,
slug: s!.slug,
description: s!.description,
sourceType: s!.sourceType,
trustLevel: s!.trustLevel,
})));
});
server.addRoute("POST", "/api/agents/:agentId/skills", (_req, res, params, token, body) => {
const agentId = params.agentId!;
const parsed = z.object({ skillId: z.string().uuid() }).safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid body: skillId (uuid) required" });
return;
}
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
const skill = db.getCompanySkill(token.companyId, parsed.data.skillId);
if (!skill) {
server.json(res, 404, { error: "Skill not found in your company" });
return;
}
db.assignSkillToAgent(agentId, parsed.data.skillId);
onDomainChanged();
server.json(res, 200, { ok: true, message: `Skill "${skill.name}" assigned to agent ${agent.name}` });
});
server.addRoute("DELETE", "/api/agents/:agentId/skills/:skillId", (_req, res, params, token) => {
const agentId = params.agentId!;
const skillId = params.skillId!;
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
db.removeSkillFromAgent(agentId, skillId);
onDomainChanged();
server.json(res, 200, { ok: true, message: `Skill removed from agent ${agent.name}` });
});
}
+334
View File
@@ -0,0 +1,334 @@
import log from "electron-log/main.js";
import { hireRequestSchema } from "@shared/contracts";
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
import { canManageOrganization, canRequestHire } from "../agent-permissions";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
import type { AgentApiServer } from "./server-core";
export function registerAgentRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
server.addRoute("GET", "/api/agents/me", (_req, res, _params, token) => {
const agent = db.getAgent(token.agentId);
const chainOfCommand = db.getChainOfCommand(token.agentId);
const directReports = db.getDirectReports(token.agentId, token.companyId);
server.json(res, 200, { agent, chainOfCommand, directReports });
});
server.addRoute("GET", "/api/agents/me/standup", (_req, res, _params, token) => {
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === token.agentId);
if (!agent) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const myTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === token.agentId && t.companyId === token.companyId);
const directReports = snapshot.agents.filter((a) => a.reportsTo === token.agentId && a.companyId === token.companyId);
const reportIds = new Set(directReports.map((a) => a.id));
const reportTasks = snapshot.tasks.filter((t) => t.assigneeAgentId && reportIds.has(t.assigneeAgentId) && t.companyId === token.companyId);
const tasksAwaitingMyReview = reportTasks.filter((t) => t.status === "in_review");
const blockedReportTasks = reportTasks.filter((t) => t.status === "blocked");
const myBlockedTasks = myTasks.filter((t) => t.status === "blocked");
const myInProgressTasks = myTasks.filter((t) => t.status === "in_progress");
const myTodoTasks = myTasks.filter((t) => t.status === "todo");
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
const recentComments = snapshot.comments
.filter((c) => {
const isMyTask = myTasks.some((t) => t.id === c.taskId);
const isReportTask = reportTasks.some((t) => t.id === c.taskId);
return (isMyTask || isReportTask) && new Date(c.createdAt).getTime() > twoHoursAgo;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 20);
const myPendingApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId,
);
const myResolvedApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId,
).slice(0, 5);
server.json(res, 200, {
agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
summary: {
tasksAwaitingReview: tasksAwaitingMyReview.length,
blockedReportTasks: blockedReportTasks.length,
myBlockedTasks: myBlockedTasks.length,
myInProgress: myInProgressTasks.length,
myTodo: myTodoTasks.length,
pendingApprovals: myPendingApprovals.length,
directReports: directReports.length,
},
tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({
id: t.id,
title: t.title,
assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown",
priority: t.priority,
})),
blockedTasks: [...blockedReportTasks, ...myBlockedTasks].map((t) => ({
id: t.id,
title: t.title,
assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown",
priority: t.priority,
})),
recentComments: recentComments.map((c) => ({
taskId: c.taskId,
taskTitle: snapshot.tasks.find((t) => t.id === c.taskId)?.title ?? "",
author: c.authorName,
body: c.body.slice(0, 200),
createdAt: c.createdAt,
})),
pendingApprovals: myPendingApprovals.map((a) => ({
id: a.id,
type: a.type,
summary: a.payloadSummary,
state: a.state,
})),
resolvedApprovals: myResolvedApprovals.map((a) => ({
id: a.id,
type: a.type,
summary: a.payloadSummary,
state: a.state,
decisionNote: a.decisionNote,
})),
reportStatuses: directReports.map((r) => {
const rTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === r.id && t.companyId === token.companyId);
return {
id: r.id,
name: r.name,
role: r.role,
status: r.status,
tasksInProgress: rTasks.filter((t) => t.status === "in_progress").length,
tasksInReview: rTasks.filter((t) => t.status === "in_review").length,
tasksBlocked: rTasks.filter((t) => t.status === "blocked").length,
tasksDone: rTasks.filter((t) => t.status === "done").length,
};
}),
socialAccounts: snapshot.socialAccounts
.filter((a) => a.companyId === token.companyId)
.map((a) => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
status: a.status,
requireApproval: a.requireApproval,
})),
});
});
server.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => {
const companyMetrics = db.getCompanyMetrics(token.companyId, 30);
const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
}
server.json(res, 200, agentMetric);
});
server.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => {
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const companyMetrics = db.getCompanyMetrics(token.companyId, 30);
const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
}
server.json(res, 200, agentMetric);
});
server.addRoute("GET", "/api/agents/:agentId", (_req, res, params, token) => {
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find(a => a.id === params.agentId && a.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const directReports = snapshot.agents.filter(a => a.reportsTo === agent.id && a.companyId === token.companyId);
const manager = agent.reportsTo ? snapshot.agents.find(a => a.id === agent.reportsTo) : null;
server.json(res, 200, {
...agent,
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
directReports: directReports.map(r => ({ id: r.id, name: r.name, role: r.role, status: r.status })),
});
});
server.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const agents = snapshot.agents.filter((a) => a.companyId === params.companyId);
server.json(res, 200, agents);
});
server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
if (!canRequestHire(db, companyId, token.agentId)) {
server.json(res, 403, { error: "Current agent is not allowed to request hires" });
return;
}
const parsed = hireRequestSchema.safeParse({
companyId,
requestedByAgentId: token.agentId,
...(typeof body === "object" && body ? body : {}),
reportsTo:
typeof body === "object" && body && "reportsTo" in body
? (body as { reportsTo?: string | null }).reportsTo
: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid hire payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.requestedByAgentId, table: "agents", label: "requesting agent" },
{ id: input.reportsTo, table: "agents", label: "manager" },
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
])) {
return;
}
if (
input.reportsTo &&
input.reportsTo !== token.agentId &&
!canManageOrganization(db, companyId, token.agentId) &&
!db.getChainOfCommand(input.reportsTo).some((agent) => agent.id === token.agentId)
) {
server.json(res, 403, { error: "Requested hire must report to you or a manager in your reporting chain." });
return;
}
const connectorRecord = (() => {
try {
return db.getConnector(input.connectorId);
} catch {
return null;
}
})();
if (!connectorRecord) {
server.json(res, 422, { error: "The selected connector does not exist." });
return;
}
const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord);
if (connectorIssue) {
server.json(res, 422, { error: connectorIssue });
return;
}
// Prevent duplicate agents: reject if a non-terminated agent with the same name already exists
const existingAgents = db.listSnapshot().agents.filter(
(a) => a.companyId === companyId && a.status !== "terminated",
);
const duplicateByName = existingAgents.find(
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
);
if (duplicateByName) {
server.json(res, 409, { error: `Agent "${input.name}" already exists in this company (id: ${duplicateByName.id}, role: ${duplicateByName.role}). Use the existing agent instead of creating a duplicate.` });
return;
}
// Also check pending hire approvals with the same name to prevent concurrent duplicate requests
const pendingHires = db.listSnapshot().approvals.filter(
(a) => a.companyId === companyId && a.type === "hire_agent" && a.state === "pending",
);
for (const pending of pendingHires) {
try {
const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record<string, unknown> : {};
if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) {
server.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
return;
}
} catch { /* invalid payloadJson, skip */ }
}
const managerLabel = input.reportsTo
? (() => {
try {
return db.getAgent(input.reportsTo).name;
} catch {
return input.reportsTo;
}
})()
: "the company board";
let result: { agentId: string; approvalId: string };
try {
result = db.createHireRequest({
companyId,
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
name: input.name,
role: input.role,
title: input.title,
department: input.department ?? null,
reportsTo: input.reportsTo ?? token.agentId,
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd,
payloadSummary: `Hire ${input.name} as ${input.role}`,
impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`,
payloadJson: JSON.stringify({
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
requestedConfigurationSnapshot: {
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
metadataJson: input.metadataJson ?? "{}",
capabilities: input.capabilities,
budgetMonthlyUsd: input.budgetMonthlyUsd,
reportsTo: input.reportsTo ?? token.agentId,
department: input.department ?? null,
title: input.title,
},
}),
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" });
return;
}
db.addActivity({
companyId,
actor: token.agentId,
action: "agent.hire_requested",
entityType: "agent",
entityId: result.agentId,
detail: input.name,
});
let autoApproved = false;
if (shouldAutoApproveApproval(db, companyId, "hire_agent")) {
resolveApprovalDecision({
db,
handleApprovalResolved: callbacks.onApprovalResolved,
companyId,
approvalId: result.approvalId,
state: "approved",
decisionNote: "Auto-approved by company policy",
detail: `Auto-approved hire: ${input.name}`,
});
autoApproved = true;
log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`);
}
onDomainChanged();
server.json(res, 201, { ...result, autoApproved });
});
}
+212
View File
@@ -0,0 +1,212 @@
import { z } from "zod";
import log from "electron-log/main.js";
import { approvalInputSchema } from "@shared/contracts";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
import type { AgentApiServer } from "./server-core";
const approvalListQuerySchema = z.object({
requestedByAgentId: z.string().uuid().optional(),
relatedTaskId: z.string().uuid().optional(),
status: z.string().optional(),
});
export function registerApprovalRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = approvalInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
requestedByAgentId: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid approval payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.relatedTaskId, table: "tasks", label: "related task" },
{ id: token.agentId, table: "agents", label: "requesting agent" },
{ id: input.relatedAgentId, table: "agents", label: "related agent" },
])) {
return;
}
let id: string;
try {
id = db.requestApproval({
companyId,
type: input.type,
relatedTaskId: input.relatedTaskId ?? null,
requestedByAgentId: token.agentId,
relatedAgentId: input.relatedAgentId ?? null,
payloadSummary: input.payloadSummary,
impactSummary: input.impactSummary,
payloadJson: input.payloadJson ?? null,
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" });
return;
}
db.addActivity({
companyId,
actor: token.agentId,
action: "approval.created",
entityType: "approval",
entityId: id,
detail: input.payloadSummary,
});
// Notify thread integration about the new approval
callbacks.onApprovalCreated(id, companyId, input.relatedTaskId ?? null, input.type, input.payloadSummary ?? "");
if (shouldAutoApproveApproval(db, companyId, input.type)) {
resolveApprovalDecision({
db,
handleApprovalResolved: callbacks.onApprovalResolved,
companyId,
approvalId: id,
state: "approved",
decisionNote: "Auto-approved by company policy",
detail: "Auto-approved by company policy",
});
log.info(`[auto-approve] Auto-approved approval ${id}: ${input.payloadSummary}`);
} else {
callbacks.notify({
title: "Approval Required",
body: input.payloadSummary ?? `${input.type} approval pending`,
urgency: "critical",
navigation: { section: "approvals", entityId: id },
batchKey: "pending_approval",
});
}
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const parsedQuery = approvalListQuerySchema.safeParse({
requestedByAgentId: url.searchParams.get("requestedByAgentId") ?? undefined,
relatedTaskId: url.searchParams.get("relatedTaskId") ?? undefined,
status: url.searchParams.get("status") ?? undefined,
});
if (!parsedQuery.success) {
server.json(res, 400, { error: "Invalid approval query" });
return;
}
const statuses = parsedQuery.data.status?.split(",").filter(Boolean) ?? null;
const snapshot = db.listSnapshot();
const approvals = snapshot.approvals.filter((approval) => {
if (approval.companyId !== companyId) return false;
if (parsedQuery.data.requestedByAgentId && approval.requestedByAgentId !== parsedQuery.data.requestedByAgentId) return false;
if (parsedQuery.data.relatedTaskId && approval.relatedTaskId !== parsedQuery.data.relatedTaskId) return false;
if (statuses && !statuses.includes(approval.state)) return false;
return true;
});
server.json(res, 200, approvals);
});
server.addRoute("GET", "/api/approvals/:approvalId", (_req, res, params, token) => {
const snapshot = db.listSnapshot();
const approval = snapshot.approvals.find((a) => a.id === params.approvalId && a.companyId === token.companyId);
if (!approval) {
server.json(res, 404, { error: "Approval not found" });
return;
}
server.json(res, 200, approval);
});
server.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => {
server.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" });
});
server.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const comments = db.listApprovalComments(params.approvalId!, approval.companyId);
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/approvals/:approvalId/comments", (_req, res, params, token, body) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const commentBody = typeof data.body === "string" ? data.body.trim() : "";
if (!commentBody) { server.json(res, 400, { error: "Comment body is required" }); return; }
const commentId = db.addApprovalComment({
companyId: approval.companyId,
approvalId: approval.id,
authorAgentId: token.agentId ?? null,
body: commentBody,
});
// Wake mentioned agents in comment
const mentionedIds = db.findMentionedAgentIds(approval.companyId, commentBody);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId) {
onWakeAgent(mentionedId, approval.companyId, "approval_comment_mentioned");
}
}
onDomainChanged();
server.json(res, 201, { id: commentId });
});
server.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
if (approval.state !== "revision_requested") {
server.json(res, 422, { error: "Only revision_requested approvals can be resubmitted" });
return;
}
if (token.agentId !== approval.requestedByAgentId) {
server.json(res, 403, { error: "Only the requesting agent can resubmit this approval" });
return;
}
db.resubmitApproval(approval.id, approval.companyId);
onDomainChanged();
server.json(res, 200, { resubmitted: true });
});
server.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const taskIds = db.listTasksForApproval(params.approvalId!);
server.json(res, 200, taskIds);
});
server.addRoute("POST", "/api/approvals/:approvalId/tasks", (_req, res, params, token, body) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const taskIds = Array.isArray(data.taskIds) ? data.taskIds.filter((id): id is string => typeof id === "string") : [];
if (taskIds.length === 0) { server.json(res, 400, { error: "taskIds array is required" }); return; }
for (const taskId of taskIds) {
if (!db.belongsToCompany("tasks", taskId, token.companyId)) {
server.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return;
}
}
db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null);
onDomainChanged();
server.json(res, 200, { linked: taskIds.length });
});
}
+296
View File
@@ -0,0 +1,296 @@
import type { AgentApiServer } from "./server-core";
export function registerFileRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const reviewerAgentId = data.reviewerAgentId ? String(data.reviewerAgentId) : null;
const projectId = data.projectId ? String(data.projectId) : null;
const goalId = data.goalId ? String(data.goalId) : null;
if (reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
if (projectId && !db.belongsToCompany("projects", projectId, token.companyId)) {
server.json(res, 400, { error: "Project does not belong to the current company" });
return;
}
if (goalId && !db.belongsToCompany("goals", goalId, token.companyId)) {
server.json(res, 400, { error: "Goal does not belong to the current company" });
return;
}
const docType = String(data.type ?? "knowledge_article");
const docTitle = String(data.title ?? "Untitled");
const id = db.saveDocument({
companyId: token.companyId,
type: docType,
title: docTitle,
content: String(data.content ?? ""),
authorAgentId: token.agentId,
reviewerAgentId,
projectId,
goalId,
status: String(data.status ?? "draft"),
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
sourceRunId: token.runId ?? null,
});
onDomainChanged();
// Derive taskId from the agent's current run so thread integration
// and checkpoint engine can bind the document to its originating task.
let taskId: string | null = null;
if (token.runId) {
try {
const run = db.getRun(token.runId);
if (run.companyId === token.companyId) {
taskId = run.taskId;
}
} catch { /* run may have finished or been deleted */ }
}
// Fire document creation automation rules
callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId);
// Wake document reviewer if specified
if (reviewerAgentId) {
onWakeAgent(reviewerAgentId, token.companyId, "assignment");
}
// Deliverable review gate: check at document creation time (most reliable).
// In ai_cofounder mode, checkpoint-engine handles document reviews via the
// onDocumentCreated callback, so skip the direct approval here to avoid duplicates.
const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]);
if (IMPORTANT_DOC_TYPES.has(docType)) {
try {
const company = db.listSnapshot().companies.find((c) => c.id === token.companyId);
const isCofounderMode = db.getCompanyExecutionMode(token.companyId) === "ai_cofounder";
if (company?.reviewDeliverables && !isCofounderMode) {
if (!db.hasPendingDeliverableReviews(token.companyId)) {
db.requestApproval({
companyId: token.companyId,
relatedTaskId: taskId,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280),
impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }),
});
try {
const agent = db.getAgent(token.agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
}
} catch { /* best effort */ }
onDomainChanged();
}
}
} catch { /* deliverable review gate is best-effort */ }
}
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/documents", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const docs = snapshot.documents.filter(d => d.companyId === token.companyId);
server.json(res, 200, docs);
});
server.addRoute("PATCH", "/api/companies/:companyId/documents/:documentId", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const documentId = params.documentId;
if (!documentId) {
server.json(res, 400, { error: "Missing documentId" });
return;
}
const snapshot = db.listSnapshot();
const doc = snapshot.documents.find(d => d.id === documentId && d.companyId === token.companyId);
if (!doc) {
server.json(res, 404, { error: "Document not found in this company" });
return;
}
const data = body as Record<string, unknown>;
const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]);
const reviewerAgentId = data.reviewerAgentId !== undefined
? (data.reviewerAgentId ? String(data.reviewerAgentId) : null)
: doc.reviewerAgentId;
if (data.reviewerAgentId !== undefined && reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
// Conflict detection: if baseRevisionId provided, verify it matches current
if (typeof data.baseRevisionId === "string") {
const currentRevId = db.getDocumentLatestRevisionId(documentId);
if (currentRevId && data.baseRevisionId !== currentRevId) {
server.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId });
return;
}
}
if (data.status !== undefined) {
const newStatus = String(data.status);
if (!VALID_STATUSES.has(newStatus)) {
server.json(res, 400, { error: `Invalid status "${newStatus}". Must be one of: ${[...VALID_STATUSES].join(", ")}` });
return;
}
}
// Content update → create revision for audit trail
let updatedVersion = doc.version;
if (data.content !== undefined) {
const newContent = String(data.content);
const revision = db.createDocumentRevision({
documentId,
companyId: token.companyId,
body: newContent,
changeSummary: typeof data.changeSummary === "string" ? data.changeSummary : null,
createdByAgentId: token.agentId ?? null,
});
updatedVersion = revision.revisionNumber;
}
// Field updates (title, reviewer, tags, etc.)
if (data.status !== undefined) {
db.updateDocumentStatus(documentId, token.companyId, String(data.status));
}
db.saveDocument({
id: documentId,
companyId: token.companyId,
type: data.type ? String(data.type) : doc.type,
title: data.title ? String(data.title) : doc.title,
content: data.content !== undefined ? String(data.content) : doc.content,
authorAgentId: doc.authorAgentId,
reviewerAgentId,
projectId: doc.projectId,
goalId: doc.goalId,
version: updatedVersion,
status: data.status ? String(data.status) : doc.status,
tagsJson: data.tagsJson ? String(data.tagsJson) : doc.tagsJson,
sourceRunId: data.content !== undefined ? (token.runId ?? null) : null,
});
onDomainChanged();
server.json(res, 200, { updated: true, latestRevisionId: db.getDocumentLatestRevisionId(documentId) });
});
server.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => {
if (params.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const documentId = params.documentId;
if (!documentId) { server.json(res, 400, { error: "Missing documentId" }); return; }
const revisions = db.listDocumentRevisions(documentId, token.companyId);
server.json(res, 200, revisions);
});
// ── Work Products ──
server.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const products = db.listWorkProductsForTask(task.id, task.companyId);
server.json(res, 200, products);
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const data = body as Record<string, unknown>;
if (!data.type || !data.provider || !data.title) {
server.json(res, 400, { error: "type, provider, and title are required" }); return;
}
const id = db.createWorkProduct({
companyId: task.companyId,
taskId: task.id,
projectId: typeof data.projectId === "string" ? data.projectId : task.projectId,
type: String(data.type),
provider: String(data.provider),
externalId: typeof data.externalId === "string" ? data.externalId : null,
title: String(data.title),
url: typeof data.url === "string" ? data.url : null,
status: typeof data.status === "string" ? data.status : "active",
reviewState: typeof data.reviewState === "string" ? data.reviewState : "none",
isPrimary: data.isPrimary === true,
summary: typeof data.summary === "string" ? data.summary : null,
metadataJson: data.metadata ? JSON.stringify(data.metadata) : null,
createdByRunId: token.runId ?? null,
});
onDomainChanged();
server.json(res, 201, { id });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => {
const wpId = params.workProductId;
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
const data = body as Record<string, unknown>;
db.updateWorkProduct(wpId, token.companyId, data);
onDomainChanged();
server.json(res, 200, { updated: true });
});
server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => {
const wpId = params.workProductId;
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
db.deleteWorkProduct(wpId, token.companyId);
onDomainChanged();
server.json(res, 200, { deleted: true });
});
// ── Task-Document Links ──
server.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const links = db.listDocumentsForTask(task.id, task.companyId);
server.json(res, 200, links);
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key) || key.length > 64) {
server.json(res, 400, { error: "Invalid document key. Must be lowercase alphanumeric with _ or -, 1-64 chars." }); return;
}
const data = body as Record<string, unknown>;
const documentId = typeof data.documentId === "string" ? data.documentId : null;
if (!documentId) { server.json(res, 400, { error: "documentId is required" }); return; }
if (!db.belongsToCompany("documents", documentId, token.companyId)) {
server.json(res, 400, { error: "Document does not belong to this company" }); return;
}
const linkId = db.linkDocumentToTask(task.id, documentId, task.companyId, key);
onDomainChanged();
server.json(res, 200, { id: linkId, key });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
db.unlinkDocumentFromTask(task.id, key, task.companyId);
onDomainChanged();
server.json(res, 200, { unlinked: true });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
}
+175
View File
@@ -0,0 +1,175 @@
import { agentMessageInputSchema } from "@shared/contracts";
import { dispatchAgentMessage } from "../message-dispatch";
import type { AgentApiServer } from "./server-core";
export function registerMessageRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
const emitEvent = server.emitEvent_;
server.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => {
const input = body as { body?: string } | null;
if (!input?.body || typeof input.body !== "string") {
server.json(res, 400, { error: "body is required" });
return;
}
if (input.body.length > 4000) {
server.json(res, 400, { error: "Comment body must not exceed 4000 characters" });
return;
}
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
const agent = db.getAgent(token.agentId);
const comment = db.addComment({
companyId: task.companyId,
taskId: task.id,
authorAgentId: token.agentId,
authorName: agent.name,
body: input.body,
});
onDomainChanged();
// Fire comment automation rules
server.callbacks_.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
wokenForComment.add(task.assigneeAgentId);
}
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
onWakeAgent(chain[0].id, task.companyId, "comment");
wokenForComment.add(chain[0].id);
}
// @mention detection: wake any mentioned agents not already woken
const mentionedIds = db.findMentionedAgentIds(task.companyId, input.body);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId && !wokenForComment.has(mentionedId)) {
onWakeAgent(mentionedId, task.companyId, "comment_mentioned");
wokenForComment.add(mentionedId);
}
}
server.json(res, 201, comment);
} catch {
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
} catch {
server.json(res, 404, { error: "Task not found" });
return;
}
const comments = db.listComments(params.issueId!);
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = agentMessageInputSchema.safeParse({
...(body as Record<string, unknown>),
companyId,
fromAgentId: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const dispatchResult = dispatchAgentMessage(
{
db,
emitEvent,
notify: server.callbacks_.notify,
publishDomainChanged: onDomainChanged,
wakeAgentIfPossible: (agentId, cid, trigger) => {
if (!agentId) return null;
return onWakeAgent(agentId, cid, trigger);
},
onHumanMessage: server.callbacks_.onHumanMessage,
},
parsed.data,
"message",
);
if (!dispatchResult.ok) {
server.json(res, 400, { error: dispatchResult.error.message, code: dispatchResult.error.code });
return;
}
server.json(res, 201, { id: dispatchResult.data.id });
});
server.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const channelParam = url.searchParams.get("channel");
const channelTargetId = url.searchParams.get("channelTargetId");
const unreadOnly = url.searchParams.get("unreadOnly") === "true";
const limitStr = url.searchParams.get("limit");
const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 200) : 50;
const allowedChannels = new Set(["direct", "department", "company", "project", "incident"]);
if (channelParam && !allowedChannels.has(channelParam)) {
server.json(res, 400, { error: `Invalid channel filter: ${channelParam}` });
return;
}
const messages = db.listAgentMessages(companyId, {
agentId: token.agentId,
channel: channelParam ?? undefined,
channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined,
unreadOnly,
limit,
});
server.json(res, 200, messages);
});
server.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const message = db.getAgentMessage(params.messageId!, companyId);
if (!message) {
server.json(res, 404, { error: "Message not found" });
return;
}
const visibleToAgent = db.listAgentMessages(companyId, {
agentId: token.agentId,
messageId: message.id,
limit: 1,
}).some((entry) => entry.id === message.id);
if (!visibleToAgent) {
server.json(res, 403, { error: "This message is not visible to the current agent" });
return;
}
db.markAgentMessageRead(params.messageId!, companyId, token.agentId);
onDomainChanged();
server.json(res, 200, { ok: true });
});
}
+349
View File
@@ -0,0 +1,349 @@
import { z } from "zod";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
const socialDraftSchema = z.object({
socialAccountId: z.string().uuid(),
platform: z.string(),
content: z.string().min(1).max(10000),
mediaUrls: z.array(z.string()).default([]),
scheduledAt: z.string().datetime().optional(),
});
export function registerMiscRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const actionSchema = z.object({
socialAccountId: z.string().uuid(),
actionType: z.enum(["post", "reply", "like", "follow", "browse_feed", "screenshot", "navigate", "search"]),
payload: z.record(z.string(), z.unknown()).default({}),
});
const parsed = actionSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid browser action payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
const snapshot = db.listSnapshot();
const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
}
const postActions = ["post", "reply", "dm"];
if (account.requireApproval && postActions.includes(input.actionType)) {
const approvalId = db.requestApproval({
companyId,
type: "dangerous_command",
relatedTaskId: null,
requestedByAgentId: token.agentId,
payloadSummary: `${input.actionType} on ${account.platform}/@${account.accountName}: ${String(input.payload.content || "").slice(0, 100)}`,
impactSummary: `Will ${input.actionType} on ${account.displayName || account.accountName}'s ${account.platform} account. Content will be publicly visible.`,
});
const actionId = db.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "approval_required",
approvalId,
});
db.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_pending_approval",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform} requires approval`,
});
onDomainChanged();
server.json(res, 202, { id: actionId, status: "approval_required", approvalId, message: "Action requires Board approval before execution" });
return;
}
const actionId = db.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "queued",
});
db.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_queued",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform}/@${account.accountName}`,
});
onDomainChanged();
const onBrowserAction = server.onBrowserAction_;
if (onBrowserAction) {
onBrowserAction(actionId, companyId, input.socialAccountId, input.actionType, input.payload as Record<string, unknown>);
}
server.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" });
});
server.addRoute("GET", "/api/companies/:companyId/social-accounts", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const accounts = db.listSocialAccounts(params.companyId!);
const sanitized = accounts.map(a => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
profileUrl: a.profileUrl,
status: a.status,
requireApproval: a.requireApproval,
}));
server.json(res, 200, sanitized);
});
server.addRoute("POST", "/api/companies/:companyId/social/draft", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = socialDraftSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid draft", details: parsed.error.issues });
return;
}
const account = db.getSocialAccount(parsed.data.socialAccountId, companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
}
const contentPreview = parsed.data.content.slice(0, 120) + (parsed.data.content.length > 120 ? "..." : "");
const approvalId = db.requestApproval({
companyId,
requestedByAgentId: token.agentId,
type: "social_post",
payloadSummary: contentPreview,
impactSummary: `Post to ${account.platform} via @${account.accountName}`,
payloadJson: JSON.stringify({
socialAccountId: parsed.data.socialAccountId,
platform: account.platform,
content: parsed.data.content,
mediaUrls: parsed.data.mediaUrls,
scheduledAt: parsed.data.scheduledAt ?? null,
}),
});
onDomainChanged();
server.json(res, 201, { approvalId, status: "pending_approval" });
});
server.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const socialAccountId = url.searchParams.get("socialAccountId") ?? undefined;
const status = url.searchParams.get("status") ?? undefined;
const limit = Number(url.searchParams.get("limit") || 50);
const actions = db.listBrowserActions(params.companyId!, { socialAccountId, status, limit });
server.json(res, 200, actions);
});
server.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const participantAgentIds = data.participantAgentIds ? String(data.participantAgentIds) : "[]";
try {
const parsedParticipants = JSON.parse(participantAgentIds) as string[];
for (const agentId of parsedParticipants) {
if (typeof agentId !== "string" || !db.belongsToCompany("agents", agentId, token.companyId)) {
server.json(res, 400, { error: "Meeting participants must belong to the current company" });
return;
}
}
} catch {
server.json(res, 400, { error: "participantAgentIds must be a JSON array of agent ids" });
return;
}
const id = db.saveMeeting({
companyId: token.companyId,
type: String(data.type ?? "department_sync"),
title: String(data.title ?? "Meeting"),
organizerAgentId: token.agentId,
participantAgentIds,
scheduledAt: String(data.scheduledAt ?? new Date().toISOString()),
durationMinutes: Number(data.durationMinutes ?? 30),
agendaJson: data.agendaJson ? String(data.agendaJson) : "[]",
status: "scheduled",
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const meetings = snapshot.meetings.filter(m => m.companyId === token.companyId);
server.json(res, 200, meetings);
});
server.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => {
const snapshot = db.listSnapshot();
const existing = snapshot.meetings.find(m => m.id === params.meetingId && m.companyId === token.companyId);
if (!existing) {
server.json(res, 404, { error: "Meeting not found" });
return;
}
const data = body as Record<string, unknown>;
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
if (data.actionItemsJson) {
try {
actionItems = JSON.parse(String(data.actionItemsJson)) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>;
} catch {
server.json(res, 400, { error: "actionItemsJson must be valid JSON" });
return;
}
for (const item of actionItems) {
if (item.assigneeAgentId && !db.belongsToCompany("agents", item.assigneeAgentId, token.companyId)) {
server.json(res, 400, { error: "Meeting action item assignees must belong to the current company" });
return;
}
}
}
db.saveMeeting({
id: params.meetingId,
companyId: token.companyId,
type: data.type ? String(data.type) : "department_sync",
title: data.title ? String(data.title) : "Meeting",
notesJson: data.notesJson ? String(data.notesJson) : undefined,
decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined,
actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined,
status: data.status ? String(data.status) : undefined,
scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(),
});
onDomainChanged();
// When action items are added, create tasks for them automatically
if (actionItems.length > 0) {
for (const item of actionItems) {
if (item.title && typeof item.title === "string") {
const meetingTaskValidation = validateTaskCreation(
{ title: item.title, description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}` },
false,
);
if (!meetingTaskValidation.valid) {
// Log but skip invalid meeting action items rather than failing the whole request
continue;
}
db.saveTask({
companyId: token.companyId,
title: item.title,
description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}`,
assigneeAgentId: item.assigneeAgentId ?? null,
status: item.assigneeAgentId ? "todo" : "backlog",
priority: "medium",
});
if (item.assigneeAgentId) {
onWakeAgent(item.assigneeAgentId, token.companyId, "assignment");
}
}
}
}
server.json(res, 200, { updated: true });
});
server.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const id = db.saveKnowledgeEntry({
companyId: token.companyId,
category: String(data.category ?? "lesson_learned"),
topic: String(data.topic ?? ""),
content: String(data.content ?? ""),
authorAgentId: token.agentId,
importance: String(data.importance ?? "medium"),
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null,
referencedEntityId: data.referencedEntityId ? String(data.referencedEntityId) : null,
sourceRunId: token.runId ?? null,
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId);
server.json(res, 200, entries);
});
// ── Skill REST API endpoints ──────────────────────────────────────
server.addRoute("GET", "/api/companies/:companyId/skills", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const skills = db.listCompanySkills(companyId);
server.json(res, 200, skills.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
description: s.description,
sourceType: s.sourceType,
trustLevel: s.trustLevel,
createdAt: s.createdAt,
})));
});
server.addRoute("GET", "/api/skills/:skillId", (_req, res, params, token) => {
const skill = db.getCompanySkill(token.companyId, params.skillId!);
if (!skill) {
server.json(res, 404, { error: "Skill not found" });
return;
}
server.json(res, 200, skill);
});
}
+92
View File
@@ -0,0 +1,92 @@
import type { TokenPayload } from "../agent-auth";
import type { AgentApiServer } from "./server-core";
const DEFAULT_EVENT_LIMIT = 500;
const MAX_EVENT_LIMIT = 5_000;
const EVENT_POLL_INTERVAL_MS = 500;
const SSE_HEARTBEAT_MS = 15_000;
function parsePositiveInt(value: string | null, fallback: number) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
function resolveAuthorizedRun(server: AgentApiServer, token: TokenPayload, runId: string) {
const runState = server.db_.getRunAuthState(runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
return null;
}
return runState;
}
function writeSse(res: Parameters<AgentApiServer["json"]>[0], event: string, data: unknown) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export function registerRunRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/runs/:runId/events", (req, res, params, token) => {
const runId = params.runId!;
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const offset = parsePositiveInt(url.searchParams.get("offset"), 0);
const limit = Math.min(parsePositiveInt(url.searchParams.get("limit"), DEFAULT_EVENT_LIMIT), MAX_EVENT_LIMIT);
const events = server.db_.listRunEvents(runId, token.companyId, { offset, limit });
server.json(res, 200, { events, nextOffset: offset + events.length });
});
server.addRoute("GET", "/api/runs/:runId/events/stream", (req, res, params, token) => {
const runId = params.runId!;
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
let cursor = parsePositiveInt(url.searchParams.get("after"), 0);
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders();
writeSse(res, "connected", { runId, after: cursor });
const flushPendingEvents = () => {
const events = server.db_
.listRunEvents(runId, token.companyId, { offset: 0, limit: MAX_EVENT_LIMIT })
.filter((event) => event.seq > cursor);
for (const event of events) {
writeSse(res, "run-event", event);
cursor = event.seq;
}
};
flushPendingEvents();
const pollTimer = setInterval(flushPendingEvents, EVENT_POLL_INTERVAL_MS);
const heartbeatTimer = setInterval(() => {
res.write(":keepalive\n\n");
}, SSE_HEARTBEAT_MS);
const cleanup = () => {
clearInterval(pollTimer);
clearInterval(heartbeatTimer);
};
req.on("close", cleanup);
req.on("error", cleanup);
res.on("close", cleanup);
res.on("error", cleanup);
});
}
+370
View File
@@ -0,0 +1,370 @@
import { z } from "zod";
import { taskInputSchema } from "@shared/contracts";
import { canAssignTask, canUpdateTask } from "../agent-permissions";
import { inferTaskType } from "../connector-matching";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
const taskUpdateSchema = z.object({
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(),
comment: z.string().min(1).max(4000).optional(),
title: z.string().min(2).max(160).optional(),
description: z.string().max(4000).optional(),
assigneeAgentId: z.string().uuid().nullable().optional(),
priority: z.enum(["critical", "high", "medium", "low"]).optional(),
});
const checkoutSchema = z.object({
agentId: z.string().uuid().optional(),
});
export function registerTaskRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("GET", "/api/companies/:companyId/issues", (req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const assigneeAgentId = url.searchParams.get("assigneeAgentId");
const statusFilter = url.searchParams.get("status");
const statuses = statusFilter ? statusFilter.split(",") : ["todo", "in_progress", "blocked"];
if (assigneeAgentId) {
const tasks = db.listAgentTasks(assigneeAgentId, companyId, statuses);
server.json(res, 200, tasks);
} else {
const snapshot = db.listSnapshot();
const tasks = snapshot.tasks.filter((t) => t.companyId === companyId && statuses.includes(t.status));
server.json(res, 200, tasks);
}
});
server.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
server.json(res, 200, task);
} catch {
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("POST", "/api/issues/:issueId/checkout", (_req, res, params, token, body) => {
const parsedBody = checkoutSchema.safeParse(body);
if (!parsedBody.success) {
server.json(res, 400, { error: "Invalid checkout payload" });
return;
}
const agentId = parsedBody.data.agentId ?? token.agentId;
if (agentId !== token.agentId) {
server.json(res, 403, { error: "Agents can only checkout work for themselves" });
return;
}
let task;
try {
task = db.getTask(params.issueId!);
} catch {
server.json(res, 404, { error: "Task not found" });
return;
}
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
if (task.assigneeAgentId && !canUpdateTask(db, token.companyId, token.agentId, task)) {
server.json(res, 403, { error: "Current agent is not allowed to checkout this task" });
return;
}
const previousStatus = task.status;
const success = db.checkoutTask(params.issueId!, token.runId);
if (!success) {
server.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
return;
}
onDomainChanged();
// Checkout changes status from todo/backlog to in_progress — use actual previous status
callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress");
server.json(res, 200, { ok: true });
});
server.addRoute("PATCH", "/api/issues/:issueId", (_req, res, params, token, body) => {
const parsedUpdate = taskUpdateSchema.safeParse(body);
if (!parsedUpdate.success) {
server.json(res, 400, { error: "Invalid task update payload" });
return;
}
const update = parsedUpdate.data;
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
if (!canUpdateTask(db, token.companyId, token.agentId, task)) {
server.json(res, 403, { error: "Current agent is not allowed to update this task" });
return;
}
const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId;
if (!canAssignTask(db, token.companyId, token.agentId, newAssignee ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
// Deliverable review gate: intercept agent trying to advance a flagged task to "done".
// This MUST succeed — silently skipping review would let unreviewed work advance.
if (update.status === "done" && task.requiresUserReview) {
const importantTypes = ["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"];
const placeholders = importantTypes.map(() => "?").join(", ");
const docs = db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders})
and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?))
order by created_at desc limit 10`,
task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id,
);
const reviewerAgent = db.getAgent(token.agentId);
const docTitles = docs.map((d) => d.title).join(", ");
db.requestApproval({
companyId: task.companyId,
relatedTaskId: task.id,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitles || task.title}`.slice(0, 280),
impactSummary: `${reviewerAgent.name} reviewed and approved. Awaiting your final review.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
});
db.setTaskRequiresUserReview(task.id, false);
if (task.assigneeAgentId) {
const prodAgent = db.getAgent(task.assigneeAgentId);
if (prodAgent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, false, prodAgent.heartbeatIntervalSec || 120);
}
}
db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: null, authorName: "System", body: "Deliverable submitted for board review." });
update.status = "in_review";
}
// Validate status transition
const effectiveStatus = update.status ?? task.status;
const VALID_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
if (update.status && !VALID_STATUSES.has(update.status)) {
server.json(res, 400, { error: `Invalid task status: ${update.status}` });
return;
}
// Apply status side-effect timestamps FIRST via advanceTaskStatus
// so started_at / completed_at / cancelled_at are set correctly.
// advanceTaskStatus checks previousStatus !== newStatus internally,
// so it must run before saveTask writes the new status.
if (update.status && update.status !== task.status) {
db.advanceTaskStatus(task.id, update.status);
}
const input: Record<string, unknown> = {
id: task.id,
companyId: task.companyId,
title: update.title ?? task.title,
description: update.description ?? task.description,
status: effectiveStatus,
priority: update.priority ?? task.priority,
projectId: task.projectId,
goalId: task.goalId,
parentId: task.parentId,
assigneeAgentId: newAssignee,
workspaceId: task.workspaceId,
};
db.saveTask(input as Parameters<typeof db.saveTask>[0]);
if (update.comment) {
const agent = db.getAgent(token.agentId);
db.addComment({
companyId: task.companyId,
taskId: task.id,
authorAgentId: token.agentId,
authorName: agent.name,
body: update.comment,
});
}
db.addActivity({
companyId: task.companyId,
actor: token.agentId,
action: `task.${update.status ? "status_changed" : "updated"}`,
entityType: "task",
entityId: task.id,
detail: update.comment ?? `Status → ${update.status ?? task.status}`,
});
onDomainChanged();
// Fire automation rules for status changes
const previousStatus = task.status;
const newStatus = update.status ?? task.status;
if (update.status && previousStatus !== newStatus) {
callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, newStatus);
}
if (update.status && ["done", "in_review"].includes(update.status)) {
if (task.parentId) {
try {
const parentTask = db.getTask(task.parentId);
if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) {
onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed");
}
} catch { /* parent task may have been deleted */ }
} else {
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0) {
onWakeAgent(chain[0].id, task.companyId, "subtask_completed");
}
}
}
if (update.status === "blocked") {
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0) {
onWakeAgent(chain[0].id, task.companyId, "report_blocked");
}
}
if (update.status && ["in_progress", "todo"].includes(update.status) &&
task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "assignment");
}
if (update.comment) {
// Fire comment automation rules
callbacks.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
wokenForComment.add(task.assigneeAgentId);
}
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
onWakeAgent(chain[0].id, task.companyId, "comment");
}
}
// Wake new assignee when task is reassigned
if (newAssignee && newAssignee !== task.assigneeAgentId && newAssignee !== token.agentId) {
onWakeAgent(newAssignee, task.companyId, "assignment");
}
server.json(res, 200, { ok: true });
} catch (error) {
if (error instanceof Error && error.message.includes("require an assignee")) {
server.json(res, 422, { error: error.message });
return;
}
if (error instanceof Error && error.message.includes("not found")) {
server.json(res, 404, { error: error.message });
return;
}
server.json(res, 500, { error: error instanceof Error ? error.message : "Task update failed" });
}
});
server.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = taskInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid task payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.assigneeAgentId, table: "agents", label: "assignee" },
{ id: input.parentId, table: "tasks", label: "parent task" },
{ id: input.projectId, table: "projects", label: "project" },
{ id: input.goalId, table: "goals", label: "goal" },
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
])) {
return;
}
if (!canAssignTask(db, companyId, token.agentId, input.assigneeAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
const isHeartbeat = input.title.startsWith("[Heartbeat]");
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat);
if (!taskValidation.valid) {
server.json(res, 422, { error: taskValidation.errors.join("; ") });
return;
}
let id: string;
try {
id = db.saveTask({
companyId,
title: input.title,
description: input.description ?? "",
assigneeAgentId: input.assigneeAgentId ?? null,
parentId: input.parentId ?? null,
projectId: input.projectId ?? null,
goalId: input.goalId ?? null,
priority: input.priority,
status: input.status,
workspaceId: input.workspaceId ?? null,
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Task could not be saved" });
return;
}
// Auto-infer task_type from assignee's department
if (input.assigneeAgentId) {
const assignee = db.getAgent(input.assigneeAgentId);
const inferredType = inferTaskType(assignee.department);
db.setTaskType(id, inferredType);
}
db.addActivity({
companyId,
actor: token.agentId,
action: "task.created",
entityType: "task",
entityId: id,
detail: input.title,
});
onDomainChanged();
// Fire the full task creation event chain (automation rules, auto-assign, etc.)
callbacks.onTaskCreated(id, companyId);
// Also wake the assignee directly if specified
if (input.assigneeAgentId && input.assigneeAgentId !== token.agentId) {
onWakeAgent(input.assigneeAgentId, companyId, "assignment");
}
// If no assignee specified, auto-assign
if (!input.assigneeAgentId) {
const assignedId = callbacks.onAutoAssignTask(id, companyId);
if (assignedId && assignedId !== token.agentId) {
onWakeAgent(assignedId, companyId, "assignment");
}
}
server.json(res, 201, { id });
});
}
+289
View File
@@ -0,0 +1,289 @@
import { z } from "zod";
import { goalInputSchema, projectInputSchema } from "@shared/contracts";
import { canManageGoal, canManageProject } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
export function registerWorkGraphRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/projects", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = projectInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid project payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.goalId, table: "goals", label: "goal" },
{ id: input.leadAgentId, table: "agents", label: "lead agent" },
])) {
return;
}
if (!canManageProject(db, companyId, token.agentId, input.leadAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to create or assign this project" });
return;
}
const id = db.saveProject({
companyId,
name: input.name,
description: input.description ?? "",
goalId: input.goalId ?? null,
leadAgentId: input.leadAgentId ?? null,
status: (input.status as "planned") ?? "planned",
targetDate: input.targetDate ?? null,
});
db.addActivity({
companyId,
actor: token.agentId,
action: "project.created",
entityType: "project",
entityId: id,
detail: input.name,
});
onDomainChanged();
if (input.leadAgentId && input.leadAgentId !== token.agentId) {
onWakeAgent(input.leadAgentId, companyId, "assignment");
}
server.json(res, 201, { id });
});
server.addRoute("PATCH", "/api/projects/:projectId", (_req, res, params, token, body) => {
const projectId = params.projectId!;
const updateSchema = z.object({
name: z.string().min(2).max(120).optional(),
description: z.string().max(2000).optional(),
status: z.enum(["planned", "active", "completed", "cancelled"]).optional(),
leadAgentId: z.string().uuid().optional(),
goalId: z.string().uuid().nullable().optional(),
targetDate: z.string().nullable().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.projects.find(p => p.id === projectId && p.companyId === token.companyId);
if (!existing) {
server.json(res, 404, { error: "Project not found" });
return;
}
const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null;
if (!canManageProject(db, token.companyId, token.agentId, nextLeadAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to update this project" });
return;
}
try {
db.saveProject({
id: projectId,
companyId: token.companyId,
name: parsed.data.name ?? existing.name,
description: parsed.data.description ?? existing.description,
status: parsed.data.status ?? existing.status,
leadAgentId: nextLeadAgentId,
goalId: parsed.data.goalId !== undefined ? parsed.data.goalId : (existing.goalId ?? null),
targetDate: parsed.data.targetDate !== undefined ? parsed.data.targetDate : (existing.targetDate ?? null),
});
onDomainChanged();
server.json(res, 200, { ok: true });
} catch {
server.json(res, 500, { error: "Failed to update project" });
}
});
server.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = goalInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid goal payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.parentId, table: "goals", label: "parent goal" },
{ id: input.ownerAgentId, table: "agents", label: "owner agent" },
])) {
return;
}
if (!canManageGoal(db, companyId, token.agentId, input.ownerAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to create this goal" });
return;
}
const id = db.saveGoal(input);
db.addActivity({
companyId,
actor: token.agentId,
action: "goal.created",
entityType: "goal",
entityId: id,
detail: input.title,
});
onDomainChanged();
if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") {
onWakeAgent(input.ownerAgentId, companyId, "goal_activated");
}
server.json(res, 201, { id });
});
server.addRoute("PATCH", "/api/goals/:goalId", (_req, res, params, token, body) => {
const goalId = params.goalId!;
const updateSchema = z.object({
title: z.string().min(2).max(120).optional(),
description: z.string().max(2000).optional(),
status: z.enum(["planned", "active", "achieved", "cancelled"]).optional(),
ownerAgentId: z.string().uuid().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.goals.find(g => g.id === goalId && g.companyId === token.companyId);
if (!existing) {
server.json(res, 404, { error: "Goal not found" });
return;
}
const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null;
if (!canManageGoal(db, token.companyId, token.agentId, nextOwnerAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to update this goal" });
return;
}
try {
db.saveGoal({
id: goalId,
companyId: token.companyId,
title: parsed.data.title ?? existing.title,
description: parsed.data.description ?? existing.description,
status: parsed.data.status ?? existing.status,
ownerAgentId: nextOwnerAgentId,
});
onDomainChanged();
server.json(res, 200, { ok: true });
} catch {
server.json(res, 500, { error: "Failed to update goal" });
}
});
server.addRoute("GET", "/api/companies/:companyId/goals", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const goals = snapshot.goals.filter((g) => g.companyId === params.companyId);
server.json(res, 200, goals);
});
server.addRoute("GET", "/api/companies/:companyId/projects", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const projects = snapshot.projects.filter((p) => p.companyId === params.companyId);
server.json(res, 200, projects);
});
server.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const sprints = snapshot.sprints.filter(s => s.companyId === companyId);
server.json(res, 200, sprints);
});
server.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
try {
const id = db.saveSprint({
companyId,
name: String(data.name ?? "Sprint"),
goal: String(data.goal ?? ""),
startDate: String(data.startDate ?? new Date().toISOString()),
endDate: String(data.endDate ?? new Date(Date.now() + 14 * 86400000).toISOString()),
status: String(data.status ?? "planning") as "planning" | "active" | "review" | "completed" | "cancelled",
retrospectiveNotes: String(data.retrospectiveNotes ?? ""),
velocityPoints: Number(data.velocityPoints ?? 0),
completedPoints: Number(data.completedPoints ?? 0),
});
onDomainChanged();
server.json(res, 201, { id });
} catch {
server.json(res, 400, { error: "Invalid sprint data" });
}
});
server.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const agents = snapshot.agents.filter((a) => a.companyId === companyId);
const tasks = snapshot.tasks.filter((t) => t.companyId === companyId);
const recentActivity = snapshot.activity
.filter((a) => a.companyId === companyId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 20);
const tasksByStatus: Record<string, number> = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
}
server.json(res, 200, {
totalAgents: agents.length,
activeAgents: agents.filter((a) => a.status === "active" || a.status === "running").length,
totalTasks: tasks.length,
tasksByStatus,
recentActivity: recentActivity.map((a) => ({
id: a.id,
actor: a.actor,
action: a.action,
entityType: a.entityType,
entityId: a.entityId,
detail: a.detail,
createdAt: a.createdAt,
})),
});
});
}
+276
View File
@@ -0,0 +1,276 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import log from "electron-log/main.js";
import { validateAgentToken, type TokenPayload } from "../agent-auth";
import type { AppDatabase } from "../database";
import type { DesktopEvent, SectionId } from "@shared/types";
/**
* Decode an HTTP request body to a UTF-8 string, handling the common Windows
* encoding issue where Chinese/CJK characters arrive in GBK (CP936) instead
* of UTF-8. On Chinese Windows the system ANSI code page is GBK; tools like
* curl encode command-line arguments using that code page when stdout is piped
* (chcp 65001 does not help because there is no real console). We try strict
* UTF-8 first and, only if the bytes are invalid UTF-8, fall back to GBK.
*/
export function decodeBodyToUtf8(raw: Buffer): string {
if (raw.length === 0) return "";
try {
return new TextDecoder("utf-8", { fatal: true }).decode(raw);
} catch {
// Bytes are not valid UTF-8 — most likely GBK from Chinese Windows.
try {
return new TextDecoder("gbk").decode(raw);
} catch {
// Last resort: lossy UTF-8 (replaces invalid bytes with U+FFFD).
return raw.toString("utf8");
}
}
}
export type RequestHandler = (
req: IncomingMessage,
res: ServerResponse,
params: Record<string, string>,
token: TokenPayload,
body: unknown,
) => void | Promise<void>;
export interface Route {
method: string;
pattern: RegExp;
paramNames: string[];
handler: RequestHandler;
}
export interface ApiServerEventCallbacks {
onDomainChanged: () => void;
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
onTaskCreated: (taskId: string, companyId: string) => void;
onTaskStatusChanged: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void;
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null, documentId: string, documentTitle: string, documentType: string, taskId: string | null) => void;
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
emitEvent: (event: DesktopEvent) => void;
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
}
export class AgentApiServer {
private routes: Route[] = [];
private server: ReturnType<typeof createServer> | null = null;
private port = 0;
private db: AppDatabase;
private onDomainChangedCb: () => void;
private onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null;
private emitEventCb: (event: DesktopEvent) => void;
private callbacks: ApiServerEventCallbacks;
private allowedOrigins: Set<string>;
private onBrowserActionCb: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
constructor(
db: AppDatabase,
onDomainChanged: () => void,
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null,
callbacks?: Partial<ApiServerEventCallbacks>,
allowedOrigins: Iterable<string> = ["app://-"],
) {
this.db = db;
this.onDomainChangedCb = onDomainChanged;
this.onWakeAgentCb = onWakeAgent;
this.emitEventCb = callbacks?.emitEvent ?? (() => {});
this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean));
this.callbacks = {
onDomainChanged,
onWakeAgent,
onTaskCreated: callbacks?.onTaskCreated ?? (() => {}),
onTaskStatusChanged: callbacks?.onTaskStatusChanged ?? (() => {}),
onCommentPosted: callbacks?.onCommentPosted ?? (() => {}),
onApprovalCreated: callbacks?.onApprovalCreated ?? (() => {}),
onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}),
onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}),
onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null),
onHumanMessage: callbacks?.onHumanMessage ?? (() => {}),
emitEvent: callbacks?.emitEvent ?? (() => {}),
notify: callbacks?.notify ?? (() => {}),
};
}
setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) {
this.onBrowserActionCb = handler;
}
getPort(): number {
return this.port;
}
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => void this.handleRequest(req, res));
this.server.listen(0, "127.0.0.1", () => {
const addr = this.server!.address();
this.port = typeof addr === "object" && addr ? addr.port : 0;
log.info(`[api] Agent API server listening on http://127.0.0.1:${this.port}`);
resolve(this.port);
});
this.server.on("error", reject);
});
}
stop() {
this.server?.close();
this.server = null;
}
addRoute(method: string, path: string, handler: RequestHandler) {
const paramNames: string[] = [];
const patternStr = path.replace(/:(\w+)/g, (_match, name: string) => {
paramNames.push(name);
return "([^/]+)";
});
this.routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler });
}
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
const origin = req.headers.origin;
if (origin) {
if (!this.allowedOrigins.has(origin)) {
this.json(res, 403, { error: "Origin not allowed" });
return;
}
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Agent-Company-Run-Id");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
const pathname = url.pathname;
const method = (req.method ?? "GET").toUpperCase();
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
this.json(res, 401, { error: "Missing or invalid Authorization header" });
return;
}
const token = validateAgentToken(authHeader.slice(7));
if (!token) {
this.json(res, 401, { error: "Invalid or expired token" });
return;
}
const runHeader = req.headers["x-agent-company-run-id"];
const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader;
if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) {
this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" });
return;
}
const runState = this.db.getRunAuthState(token.runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
this.json(res, 403, { error: "Token references an unknown or mismatched run" });
return;
}
if (!["queued", "running"].includes(runState.status)) {
this.json(res, 401, { error: "Run token is no longer active" });
return;
}
for (const route of this.routes) {
if (route.method !== method) continue;
const match = pathname.match(route.pattern);
if (!match) continue;
const params: Record<string, string> = {};
route.paramNames.forEach((name, i) => {
params[name] = match[i + 1]!;
});
let body: unknown = null;
if (["POST", "PATCH", "PUT"].includes(method)) {
try {
body = await this.readBody(req);
} catch (bodyErr) {
if (bodyErr instanceof Error && bodyErr.message === "Request body too large") {
this.json(res, 413, { error: "Request body too large (max 1 MB)" });
} else {
this.json(res, 400, { error: "Invalid JSON body" });
}
return;
}
}
try {
await route.handler(req, res, params, token, body);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[api] ${method} ${pathname}: ${message}`);
this.json(res, 500, { error: "Internal server error" });
}
return;
}
this.json(res, 404, { error: "Not found" });
}
private readBody(req: IncomingMessage): Promise<unknown> {
const MAX_BODY_BYTES = 1_048_576; // 1 MB
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let bytes = 0;
req.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_BODY_BYTES) {
req.destroy();
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks);
const data = decodeBodyToUtf8(raw);
resolve(data ? JSON.parse(data) : null);
}
catch { reject(new Error("Invalid JSON")); }
});
req.on("error", reject);
});
}
json(res: ServerResponse, status: number, data: unknown) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
companyRefError(
res: ServerResponse,
companyId: string,
refs: Array<{ id: string | null | undefined; table: "agents" | "goals" | "projects" | "tasks" | "workspaces" | "approvals"; label: string }>,
) {
const invalid = refs.find((ref) => ref.id && !this.db.belongsToCompany(ref.table, ref.id, companyId));
if (!invalid) return false;
this.json(res, 422, { error: `${invalid.label} does not belong to the current company` });
return true;
}
// Accessors for route registrars
get db_(): AppDatabase { return this.db; }
get callbacks_(): ApiServerEventCallbacks { return this.callbacks; }
get onDomainChanged_(): () => void { return this.onDomainChangedCb; }
get onWakeAgent_(): (agentId: string, companyId: string, trigger: string) => string | null { return this.onWakeAgentCb; }
get emitEvent_(): (event: DesktopEvent) => void { return this.emitEventCb; }
get onBrowserAction_(): ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null {
return this.onBrowserActionCb;
}
get port_(): number { return this.port; }
}
+2 -19
View File
@@ -1,29 +1,12 @@
import type { AppDatabase } from "./database";
const AUTO_APPROVAL_TYPES = new Set(["hire_agent", "approve_ceo_strategy"]);
import { shouldAutoApprove } from "./execution-policy";
export function shouldAutoApproveApproval(
db: AppDatabase,
companyId: string,
approvalType: string,
): boolean {
if (!AUTO_APPROVAL_TYPES.has(approvalType)) {
return false;
}
const company = db
.listSnapshot()
.companies
.find((c) => c.id === companyId);
if (!company?.autoApproveHires) return false;
// When reviewDeliverables is ON, CEO strategy needs human review — don't auto-approve
if (approvalType === "approve_ceo_strategy" && company.reviewDeliverables) {
return false;
}
return true;
return shouldAutoApprove(db, companyId, approvalType);
}
export function resolveApprovalDecision({
File diff suppressed because it is too large. Load diff
+357
View File
@@ -0,0 +1,357 @@
import type { AppDatabase } from "../database";
import type { AutomationAction, DesktopEvent } from "@shared/types";
import { dispatchAgentMessage } from "../message-dispatch";
import { validateTaskCreation } from "../task-quality-gates";
import type { AutomationEventContext } from "./types";
export interface ActionDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
}
export interface ActionCallbacks {
handleTaskCreated: (taskId: string, companyId: string) => void;
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
}
export function executeAutomationAction(
deps: ActionDeps,
callbacks: ActionCallbacks,
rule: {
id?: string;
name?: string;
action: string;
actionConfigJson: string;
targetDepartment: string | null;
trigger?: string;
},
ctx: AutomationEventContext,
): void {
const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps;
const { handleTaskCreated, handleApprovalResolved, handleTaskStatusChange } = callbacks;
const config = (() => {
try {
return JSON.parse(rule.actionConfigJson) as Record<string, unknown>;
} catch {
return {};
}
})();
switch (rule.action as AutomationAction) {
case "assign_task": {
if (ctx.taskId) {
const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, ctx.companyId, "assignment");
}
} else if (ctx.agentId) {
const snapshot = db.listSnapshot();
const unassigned = snapshot.tasks
.filter((task) => task.companyId === ctx.companyId && !task.assigneeAgentId && ["backlog", "todo"].includes(task.status))
.sort((a, b) => {
const priorityScore = { critical: 4, high: 3, medium: 2, low: 1 } as Record<string, number>;
return (priorityScore[b.priority] ?? 0) - (priorityScore[a.priority] ?? 0);
});
if (unassigned.length > 0) {
const task = unassigned[0]!;
db.saveTask({
id: task.id,
companyId: task.companyId,
projectId: task.projectId,
goalId: task.goalId,
parentId: task.parentId,
title: task.title,
description: task.description,
assigneeAgentId: ctx.agentId,
workspaceId: task.workspaceId,
priority: task.priority,
status: "todo",
});
wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment");
logger.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`);
}
}
break;
}
case "create_task": {
const title = (config.title as string) ?? "[Auto] Task from automation rule";
const description = (config.description as string) ?? "";
const priority = (config.priority as string) ?? "medium";
const assigneeAgentId = (config.assigneeAgentId as string) ?? null;
const isHeartbeat = title.startsWith("[Heartbeat]");
const taskValidation = validateTaskCreation({ title, description }, isHeartbeat);
if (!taskValidation.valid) {
logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`);
break;
}
const taskId = db.saveTask({
companyId: ctx.companyId,
projectId: ctx.projectId ?? (config.projectId as string) ?? null,
title,
description,
assigneeAgentId,
priority,
status: assigneeAgentId ? "todo" : "backlog",
});
handleTaskCreated(taskId, ctx.companyId);
logger.info(`[automation] Created task: ${title} (${taskId})`);
break;
}
case "notify_agent":
case "send_message": {
const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null;
const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId;
if (!fromAgentId) break;
const defaultChannel = toAgentId ? "direct" : "company";
const requestedChannel = (config.channel as string) ?? defaultChannel;
const channel = ["direct", "department", "company", "project", "incident"].includes(requestedChannel)
? requestedChannel
: defaultChannel;
let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null;
if (channel === "department" && !channelTargetId && fromAgentId) {
try {
const sender = db.getAgent(fromAgentId);
channelTargetId = sender.department ?? null;
} catch {
channelTargetId = null;
}
}
const dispatchResult = dispatchAgentMessage(
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
{
companyId: ctx.companyId,
fromAgentId,
toAgentId: channel === "direct" ? toAgentId : null,
channel: channel as "direct" | "department" | "company" | "project" | "incident",
channelTargetId,
subject: (config.subject as string) ?? "Automation notification",
body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`,
priority: (config.priority as "urgent" | "normal" | "low") ?? "normal",
},
"message",
);
if (!dispatchResult.ok) {
logger.warn(
`[automation] Failed to dispatch message for rule ${rule.name} (${rule.id}): ${dispatchResult.error.code} ${dispatchResult.error.message}`,
);
}
break;
}
case "trigger_heartbeat": {
let targetAgentId = (config.agentId as string) ?? null;
if (!targetAgentId && ctx.agentId) {
const triggerType = rule.trigger ?? "";
if (triggerType === "run_completed" || triggerType === "run_failed") {
const manager = db.getManagerForAgent(ctx.agentId);
targetAgentId = manager?.id ?? ctx.agentId;
} else {
targetAgentId = ctx.agentId;
}
}
if (targetAgentId) {
// Derive a descriptive wake trigger from the automation context
const triggerType = rule.trigger ?? "";
const wakeTrigger =
triggerType === "task_status_changed" && ctx.previousStatus === "in_review" ? "comment"
: triggerType === "run_completed" ? "subtask_completed"
: triggerType === "run_failed" ? "report_failed"
: triggerType === "hire_approved" ? "assignment"
: triggerType === "agent_idle" ? "timer"
: "manual";
wakeAgentIfPossible(targetAgentId, ctx.companyId, wakeTrigger);
}
break;
}
case "create_approval": {
const approvalType = (config.approvalType as string) ?? "document_review";
db.requestApproval({
companyId: ctx.companyId,
relatedTaskId: ctx.taskId ?? null,
requestedByAgentId: ctx.agentId ?? null,
type: approvalType,
payloadSummary: (config.summary as string) ?? "Auto-generated approval request",
impactSummary: (config.impact as string) ?? "Triggered by automation rule",
});
break;
}
case "update_task_status": {
if (!ctx.taskId) break;
const newStatus = (config.status as string) ?? "todo";
const previousTask = db.getTask(ctx.taskId);
db.advanceTaskStatus(ctx.taskId, newStatus, "Status changed by automation rule");
publishDomainChanged();
if (previousTask.status !== newStatus && rule.trigger !== "task_status_changed") {
handleTaskStatusChange(ctx.taskId, ctx.companyId, previousTask.status, newStatus);
}
break;
}
case "escalate_to_manager": {
const targetAgentId = ctx.agentId;
if (!targetAgentId) break;
let agentName = "an agent";
try {
agentName = db.getAgent(targetAgentId).name;
} catch {
// Fall back to the generic label when the agent was deleted mid-flight.
}
let taskTitle = ctx.taskId ?? "unknown";
try {
if (ctx.taskId) taskTitle = db.getTask(ctx.taskId).title;
} catch {
// Keep the fallback title when the task no longer exists.
}
const manager = db.getManagerForAgent(targetAgentId);
const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`;
// Pick the most descriptive wake trigger based on the originating event
const wakeTrigger = ctx.newStatus === "in_review"
? "subtask_completed"
: ctx.newStatus === "blocked"
? "report_blocked"
: rule.trigger === "run_completed"
? "subtask_completed"
: rule.trigger === "run_failed"
? "report_failed"
: "report_blocked";
if (manager) {
const escalationResult = dispatchAgentMessage(
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
{
companyId: ctx.companyId,
fromAgentId: targetAgentId,
toAgentId: manager.id,
channel: "direct",
subject: `Escalation from ${agentName}: ${taskTitle}`,
body: escalationBody,
priority: "urgent",
},
wakeTrigger,
);
if (!escalationResult.ok) {
logger.warn(
`[automation] Failed to escalate "${taskTitle}" to manager: ${escalationResult.error.code} ${escalationResult.error.message}`,
);
}
} else {
// No manager found — agent is likely the CEO or top-level.
// Post as company-wide message so the board and other leaders see it.
const escalationResult = dispatchAgentMessage(
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
{
companyId: ctx.companyId,
fromAgentId: targetAgentId,
toAgentId: null,
channel: "company",
subject: `Escalation: ${taskTitle}`,
body: escalationBody,
priority: "urgent",
},
wakeTrigger,
);
if (!escalationResult.ok) {
logger.warn(
`[automation] Failed to escalate "${taskTitle}" to company channel: ${escalationResult.error.code} ${escalationResult.error.message}`,
);
}
}
break;
}
case "cross_department_notify": {
const targetDepartment = rule.targetDepartment ?? (config.department as string);
if (!targetDepartment) break;
const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment);
const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id;
if (senderAgentId) {
const dispatchResult = dispatchAgentMessage(
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
{
companyId: ctx.companyId,
fromAgentId: senderAgentId,
toAgentId: null,
channel: "department",
channelTargetId: targetDepartment,
subject: (config.subject as string) ?? "Cross-department notification",
body: (config.body as string) ?? "Automation triggered a cross-department alert.",
priority: "normal",
},
"message",
);
if (!dispatchResult.ok) {
logger.warn(
`[automation] Failed cross-department notification for ${targetDepartment}: ${dispatchResult.error.code} ${dispatchResult.error.message}`,
);
}
}
break;
}
case "schedule_meeting": {
const meetingType = (config.meetingType as string) ?? "department_sync";
const title = (config.title as string) ?? "Auto-scheduled meeting";
db.saveMeeting({
companyId: ctx.companyId,
type: meetingType,
title,
organizerAgentId: ctx.agentId ?? null,
participantAgentIds: (config.participantAgentIds as string) ?? "[]",
scheduledAt: new Date(Date.now() + 3600_000).toISOString(),
durationMinutes: (config.durationMinutes as number) ?? 30,
});
break;
}
case "create_document": {
const documentType = (config.documentType as string) ?? "status_report";
const title = (config.title as string) ?? "Auto-generated document";
db.saveDocument({
companyId: ctx.companyId,
type: documentType,
title,
content: (config.content as string) ?? "",
authorAgentId: ctx.agentId ?? null,
projectId: ctx.projectId ?? null,
});
break;
}
case "auto_approve": {
const snapshot = db.listSnapshot();
const pendingApprovals = snapshot.approvals.filter(
(approval) => approval.companyId === ctx.companyId && approval.state === "pending",
);
for (const approval of pendingApprovals.slice(0, 5)) {
db.decideApproval({
companyId: ctx.companyId,
approvalId: approval.id,
state: "approved",
decisionNote: "Auto-approved by automation rule.",
});
handleApprovalResolved(approval.id, ctx.companyId, "approved");
}
break;
}
case "reassign_task": {
if (!ctx.taskId) break;
const newAssignee = db.autoAssignTask(ctx.taskId, ctx.companyId);
if (newAssignee) {
wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment");
}
break;
}
case "run_task": {
const targetTaskId = (config.taskId as string) ?? ctx.taskId;
if (!targetTaskId) break;
const task = db.getTask(targetTaskId);
if (!task.assigneeAgentId) break;
wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment");
break;
}
default:
throw new Error(`Unknown automation action: ${rule.action}`);
}
}
@@ -0,0 +1,374 @@
import type { AppDatabase } from "../database";
import type { NotificationOptions } from "../notification-service";
import { extractApprovalDocumentIds } from "@shared/approval-documents";
import { notifyHireApproved } from "../hire-hook";
import { autoAssignSkillsToAgent } from "./automation-skills";
import type { AutomationEventContext } from "./types";
import { normalizeWorkflowSteps } from "./automation-workflow";
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
return locale === "zh" ? zh : en;
}
function recordReviewFeedbackKnowledge(
db: AppDatabase,
approval: {
companyId: string;
relatedTaskId: string | null;
requestedByAgentId: string | null;
decisionNote: string | null;
},
decision: string,
): void {
if (!approval.relatedTaskId) return;
if (decision === "approved") return;
const task = db.getTask(approval.relatedTaskId);
const feedback = approval.decisionNote?.trim() || "Board requested revisions before approval.";
const topic = `Review feedback: ${task.title}`;
const content = [
`Decision: ${decision}`,
`Feedback: ${feedback}`,
"Use this review guidance to improve future deliverables for similar work.",
].join("\n");
db.saveKnowledgeEntry({
companyId: approval.companyId,
category: "lesson_learned",
topic,
content,
authorAgentId: approval.requestedByAgentId ?? null,
importance: "high",
tagsJson: JSON.stringify(["review_feedback", decision]),
referencedEntityType: "task",
referencedEntityId: task.id,
});
}
export interface ApprovalHandlerDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
isQuitting: () => boolean;
}
export interface ApprovalHandlerCallbacks {
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
trackTimer: (timer: ReturnType<typeof setTimeout>) => void;
}
export function handleApprovalResolved(
deps: ApprovalHandlerDeps,
callbacks: ApprovalHandlerCallbacks,
approvalId: string,
companyId: string,
decision: string,
): void {
if (deps.isQuitting()) return;
const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible, dispatchBrowserAction } = deps;
const { dispatchAutomation, handleTaskStatusChange, runWorkflowPipeline, trackTimer } = callbacks;
const snapshot = db.listSnapshot();
const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
if (!approval) return;
dispatchAutomation("approval_resolved", {
companyId,
agentId: approval.requestedByAgentId ?? undefined,
taskId: approval.relatedTaskId ?? undefined,
});
if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) {
try {
const agent = db.getAgent(approval.relatedAgentId);
if (agent.status === "idle") {
db.addActivity({
companyId,
actor: "system",
action: "agent.hired",
entityType: "agent",
entityId: agent.id,
detail: `${agent.name} hire approved and activated.`,
});
const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id);
if (skillsAssigned > 0) {
db.addActivity({
companyId,
actor: "system",
action: "skills.auto_assigned",
entityType: "agent",
entityId: agent.id,
detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`,
});
}
dispatchAutomation("hire_approved", {
companyId,
agentId: agent.id,
});
wakeAgentIfPossible(agent.id, companyId, "assignment");
const ceo = db.getCeoForCompany(companyId);
if (ceo && ceo.id !== agent.id) {
wakeAgentIfPossible(ceo.id, companyId, "assignment");
}
publishDomainChanged();
const hireLocale = db.listSnapshot().locale;
notify({
title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"),
body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`),
urgency: "informational",
navigation: { section: "orgchart", entityId: agent.id },
batchKey: "agent_hired",
});
// Notify adapter that hire was approved (Paperclip hire-hook parity)
void notifyHireApproved(db, {
companyId,
agentId: agent.id,
source: "approval",
sourceId: approvalId,
approvedAt: new Date().toISOString(),
}).catch(() => {});
}
} catch {
// Hire follow-up is best-effort; approval state is already committed in storage.
}
}
if (approval.type === "deliverable_review") {
try {
// Update linked document statuses from payloadJson
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved"
? "approved"
: decision === "rejected" || decision === "revision_requested"
? "draft"
: "in_review";
for (const docId of documentIds) {
db.updateDocumentStatus(docId, companyId, docStatus);
}
if (approval.relatedTaskId) {
const task = db.getTask(approval.relatedTaskId);
if (task.assigneeAgentId) {
try {
const agent = db.getAgent(task.assigneeAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
} catch { /* agent lookup non-fatal */ }
}
if (decision === "approved") {
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
} else if (decision === "rejected") {
recordReviewFeedbackKnowledge(db, approval, decision);
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
} else if (decision === "revision_requested") {
db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
recordReviewFeedbackKnowledge(db, approval, decision);
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
}
} else if (approval.requestedByAgentId) {
// Document-only review (no task) — resume the producing agent
try {
const agent = db.getAgent(approval.requestedByAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
} catch { /* agent lookup non-fatal */ }
if (decision !== "approved") {
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
}
}
publishDomainChanged();
} catch {
// Deliverable review follow-up is non-fatal — approval state is already committed.
}
}
if (approval.type === "draft_review" || approval.type === "strategy_review") {
try {
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved" ? "approved" : "draft";
for (const docId of documentIds) {
db.updateDocumentStatus(docId, companyId, docStatus);
}
publishDomainChanged();
} catch {
// Document checkpoint follow-up is non-fatal — approval state is already committed.
}
}
// Handle budget override approvals
if (approval.type === "budget_override" && decision === "approved") {
try {
const payload = JSON.parse(approval.payloadJson ?? "{}") as {
policyId?: string;
scopeType?: string;
scopeId?: string;
newAmount?: number;
};
if (payload.policyId) {
// Resolve the budget incident that triggered this approval
db.resolveBudgetIncident(payload.policyId, companyId);
db.addActivity({
companyId,
actor: "board",
action: "budget.override_approved",
entityType: "approval",
entityId: approvalId,
detail: `Budget override approved for ${payload.scopeType} scope`,
});
}
} catch (err) {
logger.error(`[automation] Failed to process budget_override approval: ${err instanceof Error ? err.message : String(err)}`);
}
}
const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId);
if (browserAction) {
if (decision === "approved") {
db.updateBrowserAction(browserAction.id, {
status: "queued",
resultSummary: "Approval granted. Dispatching browser action.",
errorMessage: null,
});
publishDomainChanged();
void dispatchBrowserAction(browserAction.id, companyId).catch((error) => {
logger.error(`[browser] Failed to dispatch approved browser action ${browserAction.id}: ${error instanceof Error ? error.message : String(error)}`);
});
} else if (decision === "rejected") {
db.updateBrowserAction(browserAction.id, {
status: "cancelled",
resultSummary: "Browser action was rejected during approval.",
errorMessage: "Approval rejected by board.",
finishedAt: new Date().toISOString(),
});
publishDomainChanged();
} else if (decision === "revision_requested") {
db.updateBrowserAction(browserAction.id, {
status: "approval_required",
resultSummary: "Board requested revisions before execution.",
errorMessage: "Approval requires revisions before the browser action can run.",
});
publishDomainChanged();
}
}
if (approval.type === "social_post" && decision === "approved" && approval.payloadJson) {
try {
const draft = JSON.parse(approval.payloadJson) as {
socialAccountId: string;
platform: string;
content: string;
mediaUrls: string[];
scheduledAt: string | null;
};
const dispatchSocialAction = () => {
const actionId = db.saveBrowserAction({
companyId: approval.companyId,
socialAccountId: draft.socialAccountId,
agentId: approval.requestedByAgentId ?? "",
actionType: "post",
payloadJson: JSON.stringify({ content: draft.content, mediaUrls: draft.mediaUrls, scheduledAt: draft.scheduledAt }),
status: "queued",
approvalId: approval.id,
});
publishDomainChanged();
void dispatchBrowserAction(actionId, approval.companyId).catch((error) => {
logger.error(`[automation] Failed to dispatch approved social post ${actionId}: ${error instanceof Error ? error.message : String(error)}`);
});
};
if (draft.scheduledAt) {
const delay = new Date(draft.scheduledAt).getTime() - Date.now();
if (delay > 0) {
const timer = setTimeout(() => {
if (deps.isQuitting()) return;
dispatchSocialAction();
}, delay);
trackTimer(timer);
} else {
dispatchSocialAction();
}
} else {
dispatchSocialAction();
}
} catch (err) {
logger.error("[automation] Failed to dispatch approved social post:", err);
}
}
if (approval.payloadJson) {
try {
const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string };
if (payload.workflowId && payload.stepId) {
const workflow = db.getWorkflowRecord(payload.workflowId, companyId);
if (workflow) {
const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]"));
const stepIndex = steps.findIndex((step) => step.id === payload.stepId);
if (stepIndex >= 0) {
if (decision === "approved") {
steps[stepIndex]!.status = "pending";
db.updateWorkflowExecution({
id: workflow.id,
companyId,
status: "paused",
currentStepIndex: stepIndex,
stepsJson: JSON.stringify(steps),
lastRunAt: workflow.lastRunAt,
});
publishDomainChanged();
void runWorkflowPipeline(workflow.id, companyId).catch((error) => {
logger.error(`[workflow] Failed to resume workflow ${workflow.id} after approval ${approvalId}: ${error instanceof Error ? error.message : String(error)}`);
});
} else {
steps[stepIndex]!.status = decision === "revision_requested" ? "pending" : "failed";
db.updateWorkflowExecution({
id: workflow.id,
companyId,
status: decision === "revision_requested" ? "paused" : "failed",
currentStepIndex: stepIndex,
stepsJson: JSON.stringify(steps),
lastRunAt: workflow.lastRunAt,
});
publishDomainChanged();
}
}
}
}
} catch {
// Non-workflow approvals may use payloadJson for unrelated metadata.
}
}
if (approval.requestedByAgentId) {
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved");
}
}
@@ -0,0 +1,54 @@
import type { AppDatabase } from "../database";
import type { AutomationEventContext } from "./types";
export function matchesEventConfig(
db: AppDatabase,
configJson: string,
sourceDepartment: string | null,
ctx: AutomationEventContext,
): boolean {
if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) {
return false;
}
try {
const conditions = JSON.parse(configJson) as Record<string, unknown>;
if (Object.keys(conditions).length === 0) return true;
if (conditions.status && ctx.newStatus && conditions.status !== ctx.newStatus) return false;
if (conditions.previousStatus && ctx.previousStatus && conditions.previousStatus !== ctx.previousStatus) return false;
if (conditions.priority) {
try {
const task = ctx.taskId ? db.getTask(ctx.taskId) : null;
if (task && task.priority !== conditions.priority) return false;
// If task was deleted, skip this condition instead of rejecting the rule.
} catch {
// Entity missing — treat as condition not applicable, continue matching.
}
}
if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) {
try {
const agent = db.getAgent(ctx.agentId);
if (agent.budgetMonthlyUsd > 0) {
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization < (conditions.budgetThresholdPct as number) / 100) return false;
}
// If budget is 0 (unlimited), skip budget condition instead of rejecting.
} catch {
// Agent missing — treat as condition not applicable, continue matching.
}
}
if (conditions.taskId && ctx.taskId && conditions.taskId !== ctx.taskId) return false;
if (conditions.projectId && ctx.projectId && conditions.projectId !== ctx.projectId) return false;
if (conditions.agentId && ctx.agentId && conditions.agentId !== ctx.agentId) return false;
return true;
} catch {
return false;
}
}
export function matchesConditions(
db: AppDatabase,
rule: { conditionsJson: string; sourceDepartment: string | null },
ctx: AutomationEventContext,
): boolean {
return matchesEventConfig(db, rule.conditionsJson, rule.sourceDepartment, ctx);
}
+136
View File
@@ -0,0 +1,136 @@
import type { AppDatabase } from "../database";
import type { NotificationOptions } from "../notification-service";
import type { AutomationEventContext } from "./types";
export interface HandlerDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
isQuitting: () => boolean;
}
export interface HandlerCallbacks {
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
}
export function handleTaskStatusChange(
deps: HandlerDeps,
callbacks: Pick<HandlerCallbacks, "dispatchAutomation">,
taskId: string,
companyId: string,
previousStatus: string,
newStatus: string,
): void {
if (deps.isQuitting()) return;
if (previousStatus === newStatus) return;
const { db, wakeAgentIfPossible } = deps;
const { dispatchAutomation } = callbacks;
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(taskId);
} catch {
return;
}
if (!task) return;
db.addActivity({
companyId,
actor: task.assigneeAgentId ?? "board",
action: "task.status_changed",
entityType: "task",
entityId: taskId,
detail: `${previousStatus} → ${newStatus}`,
});
const context: AutomationEventContext = {
companyId,
taskId,
agentId: task.assigneeAgentId ?? undefined,
previousStatus,
newStatus,
};
try {
if (task.assigneeAgentId) {
const agent = db.getAgent(task.assigneeAgentId);
context.department = agent.department ?? undefined;
}
} catch {
// Agent may have been deleted after the task update; keep the transition without department context.
}
dispatchAutomation("task_status_changed", context);
if (newStatus === "blocked") {
dispatchAutomation("task_blocked", context);
if (task.assigneeAgentId) {
const manager = db.getManagerForAgent(task.assigneeAgentId);
if (manager) {
wakeAgentIfPossible(manager.id, companyId, "report_blocked");
}
}
}
if (newStatus === "done" && task.parentId) {
try {
const siblings = db.listChildTasks(task.parentId, companyId);
const allDone = siblings.every((entry) => entry.id === taskId ? true : entry.status === "done");
if (allDone) {
const parent = db.getTask(task.parentId);
if (parent.assigneeAgentId) {
wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed");
} else {
const assignedId = db.autoAssignTask(parent.id, companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, companyId, "subtask_completed");
}
}
}
} catch {
// Parent task may have been deleted or reassigned during completion cascade evaluation.
}
}
}
export function handleTaskCreated(
deps: HandlerDeps,
callbacks: Pick<HandlerCallbacks, "dispatchAutomation">,
taskId: string,
companyId: string,
): void {
if (deps.isQuitting()) return;
const { db, wakeAgentIfPossible } = deps;
const { dispatchAutomation } = callbacks;
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(taskId);
} catch {
return;
}
if (!task) return;
dispatchAutomation("task_created", {
companyId,
taskId,
agentId: task.assigneeAgentId ?? undefined,
});
if (!task.assigneeAgentId) {
const assignedId = db.autoAssignTask(taskId, companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, companyId, "assignment");
}
} else {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment");
}
}
@@ -0,0 +1,292 @@
import type { AppDatabase } from "../database";
import type { RunRecord } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { AutomationEventContext, RunFinishedEvent } from "./types";
import { IMPORTANT_DOCUMENT_TYPES } from "./types";
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
return locale === "zh" ? zh : en;
}
export interface RunCompletionDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
}
export interface RunCompletionCallbacks {
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
}
function checkRunProducedDeliverable(db: AppDatabase, runId: string, companyId: string, taskId: string): boolean {
// Prefer causal binding via source_run_id (set when document/knowledge
// is created during a run). Falls back to time-window for legacy data
// that predates the source_run_id column.
const causalKnowledge = db.queryAll<{ id: string }>(
"select id from knowledge_base where company_id = ? and source_run_id = ? limit 1",
companyId, runId,
);
if (causalKnowledge.length > 0) return true;
const causalDocs = db.queryAll<{ id: string }>(
"select id from documents where company_id = ? and source_run_id = ? limit 1",
companyId, runId,
);
if (causalDocs.length > 0) return true;
// Time-window fallback: check for legacy records without source_run_id
const legacyKnowledge = db.queryAll<{ id: string }>(
"select id from knowledge_base where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1",
companyId, runId,
);
if (legacyKnowledge.length > 0) return true;
const legacyDocs = db.queryAll<{ id: string }>(
"select id from documents where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1",
companyId, runId,
);
if (legacyDocs.length > 0) return true;
const recentComments = db.queryAll<{ body: string }>(
"select body from task_comments where task_id = ? and created_at >= (select started_at from runs where id = ?) and length(body) > 100",
taskId, runId,
);
if (recentComments.length > 0) return true;
return false;
}
/** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */
function findImportantDocsForRun(db: AppDatabase, runId: string, companyId: string): Array<{ id: string; type: string; title: string }> {
const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", ");
// Prefer causal binding, fall back to time-window for legacy data
const causal = db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id = ?`,
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
);
if (causal.length > 0) return causal;
return db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id is null and created_at >= (select started_at from runs where id = ?)`,
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
);
}
/** Creates a deliverable_review approval and fires OS notification. */
function createDeliverableReviewApproval(
deps: RunCompletionDeps,
companyId: string,
taskId: string,
agentId: string,
docs: Array<{ id: string; type: string; title: string }>,
reviewerNote?: string,
): string {
const { db, logger, publishDomainChanged, notify } = deps;
const docTitles = docs.map((d) => d.title).join(", ");
const approvalId = db.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitles}`.slice(0, 280),
impactSummary: reviewerNote ?? "Agent completed work with important deliverables. Awaiting your final review.",
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
});
db.setTaskRequiresUserReview(taskId, false);
const agent = db.getAgent(agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — awaiting user review.`);
}
notify({
title: "Deliverable Ready for Review",
body: `${docTitles}`.slice(0, 200),
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
batchKey: "deliverable_review",
});
publishDomainChanged();
return approvalId;
}
export function processRunCompletion(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
event: RunFinishedEvent,
isQuitting: () => boolean,
): void {
if (isQuitting()) return;
const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps;
const { dispatchAutomation, handleTaskStatusChange } = callbacks;
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(run.taskId);
} catch {
return;
}
if (!task) return;
const isSuccess = event.status === "succeeded";
const isFailed = event.status === "failed" || event.status === "timed_out";
let effectiveStatus = task.status;
if (isSuccess && effectiveStatus === "in_progress") {
// Check if non-code task produced a deliverable before auto-advancing
const taskType = ((task as unknown as Record<string, unknown>).task_type as string) ?? "general";
const needsDeliverable = taskType === "research" || taskType === "content";
if (needsDeliverable) {
const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id);
if (!hasDeliverable) {
// Don't auto-advance -- flag for manager review
db.advanceTaskStatus(task.id, "in_progress",
`${taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`);
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_progress");
// Wake manager for review
try {
const agent = db.getAgent(run.agentId);
const chain = db.getChainOfCommand(agent.id);
if (chain.length > 0) {
wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed");
}
} catch { /* best effort */ }
publishDomainChanged();
// Skip the normal in_review advance
} else {
db.advanceTaskStatus(task.id, "in_review", "Run completed with deliverable. Awaiting review.");
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
effectiveStatus = "in_review";
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
if (company?.reviewDeliverables) {
const importantDocs = findImportantDocsForRun(db, run.id, run.companyId);
if (importantDocs.length > 0) {
db.setTaskRequiresUserReview(task.id, true);
const agent = db.getAgent(run.agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
}
const chain = db.getChainOfCommand(agent.id);
if (chain.length === 0) {
createDeliverableReviewApproval(deps, run.companyId, task.id, run.agentId, importantDocs);
}
}
}
publishDomainChanged();
}
} else {
// Code/management/general tasks advance normally
db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review.");
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
effectiveStatus = "in_review";
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
if (company?.reviewDeliverables) {
const importantDocs = findImportantDocsForRun(db, run.id, run.companyId);
if (importantDocs.length > 0) {
db.setTaskRequiresUserReview(task.id, true);
const agent = db.getAgent(run.agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
}
const chain = db.getChainOfCommand(agent.id);
if (chain.length === 0) {
createDeliverableReviewApproval(deps, run.companyId, task.id, run.agentId, importantDocs);
}
}
}
publishDomainChanged();
}
}
if (isSuccess && task.title.startsWith("[Heartbeat]") && effectiveStatus !== "done") {
db.advanceTaskStatus(task.id, "done", "Heartbeat check completed.");
handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done");
}
const automationContext: AutomationEventContext = {
companyId: run.companyId,
taskId: run.taskId,
agentId: run.agentId,
runId: run.id,
costUsd: event.costUsd ?? undefined,
};
try {
const agent = db.getAgent(run.agentId);
automationContext.department = agent.department ?? undefined;
} catch {
// Agent can disappear after a run completes; keep the context without department.
}
if (isSuccess) {
dispatchAutomation("run_completed", automationContext);
if (task.parentId) {
try {
const siblings = db.listChildTasks(task.parentId, run.companyId);
const allSiblingsDoneOrReview = siblings.every((entry) => entry.status === "done" || entry.status === "in_review");
if (allSiblingsDoneOrReview) {
const parent = db.getTask(task.parentId);
if (parent.assigneeAgentId) {
wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed");
} else {
const assignedId = db.autoAssignTask(parent.id, run.companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed");
}
}
}
} catch {
// Parent task may have been deleted or reassigned during cascade evaluation.
}
}
}
if (isFailed) {
dispatchAutomation("run_failed", automationContext);
}
if (event.costUsd && event.costUsd > 0) {
try {
const agent = db.getAgent(run.agentId);
if (agent.budgetMonthlyUsd > 0) {
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization >= 0.8) {
dispatchAutomation("budget_threshold", {
...automationContext,
agentId: agent.id,
});
// Fire OS notification for budget threshold warning
const pct = Math.round(utilization * 100);
const budgetLocale = db.listSnapshot().locale;
notify({
title: localizeNotification(budgetLocale, "Budget Warning", "预算警告"),
body: localizeNotification(budgetLocale, `${agent.name} has used ${pct}% of monthly budget`, `${agent.name} 已使用月度预算的 ${pct}%`),
urgency: "informational",
navigation: { section: "costs" },
batchKey: "budget_alerts",
});
if (utilization >= 1.0) {
// Fire OS notification for budget exhaustion (agent auto-paused by database)
notify({
title: localizeNotification(budgetLocale, "Budget Exhausted", "预算耗尽"),
body: localizeNotification(budgetLocale, `${agent.name} has reached 100% of monthly budget and is paused`, `${agent.name} 已达到月度预算上限并已暂停`),
urgency: "critical",
navigation: { section: "costs" },
});
}
}
}
} catch {
// Budget follow-up is best-effort; do not fail run completion if the agent disappeared.
}
}
}
+125
View File
@@ -0,0 +1,125 @@
import type { AppDatabase } from "../database";
const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = {
engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"],
product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"],
marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"],
sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"],
hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"],
finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"],
design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"],
research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"],
operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"],
customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"],
legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"],
executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"],
};
function scoreSkillAgentMatch(
skill: { name: string; description: string | null; markdown: string },
agent: { role: string; department: string | null; title: string | null; capabilities: string },
): number {
const agentText = [
agent.role,
agent.department ?? "",
agent.title ?? "",
agent.capabilities,
].join(" ").toLowerCase();
const skillText = [
skill.name,
skill.description ?? "",
skill.markdown.slice(0, 500),
].join(" ").toLowerCase();
let score = 0;
const dept = agent.department ?? "";
const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? [];
for (const keyword of deptKeywords) {
if (skillText.includes(keyword)) {
score += 2;
}
}
const agentWords = agentText
.replace(/[^a-z0-9\s-]/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 3);
for (const word of agentWords) {
if (skillText.includes(word)) {
score += 1;
}
}
// Executives need awareness of all domains
const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"];
const roleLower = agent.role.toLowerCase();
if (executiveRoles.some((r) => roleLower.includes(r))) {
score += 1;
}
return score;
}
export function autoAssignSkillsToAgent(
db: AppDatabase,
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void },
companyId: string,
agentId: string,
): number {
const agent = db.getAgent(agentId);
if (!agent || agent.companyId !== companyId) return 0;
const allSkills = db.listCompanySkills(companyId);
if (allSkills.length === 0) return 0;
const existingAssignments = new Set(db.listAgentSkillAssignments(agentId));
// Always assign basic-for-all builtin skills regardless of score
let assigned = 0;
for (const skill of allSkills) {
if (existingAssignments.has(skill.id)) continue;
try {
const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {};
if (meta.builtinCategory === "basic-for-all") {
db.assignSkillToAgent(agentId, skill.id);
existingAssignments.add(skill.id);
assigned++;
logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`);
}
} catch { /* skip malformed metadata */ }
}
const scored = allSkills
.filter((skill) => !existingAssignments.has(skill.id))
.map((skill) => ({
skill,
score: scoreSkillAgentMatch(skill, agent),
}))
.sort((a, b) => b.score - a.score);
const MIN_SCORE = 3;
for (const { skill, score } of scored) {
if (score < MIN_SCORE) continue;
try {
db.assignSkillToAgent(agentId, skill.id);
assigned++;
logger.info(
`[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`,
);
} catch (err) {
logger.warn(
`[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (assigned === 0) {
logger.info(
`[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`,
);
}
return assigned;
}
+234
View File
@@ -0,0 +1,234 @@
import type { AppDatabase } from "../database";
import type { AgentRecord } from "@shared/types";
import { WORKFLOW_ACTIONS, type WorkflowStepState, type AutomationEventContext } from "./types";
import type { ActionDeps, ActionCallbacks } from "./automation-actions";
import { executeAutomationAction } from "./automation-actions";
export interface WorkflowDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
}
export function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
if (!Array.isArray(raw)) {
throw new Error("Workflow steps must be a JSON array.");
}
return raw.map((entry, index) => {
const step = (entry ?? {}) as Record<string, unknown>;
const rawAction = typeof step.action === "string" ? step.action : "send_message";
if (!WORKFLOW_ACTIONS.has(rawAction as WorkflowStepState["action"])) {
throw new Error(`Workflow step ${index + 1} has unsupported action "${rawAction}".`);
}
return {
id: typeof step.id === "string" && step.id ? step.id : `step-${index + 1}`,
name: typeof step.name === "string" && step.name ? step.name : `Step ${index + 1}`,
action: rawAction as WorkflowStepState["action"],
configJson: typeof step.configJson === "string" ? step.configJson : "{}",
assigneeDepartment: typeof step.assigneeDepartment === "string" ? step.assigneeDepartment : null,
assigneeAgentId: typeof step.assigneeAgentId === "string" ? step.assigneeAgentId : null,
requiresApproval: step.requiresApproval === true,
timeoutSec: typeof step.timeoutSec === "number" ? step.timeoutSec : 0,
status: ["pending", "running", "completed", "failed", "skipped"].includes(String(step.status))
? (String(step.status) as WorkflowStepState["status"])
: "pending",
};
});
}
export function resolveWorkflowStepActor(
db: AppDatabase,
step: WorkflowStepState,
companyId: string,
): AgentRecord | null {
if (step.assigneeAgentId) {
const agent = db.getAgent(step.assigneeAgentId);
if (agent.companyId !== companyId) {
throw new Error(`Workflow step assignee ${step.assigneeAgentId} does not belong to company ${companyId}.`);
}
return agent;
}
if (step.assigneeDepartment) {
const idleAgents = db.getIdleAgentsForDepartment(companyId, step.assigneeDepartment);
if (idleAgents.length > 0) {
return db.getAgent(idleAgents[0]!.id);
}
}
const snapshot = db.listSnapshot();
return snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo && agent.status !== "terminated") ?? null;
}
export function buildWorkflowActionContext(
companyId: string,
step: WorkflowStepState,
actor: AgentRecord | null,
): { config: Record<string, unknown>; ctx: AutomationEventContext } {
let config: Record<string, unknown> = {};
try {
config = JSON.parse(step.configJson) as Record<string, unknown>;
} catch {
throw new Error(`Workflow step "${step.name}" has invalid config JSON.`);
}
return {
config,
ctx: {
companyId,
agentId: actor?.id,
department: actor?.department ?? step.assigneeDepartment ?? undefined,
taskId: typeof config.taskId === "string" ? config.taskId : undefined,
runId: typeof config.runId === "string" ? config.runId : undefined,
projectId: typeof config.projectId === "string" ? config.projectId : undefined,
newStatus: typeof config.status === "string" ? config.status : undefined,
} satisfies AutomationEventContext,
};
}
export async function runWorkflowPipeline(
workflowId: string,
companyId: string,
deps: WorkflowDeps,
actionDeps: ActionDeps,
actionCallbacks: ActionCallbacks,
): Promise<void> {
const { db, publishDomainChanged } = deps;
const workflow = db.getWorkflowRecord(workflowId, companyId);
if (!workflow) {
throw new Error("Workflow not found.");
}
const parsedSteps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]"));
if (parsedSteps.some((step) => step.status === "running")) {
throw new Error("Workflow is already running.");
}
if (parsedSteps.length === 0) {
throw new Error("Workflow has no steps.");
}
const resumeFrom = workflow.status === "paused" ? workflow.currentStepIndex : 0;
const steps: WorkflowStepState[] = parsedSteps.map((step, index) => {
if (workflow.status !== "paused") {
return { ...step, status: index < resumeFrom ? "completed" : "pending" };
}
return step;
});
db.updateWorkflowExecution({
id: workflowId,
companyId,
status: "active",
currentStepIndex: resumeFrom,
stepsJson: JSON.stringify(steps),
incrementRunCount: workflow.status !== "paused",
lastRunAt: new Date().toISOString(),
});
publishDomainChanged();
for (let index = resumeFrom; index < steps.length; index += 1) {
const step = steps[index]!;
step.status = "running";
db.updateWorkflowExecution({
id: workflowId,
companyId,
status: "active",
currentStepIndex: index,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
publishDomainChanged();
const actor = resolveWorkflowStepActor(db, step, companyId);
const { config, ctx } = buildWorkflowActionContext(companyId, step, actor);
if (step.requiresApproval) {
const approvalId = db.requestApproval({
companyId,
requestedByAgentId: actor?.id ?? null,
relatedTaskId: ctx.taskId ?? null,
type: "dangerous_command",
payloadSummary: `Workflow approval required: ${workflow.name} / ${step.name}`,
impactSummary: `Workflow step "${step.name}" requires board approval before ${step.action}.`,
payloadJson: JSON.stringify({ workflowId, stepId: step.id, stepName: step.name }),
});
step.status = "pending";
db.updateWorkflowExecution({
id: workflowId,
companyId,
status: "paused",
currentStepIndex: index,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
db.addActivity({
companyId,
actor: actor?.id ?? "system",
action: "workflow.paused_for_approval",
entityType: "workflow",
entityId: workflowId,
detail: `Paused on step "${step.name}" with approval ${approvalId}.`,
});
publishDomainChanged();
return;
}
try {
executeAutomationAction(
actionDeps,
actionCallbacks,
{
action: step.action,
actionConfigJson: JSON.stringify(config),
targetDepartment: step.assigneeDepartment,
trigger: "workflow_step",
},
ctx,
);
step.status = "completed";
db.addActivity({
companyId,
actor: actor?.id ?? "system",
action: "workflow.step_completed",
entityType: "workflow",
entityId: workflowId,
detail: step.name,
});
const isLastStep = index === steps.length - 1;
const completionStatus = isLastStep ? "completed" : "active";
db.updateWorkflowExecution({
id: workflowId,
companyId,
status: completionStatus,
currentStepIndex: index + 1,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
publishDomainChanged();
} catch (error) {
step.status = "failed";
db.updateWorkflowExecution({
id: workflowId,
companyId,
status: "failed",
currentStepIndex: index,
stepsJson: JSON.stringify(steps),
lastRunAt: new Date().toISOString(),
});
db.addActivity({
companyId,
actor: actor?.id ?? "system",
action: "workflow.step_failed",
entityType: "workflow",
entityId: workflowId,
detail: `${step.name}: ${error instanceof Error ? error.message : String(error)}`,
});
publishDomainChanged();
throw error;
}
}
}
+225
View File
@@ -0,0 +1,225 @@
import type { AutomationTrigger, AutomationAction, RunRecord } from "@shared/types";
import type { AutomationDependencies, AutomationEventContext, RunFinishedEvent } from "./types";
import { matchesConditions, matchesEventConfig } from "./automation-conditions";
import { executeAutomationAction } from "./automation-actions";
import type { ActionDeps, ActionCallbacks } from "./automation-actions";
import { runWorkflowPipeline } from "./automation-workflow";
import type { WorkflowDeps } from "./automation-workflow";
import { processRunCompletion } from "./automation-run-completion";
import type { RunCompletionDeps, RunCompletionCallbacks } from "./automation-run-completion";
import { handleApprovalResolved } from "./automation-approval-handler";
import type { ApprovalHandlerDeps, ApprovalHandlerCallbacks } from "./automation-approval-handler";
import { handleTaskStatusChange, handleTaskCreated } from "./automation-handlers";
import type { HandlerDeps } from "./automation-handlers";
export type { AutomationEventContext } from "./types";
export { autoAssignSkillsToAgent } from "./automation-skills";
export function createAutomationService({
db,
logger,
emitEvent,
publishDomainChanged,
notify,
wakeAgentIfPossible,
dispatchBrowserAction,
isQuitting,
}: AutomationDependencies) {
const activeAutomationExecutions = new Set<string>();
const activeWorkflowTriggerExecutions = new Set<string>();
const pendingScheduledTimers = new Set<ReturnType<typeof setTimeout>>();
const actionDeps: ActionDeps = {
db,
logger,
emitEvent,
publishDomainChanged,
wakeAgentIfPossible,
};
const workflowDeps: WorkflowDeps = {
db,
logger,
publishDomainChanged,
};
const handlerDeps: HandlerDeps = {
db,
logger,
publishDomainChanged,
notify,
wakeAgentIfPossible,
dispatchBrowserAction,
isQuitting,
};
const approvalDeps: ApprovalHandlerDeps = {
db,
logger,
publishDomainChanged,
notify,
wakeAgentIfPossible,
dispatchBrowserAction,
isQuitting,
};
const runCompletionDeps: RunCompletionDeps = {
db,
logger,
publishDomainChanged,
notify,
wakeAgentIfPossible,
};
// Forward-declared so callbacks can reference each other
function _runWorkflowPipeline(workflowId: string, companyId: string): Promise<void> {
return runWorkflowPipeline(workflowId, companyId, workflowDeps, actionDeps, actionCallbacks);
}
function _handleTaskCreated(taskId: string, companyId: string): void {
handleTaskCreated(handlerDeps, { dispatchAutomation }, taskId, companyId);
}
function _handleApprovalResolved(approvalId: string, companyId: string, decision: string): void {
handleApprovalResolved(approvalDeps, approvalCallbacks, approvalId, companyId, decision);
}
function _handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string): void {
handleTaskStatusChange(handlerDeps, { dispatchAutomation }, taskId, companyId, previousStatus, newStatus);
}
const actionCallbacks: ActionCallbacks = {
handleTaskCreated: _handleTaskCreated,
handleApprovalResolved: _handleApprovalResolved,
handleTaskStatusChange: _handleTaskStatusChange,
};
const approvalCallbacks: ApprovalHandlerCallbacks = {
dispatchAutomation,
handleTaskStatusChange: _handleTaskStatusChange,
runWorkflowPipeline: _runWorkflowPipeline,
trackTimer: (timer) => { pendingScheduledTimers.add(timer); },
};
const runCompletionCallbacks: RunCompletionCallbacks = {
dispatchAutomation,
handleTaskStatusChange: _handleTaskStatusChange,
};
function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) {
try {
const rules = db.listActiveAutomationRules(ctx.companyId, trigger);
for (const rule of rules) {
if (!matchesConditions(db, rule, ctx)) continue;
const executionKey = [
rule.id,
trigger,
ctx.companyId,
ctx.taskId ?? "-",
ctx.agentId ?? "-",
ctx.runId ?? "-",
ctx.previousStatus ?? "-",
ctx.newStatus ?? "-",
].join(":");
if (activeAutomationExecutions.has(executionKey)) {
logger.warn(`[automation] Skipping re-entrant execution for rule "${rule.name}" (${executionKey})`);
continue;
}
activeAutomationExecutions.add(executionKey);
try {
executeAutomationAction(actionDeps, actionCallbacks, rule, ctx);
db.recordAutomationExecution(
rule.id,
ctx.companyId,
rule.name,
trigger,
rule.action,
`Fired for ${trigger} in context: taskId=${ctx.taskId ?? "n/a"}, agentId=${ctx.agentId ?? "n/a"}`,
);
emitEvent({
type: "automation-fired",
ruleId: rule.id,
ruleName: rule.name,
trigger: trigger as AutomationTrigger,
action: rule.action as AutomationAction,
});
logger.info(`[automation] Fired rule "${rule.name}" (${trigger} → ${rule.action})`);
} catch (error) {
logger.error(`[automation] Failed to execute action for rule "${rule.name}": ${error instanceof Error ? error.message : String(error)}`);
db.recordAutomationExecution(
rule.id,
ctx.companyId,
rule.name,
trigger,
rule.action,
`FAILED: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
activeAutomationExecutions.delete(executionKey);
}
}
} catch (error) {
logger.error(`[automation] Error evaluating rules for trigger "${trigger}": ${error instanceof Error ? error.message : String(error)}`);
}
}
function evaluateWorkflowTriggers(trigger: string, ctx: AutomationEventContext) {
try {
const workflows = db.listTriggerableWorkflows(ctx.companyId, trigger);
for (const workflow of workflows) {
if (!matchesEventConfig(db, workflow.triggerConfigJson, null, ctx)) continue;
const executionKey = [
workflow.id,
trigger,
ctx.companyId,
ctx.taskId ?? "-",
ctx.agentId ?? "-",
ctx.runId ?? "-",
ctx.previousStatus ?? "-",
ctx.newStatus ?? "-",
].join(":");
if (activeWorkflowTriggerExecutions.has(executionKey)) {
logger.warn(`[workflow] Skipping re-entrant trigger for workflow "${workflow.name}" (${executionKey})`);
continue;
}
activeWorkflowTriggerExecutions.add(executionKey);
void _runWorkflowPipeline(workflow.id, ctx.companyId)
.then(() => {
logger.info(`[workflow] Triggered workflow "${workflow.name}" via ${trigger}`);
})
.catch((error) => {
logger.error(`[workflow] Trigger failed for "${workflow.name}": ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => {
activeWorkflowTriggerExecutions.delete(executionKey);
});
}
} catch (error) {
logger.error(`[workflow] Error evaluating workflow triggers for "${trigger}": ${error instanceof Error ? error.message : String(error)}`);
}
}
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
if (isQuitting()) return;
evaluateAutomationRules(trigger, ctx);
evaluateWorkflowTriggers(trigger, ctx);
}
function destroy() {
for (const timer of pendingScheduledTimers) {
clearTimeout(timer);
}
pendingScheduledTimers.clear();
}
return {
runWorkflowPipeline: _runWorkflowPipeline,
dispatchAutomation,
processRunCompletion: (run: RunRecord, event: RunFinishedEvent) =>
processRunCompletion(runCompletionDeps, runCompletionCallbacks, run, event, isQuitting),
handleApprovalResolved: _handleApprovalResolved,
handleTaskStatusChange: _handleTaskStatusChange,
handleTaskCreated: _handleTaskCreated,
destroy,
};
}
+76
View File
@@ -0,0 +1,76 @@
import type { AppDatabase } from "../database";
import type {
AutomationAction,
DesktopEvent,
RunStatus,
} from "@shared/types";
import type { NotificationOptions } from "../notification-service";
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
export const IMPORTANT_DOCUMENT_TYPES = new Set([
"prd", "technical_spec", "design_doc", "project_brief", "proposal",
"budget_proposal", "architecture_decision", "contract", "sop",
]);
export const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set([
"assign_task",
"create_task",
"notify_agent",
"send_message",
"trigger_heartbeat",
"create_approval",
"update_task_status",
"escalate_to_manager",
"cross_department_notify",
"schedule_meeting",
"create_document",
"auto_approve",
"reassign_task",
"run_task",
]);
export interface WorkflowStepState {
id: string;
name: string;
action: AutomationAction;
configJson: string;
assigneeDepartment: string | null;
assigneeAgentId: string | null;
requiresApproval: boolean;
timeoutSec: number;
status: "pending" | "running" | "completed" | "failed" | "skipped";
}
export interface AutomationEventContext {
companyId: string;
taskId?: string;
agentId?: string;
runId?: string;
projectId?: string;
department?: string;
previousStatus?: string;
newStatus?: string;
costUsd?: number;
}
export interface AutomationDependencies {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
emitEvent: (event: DesktopEvent) => void;
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
isQuitting: () => boolean;
}
export interface RunFinishedEvent {
status: RunStatus;
summary: string | null;
errorMessage: string | null;
costUsd: number | null;
}
+52 -2
View File
@@ -8,6 +8,9 @@ import { createServiceGraph } from "./service-graph";
import { createIpcSupport } from "./ipc-support";
import { createNotificationService } from "./notification-service";
import type { NotificationOptions } from "./notification-service";
import { createThreadIntegration } from "./thread-integration";
import { createCheckpointEngine } from "./checkpoint-engine";
import { createRunHousekeeping } from "./run-housekeeping";
import type { DesktopEvent, SectionId } from "@shared/types";
interface BootstrapApplicationDependencies {
@@ -29,9 +32,11 @@ interface BootstrapApplicationDependencies {
onTaskCreated: (taskId: string, companyId: string) => void;
onTaskStatusChanged: (taskId: string, companyId: string, prev: string, next: string) => void;
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: 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, documentId: string, documentTitle: string, documentType: string, taskId: string | null) => void;
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
emitEvent: (event: DesktopEvent) => void;
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
}, allowedOrigins: string[]) => AgentApiServer;
@@ -43,6 +48,7 @@ interface BootstrapApplicationDependencies {
apiServer: AgentApiServer;
serviceGraph: ReturnType<typeof createServiceGraph>;
ipcSupport: ReturnType<typeof createIpcSupport>;
threadIntegration: ReturnType<typeof createThreadIntegration>;
}) => void;
}
@@ -112,6 +118,22 @@ export async function bootstrapApplication({
ipcSupport?.publishDomainChanged();
};
const threadIntegration = createThreadIntegration({
db,
logger: log,
emitEvent,
publishDomainChanged,
notify: (options) => notify({ ...options, urgency: "informational" }),
});
const checkpointEngine = createCheckpointEngine({
db,
logger: log,
threadIntegration,
publishDomainChanged,
notify,
});
const runHousekeeping = createRunHousekeeping({ db, logger: log, threadIntegration });
serviceGraph = createServiceGraph({
db,
apiServerRef,
@@ -130,6 +152,11 @@ export async function bootstrapApplication({
notify,
createRunToken,
seedDefaultAutomationRules,
onRunStarted: (runId, taskId, agentId, companyId) =>
threadIntegration.onRunStarted(runId, taskId, agentId, companyId),
onRunCompleted: (runId, taskId, agentId, companyId, status, summary, errorMessage) =>
runHousekeeping.onRunCompleted(runId, taskId, agentId, companyId, status, summary, errorMessage),
checkpointEngine,
});
ipcSupport = createIpcSupport({
isDev,
@@ -153,16 +180,26 @@ export async function bootstrapApplication({
const automation = serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.handleTaskCreated(taskId, companyId);
threadIntegration.onTaskCreated(taskId, companyId);
};
const handleTaskStatusChange = (taskId: string, companyId: string, prev: string, next: string) => {
const automation = serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.handleTaskStatusChange(taskId, companyId, prev, next);
threadIntegration.onTaskStatusChanged(taskId, companyId, prev, next);
// In co-founder mode, create plan_review checkpoint when task starts
if (prev === "todo" && next === "in_progress") {
try {
const task = db.getTask(taskId);
checkpointEngine.onTaskStarted(taskId, companyId, task.assigneeAgentId);
} catch { /* task may not exist */ }
}
};
const handleApprovalResolved = (approvalId: string, companyId: string, decision: string) => {
const automation = serviceGraph.getAutomationService();
if (!automation) throw new Error("Automation service is not initialized.");
automation.handleApprovalResolved(approvalId, companyId, decision);
threadIntegration.onApprovalResolved(approvalId, companyId, decision);
};
const dispatchAutomation = (trigger: string, ctx: Record<string, unknown>) => {
const automation = serviceGraph.getAutomationService();
@@ -170,6 +207,10 @@ export async function bootstrapApplication({
automation.dispatchAutomation(trigger, ctx as any);
};
const handleApprovalCreated = (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => {
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary);
};
const apiServer = createApiServer(db, {
onDomainChanged: publishDomainChanged,
onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger),
@@ -178,11 +219,18 @@ export async function bootstrapApplication({
onCommentPosted: (taskId, companyId, authorAgentId) => {
dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined });
},
onApprovalCreated: handleApprovalCreated,
onApprovalResolved: handleApprovalResolved,
onDocumentCreated: (companyId, authorAgentId, projectId) => {
onDocumentCreated: (companyId, authorAgentId, projectId, documentId, documentTitle, documentType, taskId) => {
dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined });
threadIntegration.onDocumentCreated(companyId, authorAgentId, taskId, documentTitle);
checkpointEngine.onDocumentDrafted(companyId, documentId, documentTitle, documentType, authorAgentId, taskId);
},
onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId),
onHumanMessage: (companyId, toAgentId, threadId, waitStateId) => {
threadIntegration.onHumanMessage(companyId, toAgentId, threadId, waitStateId);
publishDomainChanged();
},
emitEvent,
notify,
}, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin)));
@@ -194,6 +242,7 @@ export async function bootstrapApplication({
apiServer,
serviceGraph,
ipcSupport,
threadIntegration,
});
if (apiServer && serviceGraph.getBrowserActionService()) {
@@ -337,5 +386,6 @@ export async function bootstrapApplication({
apiServer,
serviceGraph,
ipcSupport,
threadIntegration,
};
}
+18 -524
View File
@@ -1,104 +1,12 @@
import { BrowserWindow, session, type NativeImage } from "electron";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { BrowserWindow, session } from "electron";
import { existsSync, mkdirSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import log from "electron-log/main.js";
import type { BrowserAction, BrowserActionResult, QueuedAction } from "./browser/browser-types";
import { getPlatformRules, attachWindowSecurity, assertAllowedUrl, waitForLoad, runAction } from "./browser/browser-action-executor";
export interface BrowserAction {
id: string;
socialAccountId: string;
platform: string;
actionType: string;
payload: Record<string, unknown>;
}
export interface BrowserActionResult {
success: boolean;
cancelled: boolean;
summary: string;
screenshotPath: string | null;
error: string | null;
}
interface QueuedAction {
action: BrowserAction;
resolve: (result: BrowserActionResult) => void;
reject: (error: Error) => void;
}
interface PlatformRule {
loginUrl: string;
authCheckUrl: string;
allowedHosts: string[];
cookieOrigins: string[];
unauthenticatedUrlMarkers: string[];
}
const PLATFORM_RULES: Record<string, PlatformRule> = {
twitter: {
loginUrl: "https://x.com/i/flow/login",
authCheckUrl: "https://x.com/home",
allowedHosts: ["x.com", "twitter.com"],
cookieOrigins: ["https://x.com", "https://twitter.com"],
unauthenticatedUrlMarkers: ["/i/flow/login", "/login"],
},
linkedin: {
loginUrl: "https://www.linkedin.com/login",
authCheckUrl: "https://www.linkedin.com/feed/",
allowedHosts: ["linkedin.com"],
cookieOrigins: ["https://www.linkedin.com"],
unauthenticatedUrlMarkers: ["/login", "/checkpoint"],
},
instagram: {
loginUrl: "https://www.instagram.com/accounts/login/",
authCheckUrl: "https://www.instagram.com/",
allowedHosts: ["instagram.com"],
cookieOrigins: ["https://www.instagram.com"],
unauthenticatedUrlMarkers: ["/accounts/login"],
},
facebook: {
loginUrl: "https://www.facebook.com/login",
authCheckUrl: "https://www.facebook.com/",
allowedHosts: ["facebook.com"],
cookieOrigins: ["https://www.facebook.com"],
unauthenticatedUrlMarkers: ["/login"],
},
reddit: {
loginUrl: "https://www.reddit.com/login",
authCheckUrl: "https://www.reddit.com/",
allowedHosts: ["reddit.com"],
cookieOrigins: ["https://www.reddit.com"],
unauthenticatedUrlMarkers: ["/login"],
},
youtube: {
loginUrl: "https://accounts.google.com/signin",
authCheckUrl: "https://www.youtube.com/",
allowedHosts: ["youtube.com", "google.com"],
cookieOrigins: ["https://www.youtube.com", "https://accounts.google.com"],
unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"],
},
tiktok: {
loginUrl: "https://www.tiktok.com/login",
authCheckUrl: "https://www.tiktok.com/foryou",
allowedHosts: ["tiktok.com"],
cookieOrigins: ["https://www.tiktok.com"],
unauthenticatedUrlMarkers: ["/login"],
},
bluesky: {
loginUrl: "https://bsky.app",
authCheckUrl: "https://bsky.app/",
allowedHosts: ["bsky.app"],
cookieOrigins: ["https://bsky.app"],
unauthenticatedUrlMarkers: ["/login"],
},
other: {
loginUrl: "https://google.com",
authCheckUrl: "https://google.com",
allowedHosts: ["google.com"],
cookieOrigins: ["https://google.com"],
unauthenticatedUrlMarkers: ["/login"],
},
};
export type { BrowserAction, BrowserActionResult } from "./browser/browser-types";
export class BrowserManager {
private screenshotDir: string;
@@ -166,10 +74,11 @@ export class BrowserManager {
return { success: true, cancelled: false, summary: "Login window already open", screenshotPath: null, error: null };
}
const rules = this.getPlatformRules(platform);
const rules = getPlatformRules(platform);
const url = loginUrl || rules.loginUrl;
const ses = this.getSession(socialAccountId);
this.assertAllowedUrl(platform, url, "login");
assertAllowedUrl(platform, url, "login");
log.info(`[browser] Opening login window for ${platform} → ${url}`);
@@ -186,7 +95,7 @@ export class BrowserManager {
});
this.loginWindows.set(socialAccountId, win);
this.attachWindowSecurity(win, platform);
attachWindowSecurity(win, platform);
win.setMenuBarVisibility(false);
@@ -263,7 +172,13 @@ export class BrowserManager {
while (queue.length > 0) {
const item = queue.shift()!;
try {
const result = await this.runAction(item.action);
const result = await runAction(
item.action,
this.screenshotDir,
this.getSession(item.action.socialAccountId),
this.cancelledActionIds,
this.activeActionWindows,
);
item.resolve(result);
} catch (error) {
item.reject(error instanceof Error ? error : new Error(String(error)));
@@ -272,392 +187,9 @@ export class BrowserManager {
this.processing.delete(socialAccountId);
}
private async runAction(action: BrowserAction): Promise<BrowserActionResult> {
const ses = this.getSession(action.socialAccountId);
const payload = action.payload;
const screenshotPath = join(this.screenshotDir, `${action.id}.png`);
const platform = action.platform;
log.info(`[browser] Executing ${action.actionType} for ${platform}/${action.socialAccountId}`);
const win = new BrowserWindow({
width: 1280,
height: 860,
title: `AgentCompany — ${action.actionType}`,
show: true,
webPreferences: {
session: ses,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
this.activeActionWindows.set(action.id, win);
this.attachWindowSecurity(win, platform);
win.setMenuBarVisibility(false);
try {
let summary = "Action completed";
const assertNotCancelled = () => {
if (this.cancelledActionIds.has(action.id)) {
throw new Error("BROWSER_ACTION_CANCELLED");
}
};
switch (action.actionType) {
case "navigate": {
assertNotCancelled();
const url = String(payload.url || "https://google.com");
this.assertAllowedUrl(platform, url, "navigate");
await win.loadURL(url);
await this.waitForLoad(win);
assertNotCancelled();
const title = await win.webContents.executeJavaScript("document.title");
summary = `Navigated to ${title}`;
break;
}
case "post": {
assertNotCancelled();
const content = String(payload.content || "");
const postUrl = this.getComposeUrl(platform);
await win.loadURL(postUrl);
await this.waitForLoad(win);
await this.delay(2000);
assertNotCancelled();
if (platform === "twitter") {
// Use clipboard-based approach since Twitter's React editor ignores execCommand
await win.webContents.executeJavaScript(`
(async () => {
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]');
if (!editor) return;
editor.focus();
// Write content to clipboard and paste it — this triggers React's input handlers
const dt = new DataTransfer();
dt.setData('text/plain', ${JSON.stringify(content)});
const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
editor.dispatchEvent(pasteEvent);
// Fallback: if paste didn't work, set innerHTML and dispatch input
await new Promise(r => setTimeout(r, 500));
if (!editor.textContent || editor.textContent.trim().length === 0) {
editor.textContent = ${JSON.stringify(content)};
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(content)} }));
}
})()
`);
await this.delay(1500);
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
if (btn && !btn.disabled) btn.click();
})()
`);
await this.delay(3000);
} else if (platform === "linkedin") {
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('button.share-box-feed-entry__trigger, button.artdeco-button--muted');
if (btn) btn.click();
})()
`);
await this.delay(2000);
await win.webContents.executeJavaScript(`
(async () => {
const editor = document.querySelector('[role="textbox"][contenteditable="true"], .ql-editor[contenteditable="true"]');
if (editor) {
editor.focus();
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(1500);
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('button.share-actions__primary-action, button.artdeco-button--primary[type="submit"]');
if (btn && !btn.disabled) btn.click();
})()
`);
await this.delay(3000);
}
summary = `Posted to ${platform}: "${content.slice(0, 50)}..."`;
break;
}
case "reply": {
assertNotCancelled();
const targetUrl = String(payload.targetUrl || "");
this.assertAllowedUrl(platform, targetUrl, "reply");
const replyContent = String(payload.content || "");
await win.loadURL(targetUrl);
await this.waitForLoad(win);
await this.delay(2000);
assertNotCancelled();
await win.webContents.executeJavaScript(`
(async () => {
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]');
if (editor) {
editor.focus();
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(1500);
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]');
if (btn && !btn.disabled) btn.click();
})()
`);
await this.delay(3000);
summary = `Replied to ${targetUrl.slice(0, 50)}`;
break;
}
case "like": {
assertNotCancelled();
const targetUrl = String(payload.targetUrl || "");
this.assertAllowedUrl(platform, targetUrl, "like");
await win.loadURL(targetUrl);
await this.waitForLoad(win);
await this.delay(2000);
assertNotCancelled();
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]');
if (btn) btn.click();
})()
`);
await this.delay(2000);
summary = `Liked post at ${targetUrl.slice(0, 50)}`;
break;
}
case "follow": {
assertNotCancelled();
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
this.assertAllowedUrl(platform, targetUrl, "follow");
await win.loadURL(targetUrl);
await this.waitForLoad(win);
await this.delay(2000);
assertNotCancelled();
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]');
if (btn && !btn.textContent.includes('Following')) btn.click();
})()
`);
await this.delay(2000);
summary = `Followed user at ${targetUrl.slice(0, 50)}`;
break;
}
case "browse_feed": {
assertNotCancelled();
const feedUrl = String(payload.url || this.getFeedUrl(platform));
this.assertAllowedUrl(platform, feedUrl, "browse_feed");
const maxPosts = Number(payload.maxPosts || 10);
await win.loadURL(feedUrl);
await this.waitForLoad(win);
await this.delay(3000);
assertNotCancelled();
for (let i = 0; i < 3; i++) {
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
await this.delay(1500);
}
const posts: string[] = await win.webContents.executeJavaScript(`
(() => {
const articles = document.querySelectorAll('article, [data-testid="tweet"], .feed-shared-update-v2');
return Array.from(articles).slice(0, ${maxPosts}).map(el => el.innerText.slice(0, 300));
})()
`);
summary = `Browsed feed — found ${posts.length} posts`;
break;
}
case "search": {
assertNotCancelled();
const query = String(payload.query || "");
const searchUrl = this.getSearchUrl(platform, query);
this.assertAllowedUrl(platform, searchUrl, "search");
await win.loadURL(searchUrl);
await this.waitForLoad(win);
await this.delay(3000);
assertNotCancelled();
const results: string[] = await win.webContents.executeJavaScript(`
(() => {
const items = document.querySelectorAll('article, [data-testid="tweet"], .search-result');
return Array.from(items).slice(0, 10).map(el => el.innerText.slice(0, 200));
})()
`);
summary = `Search "${query.slice(0, 30)}" — ${results.length} results`;
break;
}
case "screenshot": {
assertNotCancelled();
const url = String(payload.url || "about:blank");
if (url !== "about:blank") {
this.assertAllowedUrl(platform, url, "screenshot");
await win.loadURL(url);
await this.waitForLoad(win);
await this.delay(2000);
}
assertNotCancelled();
const title = await win.webContents.executeJavaScript("document.title");
summary = `Screenshot captured: ${title}`;
break;
}
default:
throw new Error(`Unsupported browser action type: ${action.actionType}`);
}
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) {
writeFileSync(screenshotPath, image.toPNG());
}
} catch (err) {
log.warn(`[browser] Screenshot failed: ${err}`);
}
await this.delay(1000);
if (!win.isDestroyed()) win.close();
return {
success: true,
cancelled: false,
summary,
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
error: null,
};
} catch (error) {
const wasCancelled = this.cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED");
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
} catch { /* ignore */ }
if (!win.isDestroyed()) win.close();
const msg = wasCancelled ? "Action cancelled." : error instanceof Error ? error.message : String(error);
if (!wasCancelled) {
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
}
return {
success: false,
cancelled: wasCancelled,
summary: wasCancelled ? "Action cancelled" : "Action failed",
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
error: msg,
};
} finally {
this.activeActionWindows.delete(action.id);
this.cancelledActionIds.delete(action.id);
}
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private waitForLoad(win: BrowserWindow, timeoutMs = 15000): Promise<void> {
return new Promise((resolve) => {
if (win.webContents.isLoading()) {
const timer = setTimeout(resolve, timeoutMs);
win.webContents.once("did-finish-load", () => {
clearTimeout(timer);
resolve();
});
} else {
resolve();
}
});
}
private getPlatformRules(platform: string): PlatformRule {
return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other;
}
private isAllowedNavigation(platform: string, rawUrl: string) {
try {
this.assertAllowedUrl(platform, rawUrl, "navigate");
return true;
} catch {
return false;
}
}
private attachWindowSecurity(win: BrowserWindow, platform: string) {
win.webContents.setWindowOpenHandler(({ url }) => {
if (!this.isAllowedNavigation(platform, url)) {
return { action: "deny" };
}
setImmediate(() => {
void win.loadURL(url).catch((error) => {
log.warn(`[browser] Failed to open allowed popup target for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
});
});
return { action: "deny" };
});
win.webContents.on("will-navigate", (event, navigationUrl) => {
if (this.isAllowedNavigation(platform, navigationUrl)) {
return;
}
event.preventDefault();
log.warn(`[browser] Blocked navigation outside allowlist for ${platform}: ${navigationUrl}`);
});
win.webContents.on("will-redirect", (event, navigationUrl) => {
if (this.isAllowedNavigation(platform, navigationUrl)) {
return;
}
event.preventDefault();
log.warn(`[browser] Blocked redirect outside allowlist for ${platform}: ${navigationUrl}`);
});
}
private assertAllowedUrl(platform: string, rawUrl: string, actionType: string) {
const allowedHosts = this.getPlatformRules(platform).allowedHosts;
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`);
}
if (url.protocol !== "https:") {
throw new Error(`Navigation blocked for ${actionType}. Only https URLs are allowed.`);
}
const host = url.hostname.toLowerCase();
const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`));
if (!isAllowed) {
throw new Error(`Navigation blocked for ${actionType}. ${host} is outside the allowed ${platform} domains.`);
}
}
private async verifyLoginSession(socialAccountId: string, platform: string): Promise<boolean> {
const ses = this.getSession(socialAccountId);
const rules = this.getPlatformRules(platform);
const rules = getPlatformRules(platform);
const cookieSets = await Promise.all(
rules.cookieOrigins.map(async (origin) => {
try {
@@ -684,7 +216,7 @@ export class BrowserManager {
try {
await verificationWindow.loadURL(rules.authCheckUrl);
await this.waitForLoad(verificationWindow, 10000);
await waitForLoad(verificationWindow, 10000);
const finalUrl = verificationWindow.webContents.getURL();
const finalUrlLower = finalUrl.toLowerCase();
const bouncedToLogin = rules.unauthenticatedUrlMarkers.some((marker) => finalUrlLower.includes(marker.toLowerCase()));
@@ -699,44 +231,6 @@ export class BrowserManager {
}
}
private getComposeUrl(platform: string): string {
const urls: Record<string, string> = {
twitter: "https://x.com/compose/tweet",
linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/submit",
bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
};
return urls[platform] || this.getPlatformRules(platform).loginUrl;
}
private getFeedUrl(platform: string): string {
const urls: Record<string, string> = {
twitter: "https://x.com/home",
linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/",
bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
};
return urls[platform] || this.getPlatformRules(platform).loginUrl;
}
private getSearchUrl(platform: string, query: string): string {
const q = encodeURIComponent(query);
const urls: Record<string, string> = {
twitter: `https://x.com/search?q=${q}`,
linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`,
reddit: `https://www.reddit.com/search/?q=${q}`,
};
return urls[platform] || `https://google.com/search?q=${q}`;
}
shutdown() {
for (const [, win] of this.loginWindows) {
if (!win.isDestroyed()) win.close();
+335
View File
@@ -0,0 +1,335 @@
import { BrowserWindow } from "electron";
import { existsSync, writeFileSync } from "node:fs";
import type { NativeImage } from "electron";
import log from "electron-log/main.js";
import type { BrowserAction, BrowserActionResult } from "./browser-types";
import { assertAllowedUrl, attachWindowSecurity, getComposeUrl, getFeedUrl, getSearchUrl } from "./browser-url-helpers";
export { getPlatformRules, assertAllowedUrl, attachWindowSecurity } from "./browser-url-helpers";
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function waitForLoad(win: BrowserWindow, timeoutMs = 15000): Promise<void> {
return new Promise((resolve) => {
if (win.webContents.isLoading()) {
const timer = setTimeout(resolve, timeoutMs);
win.webContents.once("did-finish-load", () => {
clearTimeout(timer);
resolve();
});
} else {
resolve();
}
});
}
export async function runAction(
action: BrowserAction,
screenshotDir: string,
session: Electron.Session,
cancelledActionIds: Set<string>,
activeActionWindows: Map<string, BrowserWindow>,
): Promise<BrowserActionResult> {
const payload = action.payload;
const screenshotPath = `${screenshotDir}/${action.id}.png`;
const platform = action.platform;
log.info(`[browser] Executing ${action.actionType} for ${platform}/${action.socialAccountId}`);
const win = new BrowserWindow({
width: 1280,
height: 860,
title: `AgentCompany — ${action.actionType}`,
show: true,
webPreferences: {
session,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
activeActionWindows.set(action.id, win);
attachWindowSecurity(win, platform);
win.setMenuBarVisibility(false);
try {
let summary = "Action completed";
const assertNotCancelled = () => {
if (cancelledActionIds.has(action.id)) {
throw new Error("BROWSER_ACTION_CANCELLED");
}
};
switch (action.actionType) {
case "navigate": {
assertNotCancelled();
const url = String(payload.url || "https://google.com");
assertAllowedUrl(platform, url, "navigate");
await win.loadURL(url);
await waitForLoad(win);
assertNotCancelled();
const title = await win.webContents.executeJavaScript("document.title");
summary = `Navigated to ${title}`;
break;
}
case "post": {
assertNotCancelled();
const content = String(payload.content || "");
const postUrl = getComposeUrl(platform);
await win.loadURL(postUrl);
await waitForLoad(win);
await delay(2000);
assertNotCancelled();
if (platform === "twitter") {
// Use clipboard-based approach since Twitter's React editor ignores execCommand
await win.webContents.executeJavaScript(`
(async () => {
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]');
if (!editor) return;
editor.focus();
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, inputType: 'insertText', data: ${JSON.stringify(content)} }));
}
})()
`);
await delay(1500);
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
if (btn && !btn.disabled) btn.click();
})()
`);
await delay(3000);
} else if (platform === "linkedin") {
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('button.share-box-feed-entry__trigger, button.artdeco-button--muted');
if (btn) btn.click();
})()
`);
await delay(2000);
await win.webContents.executeJavaScript(`
(async () => {
const editor = document.querySelector('[role="textbox"][contenteditable="true"], .ql-editor[contenteditable="true"]');
if (editor) {
editor.focus();
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 delay(1500);
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('button.share-actions__primary-action, button.artdeco-button--primary[type="submit"]');
if (btn && !btn.disabled) btn.click();
})()
`);
await delay(3000);
}
summary = `Posted to ${platform}: "${content.slice(0, 50)}..."`;
break;
}
case "reply": {
assertNotCancelled();
const targetUrl = String(payload.targetUrl || "");
assertAllowedUrl(platform, targetUrl, "reply");
const replyContent = String(payload.content || "");
await win.loadURL(targetUrl);
await waitForLoad(win);
await delay(2000);
assertNotCancelled();
await win.webContents.executeJavaScript(`
(async () => {
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]');
if (editor) {
editor.focus();
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 delay(1500);
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]');
if (btn && !btn.disabled) btn.click();
})()
`);
await delay(3000);
summary = `Replied to ${targetUrl.slice(0, 50)}`;
break;
}
case "like": {
assertNotCancelled();
const targetUrl = String(payload.targetUrl || "");
assertAllowedUrl(platform, targetUrl, "like");
await win.loadURL(targetUrl);
await waitForLoad(win);
await delay(2000);
assertNotCancelled();
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid="like"], button[aria-label*="Like"]');
if (btn) btn.click();
})()
`);
await delay(2000);
summary = `Liked post at ${targetUrl.slice(0, 50)}`;
break;
}
case "follow": {
assertNotCancelled();
const targetUrl = String(payload.targetUrl || payload.profileUrl || "");
assertAllowedUrl(platform, targetUrl, "follow");
await win.loadURL(targetUrl);
await waitForLoad(win);
await delay(2000);
assertNotCancelled();
await win.webContents.executeJavaScript(`
(() => {
const btn = document.querySelector('[data-testid*="follow"], button[aria-label*="Follow"]');
if (btn && !btn.textContent.includes('Following')) btn.click();
})()
`);
await delay(2000);
summary = `Followed user at ${targetUrl.slice(0, 50)}`;
break;
}
case "browse_feed": {
assertNotCancelled();
const feedUrl = String(payload.url || getFeedUrl(platform));
assertAllowedUrl(platform, feedUrl, "browse_feed");
const maxPosts = Number(payload.maxPosts || 10);
await win.loadURL(feedUrl);
await waitForLoad(win);
await delay(3000);
assertNotCancelled();
for (let i = 0; i < 3; i++) {
await win.webContents.executeJavaScript("window.scrollBy(0, window.innerHeight)");
await delay(1500);
}
const posts: string[] = await win.webContents.executeJavaScript(`
(() => {
const articles = document.querySelectorAll('article, [data-testid="tweet"], .feed-shared-update-v2');
return Array.from(articles).slice(0, ${maxPosts}).map(el => el.innerText.slice(0, 300));
})()
`);
summary = `Browsed feed — found ${posts.length} posts`;
break;
}
case "search": {
assertNotCancelled();
const query = String(payload.query || "");
const searchUrl = getSearchUrl(platform, query);
assertAllowedUrl(platform, searchUrl, "search");
await win.loadURL(searchUrl);
await waitForLoad(win);
await delay(3000);
assertNotCancelled();
const results: string[] = await win.webContents.executeJavaScript(`
(() => {
const items = document.querySelectorAll('article, [data-testid="tweet"], .search-result');
return Array.from(items).slice(0, 10).map(el => el.innerText.slice(0, 200));
})()
`);
summary = `Search "${query.slice(0, 30)}" — ${results.length} results`;
break;
}
case "screenshot": {
assertNotCancelled();
const url = String(payload.url || "about:blank");
if (url !== "about:blank") {
assertAllowedUrl(platform, url, "screenshot");
await win.loadURL(url);
await waitForLoad(win);
await delay(2000);
}
assertNotCancelled();
const title = await win.webContents.executeJavaScript("document.title");
summary = `Screenshot captured: ${title}`;
break;
}
default:
throw new Error(`Unsupported browser action type: ${action.actionType}`);
}
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) {
writeFileSync(screenshotPath, image.toPNG());
}
} catch (err) {
log.warn(`[browser] Screenshot failed: ${err}`);
}
await delay(1000);
if (!win.isDestroyed()) win.close();
return {
success: true,
cancelled: false,
summary,
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
error: null,
};
} catch (error) {
const wasCancelled = cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED");
try {
const image: NativeImage = await win.webContents.capturePage();
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
} catch { /* ignore */ }
if (!win.isDestroyed()) win.close();
const msg = wasCancelled ? "Action cancelled." : error instanceof Error ? error.message : String(error);
if (!wasCancelled) {
log.error(`[browser] Action ${action.actionType} failed: ${msg}`);
}
return {
success: false,
cancelled: wasCancelled,
summary: wasCancelled ? "Action cancelled" : "Action failed",
screenshotPath: existsSync(screenshotPath) ? screenshotPath : null,
error: msg,
};
} finally {
activeActionWindows.delete(action.id);
cancelledActionIds.delete(action.id);
}
}
+95
View File
@@ -0,0 +1,95 @@
export interface BrowserAction {
id: string;
socialAccountId: string;
platform: string;
actionType: string;
payload: Record<string, unknown>;
}
export interface BrowserActionResult {
success: boolean;
cancelled: boolean;
summary: string;
screenshotPath: string | null;
error: string | null;
}
export interface QueuedAction {
action: BrowserAction;
resolve: (result: BrowserActionResult) => void;
reject: (error: Error) => void;
}
export interface PlatformRule {
loginUrl: string;
authCheckUrl: string;
allowedHosts: string[];
cookieOrigins: string[];
unauthenticatedUrlMarkers: string[];
}
export const PLATFORM_RULES: Record<string, PlatformRule> = {
twitter: {
loginUrl: "https://x.com/i/flow/login",
authCheckUrl: "https://x.com/home",
allowedHosts: ["x.com", "twitter.com"],
cookieOrigins: ["https://x.com", "https://twitter.com"],
unauthenticatedUrlMarkers: ["/i/flow/login", "/login"],
},
linkedin: {
loginUrl: "https://www.linkedin.com/login",
authCheckUrl: "https://www.linkedin.com/feed/",
allowedHosts: ["linkedin.com"],
cookieOrigins: ["https://www.linkedin.com"],
unauthenticatedUrlMarkers: ["/login", "/checkpoint"],
},
instagram: {
loginUrl: "https://www.instagram.com/accounts/login/",
authCheckUrl: "https://www.instagram.com/",
allowedHosts: ["instagram.com"],
cookieOrigins: ["https://www.instagram.com"],
unauthenticatedUrlMarkers: ["/accounts/login"],
},
facebook: {
loginUrl: "https://www.facebook.com/login",
authCheckUrl: "https://www.facebook.com/",
allowedHosts: ["facebook.com"],
cookieOrigins: ["https://www.facebook.com"],
unauthenticatedUrlMarkers: ["/login"],
},
reddit: {
loginUrl: "https://www.reddit.com/login",
authCheckUrl: "https://www.reddit.com/",
allowedHosts: ["reddit.com"],
cookieOrigins: ["https://www.reddit.com"],
unauthenticatedUrlMarkers: ["/login"],
},
youtube: {
loginUrl: "https://accounts.google.com/signin",
authCheckUrl: "https://www.youtube.com/",
allowedHosts: ["youtube.com", "google.com"],
cookieOrigins: ["https://www.youtube.com", "https://accounts.google.com"],
unauthenticatedUrlMarkers: ["/signin", "/ServiceLogin"],
},
tiktok: {
loginUrl: "https://www.tiktok.com/login",
authCheckUrl: "https://www.tiktok.com/foryou",
allowedHosts: ["tiktok.com"],
cookieOrigins: ["https://www.tiktok.com"],
unauthenticatedUrlMarkers: ["/login"],
},
bluesky: {
loginUrl: "https://bsky.app",
authCheckUrl: "https://bsky.app/",
allowedHosts: ["bsky.app"],
cookieOrigins: ["https://bsky.app"],
unauthenticatedUrlMarkers: ["/login"],
},
other: {
loginUrl: "https://google.com",
authCheckUrl: "https://google.com",
allowedHosts: ["google.com"],
cookieOrigins: ["https://google.com"],
unauthenticatedUrlMarkers: ["/login"],
},
};
+100
View File
@@ -0,0 +1,100 @@
import { BrowserWindow } from "electron";
import log from "electron-log/main.js";
import { PLATFORM_RULES } from "./browser-types";
export function getPlatformRules(platform: string) {
return PLATFORM_RULES[platform] ?? PLATFORM_RULES.other;
}
export function assertAllowedUrl(platform: string, rawUrl: string, actionType: string) {
const allowedHosts = getPlatformRules(platform).allowedHosts;
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`Invalid URL for ${actionType}: ${rawUrl}`);
}
if (url.protocol !== "https:") {
throw new Error(`Navigation blocked for ${actionType}. Only https URLs are allowed.`);
}
const host = url.hostname.toLowerCase();
const isAllowed = allowedHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`));
if (!isAllowed) {
throw new Error(`Navigation blocked for ${actionType}. ${host} is outside the allowed ${platform} domains.`);
}
}
export function isAllowedNavigation(platform: string, rawUrl: string): boolean {
try {
assertAllowedUrl(platform, rawUrl, "navigate");
return true;
} catch {
return false;
}
}
export function attachWindowSecurity(win: BrowserWindow, platform: string) {
win.webContents.setWindowOpenHandler(({ url }) => {
if (!isAllowedNavigation(platform, url)) {
return { action: "deny" };
}
setImmediate(() => {
void win.loadURL(url).catch((error) => {
log.warn(`[browser] Failed to open allowed popup target for ${platform}: ${error instanceof Error ? error.message : String(error)}`);
});
});
return { action: "deny" };
});
win.webContents.on("will-navigate", (event, navigationUrl) => {
if (isAllowedNavigation(platform, navigationUrl)) {
return;
}
event.preventDefault();
log.warn(`[browser] Blocked navigation outside allowlist for ${platform}: ${navigationUrl}`);
});
win.webContents.on("will-redirect", (event, navigationUrl) => {
if (isAllowedNavigation(platform, navigationUrl)) {
return;
}
event.preventDefault();
log.warn(`[browser] Blocked redirect outside allowlist for ${platform}: ${navigationUrl}`);
});
}
export function getComposeUrl(platform: string): string {
const urls: Record<string, string> = {
twitter: "https://x.com/compose/tweet",
linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/submit",
bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
};
return urls[platform] || getPlatformRules(platform).loginUrl;
}
export function getFeedUrl(platform: string): string {
const urls: Record<string, string> = {
twitter: "https://x.com/home",
linkedin: "https://www.linkedin.com/feed/",
instagram: "https://www.instagram.com/",
facebook: "https://www.facebook.com/",
reddit: "https://www.reddit.com/",
bluesky: "https://bsky.app/",
youtube: "https://www.youtube.com/",
tiktok: "https://www.tiktok.com/",
};
return urls[platform] || getPlatformRules(platform).loginUrl;
}
export function getSearchUrl(platform: string, query: string): string {
const q = encodeURIComponent(query);
const urls: Record<string, string> = {
twitter: `https://x.com/search?q=${q}`,
linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`,
reddit: `https://www.reddit.com/search/?q=${q}`,
};
return urls[platform] || `https://google.com/search?q=${q}`;
}
+2 -2
View File
@@ -27,7 +27,7 @@ export interface BudgetGateResult {
function getScopeLabel(db: AppDatabase, policy: BudgetPolicyRecord): string {
if (policy.scopeType === "agent") {
try {
const agents = db.loadSnapshot(null).agents;
const agents = db.listSnapshot({ companyId: null }).agents;
const agent = agents.find((a) => a.id === policy.scopeId);
return agent ? `Agent "${agent.name}"` : `Agent ${policy.scopeId.slice(0, 8)}`;
} catch {
@@ -36,7 +36,7 @@ function getScopeLabel(db: AppDatabase, policy: BudgetPolicyRecord): string {
}
if (policy.scopeType === "project") {
try {
const projects = db.loadSnapshot(null).projects;
const projects = db.listSnapshot({ companyId: null }).projects;
const project = projects.find((p) => p.id === policy.scopeId);
return project ? `Project "${project.name}"` : `Project ${policy.scopeId.slice(0, 8)}`;
} catch {
+164
View File
@@ -0,0 +1,164 @@
/**
* Checkpoint Engine
*
* Creates approval checkpoints based on company executionMode.
* In ai_cofounder mode, key orchestration junctures pause for user confirmation.
* In ai_ceo mode, only high-risk actions require approval.
*
* Policy decisions (which types require review, which can be auto-approved)
* are centralized in execution-policy.ts — the single source of truth.
*/
import type { AppDatabase } from "./database";
import type { ApprovalType, ExecutionMode, SectionId } from "@shared/types";
import type { ThreadIntegrationService } from "./thread-integration";
import {
CHECKPOINT_TASK_TYPES,
STRATEGY_DOCUMENT_TYPES,
} from "./execution-policy";
interface CheckpointDeps {
db: AppDatabase;
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
threadIntegration: ThreadIntegrationService;
publishDomainChanged: () => void;
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string } }) => void;
}
export function getCompanyExecutionMode(db: AppDatabase, companyId: string): ExecutionMode {
return db.getCompanyExecutionMode(companyId) as ExecutionMode;
}
export function createCheckpointEngine(deps: CheckpointDeps) {
const { db, logger, threadIntegration, publishDomainChanged, notify } = deps;
return {
/**
* Called when a task transitions to in_progress for the first time.
* In co-founder mode, creates a plan_review checkpoint for non-code tasks
* so the user can review the task plan before execution begins.
*/
onTaskStarted(taskId: string, companyId: string, agentId: string | null): string | null {
try {
const mode = getCompanyExecutionMode(db, companyId);
if (mode !== "ai_cofounder") return null;
const task = db.getTask(taskId);
const taskType = db.queryAll<{ taskType: string }>(
"SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1",
taskId,
)[0]?.taskType;
// Only create plan checkpoints for non-code task types
if (!taskType || !CHECKPOINT_TASK_TYPES.has(taskType)) return null;
const approvalId = db.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: agentId,
type: "plan_review",
payloadSummary: `Review plan for: ${task.title}`.slice(0, 280),
impactSummary: `Task "${task.title}" is starting. Review the scope and approach before execution proceeds.`.slice(0, 400),
payloadJson: JSON.stringify({ taskId, taskTitle: task.title, taskType }),
});
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "plan_review", `Plan review: ${task.title}`);
notify({
title: "Plan Review Needed",
body: `"${task.title}" needs your review before work begins`,
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
});
publishDomainChanged();
return approvalId;
} catch (err) {
logger.error(`[checkpoint] onTaskStarted failed: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
},
/**
* Called when a document is created in co-founder mode.
* Creates a draft_review checkpoint so the user can review before
* the document is considered final.
*/
onDocumentDrafted(companyId: string, documentId: string, documentTitle: string, documentType: string, authorAgentId: string | null, taskId: string | null): string | null {
try {
const mode = getCompanyExecutionMode(db, companyId);
if (mode !== "ai_cofounder") return null;
// Strategy-related document types get strategy_review instead
const approvalType: ApprovalType = STRATEGY_DOCUMENT_TYPES.has(documentType)
? "strategy_review"
: "draft_review";
const approvalId = db.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: authorAgentId,
type: approvalType,
payloadSummary: `Review ${documentType.replaceAll("_", " ")}: ${documentTitle}`.slice(0, 280),
impactSummary: `New ${documentType.replaceAll("_", " ")} drafted. Please review before it becomes final.`.slice(0, 400),
payloadJson: JSON.stringify({ documentId, documentTitle, documentType }),
});
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, `${approvalType}: ${documentTitle}`);
notify({
title: "Draft Review Needed",
body: `"${documentTitle}" needs your review`,
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
});
publishDomainChanged();
return approvalId;
} catch (err) {
logger.error(`[checkpoint] onDocumentDrafted failed: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
},
/**
* Checks if a company in ai_cofounder mode has unresolved checkpoints
* that should pause heartbeat for an agent.
*/
shouldPauseHeartbeat(companyId: string, agentId: string): boolean {
try {
const mode = getCompanyExecutionMode(db, companyId);
if (mode !== "ai_cofounder") return false;
// Check for unresolved wait states for THIS agent's threads (not company-wide)
if (threadIntegration.hasUnresolvedWaitStatesForAgent(companyId, agentId)) {
return true;
}
// Check for pending approvals related to this agent's tasks
const pendingApprovals = db.queryAll<{ id: string }>(
`SELECT a.id FROM approvals a
JOIN tasks t ON a.related_task_id = t.id
WHERE a.company_id = ? AND a.state = 'pending'
AND t.assignee_agent_id = ?
AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review')
LIMIT 1`,
companyId, agentId,
);
return pendingApprovals.length > 0;
} catch (err) {
logger.error(`[checkpoint] shouldPauseHeartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
},
/**
* Returns execution mode for a company. Convenience accessor.
*/
getExecutionMode(companyId: string): ExecutionMode {
return getCompanyExecutionMode(db, companyId);
},
};
}
export type CheckpointEngineService = ReturnType<typeof createCheckpointEngine>;
+18 -5
View File
@@ -1,5 +1,6 @@
import { spawn, spawnSync } from "node:child_process";
import { homedir } from "node:os";
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
import type { ConnectorId } from "@shared/types";
function quoteShell(value: string) {
@@ -10,11 +11,18 @@ function escapeAppleScript(value: string) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function quoteCmd(value: string) {
const normalized = normalizeCommandValue(value);
if (!normalized.length) return '""';
const escaped = normalized.replace(/"/g, '""');
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
}
/* ------------------------------------------------------------------ */
/* Shared terminal spawning — used by both auth and generic commands */
/* ------------------------------------------------------------------ */
function spawnTerminalWithScript(script: string, title: string, cwd: string): boolean {
function spawnTerminalWithScript(script: string, _title: string, _cwd: string): boolean {
if (process.platform === "darwin") {
const appleScript = `tell application "Terminal" to do script "${escapeAppleScript(script)}"\ntell application "Terminal" to activate`;
const child = spawn("osascript", ["-e", appleScript], {
@@ -70,23 +78,27 @@ function spawnTerminalWithScript(script: string, title: string, cwd: string): bo
/* ------------------------------------------------------------------ */
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
const normalizedCommand = normalizeCommandValue(command);
switch (connectorId) {
case "codex_local":
return {
title: "Codex Login",
shellCommand: `${quoteShell(command)} login`,
shellCommand: `${quoteShell(normalizedCommand)} login`,
windowsCommand: `${quoteCmd(normalizedCommand)} login`,
intro: "AgentCompany is opening the Codex login flow.",
};
case "claude_local":
return {
title: "Claude Code Login",
shellCommand: `${quoteShell(command)} auth login`,
shellCommand: `${quoteShell(normalizedCommand)} auth login`,
windowsCommand: `${quoteCmd(normalizedCommand)} auth login`,
intro: "AgentCompany is opening the Claude Code login flow.",
};
case "gemini_local":
return {
title: "Gemini CLI Login",
shellCommand: `${quoteShell(command)}`,
shellCommand: `${quoteShell(normalizedCommand)}`,
windowsCommand: quoteCmd(normalizedCommand),
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
};
}
@@ -95,6 +107,7 @@ function connectorAuthSpec(connectorId: ConnectorId, command: string) {
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
const spec = connectorAuthSpec(connectorId, command);
if (!spec) return false;
if (process.platform === "win32") {
// Windows: build a CMD-compatible script directly
@@ -103,7 +116,7 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
`echo ${spec.intro}`,
`echo.`,
`cd /d "${targetCwd}"`,
spec.shellCommand.replace(/'/g, ""), // Remove shell quotes for CMD
spec.windowsCommand,
`echo.`,
`echo You can close this terminal after the login flow completes.`,
`pause`,
+353
View File
@@ -0,0 +1,353 @@
import type { ServerAdapterModule } from "@agentcompany/adapter-utils";
import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local";
import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server";
import {
agentConfigurationDoc as codexDoc,
DEFAULT_CODEX_LOCAL_MODEL,
models as codexModels,
} from "@agentcompany/adapter-codex-local";
import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server";
import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local";
import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server";
import type { CapabilityMatrix, ConnectorId } from "@shared/types";
export interface ConnectorDefinition {
id: ConnectorId;
label: string;
description: string;
defaultCommand: string;
defaultModel: string | null;
notes: string;
capabilityMatrix: CapabilityMatrix;
configurationDoc: string;
module: ServerAdapterModule;
listModels?: () => Promise<Array<{ id: string; label: string }>>;
}
export const CONNECTORS: ConnectorDefinition[] = [
{
id: "codex_local",
label: "Codex",
description: "Local Codex CLI with session resume, structured event parsing, and model selection.",
defaultCommand: "codex",
defaultModel: DEFAULT_CODEX_LOCAL_MODEL,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: codexDoc,
module: {
type: "codex_local",
execute: codexExecute,
testEnvironment: codexTestEnvironment,
sessionCodec: codexSessionCodec,
nativeContextManagement: true,
models: codexModels,
},
listModels: async () => codexModels,
},
{
id: "claude_local",
label: "Claude Code",
description: "Local Claude Code CLI with session persistence and structured result parsing.",
defaultCommand: "claude",
defaultModel: claudeModels[0]?.id ?? null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: true,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compatible",
label: "Claude Code Compatible API",
description: "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama).",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_2",
label: "Claude Code Compatible API (2)",
description: "Second Claude Code CLI compatible API slot for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_3",
label: "Claude Code Compatible API (3)",
description: "Third Claude Code CLI compatible API slot for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_4",
label: "Claude Code Compatible API (4)",
description: "Claude Code CLI compatible API slot 4 for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_5",
label: "Claude Code Compatible API (5)",
description: "Claude Code CLI compatible API slot 5 for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_6",
label: "Claude Code Compatible API (6)",
description: "Claude Code CLI compatible API slot 6 for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_7",
label: "Claude Code Compatible API (7)",
description: "Claude Code CLI compatible API slot 7 for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_8",
label: "Claude Code Compatible API (8)",
description: "Claude Code CLI compatible API slot 8 for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_9",
label: "Claude Code Compatible API (9)",
description: "Claude Code CLI compatible API slot 9 for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "claude_compat_10",
label: "Claude Code Compatible API (10)",
description: "Claude Code CLI compatible API slot 10 for a third-party provider.",
defaultCommand: "claude",
defaultModel: null,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: false,
},
configurationDoc: claudeDoc,
module: {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
nativeContextManagement: true,
models: claudeModels,
},
listModels: async () => claudeModels,
},
{
id: "gemini_local",
label: "Gemini CLI",
description: "Local Gemini CLI with resume support and JSON event parsing.",
defaultCommand: "gemini",
defaultModel: DEFAULT_GEMINI_LOCAL_MODEL,
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: false,
structuredTranscript: true,
costAttribution: true,
},
configurationDoc: geminiDoc,
module: {
type: "gemini_local",
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
sessionCodec: geminiSessionCodec,
nativeContextManagement: false,
models: geminiModels,
},
listModels: async () => geminiModels,
},
];
+6 -11
View File
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
import type { AppDatabase } from "./database";
import { getConnectorDefinition } from "./connectors";
import { buildSanitizedRuntimeEnv } from "./runtime-env";
@@ -36,9 +37,10 @@ export function createConnectorHealthService({
function parseVersion(command: string): Promise<string | null> {
return new Promise((resolve) => {
let resolved = false;
const child = spawn(command, ["--version"], {
const normalizedCommand = normalizeCommandValue(command);
const child = spawn(normalizedCommand, ["--version"], {
env: buildSanitizedRuntimeEnv(process.env, {}),
shell: process.platform === "win32",
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let buffer = "";
@@ -167,19 +169,12 @@ export function createConnectorHealthService({
const errorMessage = error instanceof Error ? error.message : String(error);
_logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`);
const isNotInstalled = /command.*not found|not executable|ENOENT/i.test(errorMessage);
const isTimeout = /timed out/i.test(errorMessage);
// If the command exists on PATH but the probe failed (timeout, unexpected output, etc.),
// still treat it as ready — the user configured it and it's resolvable.
const status = isNotInstalled
? "not_installed"
: isTimeout
? "ready"
: "error";
const status = isNotInstalled ? "not_installed" : "error";
db.updateConnectorHealth({
id,
status,
authState: isNotInstalled ? "error" : "unknown",
version: isTimeout ? connector.version : null,
version: null,
lastError: errorMessage,
});
}
+2
View File
@@ -2,6 +2,8 @@ import type { ConnectorId, ConnectorStatus, TaskType } from "@shared/types";
interface ConnectorMatchInput {
taskType: TaskType;
agentRole?: string;
agentDepartment?: string;
availableConnectors: Array<{
id: ConnectorId;
status: ConnectorStatus;
+111
View File
@@ -0,0 +1,111 @@
import type { ConnectorId } from "@shared/types";
import { getConnectorDefinition } from "./connectors";
interface ConnectorModelPolicyDeps {
getConnector: (id: ConnectorId) => { id: ConnectorId; label?: string | null; model: string | null };
getDisabledModels: (connectorId: string) => Array<{ id: string; label: string }>;
}
export interface ConnectorModelPolicyResult {
connectorId: ConnectorId;
connectorLabel: string;
enabledModels: Array<{ id: string; label: string }>;
defaultModel: string | null;
}
export function parseCompatConnectorModelCatalog(model: string | null | undefined): string[] {
return uniqueModels(
(model ?? "")
.split(",")
.map((entry) => ({ id: entry.trim(), label: entry.trim(), enabled: true }))
.filter((entry) => entry.id.length > 0),
).map((entry) => entry.id);
}
function uniqueModels(models: Array<{ id: string; label: string }>) {
const byId = new Map<string, { id: string; label: string }>();
for (const model of models) {
const id = model.id.trim();
if (!id) continue;
if (!byId.has(id)) {
byId.set(id, { id, label: model.label || id });
}
}
return Array.from(byId.values());
}
export function getConnectorModelPolicy(
deps: ConnectorModelPolicyDeps,
connectorId: ConnectorId,
): ConnectorModelPolicyResult {
const connector = deps.getConnector(connectorId);
const definition = getConnectorDefinition(connectorId);
const disabledModels = new Set(deps.getDisabledModels(connectorId).map((model) => model.id));
const catalog = connectorId.startsWith("claude_compat")
? parseCompatConnectorModelCatalog(connector.model).map((model) => ({ id: model, label: model }))
: (definition.module.models ?? []).map((model) => ({ id: model.id, label: model.label }));
const enabledModels = uniqueModels(catalog).filter((model) => !disabledModels.has(model.id));
return {
connectorId,
connectorLabel: connector.label?.trim() || definition.label,
enabledModels,
defaultModel: connectorId.startsWith("claude_compat")
? null
: (connector.model?.trim() || definition.defaultModel),
};
}
export function validateConnectorModelSelection(
deps: ConnectorModelPolicyDeps,
connectorId: ConnectorId,
model: string | null | undefined,
): { ok: true; effectiveModel: string | null } | { ok: false; error: string } {
const policy = getConnectorModelPolicy(deps, connectorId);
const selectedModel = model?.trim() || null;
if (policy.enabledModels.length === 0) {
return {
ok: false,
error: `${policy.connectorLabel} has no enabled models available.`,
};
}
if (selectedModel) {
if (!policy.enabledModels.some((entry) => entry.id === selectedModel)) {
return {
ok: false,
error: `${policy.connectorLabel} does not allow model "${selectedModel}".`,
};
}
return { ok: true, effectiveModel: selectedModel };
}
if (policy.defaultModel && policy.enabledModels.some((entry) => entry.id === policy.defaultModel)) {
return { ok: true, effectiveModel: policy.defaultModel };
}
return { ok: true, effectiveModel: policy.enabledModels[0]?.id ?? null };
}
export function validateConnectorConfigurationModel(
connectorId: ConnectorId,
model: string | null | undefined,
): { ok: true } | { ok: false; error: string } {
if (!connectorId.startsWith("claude_compat")) {
return { ok: true };
}
if (model == null) {
return { ok: true };
}
const catalog = parseCompatConnectorModelCatalog(model);
if (catalog.length === 0) {
return { ok: false, error: "Compatible connectors require at least one model in the configured model catalog." };
}
return { ok: true };
}
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff. Show more