Compare commits

...
Author SHA1 Message Date
ZacharyZhang-NY 52a6ed449b Package release 0.4.160 from the current workspace snapshot
Constraint: Windows and Linux artifacts are built on native GitHub runners from a temporary snapshot branch
Rejected: Build Windows/Linux locally on Apple Silicon | docker/wine packaging is not stable on this host
Confidence: medium
Scope-risk: moderate
Directive: This snapshot branch is transport-only and should be deleted after the release completes
Tested: local verify/build stages before snapshot creation
Not-tested: remote packaging before workflow execution
2026-04-06 02:22:08 -04:00
ZacharyZhang-NY f5d4f8b705 update 2026-04-06 00:35:27 -04:00
ZacharyZhang-NY 13166fd860 Merge branch 'main' of github.com:ZacharyZhang-NY/AgentCompany 2026-04-05 23:47:58 -04:00
ZacharyZhang-NY 72157f0703 update 2026-04-05 23:47:56 -04:00
ZacharyZhang-NY 69a1139eb3 Add remote Boss workflow used by automated release script 2026-04-05 21:23:18 -04:00
ZacharyZhang-NY 9145572422 update 2026-04-05 20:14:48 -04:00
ZacharyZhang-NY 09971cabc2 Merge branch 'main' of github.com:ZacharyZhang-NY/AgentCompany 2026-04-04 17:01:28 -04:00
ZacharyZhang-NY b8dda3c8d0 update 2026-04-04 17:01:26 -04:00
ZacharyZhang-NY 42bb2f196f Tighten Linux x64 upload globs in the remote release workflow
Constraint: Linux arm64 assets and x64 assets share the same draft release transport bucket
Rejected: Broad *.AppImage/*.deb/*.rpm globs | x64 upload step collided with arm64 assets and failed the workflow
Confidence: high
Scope-risk: narrow
Directive: Keep per-architecture upload globs disjoint whenever multiple jobs share one draft release
Tested: Prior run proved packaging succeeded and isolated the failure to the x64 upload selection
Not-tested: Updated workflow dispatch before rerun
2026-04-04 16:19:37 -04:00
ZacharyZhang-NY bc878c8f93 Add remote workflow used by automated Cloudflare release script 2026-04-04 15:47:50 -04:00
ZacharyZhang-NY 67c757f481 update 2026-04-04 12:03:50 -04:00
419 changed files with 41424 additions and 26581 deletions

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=
+71 -12
View File
@@ -4,6 +4,8 @@ on:
push:
tags:
- 'v*'
pull_request:
branches: [main]
workflow_dispatch:
inputs:
publish:
@@ -19,6 +21,7 @@ concurrency:
env:
PNPM_VERSION: '10.24.0'
NODE_VERSION: '22'
CI_RELEASE_TAG: ci-build-${{ github.run_id }}
jobs:
# ─────────────────────────────────────────────
@@ -37,8 +40,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 +61,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 +72,8 @@ jobs:
name: macOS (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs: test
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@@ -103,7 +110,25 @@ 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
- name: Upload to CI release (Cloudflare transport)
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
- name: Upload artifacts for release job
uses: actions/upload-artifact@v4
with:
name: macos-${{ matrix.arch }}
path: |
@@ -111,12 +136,13 @@ jobs:
build/release/*.zip
build/release/*.blockmap
build/release/latest-mac.yml
if-no-files-found: error
build-windows:
name: Windows (${{ matrix.arch }})
runs-on: windows-latest
needs: test
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@@ -142,19 +168,38 @@ jobs:
env:
CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- uses: actions/upload-artifact@v4
- name: Upload to CI release (Cloudflare transport)
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
- name: Upload artifacts for release job
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
build-linux:
name: Linux (${{ matrix.arch }})
runs-on: ubuntu-latest
needs: test
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@@ -173,24 +218,38 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install snapcraft (x64 only)
if: matrix.arch == 'x64'
run: sudo snap install snapcraft --classic
- name: Build electron-vite
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
- 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
- name: Upload artifacts for release job
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
# ─────────────────────────────────────────────
# Stage 4: Publish to GitHub Releases
@@ -0,0 +1,75 @@
name: Remote Boss Platforms
on:
workflow_dispatch:
inputs:
version:
description: Boss build version
required: true
type: string
release_tag:
description: Draft GitHub release tag used as the Boss transport bucket
required: true
type: string
release_title:
description: Human-readable title for the draft release
required: false
type: string
concurrency:
group: remote-boss-${{ github.ref }}-${{ inputs.release_tag }}
cancel-in-progress: false
env:
PNPM_VERSION: "10.24.0"
NODE_VERSION: "22"
jobs:
build-windows:
name: Boss Windows Installer
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build Boss edition
run: node boss-build/build-boss-edition.mjs --platform=win --version=${{ inputs.version }}
shell: bash
env:
CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload release files
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_TITLE: ${{ inputs.release_title }}
run: |
set -euo pipefail
gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$RELEASE_TAG" \
--draft \
--prerelease \
--title "${RELEASE_TITLE:-Boss Release $GITHUB_RUN_ID}" \
--notes "Temporary Boss transport generated by remote-boss-platforms.yml." \
--target "$GITHUB_SHA"
shopt -s nullglob
files=(
boss-build/build/out/*.exe
boss-build/build/out/*.blockmap
)
if [ ${#files[@]} -eq 0 ]; then
echo "No Boss Windows release files found."
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
@@ -0,0 +1,173 @@
name: Remote Release Platforms
on:
workflow_dispatch:
inputs:
release_tag:
description: Draft GitHub release tag used as the transport bucket
required: true
type: string
release_title:
description: Human-readable title for the draft release
required: false
type: string
concurrency:
group: remote-release-${{ github.ref }}-${{ inputs.release_tag }}
cancel-in-progress: false
env:
PNPM_VERSION: "10.24.0"
NODE_VERSION: "22"
jobs:
build-windows:
name: Windows Universal Installer
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build electron-vite
run: pnpm exec electron-vite build
- name: Package
run: pnpm exec electron-builder --win --x64 --ia32 --arm64 --config electron-builder.yml --publish never
env:
CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload release files
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_TITLE: ${{ inputs.release_title }}
run: |
set -euo pipefail
gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$RELEASE_TAG" \
--draft \
--prerelease \
--title "${RELEASE_TITLE:-Remote Release $GITHUB_RUN_ID}" \
--notes "Temporary release transport generated by remote-release-platforms.yml." \
--target "$GITHUB_SHA"
shopt -s nullglob
files=(
build/release/*.exe
build/release/*.blockmap
build/release/latest.yml
)
if [ ${#files[@]} -eq 0 ]; then
echo "No Windows release files found."
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
build-linux-x64:
name: Linux x64
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build electron-vite
run: pnpm exec electron-vite build
- name: Package
run: pnpm exec electron-builder --linux --x64 --config electron-builder.yml --publish never
- name: Upload release files
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_TITLE: ${{ inputs.release_title }}
run: |
set -euo pipefail
gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$RELEASE_TAG" \
--draft \
--prerelease \
--title "${RELEASE_TITLE:-Remote Release $GITHUB_RUN_ID}" \
--notes "Temporary release transport generated by remote-release-platforms.yml." \
--target "$GITHUB_SHA"
shopt -s nullglob
files=()
for file in build/release/AgentCompany-*.AppImage; do
if [[ "$file" != *arm64* ]]; then
files+=("$file")
fi
done
files+=(
build/release/agent-company_*_amd64.deb
build/release/agent-company-*.x86_64.rpm
build/release/latest-linux.yml
)
if [ ${#files[@]} -eq 0 ]; then
echo "No Linux x64 release files found."
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
build-linux-arm64:
name: Linux arm64
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build electron-vite
run: pnpm exec electron-vite build
- name: Package
run: pnpm exec electron-builder --linux --arm64 --config electron-builder.yml --publish never
- name: Upload release files
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_TITLE: ${{ inputs.release_title }}
run: |
set -euo pipefail
gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$RELEASE_TAG" \
--draft \
--prerelease \
--title "${RELEASE_TITLE:-Remote Release $GITHUB_RUN_ID}" \
--notes "Temporary release transport generated by remote-release-platforms.yml." \
--target "$GITHUB_SHA"
shopt -s nullglob
files=(
build/release/*arm64*.AppImage
build/release/*arm64*.deb
build/release/*aarch64*.rpm
)
if [ ${#files[@]} -eq 0 ]; then
echo "No Linux arm64 release files found."
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
+11 -6
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,14 +60,14 @@ 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*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
build/release/AgentCompany-0.4.61-arm64.dmg
CLAUDE.md
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`
---
+9 -5
View File
@@ -4,12 +4,16 @@
"output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release"
},
"extraMetadata": {
"version": "0.4.136"
"version": "0.4.156"
},
"publish": [
{
"provider": "generic",
"url": "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC"
}
],
"mac": {
"hardenedRuntime": false,
"target": [
"zip"
]
"hardenedRuntime": true,
"sign": "/Users/zacharyzhang/Documents/Github/AgentCompany/scripts/electron-builder-mac-sign.mjs"
}
}
-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.
+1 -11
View File
@@ -40,9 +40,6 @@ win:
arch:
- x64
- arm64
- target: portable
arch:
- x64
linux:
icon: assets/icon.png
@@ -58,9 +55,7 @@ linux:
- target: rpm
arch:
- x64
- target: snap
arch:
- x64
- arm64
category: Development
nsis:
@@ -79,8 +74,3 @@ deb:
- libatspi2.0-0
- libuuid1
- libsecret-1-0
snap:
plugs:
- default
- removable-media
+1 -1
View File
@@ -23,7 +23,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve("src/main/index.ts"),
worker: resolve("src/main/orchestrator/worker.ts"),
worker: resolve("src/main/worker/worker-entry.ts"),
},
},
},
+10
View File
@@ -8,10 +8,18 @@ export default tseslint.config(
{
ignores: [
"out/**",
"**/out/**",
"build/**",
"dist/**",
"oldreference/**",
"Reference/**",
"boss-build/**",
"local-release/**",
"builtin-skills/**",
"node_modules/**",
"**/node_modules/**",
".next/**",
"**/.next/**",
"test-results/**",
"playwright-report/**",
"coverage/**",
@@ -52,8 +60,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>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { AsciiBackground } from "./ascii-background";
import { platformDownloads, releaseVersion } from "./release-links.generated";
import { DOWNLOAD_COUNT_SEED, getTrackedDownloadHref } from "../lib/downloads";
export function DownloadSection() {
const [downloadCount, setDownloadCount] = useState(DOWNLOAD_COUNT_SEED);
useEffect(() => {
let cancelled = false;
async function loadDownloadCount() {
try {
const response = await fetch("/api/download-stats", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to fetch download count: ${response.status}`);
}
const data = await response.json() as { downloads?: number };
if (!cancelled && typeof data.downloads === "number") {
setDownloadCount(data.downloads);
}
} catch {
// Keep the seeded count visible when the dynamic endpoint is unavailable.
}
}
void loadDownloadCount();
return () => {
cancelled = true;
};
}, []);
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">
Version {releaseVersion}.
</p>
<p className="text-[var(--text-on-cta-faint)] text-sm max-w-xl mx-auto mb-12 leading-relaxed -mt-8">
Downloads: {downloadCount.toLocaleString("en-US")}
</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>
<div className="space-y-2.5">
{download.assets.map((asset) => (
<a
key={asset.id}
href={getTrackedDownloadHref(asset.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center 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>
</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>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { AsciiBackground } from "./ascii-background";
import { IconMark } from "./icon-mark";
import { CONTACT_EMAIL, WEBSITE_URL } from "./site-constants";
import { ThemeToggle } from "./theme-toggle";
import { getTrackedDownloadHref } from "../lib/downloads";
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: getTrackedDownloadHref("macos-arm64-dmg"), external: true },
{ label: "Intel Mac", href: getTrackedDownloadHref("macos-x64-dmg"), external: true },
{ label: "Windows Installer", href: getTrackedDownloadHref("windows-universal-installer"), external: true },
{ label: "Linux x64 AppImage", href: getTrackedDownloadHref("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>
</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)]">
Agent Company
</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.159";
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.159-arm64.dmg",
note: "arm64",
},
{
id: "macos-x64-dmg",
label: "Intel Mac DMG",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.159.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.159.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.159.AppImage",
note: "x64",
},
{
id: "linux-x64-deb",
label: "Linux x64 DEB",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.159_amd64.deb",
note: "x64",
},
{
id: "linux-x64-rpm",
label: "Linux x64 RPM",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.159.x86_64.rpm",
note: "x64",
},
{
id: "linux-arm64-appimage",
label: "Linux ARM64 AppImage",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.159-arm64.AppImage",
note: "arm64",
},
{
id: "linux-arm64-deb",
label: "Linux ARM64 DEB",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.159_arm64.deb",
note: "arm64",
},
{
id: "linux-arm64-rpm",
label: "Linux ARM64 RPM",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.159.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>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { CounterDatabase, PagesHandler } from "../types";
import { DOWNLOAD_COUNT_SEED } from "../../lib/downloads";
interface Env {
DOWNLOAD_COUNTER_DB: CounterDatabase;
}
async function readDownloadCount(db: CounterDatabase) {
const result = await db
.prepare(`
SELECT
COALESCE(
(SELECT value FROM download_counter_settings WHERE key = 'base_downloads'),
?
) + COUNT(*) AS downloads
FROM unique_download_windows
`)
.bind(DOWNLOAD_COUNT_SEED)
.first<{ downloads: number }>();
return result?.downloads ?? DOWNLOAD_COUNT_SEED;
}
export const onRequestGet: PagesHandler<Env> = async (context) => {
const downloads = await readDownloadCount(context.env.DOWNLOAD_COUNTER_DB);
return Response.json(
{ downloads },
{
headers: {
"Cache-Control": "no-store",
},
},
);
};
+73
View File
@@ -0,0 +1,73 @@
import {
DOWNLOAD_DEDUP_WINDOW_MS,
getDownloadAsset,
} from "../../lib/downloads";
import type { CounterDatabase, PagesHandler } from "../types";
interface Env {
DOWNLOAD_COUNTER_DB: CounterDatabase;
DOWNLOAD_COUNTER_SALT?: string;
}
function getClientIp(request: Request) {
const cfConnectingIp = request.headers.get("CF-Connecting-IP")?.trim();
if (cfConnectingIp) {
return cfConnectingIp;
}
const forwardedFor = request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
return forwardedFor || null;
}
function getWindowKey(now = Date.now()) {
return Math.floor(now / DOWNLOAD_DEDUP_WINDOW_MS);
}
async function sha256Hex(input: string) {
const buffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function countUniqueDownload(db: CounterDatabase, request: Request, assetId: string, salt: string | undefined) {
const clientIp = getClientIp(request);
if (!clientIp) {
return;
}
const ipHash = await sha256Hex(`${salt ?? ""}:${clientIp}`);
const windowKey = getWindowKey();
await db
.prepare(`
INSERT OR IGNORE INTO unique_download_windows (
ip_hash,
window_key,
first_asset_id,
created_at
) VALUES (?, ?, ?, datetime('now'))
`)
.bind(ipHash, windowKey, assetId)
.run();
}
export const onRequestGet: PagesHandler<Env> = async (context) => {
const url = new URL(context.request.url);
const assetId = url.searchParams.get("asset")?.trim();
if (!assetId) {
return new Response("Missing asset parameter.", { status: 400 });
}
const asset = getDownloadAsset(assetId);
if (!asset) {
return new Response("Unknown asset.", { status: 404 });
}
await countUniqueDownload(
context.env.DOWNLOAD_COUNTER_DB,
context.request,
asset.id,
context.env.DOWNLOAD_COUNTER_SALT,
);
return Response.redirect(asset.href, 302);
};
+17
View File
@@ -0,0 +1,17 @@
export interface CounterDbStatement {
bind(...values: unknown[]): {
first<T = unknown>(): Promise<T | null>;
run(): Promise<unknown>;
};
}
export interface CounterDatabase {
prepare(query: string): CounterDbStatement;
}
export interface PagesHandlerContext<Env> {
request: Request;
env: Env;
}
export type PagesHandler<Env> = (context: PagesHandlerContext<Env>) => Response | Promise<Response>;
+28
View File
@@ -0,0 +1,28 @@
import { platformDownloads, type PlatformDownloadAsset } from "../components/release-links.generated";
export const DOWNLOAD_COUNT_SEED = 13674;
export const DOWNLOAD_DEDUP_WINDOW_HOURS = 24;
export const DOWNLOAD_DEDUP_WINDOW_MS = DOWNLOAD_DEDUP_WINDOW_HOURS * 60 * 60 * 1000;
export type ReleaseDownloadAsset = PlatformDownloadAsset & {
platformId: "macos" | "windows" | "linux";
platformTitle: string;
};
export const downloadAssets: ReleaseDownloadAsset[] = platformDownloads.flatMap((platform) =>
platform.assets.map((asset) => ({
...asset,
platformId: platform.id,
platformTitle: platform.title,
})),
);
const downloadAssetMap = new Map(downloadAssets.map((asset) => [asset.id, asset]));
export function getDownloadAsset(assetId: string) {
return downloadAssetMap.get(assetId) ?? null;
}
export function getTrackedDownloadHref(assetId: string) {
return `/api/download?asset=${encodeURIComponent(assetId)}`;
}
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS download_counter_settings (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL
);
INSERT OR IGNORE INTO download_counter_settings (key, value)
VALUES ('base_downloads', 13674);
CREATE TABLE IF NOT EXISTS unique_download_windows (
ip_hash TEXT NOT NULL,
window_key INTEGER NOT NULL,
first_asset_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ip_hash, window_key)
);
CREATE INDEX IF NOT EXISTS idx_unique_download_windows_created_at
ON unique_download_windows(created_at);
+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"
]
}
+11
View File
@@ -0,0 +1,11 @@
name = "agentcompany"
compatibility_date = "2026-04-03"
pages_build_output_dir = "out"
[vars]
DOWNLOAD_COUNTER_SALT = "agentcompany-download-counter-v1"
[[d1_databases]]
binding = "DOWNLOAD_COUNTER_DB"
database_name = "agentcompany-landing-downloads"
database_id = "dfa3f0f3-009b-4436-b519-5b50ae6b1cf9"
+11 -15
View File
@@ -1,10 +1,6 @@
{
"name": "agent-company",
<<<<<<< Updated upstream
"version": "0.4.148",
=======
"version": "0.4.136",
>>>>>>> Stashed changes
"version": "0.4.160",
"private": true,
"type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
@@ -20,17 +16,20 @@
"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:cloudflare": "node scripts/release-cloudflare.mjs",
"release:cloudflare:public": "node scripts/release-public-cloudflare.mjs",
"release:boss": "node scripts/release-boss.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:packages": "echo 'no standalone package tests'",
"test:e2e": "playwright 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",
"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: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:e2e",
"verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:e2e",
"verify:release": "pnpm verify && pnpm build:dir",
"postinstall": "node scripts/postinstall.mjs"
},
@@ -41,15 +40,12 @@
"@agentcompany/adapter-utils": "workspace:*",
"@phosphor-icons/react": "2.1.10",
"@tanstack/react-virtual": "^3.13.23",
"clsx": "2.1.1",
"electron-log": "5.4.3",
"electron-updater": "6.8.3",
"framer-motion": "12.36.0",
"immer": "^11.1.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "3.8.1",
"tailwind-merge": "3.5.0",
"zod": "4.3.6",
"zustand": "^5.0.12"
},
@@ -0,0 +1,134 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
import {
asString,
asNumber,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "@agentcompany/adapter-utils";
export {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir as resolveAgentCompanySkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
};
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
/**
* 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 });
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, target, onLog);
return tmp;
}
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
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 resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
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 { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } = await buildCommonAgentCompanyEnv(
{ runId, agent, config, context, authToken },
{ ensureAbsoluteDirectory },
);
const runtimeEnv = ensurePathInEnv(env);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = parseExtraArgs(config);
return {
command,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
timeoutSec,
graceSec,
extraArgs,
};
}
@@ -1,26 +1,17 @@
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";
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 +20,14 @@ 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,
parseManagedSkills,
resolveSessionResumeState,
} from "./execute-helpers.js";
export async function runClaudeLogin(input: {
runId: string;
@@ -350,11 +105,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
extraArgs,
} = runtimeConfig;
const billingType = resolveClaudeBillingType(env);
const managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
const managedSkills = parseManagedSkills(config);
const skillsDir = await buildSkillsDir(managedSkills, onLog);
// When instructionsFilePath is configured, create a combined temp file that
@@ -369,13 +120,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
effectiveInstructionsFilePath = combinedPath;
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
const sessionResume = resolveSessionResumeState(runtime, cwd);
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } = sessionResume;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
@@ -0,0 +1,96 @@
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "@agentcompany/adapter-utils";
import {
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import fs from "node:fs/promises";
export {
hasNonEmptyEnvValue,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
};
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
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 resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
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> {
return resolveSkillsDir(SKILLS_CANDIDATES);
}
export async function ensureCodexSkillsInjected(
managedSkills: ManagedSkillRef[],
onLog: AdapterExecutionContext["onLog"],
) {
const skillsHome = path.join(codexHomeDir(), "skills");
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
return;
}
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
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,15 +1,10 @@
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 fs from "node:fs/promises";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
import {
asString,
asNumber,
asBoolean,
asStringArray,
parseObject,
buildAgentCompanyEnv,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -17,96 +12,18 @@ 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,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "./execute-helpers.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
@@ -127,155 +44,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
asBoolean(config.dangerouslyBypassSandbox, false),
);
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, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceBranch = asString(workspaceContext.branchName, "");
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
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 managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
const { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } =
await buildCommonAgentCompanyEnv(
{ runId, agent, config, context, authToken },
{ ensureAbsoluteDirectory },
);
const managedSkills = parseManagedSkills(config);
await ensureCodexSkillsInjected(managedSkills, onLog);
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 [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
if (!hasExplicitApiKey && authToken) {
env.AGENT_COMPANY_API_KEY = authToken;
}
const billingType = resolveCodexBillingType(env);
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);
})();
const extraArgs = parseExtraArgs(config);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } =
resolveSessionResumeState(runtime, cwd);
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[agent-company] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
@@ -328,8 +122,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (model) args.push("--model", model);
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
if (extraArgs.length > 0) {
// Filter out flags the adapter already manages to prevent duplicates
// (e.g. user shell aliases that inject --dangerously-bypass-approvals-and-sandbox)
const managedFlags = [
"--dangerously-bypass-approvals-and-sandbox",
"--json",
@@ -437,7 +229,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
provider: "openai",
model,
billingType,
costUsd: null,
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
@@ -4,6 +4,7 @@ export function parseCodexJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let errorMessage: string | null = null;
let costUsd: number | null = null;
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
@@ -43,6 +44,8 @@ export function parseCodexJsonl(stdout: string) {
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
const rawCost = event.total_cost_usd;
if (typeof rawCost === "number" && Number.isFinite(rawCost)) costUsd = rawCost;
continue;
}
@@ -50,6 +53,8 @@ export function parseCodexJsonl(stdout: string) {
const err = parseObject(event.error);
const msg = asString(err.message, "").trim();
if (msg) errorMessage = msg;
const rawCost = event.total_cost_usd;
if (typeof rawCost === "number" && Number.isFinite(rawCost)) costUsd = rawCost;
}
}
@@ -57,6 +62,7 @@ export function parseCodexJsonl(stdout: string) {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd,
errorMessage,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@agentcompany/adapter-gemini-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",
@@ -0,0 +1,127 @@
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "@agentcompany/adapter-utils";
import {
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
export {
hasNonEmptyEnvValue,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
};
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
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> {
return resolveSkillsDir(SKILLS_CANDIDATES);
}
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();
if (managedSkills.length > 0) {
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
return;
}
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
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,24 +1,18 @@
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 type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
import {
asBoolean,
asNumber,
asString,
asStringArray,
buildAgentCompanyEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
parseObject,
filterConflictingArgs,
redactEnvForLogs,
renderTemplate,
runChildProcess,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import {
@@ -29,122 +23,16 @@ 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,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "./execute-helpers.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
@@ -157,95 +45,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const sandbox = asBoolean(config.sandbox, false);
const workspaceContext = parseObject(context.agentCompanyWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
? context.agentCompanyWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
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 managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
const { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } =
await buildCommonAgentCompanyEnv(
{ runId, agent, config, context, authToken },
{ ensureAbsoluteDirectory },
);
const managedSkills = parseManagedSkills(config);
await ensureGeminiSkillsInjected(managedSkills, onLog);
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 (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 (workspaceHints.length > 0) env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
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 billingType = resolveGeminiBillingType(env);
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);
})();
const extraArgs = parseExtraArgs(config);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } =
resolveSessionResumeState(runtime, cwd);
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
@@ -275,6 +93,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
}
const renderedPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
const envNote = renderEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${envNote}${apiAccessNote}${renderedPrompt}`;
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)."
@@ -293,26 +126,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
const envNote = renderEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${envNote}${apiAccessNote}${renderedPrompt}`;
// When the prompt is short enough for a command-line argument, pass it
// directly as a positional arg (Gemini CLI's native interface). When it
// exceeds the OS limit (~32k chars on Windows), write it to a temp file
// and pass the content via stdin to avoid ENAMETOOLONG.
const SAFE_ARG_LIMIT = 30_000;
const useStdin = prompt.length > SAFE_ARG_LIMIT;
let promptFilePath: string | null = null;
if (useStdin) {
promptFilePath = path.join(os.tmpdir(), `agentcompany-gemini-prompt-${runId}.txt`);
@@ -409,7 +222,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
// On retry, don't fall back to old session ID — the old session was stale
const canFallbackToRuntimeSession = !isRetry;
const resolvedSessionId = attempt.parsed.sessionId
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
@@ -475,7 +287,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
// Clean up temp prompt file if used
if (promptFilePath) {
await fs.unlink(promptFilePath).catch(() => {});
}
-50
View File
@@ -1,50 +0,0 @@
{
"name": "@agentcompany/adapter-kimi-local",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentcompany/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}
@@ -1,9 +0,0 @@
import pc from "picocolors";
export function printKimiStreamEvent(line: string, debug = false) {
if (debug) {
console.log(pc.dim(`[kimi] ${line}`));
return;
}
console.log(line);
}
@@ -1 +0,0 @@
export { printKimiStreamEvent } from "./format-event.js";
-33
View File
@@ -1,33 +0,0 @@
export const type = "kimi_local";
export const label = "Kimi Code (local)";
export const DEFAULT_KIMI_LOCAL_MODEL = "kimi-for-coding";
export const models = [
{ id: DEFAULT_KIMI_LOCAL_MODEL, label: DEFAULT_KIMI_LOCAL_MODEL },
];
export const agentConfigurationDoc = `# kimi_local agent configuration
Adapter: kimi_local
Use when:
- You want AgentCompany to invoke the official Kimi Code CLI locally
- You accept generic CLI execution without session resume or structured transcript guarantees
Core fields:
- cwd (string, optional): absolute working directory fallback for the agent process
- promptTemplate (string, optional): prompt template rendered by AgentCompany before execution
- model (string, optional): informational only, defaults to kimi-for-coding
- command (string, optional): defaults to "kimi"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): graceful termination period in seconds
Notes:
- Kimi integration currently runs in generic CLI mode.
- Session resume, model discovery, graceful cancel handshakes, structured transcript, and cost attribution are not guaranteed.
- AgentCompany must surface these capability downgrades explicitly in the desktop UI.
`;
@@ -1,86 +0,0 @@
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils";
import {
asNumber,
asString,
asStringArray,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import { normalizeEnv } from "@agentcompany/adapter-utils/test-utils";
import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js";
function kimiSkillsHome(): string {
return path.join(os.homedir(), ".kimi", "skills");
}
async function ensureKimiSkillsInjected(
managedSkills: ManagedSkillRef[],
onLog: AdapterExecutionContext["onLog"],
): Promise<void> {
if (managedSkills.length === 0) return;
await createManagedSkillSymlinks(managedSkills, kimiSkillsHome(), onLog);
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, config, context, onLog } = ctx;
const command = asString(config.command, "kimi");
const cwd = asString(config.cwd, process.cwd());
const model = asString(config.model, DEFAULT_KIMI_LOCAL_MODEL);
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = asStringArray(config.extraArgs);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
await ensureKimiSkillsInjected(managedSkills, onLog);
const prompt = typeof context.taskTitle === "string" && context.taskTitle.length > 0
? [
context.taskTitle,
typeof context.taskDescription === "string" && context.taskDescription.length > 0
? context.taskDescription
: "",
].filter(Boolean).join("\n\n")
: [
`You are agent ${ctx.agent.name}.`,
"Respond with a concise execution summary when complete.",
].join("\n\n");
const env = normalizeEnv(ensurePathInEnv(normalizeEnv(config.env)));
await ensureCommandResolvable(command, cwd, env);
const result = await runChildProcess(runId, command, extraArgs, {
cwd,
env,
timeoutSec,
graceSec,
stdin: prompt,
onLog,
});
const summary =
result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(-8)
.join("\n") || null;
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
summary,
model,
billingType: "unknown",
};
}
@@ -1,2 +0,0 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
@@ -1,48 +0,0 @@
import type {
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@agentcompany/adapter-utils";
import {
asString,
ensureCommandResolvable,
ensurePathInEnv,
} from "@agentcompany/adapter-utils/server-utils";
import { normalizeEnv } from "@agentcompany/adapter-utils/test-utils";
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const command = asString(ctx.config.command, "kimi");
const cwd = asString(ctx.config.cwd, process.cwd());
const env = normalizeEnv(ensurePathInEnv(normalizeEnv(ctx.config.env)));
try {
await ensureCommandResolvable(command, cwd, env);
return {
adapterType: "kimi_local",
status: "pass",
checks: [
{
code: "kimi_command_detected",
level: "info",
message: `Detected Kimi Code command "${command}".`,
},
],
testedAt: new Date().toISOString(),
};
} catch (error) {
return {
adapterType: "kimi_local",
status: "fail",
checks: [
{
code: "kimi_command_missing",
level: "error",
message: error instanceof Error ? error.message : "Kimi command is not available.",
hint: "Install Kimi Code with `curl -L code.kimi.com/install.sh | bash` and ensure the CLI is available on PATH.",
},
],
testedAt: new Date().toISOString(),
};
}
}
@@ -1,23 +0,0 @@
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
import { parseCommaArgs, parseEnvVars, parseEnvBindings } from "@agentcompany/adapter-utils/config-utils";
import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js";
export function buildKimiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const config: Record<string, unknown> = {};
if (v.cwd) config.cwd = v.cwd;
if (v.promptTemplate) config.promptTemplate = v.promptTemplate;
config.model = v.model || DEFAULT_KIMI_LOCAL_MODEL;
config.timeoutSec = 0;
config.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) config.env = env;
if (v.command) config.command = v.command;
if (v.extraArgs) config.extraArgs = parseCommaArgs(v.extraArgs);
return config;
}
@@ -1,2 +0,0 @@
export { buildKimiLocalConfig } from "./build-config.js";
export { parseKimiStdoutLine } from "./parse-stdout.js";
@@ -1,7 +0,0 @@
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
export function parseKimiStdoutLine(line: string, ts: string): TranscriptEntry[] {
const text = line.trim();
if (!text) return [];
return [{ kind: "stdout", ts, text: line }];
}
@@ -1,15 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseKimiStdoutLine } from "../src/ui/parse-stdout.js";
describe("parseKimiStdoutLine", () => {
it("returns a stdout entry for non-empty lines", () => {
const entries = parseKimiStdoutLine("hello", "2026-03-17T00:00:00.000Z");
expect(entries).toEqual([
{ kind: "stdout", ts: "2026-03-17T00:00:00.000Z", text: "hello" },
]);
});
it("drops blank lines", () => {
expect(parseKimiStdoutLine(" ", "2026-03-17T00:00:00.000Z")).toEqual([]);
});
});
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -1,51 +0,0 @@
{
"name": "@agentcompany/adapter-opencode-local",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist",
"skills"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentcompany/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}
@@ -1,83 +0,0 @@
import pc from "picocolors";
import { asRecord, safeJsonParse, asString, asNumber, errorText } from "@agentcompany/adapter-utils/parse-utils";
export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
console.log(line);
return;
}
const type = asString(parsed.type);
if (type === "step_start") {
const sessionId = asString(parsed.sessionID);
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
return;
}
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (text) console.log(pc.green(`assistant: ${text}`));
return;
}
if (type === "reasoning") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
return;
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
const tool = asString(part?.tool, "tool");
const callID = asString(part?.callID);
const state = asRecord(part?.state);
const status = asString(state?.status);
const isError = status === "error";
const metadata = asRecord(state?.metadata);
console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`));
if (status) {
const metaParts = [`status=${status}`];
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`);
}
}
console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`));
}
const output = (asString(state?.output) || asString(state?.error)).trim();
if (output) console.log((isError ? pc.red : pc.gray)(output));
return;
}
if (type === "step_finish") {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const input = asNumber(tokens?.input, 0);
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
const cached = asNumber(cache?.read, 0);
const cost = asNumber(part?.cost, 0);
const reason = asString(part?.reason, "step");
console.log(pc.blue(`step finished: reason=${reason}`));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
return;
}
if (type === "error") {
const message = errorText(parsed.error ?? parsed.message);
if (message) console.log(pc.red(`error: ${message}`));
return;
}
console.log(line);
}
@@ -1 +0,0 @@
export { printOpenCodeStreamEvent } from "./format-event.js";
@@ -1,40 +0,0 @@
export const type = "opencode_local";
export const label = "OpenCode (local)";
export const models: Array<{ id: string; label: string }> = [];
export const agentConfigurationDoc = `# opencode_local agent configuration
Adapter: opencode_local
Use when:
- You want AgentCompany to run OpenCode locally as the agent runtime
- You want provider/model routing in OpenCode format (provider/model)
- You want OpenCode session resume across heartbeats via --session
Don't use when:
- You need webhook-style external invocation (use openclaw_gateway or http)
- You only need one-shot shell commands (use process)
- OpenCode CLI is not installed on the machine
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
- promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- OpenCode supports multiple providers and models. Use \
\`opencode models\` to list available options in provider/model format.
- AgentCompany requires an explicit \`model\` value for \`opencode_local\` agents.
- Runs are executed with: opencode run --format json ...
- Sessions are resumed with --session when stored session cwd matches current cwd.
`;
@@ -1,378 +0,0 @@
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,
asNumber,
asStringArray,
parseObject,
buildAgentCompanyEnv,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
renderTemplate,
runChildProcess,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return null;
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
}
function openCodeSkillsHome(): string {
return path.join(os.homedir(), ".opencode", "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;
}
async function ensureOpenCodeSkillsInjected(
managedSkills: ManagedSkillRef[],
onLog: AdapterExecutionContext["onLog"],
) {
const skillsHome = openCodeSkillsHome();
// 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 OpenCode skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[agent-company] Failed to inject OpenCode skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
const promptTemplate = asString(
config.promptTemplate,
"{{context.taskTitle}}\n\n{{context.taskDescription}}",
);
const command = asString(config.command, "opencode");
const model = asString(config.model, "").trim();
const variant = asString(config.variant, "").trim();
const workspaceContext = parseObject(context.agentCompanyWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
? context.agentCompanyWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
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 managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills)
? (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
)
: [];
await ensureOpenCodeSkillsInjected(managedSkills, onLog);
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 (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 (workspaceHints.length > 0) env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
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 = Object.fromEntries(
Object.entries(ensurePathInEnv(env)).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureOpenCodeModelConfiguredAndAvailable({
model,
command,
cwd,
env: 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);
})();
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[agent-company] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stderr",
`[agent-company] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[agent-company] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
}
}
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
if (instructionsPrefix.length > 0) {
return [
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
];
}
return [
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
const buildArgs = (resumeSessionId: string | null) => {
const args = ["run", "--format", "json"];
if (resumeSessionId) args.push("--session", resumeSessionId);
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "opencode_local",
command,
cwd,
commandNotes,
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env),
prompt,
context,
});
}
const proc = await runChildProcess(runId, command, args, {
cwd,
env: runtimeEnv,
stdin: prompt,
timeoutSec,
graceSec,
onLog,
});
return {
proc,
rawStderr: proc.stderr,
parsed: parseOpenCodeJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
rawStderr: string;
parsed: ReturnType<typeof parseOpenCodeJsonl>;
},
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
}
const resolvedSessionId =
attempt.parsed.sessionId ??
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const rawExitCode = attempt.proc.exitCode;
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
const modelId = model || null;
return {
exitCode: synthesizedExitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
usage: {
inputTokens: attempt.parsed.usage.inputTokens,
outputTokens: attempt.parsed.usage.outputTokens,
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: parseModelProvider(modelId),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
};
};
const initial = await runAttempt(sessionId);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
if (
sessionId &&
initialFailed &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stderr",
`[agent-company] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);
}
return toResult(initial);
}
@@ -1,13 +0,0 @@
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
export const sessionCodec = createStandardSessionCodec({ extraSessionIdKeys: ["sessionID"] });
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export {
listOpenCodeModels,
discoverOpenCodeModels,
ensureOpenCodeModelConfiguredAndAvailable,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
@@ -1,33 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
ensureOpenCodeModelConfiguredAndAvailable,
listOpenCodeModels,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
describe("openCode models", () => {
afterEach(() => {
delete process.env.AGENT_COMPANY_OPENCODE_COMMAND;
resetOpenCodeModelsCacheForTests();
});
it("returns an empty list when discovery command is unavailable", async () => {
process.env.AGENT_COMPANY_OPENCODE_COMMAND = "__agentcompany_missing_opencode_command__";
await expect(listOpenCodeModels()).resolves.toEqual([]);
});
it("rejects when model is missing", async () => {
await expect(
ensureOpenCodeModelConfiguredAndAvailable({ model: "" }),
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
});
it("rejects when discovery cannot run for configured model", async () => {
process.env.AGENT_COMPANY_OPENCODE_COMMAND = "__agentcompany_missing_opencode_command__";
await expect(
ensureOpenCodeModelConfiguredAndAvailable({
model: "openai/gpt-5",
}),
).rejects.toThrow("Failed to start command");
});
});
@@ -1,187 +0,0 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@agentcompany/adapter-utils";
import {
asString,
ensurePathInEnv,
runChildProcess,
} from "@agentcompany/adapter-utils/server-utils";
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
const MODELS_CACHE_TTL_MS = 60_000;
function resolveOpenCodeCommand(input: unknown): string {
const envOverride =
typeof process.env.AGENT_COMPANY_OPENCODE_COMMAND === "string" &&
process.env.AGENT_COMPANY_OPENCODE_COMMAND.trim().length > 0
? process.env.AGENT_COMPANY_OPENCODE_COMMAND.trim()
: "opencode";
return asString(input, envOverride);
}
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
const VOLATILE_ENV_KEY_PREFIXES = ["AGENT_COMPANY_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function sortModels(models: AdapterModel[]): AdapterModel[] {
return [...models].sort((a, b) =>
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
if (!firstToken.includes("/")) continue;
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
if (!provider || !model) continue;
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
}
return dedupeModels(parsed);
}
function normalizeEnv(input: unknown): Record<string, string> {
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envInput)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
function isVolatileEnvKey(key: string): boolean {
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n");
return `${command}\n${cwd}\n${envKey}`;
}
function pruneExpiredDiscoveryCache(now: number) {
for (const [key, value] of discoveryCache.entries()) {
if (value.expiresAt <= now) discoveryCache.delete(key);
}
}
export async function discoverOpenCodeModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolveOpenCodeCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const runtimeEnv = normalizeEnv(ensurePathInEnv(env));
const result = await runChildProcess(
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
["models"],
{
cwd,
env: runtimeEnv,
timeoutSec: 20,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error("`opencode models` timed out.");
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
}
return sortModels(parseModelsOutput(result.stdout));
}
export async function discoverOpenCodeModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolveOpenCodeCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env);
const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models;
const models = await discoverOpenCodeModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
model?: unknown;
command?: unknown;
cwd?: unknown;
env?: unknown;
}): Promise<AdapterModel[]> {
const model = asString(input.model, "").trim();
if (!model) {
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
}
const models = await discoverOpenCodeModelsCached({
command: input.command,
cwd: input.cwd,
env: input.env,
});
if (models.length === 0) {
throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth.");
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
);
}
return models;
}
export async function listOpenCodeModels(): Promise<AdapterModel[]> {
try {
return await discoverOpenCodeModelsCached();
} catch {
return [];
}
}
export function resetOpenCodeModelsCacheForTests() {
discoveryCache.clear();
}
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
describe("parseOpenCodeJsonl", () => {
it("parses assistant text, usage, cost, and errors", () => {
const stdout = [
JSON.stringify({
type: "text",
sessionID: "session_123",
part: { text: "Hello from OpenCode" },
}),
JSON.stringify({
type: "step_finish",
sessionID: "session_123",
part: {
reason: "done",
cost: 0.0025,
tokens: {
input: 120,
output: 40,
reasoning: 10,
cache: { read: 20, write: 0 },
},
},
}),
JSON.stringify({
type: "error",
sessionID: "session_123",
error: { message: "model unavailable" },
}),
].join("\n");
const parsed = parseOpenCodeJsonl(stdout);
expect(parsed.sessionId).toBe("session_123");
expect(parsed.summary).toBe("Hello from OpenCode");
expect(parsed.usage).toEqual({
inputTokens: 120,
cachedInputTokens: 20,
outputTokens: 50,
});
expect(parsed.costUsd).toBeCloseTo(0.0025, 6);
expect(parsed.errorMessage).toContain("model unavailable");
});
it("detects unknown session errors", () => {
expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true);
expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true);
expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false);
});
});
@@ -1,99 +0,0 @@
import { asNumber, asString, parseJson, parseObject } from "@agentcompany/adapter-utils/server-utils";
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message = asString(rec.message, "").trim();
if (message) return message;
const data = parseObject(rec.data);
const nestedMessage = asString(data.message, "").trim();
if (nestedMessage) return nestedMessage;
const name = asString(rec.name, "").trim();
if (name) return name;
const code = asString(rec.code, "").trim();
if (code) return code;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
export function parseOpenCodeJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
const errors: string[] = [];
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
};
let costUsd = 0;
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const currentSessionId = asString(event.sessionID, "").trim();
if (currentSessionId) sessionId = currentSessionId;
const type = asString(event.type, "");
if (type === "text") {
const part = parseObject(event.part);
const text = asString(part.text, "").trim();
if (text) messages.push(text);
continue;
}
if (type === "step_finish") {
const part = parseObject(event.part);
const tokens = parseObject(part.tokens);
const cache = parseObject(tokens.cache);
usage.inputTokens += asNumber(tokens.input, 0);
usage.cachedInputTokens += asNumber(cache.read, 0);
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
costUsd += asNumber(part.cost, 0);
continue;
}
if (type === "tool_use") {
const part = parseObject(event.part);
const state = parseObject(part.state);
if (asString(state.status, "") === "error") {
const text = asString(state.error, "").trim();
if (text) errors.push(text);
}
continue;
}
if (type === "error") {
const text = errorText(event.error ?? event.message).trim();
if (text) errors.push(text);
continue;
}
}
return {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd,
errorMessage: errors.length > 0 ? errors.join("\n") : null,
};
}
export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
haystack,
);
}
@@ -1,309 +0,0 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@agentcompany/adapter-utils";
import {
asString,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@agentcompany/adapter-utils/server-utils";
import { summarizeStatus, summarizeProbeDetail, normalizeEnv } from "@agentcompany/adapter-utils/test-utils";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
const OPENCODE_AUTH_REQUIRED_RE =
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
// Wrap the entire test in a safety net — if anything crashes,
// still return a usable result instead of leaving status at "detected".
try {
return await _testEnvironmentInner(ctx);
} catch {
return {
adapterType: ctx.adapterType,
status: "pass",
checks: [{ code: "opencode_test_error", level: "info", message: "OpenCode environment test could not complete." }],
testedAt: new Date().toISOString(),
};
}
}
async function _testEnvironmentInner(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "opencode");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({
code: "opencode_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "opencode_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null;
if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") {
checks.push({
code: "opencode_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY override is empty.",
hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.",
});
}
const runtimeEnv = normalizeEnv(ensurePathInEnv(env));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
if (cwdInvalid) {
checks.push({
code: "opencode_command_skipped",
level: "warn",
message: "Skipped command check because working directory validation failed.",
detail: command,
});
} else {
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "opencode_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "opencode_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
}
const canRunProbe =
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
let modelValidationPassed = false;
const configuredModel = asString(config.model, "").trim();
if (canRunProbe && configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
} else {
checks.push({
code: "opencode_models_empty",
level: "error",
message: "OpenCode returned no models.",
hint: "Run `opencode models` and verify provider authentication.",
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "error",
message: errMsg || "OpenCode model discovery failed.",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
} else if (canRunProbe && !configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "warn",
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
}
if (!configuredModel) {
checks.push({
code: "opencode_model_missing",
level: "warn",
message: "OpenCode requires a configured provider/model ID before runs can start.",
hint: "Run `opencode models`, choose a provider/model, and save it in the connector settings.",
});
} else if (configuredModel && canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
command,
cwd,
env: runtimeEnv,
});
checks.push({
code: "opencode_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
modelValidationPassed = true;
} catch (err) {
checks.push({
code: "opencode_model_invalid",
level: "error",
message: err instanceof Error ? err.message : "Configured model is unavailable.",
hint: "Run `opencode models` and choose a currently available provider/model ID.",
});
}
}
if (canRunProbe && modelValidationPassed) {
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const variant = asString(config.variant, "").trim();
const probeModel = configuredModel;
const args = ["run", "--format", "json"];
args.push("--model", probeModel);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
},
);
const parsed = parseOpenCodeJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
if (probe.timedOut) {
checks.push({
code: "opencode_hello_probe_timed_out",
level: "warn",
message: "OpenCode hello probe timed out.",
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
});
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "OpenCode hello probe succeeded."
: "OpenCode probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
...(detail ? { detail } : {}),
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_auth_required",
level: "warn",
message: "OpenCode is installed, but provider authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
});
} else {
checks.push({
code: "opencode_hello_probe_failed",
level: "error",
message: "OpenCode hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
} catch (err) {
checks.push({
code: "opencode_hello_probe_failed",
level: "error",
message: "OpenCode hello probe failed.",
detail: err instanceof Error ? err.message : String(err),
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
@@ -1,26 +0,0 @@
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
import { parseCommaArgs, parseEnvVars, parseEnvBindings } from "@agentcompany/adapter-utils/config-utils";
export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
ac.timeoutSec = 0;
ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}
@@ -1,2 +0,0 @@
export { parseOpenCodeStdoutLine } from "./parse-stdout.js";
export { buildOpenCodeLocalConfig } from "./build-config.js";
@@ -1,140 +0,0 @@
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
import { asRecord, safeJsonParse } from "@agentcompany/adapter-utils/parse-utils";
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const data = asRecord(rec.data);
const msg =
asString(rec.message) ||
asString(data?.message) ||
asString(rec.name) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
const part = asRecord(parsed.part);
if (!part) return [{ kind: "system", ts, text: "tool event" }];
const toolName = asString(part.tool, "tool");
const state = asRecord(part.state);
const input = state?.input ?? {};
const callEntry: TranscriptEntry = {
kind: "tool_call",
ts,
name: toolName,
toolUseId: asString(part.callID) || asString(part.id) || undefined,
input,
};
const status = asString(state?.status);
if (status !== "completed" && status !== "error") return [callEntry];
const rawOutput =
asString(state?.output) ||
asString(state?.error) ||
asString(part.title) ||
`${toolName} ${status}`;
const metadata = asRecord(state?.metadata);
const headerParts: string[] = [`status: ${status}`];
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`);
}
}
const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim();
return [
callEntry,
{
kind: "tool_result",
ts,
toolUseId: asString(part.callID) || asString(part.id, toolName),
content,
isError: status === "error",
},
];
}
export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = asString(parsed.type);
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "assistant", ts, text }];
}
if (type === "reasoning") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "thinking", ts, text }];
}
if (type === "tool_use") {
return parseToolUse(parsed, ts);
}
if (type === "step_start") {
const sessionId = asString(parsed.sessionID);
return [
{
kind: "system",
ts,
text: `step started${sessionId ? ` (${sessionId})` : ""}`,
},
];
}
if (type === "step_finish") {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason, "step");
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
return [
{
kind: "result",
ts,
text: reason,
inputTokens: asNumber(tokens?.input, 0),
outputTokens: output,
cachedTokens: asNumber(cache?.read, 0),
costUsd: asNumber(part?.cost, 0),
subtype: reason,
isError: false,
errors: [],
},
];
}
if (type === "error") {
const text = errorText(parsed.error ?? parsed.message);
return [{ kind: "stderr", ts, text: text || line }];
}
return [{ kind: "stdout", ts, text: line }];
}
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
@@ -0,0 +1,232 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
import type { ManagedSkillRef } from "./types.js";
import { asString, asStringArray, parseObject } from "./server-utils-parsers.js";
import { buildAgentCompanyEnv } from "./server-utils-parsers.js";
// ---------------------------------------------------------------------------
// hasNonEmptyEnvValue
// ---------------------------------------------------------------------------
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
// ---------------------------------------------------------------------------
// Skills directory resolution
// ---------------------------------------------------------------------------
/**
* Build the standard two-candidate list for a given adapter package.
* `importMetaUrl` should be `import.meta.url` from the adapter's execute-helpers.ts.
* - published: <pkg>/dist/server/ -> <pkg>/skills/
* - dev: src/server/ -> repo root/skills/
*/
export function buildSkillsCandidates(importMetaUrl: string): string[] {
const moduleDir = path.dirname(fileURLToPath(importMetaUrl));
return [
path.resolve(moduleDir, "../../skills"),
path.resolve(moduleDir, "../../../../../skills"),
];
}
/**
* Resolve the first existing skills directory from the given candidates.
*/
export async function resolveSkillsDir(candidates: string[]): Promise<string | null> {
for (const candidate of candidates) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
// ---------------------------------------------------------------------------
// ManagedSkills type guard + parser
// ---------------------------------------------------------------------------
export function parseManagedSkills(config: Record<string, unknown>): ManagedSkillRef[] {
if (!Array.isArray(config.managedSkills)) return [];
return (config.managedSkills as ManagedSkillRef[]).filter(
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
);
}
// ---------------------------------------------------------------------------
// extraArgs parser
// ---------------------------------------------------------------------------
export function parseExtraArgs(config: Record<string, unknown>): string[] {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
}
// ---------------------------------------------------------------------------
// Common env building
// ---------------------------------------------------------------------------
export interface CommonAgentCompanyEnvInput {
runId: string;
agent: { id: string; companyId: string };
config: Record<string, unknown>;
context: Record<string, unknown>;
authToken?: string;
}
export interface CommonAgentCompanyEnvResult {
env: Record<string, string>;
cwd: string;
effectiveWorkspaceCwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
hasExplicitApiKey: boolean;
}
/**
* Build the AGENT_COMPANY_* environment block common to all local adapters.
* Returns the env map plus resolved workspace fields needed for session params.
*/
export async function buildCommonAgentCompanyEnv(
input: CommonAgentCompanyEnvInput,
opts: {
ensureAbsoluteDirectory: (dir: string, options: { createIfMissing: boolean }) => Promise<void>;
},
): Promise<CommonAgentCompanyEnvResult> {
const { runId, agent, config, context, authToken } = input;
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 as unknown, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await opts.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;
// Merge caller-configured env overrides
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.AGENT_COMPANY_API_KEY = authToken;
}
return {
env,
cwd,
effectiveWorkspaceCwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
hasExplicitApiKey,
};
}
// ---------------------------------------------------------------------------
// Session resume validation
// ---------------------------------------------------------------------------
export interface SessionResumeState {
sessionId: string | null;
runtimeSessionId: string;
runtimeSessionCwd: string;
canResumeSession: boolean;
}
export function resolveSessionResumeState(
runtime: { sessionId?: string | null; sessionParams?: Record<string, unknown> | null },
cwd: string,
): SessionResumeState {
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
return {
sessionId: canResumeSession ? runtimeSessionId : null,
runtimeSessionId,
runtimeSessionCwd,
canResumeSession,
};
}
+14 -1
View File
@@ -33,5 +33,18 @@ export {
REDACTED_HOME_PATH_USER,
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";
export type {
CommonAgentCompanyEnvInput,
CommonAgentCompanyEnvResult,
SessionResumeState,
} from "./execute-helpers.js";
export {
hasNonEmptyEnvValue,
buildSkillsCandidates,
resolveSkillsDir,
parseManagedSkills,
parseExtraArgs,
buildCommonAgentCompanyEnv,
resolveSessionResumeState,
} from "./execute-helpers.js";
@@ -1,5 +1,3 @@
import type { TranscriptEntry } from "./types.js";
export const REDACTED_HOME_PATH_USER = "[]";
const HOME_PATH_PATTERNS = [
@@ -49,33 +47,3 @@ export function redactHomePathUserSegmentsInValue<T>(value: T): T {
return redacted as T;
}
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
switch (entry.kind) {
case "assistant":
case "thinking":
case "user":
case "stderr":
case "system":
case "stdout":
return { ...entry, text: redactHomePathUserSegments(entry.text) };
case "tool_call":
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
case "tool_result":
return { ...entry, content: redactHomePathUserSegments(entry.content) };
case "init":
return {
...entry,
model: redactHomePathUserSegments(entry.model),
sessionId: redactHomePathUserSegments(entry.sessionId),
};
case "result":
return {
...entry,
text: redactHomePathUserSegments(entry.text),
subtype: redactHomePathUserSegments(entry.subtype),
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
};
default:
return entry;
}
}
@@ -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, spawnSync, 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 {
spawnSync("taskkill", ["/T", "/F", "/PID", String(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,305 @@
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]));
}
/**
* 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 -9
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`;
}
@@ -33,11 +35,3 @@ export function summarizeProbeDetail(stdout: string, stderr: string, parsedError
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
}
export function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
-46
View File
@@ -26,9 +26,6 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.13.23
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
clsx:
specifier: 2.1.1
version: 2.1.1
electron-log:
specifier: 5.4.3
version: 5.4.3
@@ -38,9 +35,6 @@ importers:
framer-motion:
specifier: 12.36.0
version: 12.36.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
immer:
specifier: ^11.1.4
version: 11.1.4
react:
specifier: 19.2.4
version: 19.2.4
@@ -50,9 +44,6 @@ importers:
recharts:
specifier: 3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1)
tailwind-merge:
specifier: 3.5.0
version: 3.5.0
zod:
specifier: 4.3.6
version: 4.3.6
@@ -169,38 +160,6 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/adapter-kimi-local:
dependencies:
'@agentcompany/adapter-utils':
specifier: workspace:*
version: link:../adapter-utils
picocolors:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^24.6.0
version: 24.12.0
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/adapter-opencode-local:
dependencies:
'@agentcompany/adapter-utils':
specifier: workspace:*
version: link:../adapter-utils
picocolors:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^24.6.0
version: 24.12.0
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/adapter-utils:
devDependencies:
'@types/node':
@@ -2811,9 +2770,6 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwindcss@4.2.1:
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
@@ -5798,8 +5754,6 @@ snapshots:
dependencies:
has-flag: 4.0.0
tailwind-merge@3.5.0: {}
tailwindcss@4.2.1: {}
tapable@2.3.0: {}
Loaded 100 of 419 files, more files were not shown because too many files have changed in this diff. Show more