Compare commits

...
9 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 4.6 c1de05a7f1 chore: bump version to 0.4.97, update build config
Build & Release / Lint & Typecheck (push) Successful in 35s
Build & Release / Unit Tests (push) Successful in 34s
Build & Release / Windows (arm64) (push) Failing after 14s
Build & Release / Windows (x64) (push) Failing after 5m51s
Build & Release / macOS (arm64) (push) Canceled after 0s
Build & Release / macOS (x64) (push) Canceled after 0s
Build & Release / Linux (arm64) (push) Canceled after 0s
Build & Release / Linux (x64) (push) Canceled after 0s
Build & Release / Publish Release (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:42:00 -04:00
ZacharyZhang-NYandClaude Opus 4.6 05c57be98f fix: prevent agents from running indefinitely after app quit
Agents kept running after closing AgentCompany because worker processes
auto-recreated on exit, retry timers were never cleared, child CLI
processes weren't terminated, and orchestration had no quit guards.

Changes:
- Add isQuitting guards to all orchestration/automation entry points
- Track and clear all setTimeout/setInterval timers on quit
- Send cancel-run to all active CLI processes before killing worker
- Add destroyed flag to prevent worker recreation after intentional destroy
- Add quit confirmation dialog: Stop All & Quit / Minimize to Background / Cancel
- Bump version to 0.4.96

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:40:58 -04:00
ZacharyZhang-NY 222c80e544 更新 README.md 2026-03-25 08:06:13 -04:00
ZacharyZhang-NY 8dd6dd09d6 update 2026-03-25 01:38:49 -04:00
ZacharyZhang-NYandClaude Opus 4.6 eda0e26adb feat: rebrand to website design, Apple HIG icons, ASCII onboarding animation
Build & Release / Lint & Typecheck (push) Successful in 38s
Build & Release / Unit Tests (push) Successful in 31s
Build & Release / Windows (arm64) (push) Failing after 11s
Build & Release / Windows (x64) (push) Failing after 27s
Build & Release / Linux (arm64) (push) Canceled after 0s
Build & Release / Linux (x64) (push) Canceled after 0s
Build & Release / Publish Release (push) Canceled after 0s
Build & Release / macOS (arm64) (push) Canceled after 0s
Build & Release / macOS (x64) (push) Canceled after 0s
- Align Electron CSS with website design schema (Inter font, #FBFAF5/#191919
  palette, #213448/#94B4C1 accent)
- Generate proper app icons from logo.svg via cairosvg (no more Pillow ladder)
- Square bg icons for Electron/tray (Apple HIG — OS applies squircle mask)
- Circle bg icons for website favicon only
- Add scrolling ASCII agent-activity animation to onboarding hero left panel
- Remove dock.setIcon() — let macOS use bundle .icns with system mask
- Fix goal title overflow with min-w-0 for flex truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:39:28 -04:00
ZacharyZhang-NYandClaude Opus 4.6 273244817a feat: rebrand icons — #191919 circle bg, #FBFAF5 logo
Build & Release / Lint & Typecheck (push) Successful in 36s
Build & Release / Unit Tests (push) Successful in 34s
Build & Release / Windows (x64) (push) Failing after 34s
Build & Release / Windows (arm64) (push) Failing after 34s
Build & Release / macOS (x64) (push) Canceled after 0s
Build & Release / macOS (arm64) (push) Canceled after 0s
Build & Release / Linux (arm64) (push) Canceled after 50s
Build & Release / Linux (x64) (push) Canceled after 0s
Build & Release / Publish Release (push) Canceled after 0s
Replace all orange/red icon assets with new brand colors:
- Circle background #191919, logo mark #FBFAF5
- Regenerated: icon.png, icon.ico, icon.icns, all tray icons
- Updated generate-icons.py with new palette and circle shape
- Added source logo.svg
- Bump version to 0.4.92

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:24:20 -04:00
ZacharyZhang-NYandClaude Opus 4.6 02386999f7 docs: remove local-only tagline, drop Kimi CLI and OpenCode from README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:58:43 -04:00
ZacharyZhang-NYandClaude Opus 4.6 69b25bbda5 docs: add screenshots and redesign README with feature showcase
- Add 7 product screenshots (Dashboard, OrgChart, Messages, Tasks, Approvals, Documents, Onboarding)
- Redesign README with centered logo, badge row, nav links, and feature sections with screenshots
- Add supported AI runtimes table, tech stack table, and architecture overview
- Add landing/ to .gitignore (deployed separately to Cloudflare Pages)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:57:30 -04:00
ZacharyZhang-NYandClaude Opus 4.6 a4fd98d934 fix: add system tray icon for all platforms (macOS, Windows, Linux)
Build & Release / Lint & Typecheck (push) Successful in 1m29s
Build & Release / Unit Tests (push) Successful in 31s
Build & Release / Windows (arm64) (push) Failing after 4m48s
Build & Release / Windows (x64) (push) Failing after 4m42s
Build & Release / Linux (x64) (push) Failing after 19s
Build & Release / macOS (arm64) (push) Canceled after 0s
Build & Release / macOS (x64) (push) Canceled after 0s
Build & Release / Publish Release (push) Canceled after 0s
Build & Release / Linux (arm64) (push) Failing after 1m9s
Tray was invisible on Windows/Linux (icon not bundled in asar) and
completely absent on macOS (early return guard). Generate dedicated
tray-sized icons, bundle them in the packaged app, and create tray
on all platforms with macOS Template image support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:21:17 -04:00
39 changed files with 732 additions and 291 deletions

No files matched your search

+4
View File
@@ -48,6 +48,9 @@ oldreference/
# Local design scratch files # Local design scratch files
homepagedesign.html homepagedesign.html
# Landing page (deployed separately to Cloudflare Pages)
landing/
# Package manager logs # Package manager logs
npm-debug.log* npm-debug.log*
pnpm-debug.log* pnpm-debug.log*
@@ -57,3 +60,4 @@ CLAUDE.md
build/release/AgentCompany-0.4.61-arm64.dmg build/release/AgentCompany-0.4.61-arm64.dmg
CLAUDE.md CLAUDE.md
CLAUDE.md CLAUDE.md
build/release/AgentCompany-0.4.90-arm64.dmg
+146 -12
View File
@@ -1,16 +1,113 @@
# AgentCompany <p align="center">
<img src="assets/icon.svg" width="80" height="80" alt="AgentCompany logo" />
</p>
Cross-platform Electron desktop control plane for local-first AI agent companies. <h1 align="center">AgentCompany</h1>
AgentCompany is a standalone desktop application for managing multiple AI coding agents (Codex, Claude Code, Gemini CLI, OpenCode, Kimi Code) as an organization with goals, tasks, approvals, budgets, and audit trails. <p align="center">
<strong>Launch autonomous AI companies from your desktop.</strong><br/>
Set a goal. Watch AI agents self-organize, delegate, and deliver.<br/>
<strong>PLEASE USE A CODING PLAN, IT COMSUMES A LOT OF TOKENS</strong><br/>
I will add support for Ollama and Github Copilot in the near future.
</p>
<p align="center">
<a href="https://github.com/ZacharyZhang-NY/AgentCompany/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/ZacharyZhang-NY/AgentCompany?style=flat-square&color=4a5d7a" /></a>
<a href="https://github.com/ZacharyZhang-NY/AgentCompany/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/ZacharyZhang-NY/AgentCompany?style=flat-square&color=4a5d7a" /></a>
<img alt="Platform" src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-4a5d7a?style=flat-square" />
<img alt="Electron" src="https://img.shields.io/badge/electron-39.8.0-4a5d7a?style=flat-square" />
</p>
<p align="center">
<a href="https://github.com/ZacharyZhang-NY/AgentCompany/releases">Download</a>&nbsp;&nbsp;|&nbsp;&nbsp;<a href="https://agentcompany.pages.dev">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;<a href="#features">Features</a>&nbsp;&nbsp;|&nbsp;&nbsp;<a href="#quick-start">Quick Start</a>
</p>
---
<p align="center">
<img src="screenshots/Dashboard.jpeg" width="800" alt="AgentCompany Dashboard — active agents, tasks, and company metrics" />
</p>
## What is AgentCompany?
AgentCompany is a cross-platform desktop app that turns a single goal into a fully autonomous AI company. You provide a company name, description, and mission. A CEO agent self-organizes departments, hires specialists, delegates tasks, and delivers real results — code, content, research, business plans, and more — while you observe through a Slack-style command hub. Inspired by **[Paperclip](https://github.com/paperclipai/paperclip)**
### Supported AI Runtimes
| Runtime | Status |
|---------|--------|
| Claude Code | Ready |
| Codex CLI | Ready |
| Gemini CLI | Ready |
---
## Features
### Self-Organizing Teams
Your AI CEO builds the org chart — recruiting specialists, creating departments, and establishing reporting lines from a single goal.
<p align="center">
<img src="screenshots/Auto-OrgChart-Team.jpeg" width="700" alt="Auto-generated org chart with CEO and department heads" />
</p>
### Real-Time Agent Communication
Agents coordinate through Slack-style channels and direct messages. Watch strategic decisions unfold in real time.
<p align="center">
<img src="screenshots/AI-Internal-Realtime-Message.jpeg" width="700" alt="Real-time messaging between AI agents" />
</p>
### Autonomous Task Management
Goals break down into tasks automatically. Agents pick up work, report progress, and escalate blockers without intervention.
<p align="center">
<img src="screenshots/Auto-Task.jpeg" width="700" alt="Autonomous task queue with goals and status tracking" />
</p>
### Human-in-the-Loop Approvals
Critical decisions surface for your review. Approve, reject, or redirect — your company respects the chain of command.
<p align="center">
<img src="screenshots/Approval-by-Human.jpeg" width="700" alt="Human approval workflow for agent decisions" />
</p>
### AI Document Generation
Agents draft business plans, marketing strategies, and technical specs — stored in a shared document center.
<p align="center">
<img src="screenshots/Document-Center.jpeg" width="700" alt="Document center with AI-generated deliverables" />
</p>
### One-Click Company Creation
Name it, describe the mission, connect a runtime, and launch. Agents start working in under a minute.
<p align="center">
<img src="screenshots/Onboarding.jpeg" width="700" alt="Company creation onboarding flow" />
</p>
---
## Quick Start ## Quick Start
### Download
Grab the latest release for your platform:
**[Download AgentCompany](https://github.com/ZacharyZhang-NY/AgentCompany/releases)**
> Available for macOS (Apple Silicon & Intel), Windows, and Linux.
### Build from Source
```sh ```sh
# Prerequisites: Node.js 22+, pnpm 10+
pnpm install pnpm install
pnpm dev pnpm dev
``` ```
---
## Verification ## Verification
```sh ```sh
@@ -26,25 +123,62 @@ pnpm build:verify
## Packaging ## Packaging
```sh ```sh
pnpm build # Compile + package the current host platform into /build/release pnpm build # Compile + package for current platform → /build/release
pnpm build:dir # Compile + unpackaged directory pnpm build:dir # Compile + unpackaged directory
pnpm build:all # Build for all platforms
``` ```
- Supported targets: macOS, Windows, Linux - **Targets:** macOS (`.dmg`), Windows (`.exe` / NSIS), Linux (`.AppImage`)
- Packaging uses `electron-builder` - Packaging uses `electron-builder`
- Local packaging cleans `/build` and injects a non-mutating build version
- Local builds use ad-hoc signing (`CSC_IDENTITY_AUTO_DISCOVERY=false`) - Local builds use ad-hoc signing (`CSC_IDENTITY_AUTO_DISCOVERY=false`)
- Defaults to `--publish never`; override with `AGENTCOMPANY_ELECTRON_PUBLISH` env var - Defaults to `--publish never`; override with `AGENTCOMPANY_ELECTRON_PUBLISH`
---
## Architecture ## Architecture
``` ```
src/ src/
main/ # Electron main process, IPC, orchestration, database main/ # Electron main process — database, services, IPC, orchestration
preload/ # Typed bridge (contextIsolation) preload/ # Context-isolated bridge — DesktopApi via contextBridge
renderer/ # React UI renderer/ # React 19 UI — components, hooks, i18n
shared/ # Contracts, types shared across processes shared/ # Contracts, types, Zod schemas shared across processes
packages/ packages/
adapter-utils/ # Shared adapter utilities adapter-utils/ # Shared adapter utilities
adapter-*/ # Per-connector adapter packages adapter-claude-local/
adapter-codex-local/
adapter-gemini-local/
``` ```
### Key Design Decisions
- **Local-first:** SQLite database, secrets in OS keychain, no external services required
- **Security:** `contextIsolation: true`, `nodeIntegration: false`, Zod-validated IPC, JWT agent tokens
- **Real autonomy:** Agents communicate, delegate, escalate, and produce deliverables without manual prompting
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | Electron 39.8.0 |
| Runtime | Node.js 22+ |
| Language | TypeScript 5.9 (strict) |
| UI | React 19, Tailwind CSS 4, Framer Motion |
| Build | electron-vite, Vite 7, electron-builder |
| Database | SQLite (WAL mode, 25+ tables) |
| Validation | Zod 4 |
| Testing | Vitest, Playwright |
---
## License
[MIT](LICENSE)
---
<p align="center">
<sub>Built with quiet authority. Ship your AI company.</sub>
</p>
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
<!-- Apple HIG: full-bleed square, no circle, OS applies mask -->
<rect width="1024" height="1024" fill="#191919"/>
<g transform="translate(146 134) scale(6)">
<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.4 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+5 -19
View File
@@ -1,21 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
<defs> <circle cx="512" cy="512" r="480" fill="#191919"/>
<linearGradient id="bg" x1="200" x2="824" y1="96" y2="928" gradientUnits="userSpaceOnUse"> <g transform="translate(268 255) scale(4)">
<stop stop-color="#111315"/> <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"/>
<stop offset="1" stop-color="#1b1f23"/> <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"/>
</linearGradient> </g>
<radialGradient id="glow" cx="0" cy="0" r="1"
gradientTransform="translate(450 348) rotate(36) scale(800)" gradientUnits="userSpaceOnUse">
<stop stop-color="#d97745" stop-opacity=".13"/>
<stop offset="1" stop-color="#d97745" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#bg)"/>
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#glow)"/>
<polygon points="490,200 534,200 578,800 298,800" fill="#d97745" fill-opacity=".94"/>
<polygon points="490,200 534,200 726,800 446,800" fill="#d97745" fill-opacity=".94"/>
<rect x="400" y="419" width="224" height="42" rx="12" fill="#ffebd7"/>
<rect x="374" y="524" width="276" height="42" rx="12" fill="#ebb48c"/>
<rect x="348" y="629" width="328" height="42" rx="12" fill="#be8c69"/>
<circle cx="512" cy="198" r="20" fill="#ffebd7"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

+1 -1
View File
@@ -4,7 +4,7 @@
"output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release" "output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release"
}, },
"extraMetadata": { "extraMetadata": {
"version": "0.4.80" "version": "0.4.95"
}, },
"mac": { "mac": {
"hardenedRuntime": false, "hardenedRuntime": false,
+4
View File
@@ -5,6 +5,8 @@ arm64:
- '!build/release{,/**/*}' - '!build/release{,/**/*}'
- out/** - out/**
- package.json - 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}' - '!**/*.{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}' - '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
@@ -16,3 +18,5 @@ arm64:
- '**/*' - '**/*'
- out/** - out/**
- package.json - package.json
- assets/icon.png
- assets/tray-icon*.png
+7 -7
View File
@@ -1,8 +1,8 @@
version: 0.4.80 version: 0.4.95
files: files:
- url: AgentCompany-0.4.80-arm64-mac.zip - url: AgentCompany-0.4.95-arm64-mac.zip
sha512: 5gKuP0v6BeMrgiEre/WXXERVAyjh7imWgleoV18ZU5fly+/wq8witKgg8o+E5kid7VUSqTYlvePRtkT8NrYYhg== sha512: /Ew0ls0nISOD/65MHxVbc23TRmgP/bbCWR89j/FhoAwKkE0Vmexjsp6IxY22X8tXnwuMgP+A/DJgqenNlKslzQ==
size: 113614651 size: 115753870
path: AgentCompany-0.4.80-arm64-mac.zip path: AgentCompany-0.4.95-arm64-mac.zip
sha512: 5gKuP0v6BeMrgiEre/WXXERVAyjh7imWgleoV18ZU5fly+/wq8witKgg8o+E5kid7VUSqTYlvePRtkT8NrYYhg== sha512: /Ew0ls0nISOD/65MHxVbc23TRmgP/bbCWR89j/FhoAwKkE0Vmexjsp6IxY22X8tXnwuMgP+A/DJgqenNlKslzQ==
releaseDate: '2026-03-23T20:47:41.499Z' releaseDate: '2026-03-24T20:37:33.571Z'
+2
View File
@@ -6,6 +6,8 @@ directories:
files: files:
- out/** - out/**
- package.json - package.json
- assets/icon.png
- assets/tray-icon*.png
asar: true asar: true
icon: assets/icon.png icon: assets/icon.png
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="122" height="126" viewBox="0 0 122 126" xmlns="http://www.w3.org/2000/svg">
<path id="Path" fill="#FBFAF5" stroke="none" 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 id="path1" fill="#FBFAF5" stroke="none" 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"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-company", "name": "agent-company",
"version": "0.4.90", "version": "0.4.97",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
@@ -35,7 +35,9 @@
"@agentcompany/adapter-codex-local": "workspace:*", "@agentcompany/adapter-codex-local": "workspace:*",
"@agentcompany/adapter-gemini-local": "workspace:*", "@agentcompany/adapter-gemini-local": "workspace:*",
"@agentcompany/adapter-utils": "workspace:*", "@agentcompany/adapter-utils": "workspace:*",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "5.2.8", "@fontsource-variable/jetbrains-mono": "5.2.8",
"@fontsource/instrument-serif": "^5.2.8",
"@phosphor-icons/react": "2.1.10", "@phosphor-icons/react": "2.1.10",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"clsx": "2.1.1", "clsx": "2.1.1",
+16
View File
@@ -20,9 +20,15 @@ importers:
'@agentcompany/adapter-utils': '@agentcompany/adapter-utils':
specifier: workspace:* specifier: workspace:*
version: link:packages/adapter-utils version: link:packages/adapter-utils
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
'@fontsource-variable/jetbrains-mono': '@fontsource-variable/jetbrains-mono':
specifier: 5.2.8 specifier: 5.2.8
version: 5.2.8 version: 5.2.8
'@fontsource/instrument-serif':
specifier: ^5.2.8
version: 5.2.8
'@phosphor-icons/react': '@phosphor-icons/react':
specifier: 2.1.10 specifier: 2.1.10
version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -699,9 +705,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/jetbrains-mono@5.2.8': '@fontsource-variable/jetbrains-mono@5.2.8':
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
'@fontsource/instrument-serif@5.2.8':
resolution: {integrity: sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==}
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -3387,8 +3399,12 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/jetbrains-mono@5.2.8': {} '@fontsource-variable/jetbrains-mono@5.2.8': {}
'@fontsource/instrument-serif@5.2.8': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7': '@humanfs/node@0.16.7':
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

+144 -163
View File
@@ -1,214 +1,195 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate AgentCompany app icons (PNG, ICO, ICNS, SVG). """Generate AgentCompany icons from SVG sources using cairosvg.
Design v3b: Bold "A" monogram that doubles as an org-chart hierarchy. Two icon families:
Two terracotta legs meet at an apex, with three warm crossbars of - Web (circle bg): icon.svg → landing favicon + website logo
decreasing brightness (CEO → Managers → ICs). All tones stay warm. - App (square bg): icon-app.svg → Electron app icon (Apple HIG, OS applies mask)
- Tray: rendered from logo.svg for menu bar / notification area
""" """
from __future__ import annotations from __future__ import annotations
import math import io
import os
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter # ── Ensure cairo library is discoverable on macOS (Homebrew) ──────────
for _lib_dir in ("/opt/homebrew/opt/cairo/lib", "/usr/local/opt/cairo/lib"):
if Path(_lib_dir).exists():
os.environ["DYLD_LIBRARY_PATH"] = (
_lib_dir + ":" + os.environ.get("DYLD_LIBRARY_PATH", "")
)
break
import cairosvg # noqa: E402 — must come after DYLD_LIBRARY_PATH setup
from PIL import Image # noqa: E402
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / "assets" ASSETS = ROOT / "assets"
LANDING = ROOT / "landing" / "public"
ICONSET = ASSETS / "icon.iconset" ICONSET = ASSETS / "icon.iconset"
PNG_PATH = ASSETS / "icon.png" LOGO_SVG = ROOT / "logo.svg"
ICO_PATH = ASSETS / "icon.ico"
ICNS_PATH = ASSETS / "icon.icns" # SVG sources
SVG_PATH = ASSETS / "icon.svg" WEB_SVG = ASSETS / "icon.svg" # circle bg — web favicon
APP_SVG = ASSETS / "icon-app.svg" # square bg — Electron (Apple HIG)
SIZE = 1024 SIZE = 1024
# ── Brand palette ──────────────────────────────────────────────────────
TERRACOTTA = (217, 119, 69) # #d97745
SLATE_TOP = (17, 19, 21) # #111315
SLATE_BOT = (27, 31, 35) # #1b1f23
# Bar colours — all warm, decreasing brightness # ── Helpers ───────────────────────────────────────────────────────────
BAR_CEO = (255, 235, 215) # warm cream — brightest
BAR_MGR = (235, 180, 140) # warm peach
BAR_IC = (190, 140, 105) # muted terracotta
def svg_to_image(svg_path: Path, width: int, height: int | None = None) -> Image.Image:
def rgba(r: int, g: int, b: int, a: int = 255) -> tuple[int, int, int, int]: """Render an SVG to a Pillow RGBA Image at the given size."""
return (r, g, b, a) h = height or width
png_data = cairosvg.svg2png(
url=str(svg_path), output_width=width, output_height=h,
def blend(
a: tuple[int, ...], b: tuple[int, ...], t: float,
) -> tuple[int, ...]:
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(len(a)))
def build_icon() -> Image.Image:
# ── 1. Gradient background with warm glow ──────────────────────────
bg = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
px = bg.load()
top_c = rgba(*SLATE_TOP)
bot_c = rgba(*SLATE_BOT)
glow_cx, glow_cy = SIZE * 0.44, SIZE * 0.34
glow_r = SIZE * 0.78
for y in range(SIZE):
t = y / (SIZE - 1)
base = blend(top_c, bot_c, t)
for x in range(SIZE):
d = math.hypot(x - glow_cx, y - glow_cy) / glow_r
warmth = max(0.0, 1.0 - d) ** 1.8 * 0.13
px[x, y] = blend(base, rgba(*TERRACOTTA), warmth)
# ── 2. Rounded-rect mask + shadow ──────────────────────────────────
icon = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
mask = Image.new("L", (SIZE, SIZE), 0)
inset = 64
ImageDraw.Draw(mask).rounded_rectangle(
(inset, inset, SIZE - inset, SIZE - inset), radius=224, fill=255,
) )
shadow_alpha = mask.filter(ImageFilter.GaussianBlur(28)) return Image.open(io.BytesIO(png_data)).convert("RGBA")
shadow_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 48))
shadow_layer.putalpha(shadow_alpha)
icon.alpha_composite(shadow_layer, (0, 12))
icon.alpha_composite(bg)
icon.putalpha(mask)
# ── 3. Draw the "A" mark ───────────────────────────────────────────
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
cx = SIZE // 2 def svg_bytes_to_image(svg_bytes: bytes, width: int, height: int | None = None) -> Image.Image:
apex_y = 200 """Render SVG bytes to a Pillow RGBA Image."""
base_y = 800 h = height or width
leg_w = 64 png_data = cairosvg.svg2png(
base_half = 250 bytestring=svg_bytes, output_width=width, output_height=h,
def leg_x(y: int) -> tuple[float, float]:
t = (y - apex_y) / (base_y - apex_y)
spread = base_half * t
return (cx - spread, cx + spread)
ll_top, _ = leg_x(apex_y)
ll_bot, _ = leg_x(base_y)
_, lr_top = leg_x(apex_y)
_, lr_bot = leg_x(base_y)
left_leg = [
(ll_top - leg_w * 0.35, apex_y),
(ll_top + leg_w * 0.35, apex_y),
(ll_bot + leg_w, base_y),
(ll_bot - leg_w, base_y),
]
right_leg = [
(lr_top - leg_w * 0.35, apex_y),
(lr_top + leg_w * 0.35, apex_y),
(lr_bot + leg_w, base_y),
(lr_bot - leg_w, base_y),
]
draw.polygon(left_leg, fill=rgba(*TERRACOTTA, 240))
draw.polygon(right_leg, fill=rgba(*TERRACOTTA, 240))
# ── 4. Three crossbars — warm tones, hierarchy brightness ──────────
bar_h = 42
bar_r = 12
bar_pad = 14
tiers = [
(440, rgba(*BAR_CEO)),
(545, rgba(*BAR_MGR)),
(650, rgba(*BAR_IC)),
]
for bar_cy, fill in tiers:
left_cx, right_cx = leg_x(bar_cy)
bx0 = int(left_cx + bar_pad)
bx1 = int(right_cx - bar_pad)
draw.rounded_rectangle(
(bx0, bar_cy - bar_h // 2, bx1, bar_cy + bar_h // 2),
radius=bar_r,
fill=fill,
)
# ── 5. Apex beacon ─────────────────────────────────────────────────
dot_r = 20
dot_cy = apex_y - 2
draw.ellipse(
(cx - dot_r, dot_cy - dot_r, cx + dot_r, dot_cy + dot_r),
fill=rgba(*BAR_CEO),
) )
return Image.open(io.BytesIO(png_data)).convert("RGBA")
icon.alpha_composite(overlay)
return icon
def save_svg() -> None: # ── Web icons (circle background) ────────────────────────────────────
svg = """\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
<defs>
<linearGradient id="bg" x1="200" x2="824" y1="96" y2="928" gradientUnits="userSpaceOnUse">
<stop stop-color="#111315"/>
<stop offset="1" stop-color="#1b1f23"/>
</linearGradient>
<radialGradient id="glow" cx="0" cy="0" r="1"
gradientTransform="translate(450 348) rotate(36) scale(800)" gradientUnits="userSpaceOnUse">
<stop stop-color="#d97745" stop-opacity=".13"/>
<stop offset="1" stop-color="#d97745" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#bg)"/>
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#glow)"/>
<polygon points="490,200 534,200 578,800 298,800" fill="#d97745" fill-opacity=".94"/>
<polygon points="490,200 534,200 726,800 446,800" fill="#d97745" fill-opacity=".94"/>
<rect x="400" y="419" width="224" height="42" rx="12" fill="#ffebd7"/>
<rect x="374" y="524" width="276" height="42" rx="12" fill="#ebb48c"/>
<rect x="348" y="629" width="328" height="42" rx="12" fill="#be8c69"/>
<circle cx="512" cy="198" r="20" fill="#ffebd7"/>
</svg>
"""
SVG_PATH.write_text(svg, encoding="utf-8")
def generate_web_icons() -> None:
"""Generate favicon.ico and icon.png for the landing site (circle bg)."""
print("Generating web icons from icon.svg (circle bg)…")
master = svg_to_image(WEB_SVG, SIZE)
def save_png_and_ico(image: Image.Image) -> None: # PNG for apple-touch-icon / og
ASSETS.mkdir(parents=True, exist_ok=True) web_png = LANDING / "icon.png"
image.save(PNG_PATH, format="PNG") master.save(web_png, format="PNG")
image.save( print(f" → {web_png}")
ICO_PATH,
format="ICO", # Copy SVG to landing
shutil.copy2(WEB_SVG, LANDING / "icon.svg")
print(f" → {LANDING / 'icon.svg'}")
# ICO with multiple sizes for favicon
favicon = LANDING / "favicon.ico"
master.save(
favicon, format="ICO",
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
) )
print(f" → {favicon}")
def save_icns(image: Image.Image) -> None: # ── App icons (square background, Apple HIG) ─────────────────────────
def generate_app_icons() -> None:
"""Generate Electron app icons: PNG, ICO, ICNS (square bg, Apple HIG)."""
print("Generating app icons from icon-app.svg (square bg, Apple HIG)…")
master = svg_to_image(APP_SVG, SIZE)
# Main PNG
png_path = ASSETS / "icon.png"
master.save(png_path, format="PNG")
print(f" → {png_path}")
# Windows ICO
ico_path = ASSETS / "icon.ico"
master.save(
ico_path, format="ICO",
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
)
print(f" → {ico_path}")
# macOS ICNS via iconutil
generate_icns(master)
def generate_icns(master: Image.Image) -> None:
"""Generate .icns from a master image using macOS iconutil."""
if ICONSET.exists(): if ICONSET.exists():
shutil.rmtree(ICONSET) shutil.rmtree(ICONSET)
ICONSET.mkdir(parents=True, exist_ok=True) ICONSET.mkdir(parents=True, exist_ok=True)
for size in (16, 32, 64, 128, 256, 512): for size in (16, 32, 64, 128, 256, 512):
image.resize((size, size), Image.Resampling.LANCZOS).save( master.resize((size, size), Image.Resampling.LANCZOS).save(
ICONSET / f"icon_{size}x{size}.png", format="PNG", ICONSET / f"icon_{size}x{size}.png", format="PNG",
) )
image.resize((size * 2, size * 2), Image.Resampling.LANCZOS).save( master.resize((size * 2, size * 2), Image.Resampling.LANCZOS).save(
ICONSET / f"icon_{size}x{size}@2x.png", format="PNG", ICONSET / f"icon_{size}x{size}@2x.png", format="PNG",
) )
icns_path = ASSETS / "icon.icns"
subprocess.run( subprocess.run(
["iconutil", "-c", "icns", str(ICONSET), "-o", str(ICNS_PATH)], ["iconutil", "-c", "icns", str(ICONSET), "-o", str(icns_path)],
check=True, check=True,
) )
shutil.rmtree(ICONSET) shutil.rmtree(ICONSET)
print(f" → {icns_path}")
# ── Tray / notification area icons ───────────────────────────────────
def generate_tray_icons() -> None:
"""Generate tray icons following Apple HIG for menu bar.
- Color (Windows/Linux): square bg with logo, 32×32 and 64×64
- Template (macOS): monochrome black logo on transparent, 22×22 and 44×44
"""
print("Generating tray icons…")
# Color tray icons — render app icon (square bg) at small sizes
for name, size in [("tray-icon.png", 32), ("tray-icon@2x.png", 64)]:
img = svg_to_image(APP_SVG, size)
img.save(ASSETS / name, format="PNG")
print(f" → {ASSETS / name}")
# macOS template icons — black logo silhouette on transparent
# Read logo.svg paths, render as black on transparent
logo_svg = LOGO_SVG.read_text()
for name, size in [("tray-iconTemplate.png", 22), ("tray-iconTemplate@2x.png", 44)]:
# Create SVG with black fill, sized to target
template_svg = _make_template_svg(logo_svg, size)
img = svg_bytes_to_image(template_svg.encode("utf-8"), size)
img.save(ASSETS / name, format="PNG")
print(f" → {ASSETS / name}")
def _make_template_svg(logo_svg_content: str, target_size: int) -> str:
"""Create a macOS template SVG: black logo silhouette on transparent bg."""
# Logo viewBox is 0 0 122 126 — scale to fit target with padding
padding = max(2, int(target_size * 0.1))
available = target_size - 2 * padding
# Scale to fit height (taller dimension)
scale = available / 126
logo_w = 122 * scale
# Center horizontally
offset_x = (target_size - logo_w) / 2
offset_y = padding
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {target_size} {target_size}" fill="none">
<g transform="translate({offset_x:.1f} {offset_y:.1f}) scale({scale:.4f})">
<path fill="#000000" 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="#000000" 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>"""
# ── Main ──────────────────────────────────────────────────────────────
def main() -> None: def main() -> None:
ASSETS.mkdir(parents=True, exist_ok=True) ASSETS.mkdir(parents=True, exist_ok=True)
save_svg() LANDING.mkdir(parents=True, exist_ok=True)
image = build_icon()
save_png_and_ico(image) generate_web_icons()
save_icns(image) generate_app_icons()
print(f"Generated: {PNG_PATH}, {ICO_PATH}, {ICNS_PATH}, {SVG_PATH}") generate_tray_icons()
print("\nDone! All icons generated.")
if __name__ == "__main__": if __name__ == "__main__":
+22 -1
View File
@@ -69,6 +69,7 @@ interface AutomationDependencies {
notify: (options: NotificationOptions) => void; notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>; dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
isQuitting: () => boolean;
} }
interface RunFinishedEvent { interface RunFinishedEvent {
@@ -86,9 +87,11 @@ export function createAutomationService({
notify, notify,
wakeAgentIfPossible, wakeAgentIfPossible,
dispatchBrowserAction, dispatchBrowserAction,
isQuitting,
}: AutomationDependencies) { }: AutomationDependencies) {
const activeAutomationExecutions = new Set<string>(); const activeAutomationExecutions = new Set<string>();
const activeWorkflowTriggerExecutions = new Set<string>(); const activeWorkflowTriggerExecutions = new Set<string>();
const pendingScheduledTimers = new Set<ReturnType<typeof setTimeout>>();
function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] { function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
@@ -642,6 +645,7 @@ export function createAutomationService({
} }
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) { function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
if (isQuitting()) return;
evaluateAutomationRules(trigger, ctx); evaluateAutomationRules(trigger, ctx);
evaluateWorkflowTriggers(trigger, ctx); evaluateWorkflowTriggers(trigger, ctx);
} }
@@ -859,6 +863,7 @@ export function createAutomationService({
} }
function processRunCompletion(run: RunRecord, event: RunFinishedEvent) { function processRunCompletion(run: RunRecord, event: RunFinishedEvent) {
if (isQuitting()) return;
let task: ReturnType<typeof db.getTask> | null = null; let task: ReturnType<typeof db.getTask> | null = null;
try { try {
task = db.getTask(run.taskId); task = db.getTask(run.taskId);
@@ -1032,6 +1037,7 @@ export function createAutomationService({
} }
function handleApprovalResolved(approvalId: string, companyId: string, decision: string) { function handleApprovalResolved(approvalId: string, companyId: string, decision: string) {
if (isQuitting()) return;
const snapshot = db.listSnapshot(); const snapshot = db.listSnapshot();
const approval = snapshot.approvals.find((entry) => entry.id === approvalId); const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
if (!approval) return; if (!approval) return;
@@ -1168,7 +1174,12 @@ export function createAutomationService({
if (draft.scheduledAt) { if (draft.scheduledAt) {
const delay = new Date(draft.scheduledAt).getTime() - Date.now(); const delay = new Date(draft.scheduledAt).getTime() - Date.now();
if (delay > 0) { if (delay > 0) {
setTimeout(dispatchSocialAction, delay); const timer = setTimeout(() => {
pendingScheduledTimers.delete(timer);
if (isQuitting()) return;
dispatchSocialAction();
}, delay);
pendingScheduledTimers.add(timer);
} else { } else {
dispatchSocialAction(); dispatchSocialAction();
} }
@@ -1229,6 +1240,7 @@ export function createAutomationService({
} }
function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) { function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) {
if (isQuitting()) return;
if (previousStatus === newStatus) return; if (previousStatus === newStatus) return;
let task: ReturnType<typeof db.getTask> | null = null; let task: ReturnType<typeof db.getTask> | null = null;
@@ -1298,6 +1310,7 @@ export function createAutomationService({
} }
function handleTaskCreated(taskId: string, companyId: string) { function handleTaskCreated(taskId: string, companyId: string) {
if (isQuitting()) return;
let task: ReturnType<typeof db.getTask> | null = null; let task: ReturnType<typeof db.getTask> | null = null;
try { try {
task = db.getTask(taskId); task = db.getTask(taskId);
@@ -1322,6 +1335,13 @@ export function createAutomationService({
} }
} }
function destroy() {
for (const timer of pendingScheduledTimers) {
clearTimeout(timer);
}
pendingScheduledTimers.clear();
}
return { return {
runWorkflowPipeline, runWorkflowPipeline,
dispatchAutomation, dispatchAutomation,
@@ -1329,5 +1349,6 @@ export function createAutomationService({
handleApprovalResolved, handleApprovalResolved,
handleTaskStatusChange, handleTaskStatusChange,
handleTaskCreated, handleTaskCreated,
destroy,
}; };
} }
+16 -2
View File
@@ -192,6 +192,7 @@ export async function bootstrapApplication({
// Re-register timers for scheduled social posts (survives app restart). // Re-register timers for scheduled social posts (survives app restart).
// Scan all companies for queued browser actions with a future scheduledAt time // Scan all companies for queued browser actions with a future scheduledAt time
// and set up setTimeout to dispatch them at the right moment. // and set up setTimeout to dispatch them at the right moment.
const scheduledPostTimers = new Set<ReturnType<typeof setTimeout>>();
try { try {
const snapshot = db.listSnapshot(); const snapshot = db.listSnapshot();
for (const company of snapshot.companies) { for (const company of snapshot.companies) {
@@ -203,12 +204,15 @@ export async function bootstrapApplication({
if (payload.scheduledAt) { if (payload.scheduledAt) {
const delay = new Date(payload.scheduledAt).getTime() - Date.now(); const delay = new Date(payload.scheduledAt).getTime() - Date.now();
if (delay > 0) { if (delay > 0) {
setTimeout(() => { const timer = setTimeout(() => {
const browserActions = serviceGraph.getBrowserActionService(); scheduledPostTimers.delete(timer);
if (isQuittingRef.current) return;
const browserActions = serviceGraph?.getBrowserActionService();
if (browserActions) { if (browserActions) {
void browserActions.dispatchBrowserAction(action.id, company.id); void browserActions.dispatchBrowserAction(action.id, company.id);
} }
}, delay); }, delay);
scheduledPostTimers.add(timer);
log.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`); log.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`);
} }
// If delay <= 0, the action is overdue -- normal dispatch cycle will pick it up // If delay <= 0, the action is overdue -- normal dispatch cycle will pick it up
@@ -221,6 +225,16 @@ export async function bootstrapApplication({
log.warn(`[bootstrap] Failed to re-register scheduled post timers: ${error instanceof Error ? error.message : String(error)}`); log.warn(`[bootstrap] Failed to re-register scheduled post timers: ${error instanceof Error ? error.message : String(error)}`);
} }
// Register cleanup for bootstrap-owned resources during quit.
// This runs BEFORE service-graph's onBeforeQuit (which is registered later).
app.on("before-quit", () => {
for (const timer of scheduledPostTimers) {
clearTimeout(timer);
}
scheduledPostTimers.clear();
notificationService.destroy();
});
// Show the window immediately so the user sees the app launch without delay. // Show the window immediately so the user sees the app launch without delay.
await serviceGraph.getDesktopShellService()?.ensureWindow(); await serviceGraph.getDesktopShellService()?.ensureWindow();
serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock); serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock);
+125 -9
View File
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { extname } from "node:path"; import { extname, dirname, join } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { resolve, sep } from "node:path"; import { resolve, sep } from "node:path";
import { import {
@@ -30,6 +30,7 @@ interface DesktopShellDependencies {
onBackupProfile: () => Promise<void>; onBackupProfile: () => Promise<void>;
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void; onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
notify: (options: NotificationOptions) => void; notify: (options: NotificationOptions) => void;
getActiveRunCount: () => number;
} }
function isSocialBrowserSession(contents: Electron.WebContents) { function isSocialBrowserSession(contents: Electron.WebContents) {
@@ -55,11 +56,91 @@ export function createDesktopShellService({
onBackupProfile, onBackupProfile,
onShortcut, onShortcut,
notify, notify,
getActiveRunCount,
}: DesktopShellDependencies) { }: DesktopShellDependencies) {
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let appTray: Tray | null = null; let appTray: Tray | null = null;
let backgroundCloseNoticeShown = false; let backgroundCloseNoticeShown = false;
let windowCreationPromise: Promise<BrowserWindow | null> | null = null; let windowCreationPromise: Promise<BrowserWindow | null> | null = null;
let quitDialogShowing = false;
/**
* Quit confirmation gate — shown when agents are actively running.
* Gives the user three choices:
* 0 = "Stop All & Quit" → terminate all agents and exit
* 1 = "Minimize to Background" → hide window, agents keep running
* 2 = "Cancel" → do nothing
*
* When no agents are active the app quits immediately without a dialog.
*/
async function requestQuitWithConfirmation() {
if (quitDialogShowing) return;
let activeRunCount = 0;
try {
activeRunCount = getActiveRunCount();
} catch {
// Cannot determine active runs — proceed with immediate quit as fail-safe
onRequestQuit();
app.quit();
return;
}
if (activeRunCount === 0) {
onRequestQuit();
app.quit();
return;
}
quitDialogShowing = true;
try {
const message = activeRunCount === 1
? "1 agent run is currently active."
: `${activeRunCount} agent runs are currently active.`;
const options: Electron.MessageBoxOptions = {
type: "question",
buttons: ["Stop All & Quit", "Minimize to Background", "Cancel"],
defaultId: 2,
cancelId: 2,
title: "AgentCompany",
message,
detail: "Quitting will stop all running agents and their tasks. You can also minimize to keep agents running in the background.",
};
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
const result = parentWindow
? await dialog.showMessageBox(parentWindow, options)
: await dialog.showMessageBox(options);
if (result.response === 0) {
// Stop All & Quit
onRequestQuit();
app.quit();
} else if (result.response === 1) {
// Minimize to Background
if (parentWindow) {
parentWindow.hide();
}
if (!backgroundCloseNoticeShown) {
backgroundCloseNoticeShown = true;
notify({
title: "Running in Background",
body: "AgentCompany is still running in the system tray",
urgency: "informational",
navigation: { section: "overview" },
});
}
}
// Cancel: do nothing
} catch {
// Dialog failed (e.g. window destroyed mid-flight) — quit as fail-safe
onRequestQuit();
app.quit();
} finally {
quitDialogShowing = false;
}
}
function getMainWindow() { function getMainWindow() {
return mainWindow; return mainWindow;
@@ -195,7 +276,11 @@ export function createDesktopShellService({
{ role: "hideOthers" }, { role: "hideOthers" },
{ role: "unhide" }, { role: "unhide" },
{ type: "separator" }, { type: "separator" },
{ role: "quit" }, {
label: `Quit ${app.name}`,
accelerator: "CmdOrCtrl+Q",
click: () => void requestQuitWithConfirmation(),
},
], ],
}; };
const template: Electron.MenuItemConstructorOptions[] = [ const template: Electron.MenuItemConstructorOptions[] = [
@@ -222,7 +307,14 @@ export function createDesktopShellService({
await onBackupProfile(); await onBackupProfile();
}, },
}, },
...(process.platform === "darwin" ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), ...(process.platform === "darwin" ? [] : [
{ type: "separator" as const },
{
label: "Quit",
accelerator: "Alt+F4",
click: () => void requestQuitWithConfirmation(),
},
]),
], ],
}, },
{ {
@@ -275,11 +367,38 @@ export function createDesktopShellService({
} }
function createTray() { function createTray() {
if (process.platform === "darwin" || appTray) { if (appTray) {
return; return;
} }
const trayIcon = nativeImage.createFromPath(iconPath); const assetsDir = dirname(iconPath);
let trayIcon: Electron.NativeImage;
if (process.platform === "darwin") {
// macOS menu bar: use Template images (monochrome black + alpha).
// Electron auto-detects the @2x variant when the base name includes "Template".
const templatePath = join(assetsDir, "tray-iconTemplate.png");
trayIcon = nativeImage.createFromPath(templatePath);
if (!trayIcon.isEmpty()) {
trayIcon.setTemplateImage(true);
}
} else {
// Windows / Linux: use the color tray icon
const trayPath = join(assetsDir, "tray-icon.png");
trayIcon = nativeImage.createFromPath(trayPath);
}
// Fallback: resize the full app icon
if (trayIcon.isEmpty()) {
const fallback = nativeImage.createFromPath(iconPath);
if (!fallback.isEmpty()) {
trayIcon = fallback.resize({ width: 32, height: 32 });
if (process.platform === "darwin") {
trayIcon.setTemplateImage(true);
}
}
}
appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon); appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon);
appTray.setToolTip("AgentCompany"); appTray.setToolTip("AgentCompany");
appTray.setContextMenu( appTray.setContextMenu(
@@ -306,10 +425,7 @@ export function createDesktopShellService({
{ type: "separator" }, { type: "separator" },
{ {
label: "Quit", label: "Quit",
click: () => { click: () => void requestQuitWithConfirmation(),
onRequestQuit();
app.quit();
},
}, },
]), ]),
); );
+4 -7
View File
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { delimiter as pathDelimiter, join } from "node:path"; import { delimiter as pathDelimiter, join } from "node:path";
import { app, nativeImage } from "electron"; import { app } from "electron";
interface HostEnvironmentDependencies { interface HostEnvironmentDependencies {
iconPath: string; iconPath: string;
@@ -91,10 +91,7 @@ export async function prepareHostEnvironment({
logger.info("[path] Skipping interactive shell PATH probe; using deterministic PATH setup only."); logger.info("[path] Skipping interactive shell PATH probe; using deterministic PATH setup only.");
} }
const appIcon = nativeImage.createFromPath(iconPath); // Dock icon: macOS uses the .icns from the app bundle with the system squircle
if (!appIcon.isEmpty()) { // mask applied automatically. Do not call dock.setIcon() — it bypasses the mask
if (process.platform === "darwin" && app.dock) { // and renders the raw PNG oversized and square.
app.dock.setIcon(appIcon);
}
}
} }
+12 -3
View File
@@ -60,6 +60,7 @@ interface OrchestrationDependencies {
dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void; dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void;
hasWorkerReady: () => boolean; hasWorkerReady: () => boolean;
enqueueWorkerRun: (payload: EnqueueRunPayload) => void; enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
isQuitting: () => boolean;
} }
export function createOrchestrationService({ export function createOrchestrationService({
@@ -72,6 +73,7 @@ export function createOrchestrationService({
dispatchAutomation, dispatchAutomation,
hasWorkerReady, hasWorkerReady,
enqueueWorkerRun, enqueueWorkerRun,
isQuitting,
}: OrchestrationDependencies) { }: OrchestrationDependencies) {
let heartbeatTimer: ReturnType<typeof setInterval> | null = null; let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const scheduleLastFiredMap = new Map<string, number>(); const scheduleLastFiredMap = new Map<string, number>();
@@ -182,6 +184,9 @@ export function createOrchestrationService({
trigger: string; trigger: string;
}) { }) {
const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params; const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params;
if (isQuitting()) {
throw new Error("Application is shutting down — refusing to dispatch new run.");
}
if (!hasWorkerReady()) { if (!hasWorkerReady()) {
throw new Error("Orchestration worker is not ready."); throw new Error("Orchestration worker is not ready.");
} }
@@ -311,6 +316,7 @@ export function createOrchestrationService({
} }
function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null { function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null {
if (isQuitting()) return null;
if (!hasWorkerReady()) return null; if (!hasWorkerReady()) return null;
const companyStatus = db.getCompanyStatus(companyId); const companyStatus = db.getCompanyStatus(companyId);
if (companyStatus !== "active") { if (companyStatus !== "active") {
@@ -448,7 +454,7 @@ export function createOrchestrationService({
} }
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) { function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
if (!agentId) return null; if (!agentId || isQuitting()) return null;
try { try {
const agent = db.getAgent(agentId); const agent = db.getAgent(agentId);
if (agent.companyId !== companyId) { if (agent.companyId !== companyId) {
@@ -473,6 +479,9 @@ export function createOrchestrationService({
} }
function queueTaskRun(taskId: string, trigger: string, actor: string) { function queueTaskRun(taskId: string, trigger: string, actor: string) {
if (isQuitting()) {
return fail("APP_SHUTTING_DOWN", "Application is shutting down.");
}
if (!hasWorkerReady()) { if (!hasWorkerReady()) {
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`); logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready."); return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
@@ -556,7 +565,7 @@ export function createOrchestrationService({
} }
function resumeQueuedRuns() { function resumeQueuedRuns() {
if (!hasWorkerReady()) { if (isQuitting() || !hasWorkerReady()) {
return; return;
} }
const snapshot = db.listSnapshot(); const snapshot = db.listSnapshot();
@@ -610,7 +619,7 @@ export function createOrchestrationService({
heartbeatTimer = setInterval(() => { heartbeatTimer = setInterval(() => {
try { try {
if (!hasWorkerReady()) return; if (isQuitting() || !hasWorkerReady()) return;
const activeCompanies = db.listActiveCompanyIds(); const activeCompanies = db.listActiveCompanyIds();
const now = Date.now(); const now = Date.now();
+11
View File
@@ -135,6 +135,14 @@ export function createServiceGraph({
}, },
onShortcut: (action) => emitEvent({ type: "shortcut", action }), onShortcut: (action) => emitEvent({ type: "shortcut", action }),
notify, notify,
getActiveRunCount: () => {
try {
const snapshot = db.listSnapshot();
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
} catch {
return 0;
}
},
}); });
desktopShellService.initialize(); desktopShellService.initialize();
@@ -169,6 +177,7 @@ export function createServiceGraph({
enqueueWorkerRun: (payload) => { enqueueWorkerRun: (payload) => {
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload }); workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
}, },
isQuitting: () => isQuittingRef.current,
}); });
workerService.createWorker(); workerService.createWorker();
@@ -180,6 +189,7 @@ export function createServiceGraph({
notify, notify,
wakeAgentIfPossible, wakeAgentIfPossible,
dispatchBrowserAction, dispatchBrowserAction,
isQuitting: () => isQuittingRef.current,
}); });
onboardingService = createOnboardingService({ onboardingService = createOnboardingService({
@@ -210,6 +220,7 @@ export function createServiceGraph({
onBeforeQuit: () => { onBeforeQuit: () => {
isQuittingRef.current = true; isQuittingRef.current = true;
orchestrationService?.stopHeartbeatScheduler(); orchestrationService?.stopHeartbeatScheduler();
automationService?.destroy();
apiServerRef.current?.stop(); apiServerRef.current?.stop();
apiServerRef.current = null; apiServerRef.current = null;
browserManagerRef.current?.shutdown(); browserManagerRef.current?.shutdown();
+33 -6
View File
@@ -83,8 +83,11 @@ export function createWorkerService({
const MAX_CONSECUTIVE_WAKES = 5; const MAX_CONSECUTIVE_WAKES = 5;
let worker: ReturnType<typeof utilityProcess.fork> | null = null; let worker: ReturnType<typeof utilityProcess.fork> | null = null;
let destroyed = false;
const pendingRetryTimers = new Set<ReturnType<typeof setTimeout>>();
function getWorker() { function getWorker() {
if (destroyed) return null;
return worker; return worker;
} }
@@ -98,7 +101,7 @@ export function createWorkerService({
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`)); worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
worker.on("exit", (code) => { worker.on("exit", (code) => {
if (isQuitting()) return; if (isQuitting() || destroyed) return;
logger.warn(`[worker] Worker exited with code ${code}, recovering...`); logger.warn(`[worker] Worker exited with code ${code}, recovering...`);
@@ -320,7 +323,7 @@ export function createWorkerService({
publishDomainChanged(); publishDomainChanged();
} }
if (event.status === "failed" || event.status === "timed_out") { if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) {
const retryCount = db.getTaskRetryCount(run.taskId); const retryCount = db.getTaskRetryCount(run.taskId);
if (retryCount < MAX_RETRY_ATTEMPTS) { if (retryCount < MAX_RETRY_ATTEMPTS) {
@@ -329,9 +332,12 @@ export function createWorkerService({
db.advanceTaskStatus(run.taskId, "todo", db.advanceTaskStatus(run.taskId, "todo",
`Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`); `Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`);
logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`); logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`);
setTimeout(() => { const retryTimer = setTimeout(() => {
pendingRetryTimers.delete(retryTimer);
if (isQuitting() || destroyed) return;
wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed"); wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed");
}, RETRY_COOLDOWN_MS); }, RETRY_COOLDOWN_MS);
pendingRetryTimers.add(retryTimer);
} else { } else {
// Retry limit reached -- escalate up the chain // Retry limit reached -- escalate up the chain
db.resetTaskRetryCount(run.taskId); db.resetTaskRetryCount(run.taskId);
@@ -376,13 +382,15 @@ export function createWorkerService({
} }
const pendingWakes = db.consumePendingWakes(run.agentId); const pendingWakes = db.consumePendingWakes(run.agentId);
if (pendingWakes.length > 0) { if (pendingWakes.length > 0 && !isQuitting() && !destroyed) {
for (const wake of pendingWakes) { for (const wake of pendingWakes) {
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger); wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
} }
} }
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" }); if (!isQuitting() && !destroyed) {
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" });
}
// Reset retry count on successful completion // Reset retry count on successful completion
if (event.status === "succeeded") { if (event.status === "succeeded") {
@@ -396,7 +404,7 @@ export function createWorkerService({
} }
// Self-wake with circuit breaker to prevent infinite loops // Self-wake with circuit breaker to prevent infinite loops
if (pendingWakes.length === 0) { if (pendingWakes.length === 0 && !isQuitting() && !destroyed) {
try { try {
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]); const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
if (actionableTasks.length > 0) { if (actionableTasks.length > 0) {
@@ -426,7 +434,26 @@ export function createWorkerService({
} }
function destroy() { function destroy() {
destroyed = true;
// Clear all pending retry timers so they never fire after quit
for (const timer of pendingRetryTimers) {
clearTimeout(timer);
}
pendingRetryTimers.clear();
if (worker) { if (worker) {
// Cancel all running/queued runs in the worker before killing it.
// This tells the worker to SIGTERM all child CLI processes (claude, codex, etc.)
const snapshot = db.listSnapshot();
for (const run of snapshot.runs) {
if (run.status === "running" || run.status === "queued") {
try {
worker.postMessage({ type: "cancel-run", runId: run.id });
} catch { /* worker may already be dead */ }
}
}
worker.kill(); worker.kill();
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive) // Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
const ref = worker; const ref = worker;
@@ -0,0 +1,85 @@
import { memo } from "react";
/**
* Scrolling ASCII agent-activity backdrop for the onboarding hero panel.
* Pure CSS animation (translateY on compositor thread) — no JS ticks.
* Content is doubled for seamless looping.
*/
const LINES = [
"",
" ╭──────────╮",
" │ CEO │",
" ╰────┬─────╯",
" ┌────┴─────┐",
" ╭──┴──╮ ╭──┴──╮",
" │ CTO │ │ CMO │",
" ╰──┬──╯ ╰──┬──╯",
" ╭──┴──╮ ╭──┴──╮",
" │ Eng │ │ Mkt │",
" ╰─────╯ ╰─────╯",
"",
" $ deploy --agents 12",
" ████████████████████ 100%",
" [OK] all agents online",
"",
" CEO │ analyzing requirements",
" CEO │ identifying deliverables",
" CEO │ delegating to leads",
"",
" CTO │ reviewing architecture",
" CTO │ → Dev.01: auth module",
" CTO │ → Dev.02: API layer",
" CTO │ → QA: test strategy",
"",
" CMO │ go-to-market strategy",
" CMO │ → Mkt.01: launch copy",
" CMO │ → Mkt.02: social plan",
"",
" ┌─ SPRINT 1 ────────────────┐",
" │ tasks ████████░░ 80% │",
" │ agents 12 active │",
" │ tests 128 passing │",
" └──────────────────────────┘",
"",
" Dev.01 │ scaffolding auth",
" Dev.02 │ REST endpoints",
" Design │ component library",
" QA │ integration tests",
" Mkt.01 │ blog post drafted",
"",
" CEO │ sprint review: ✓",
" CTO │ code review: merged",
" Dev.01 │ PR #12 → main",
" QA │ all tests green",
" CMO │ brief approved",
"",
" [INFO] Sprint 1 complete",
" [INFO] Advancing to Sprint 2",
"",
];
const BLOCK = LINES.join("\n");
export const AsciiAnimation = memo(function AsciiAnimation() {
return (
<div className="pointer-events-none absolute inset-0 select-none overflow-hidden">
{/* Fade masks */}
<div
className="absolute inset-x-0 top-0 z-10 h-32"
style={{ background: "linear-gradient(to bottom, var(--bg), transparent)" }}
/>
<div
className="absolute inset-x-0 bottom-0 z-10 h-32"
style={{ background: "linear-gradient(to top, var(--bg), transparent)" }}
/>
{/* Scrolling track — doubled for seamless loop */}
<div className="ascii-scroll-track whitespace-pre font-mono text-[11px] leading-[1.8] text-[color:var(--accent)] opacity-[0.08]">
{BLOCK}
{"\n"}
{BLOCK}
</div>
</div>
);
});
@@ -8,6 +8,7 @@ import {
import type { ProfileSnapshot } from "@shared/types"; import type { ProfileSnapshot } from "@shared/types";
import type { AppLocale } from "@shared/locale"; import type { AppLocale } from "@shared/locale";
import { ConnectorReadiness } from "./ConnectorReadiness"; import { ConnectorReadiness } from "./ConnectorReadiness";
import { AsciiAnimation } from "./AsciiAnimation";
type Step = "language" | "company" | "mission" | "launch"; type Step = "language" | "company" | "mission" | "launch";
@@ -57,6 +58,9 @@ export function OnboardingHero({
}} }}
/> />
{/* ASCII agent activity backdrop */}
<AsciiAnimation />
<motion.div <motion.div
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -22,12 +22,12 @@ export function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]
onClick={() => onNavigate("goals")} onClick={() => onNavigate("goals")}
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3.5 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm" className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3.5 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Target size={15} weight="fill" className="text-[color:var(--accent)]" /> <Target size={15} weight="fill" className="shrink-0 text-[color:var(--accent)]" />
<span className="truncate text-[13px] font-medium text-[color:var(--text)]">{goal.title}</span> <span className="truncate text-[13px] font-medium text-[color:var(--text)]">{goal.title}</span>
</div> </div>
<span className="whitespace-nowrap text-[13px] font-bold tabular-nums text-[color:var(--accent)]">{pct}%</span> <span className="shrink-0 whitespace-nowrap text-[13px] font-bold tabular-nums text-[color:var(--accent)]">{pct}%</span>
</div> </div>
<div className="mt-2.5 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]"> <div className="mt-2.5 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
<div <div
@@ -32,10 +32,10 @@ export function GoalList({
onClick={() => onSelect(goal.id)} onClick={() => onSelect(goal.id)}
className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`} className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
> >
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{isSelected && ( {isSelected && (
<CheckCircle size={16} weight="fill" className="text-[color:var(--accent)]" /> <CheckCircle size={16} weight="fill" className="shrink-0 text-[color:var(--accent)]" />
)} )}
<div className="truncate text-[14px] font-semibold text-[color:var(--text)]">{goal.title}</div> <div className="truncate text-[14px] font-semibold text-[color:var(--text)]">{goal.title}</div>
</div> </div>
+2
View File
@@ -1,4 +1,6 @@
import "@fontsource-variable/inter";
import "@fontsource-variable/jetbrains-mono"; import "@fontsource-variable/jetbrains-mono";
import "@fontsource/instrument-serif";
import "./styles.css"; import "./styles.css";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
+66 -53
View File
@@ -1,30 +1,32 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, --font-sans: "Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", Roboto, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB",
"Noto Sans CJK SC", Helvetica, Arial, sans-serif; "Noto Sans CJK SC", Helvetica, Arial, sans-serif;
--font-serif: "Instrument Serif", Georgia, "Times New Roman", serif;
--font-mono: "JetBrains Mono Variable", "Microsoft YaHei", ui-monospace, --font-mono: "JetBrains Mono Variable", "Microsoft YaHei", ui-monospace,
SFMono-Regular, Menlo, Monaco, Consolas, monospace; SFMono-Regular, Menlo, Monaco, Consolas, monospace;
} }
/* ─── Light theme — aligned with website ─── */
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f7f8fa; --bg: #FBFAF5;
--bg-subtle: #ffffff; --bg-subtle: #FFFFFF;
--panel: #ffffff; --panel: #FFFFFF;
--panel-soft: #f0f2f5; --panel-soft: #F3F2ED;
--panel-strong: #f7f8fa; --panel-strong: #FBFAF5;
--line: rgba(50, 65, 85, 0.08); --line: rgba(25, 25, 25, 0.08);
--line-strong: rgba(50, 65, 85, 0.15); --line-strong: rgba(25, 25, 25, 0.15);
--text: #1a1d24; --text: #191919;
--muted: #6b7280; --muted: #7a7a72;
--muted-strong: #4b5563; --muted-strong: #565650;
--accent: #4a5d7a; --accent: #213448;
--accent-text: #3d5070; --accent-text: #213448;
--accent-soft: #edf1f7; --accent-soft: rgba(33, 52, 72, 0.07);
--accent-hover: #3d4f6d; --accent-hover: #192838;
--text-on-accent: #ffffff; --text-on-accent: #FBFAF5;
--danger: #c53030; --danger: #c53030;
--danger-soft: #fef2f2; --danger-soft: #fef2f2;
--warn: #b45309; --warn: #b45309;
@@ -37,23 +39,24 @@
color-scheme: light; color-scheme: light;
} }
/* ─── Dark theme — aligned with website ─── */
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--bg: #16181f; --bg: #191919;
--bg-subtle: #1a1c24; --bg-subtle: #1e1e1e;
--panel: #1e2028; --panel: #222222;
--panel-soft: #24262e; --panel-soft: #2a2a2a;
--panel-strong: #1c1e26; --panel-strong: #1c1c1c;
--line: rgba(148, 163, 190, 0.12); --line: rgba(251, 250, 245, 0.08);
--line-strong: rgba(148, 163, 190, 0.2); --line-strong: rgba(251, 250, 245, 0.15);
--text: #edf0f7; --text: #FBFAF5;
--muted: #8b95a5; --muted: #8a8a84;
--muted-strong: #b0b8c8; --muted-strong: #b8b8b2;
--accent: #7b9cc4; --accent: #94B4C1;
--accent-text: #7b9cc4; --accent-text: #94B4C1;
--accent-soft: rgba(123, 156, 196, 0.14); --accent-soft: rgba(148, 180, 193, 0.12);
--accent-hover: #93b0d4; --accent-hover: #a8c4cf;
--text-on-accent: #ffffff; --text-on-accent: #191919;
--danger: #f87171; --danger: #f87171;
--danger-soft: rgba(248, 113, 113, 0.12); --danger-soft: rgba(248, 113, 113, 0.12);
--warn: #f59e0b; --warn: #f59e0b;
@@ -65,21 +68,21 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root[data-theme="system"] { :root[data-theme="system"] {
color-scheme: dark; color-scheme: dark;
--bg: #16181f; --bg: #191919;
--bg-subtle: #1a1c24; --bg-subtle: #1e1e1e;
--panel: #1e2028; --panel: #222222;
--panel-soft: #24262e; --panel-soft: #2a2a2a;
--panel-strong: #1c1e26; --panel-strong: #1c1c1c;
--line: rgba(148, 163, 190, 0.12); --line: rgba(251, 250, 245, 0.08);
--line-strong: rgba(148, 163, 190, 0.2); --line-strong: rgba(251, 250, 245, 0.15);
--text: #edf0f7; --text: #FBFAF5;
--muted: #8b95a5; --muted: #8a8a84;
--muted-strong: #b0b8c8; --muted-strong: #b8b8b2;
--accent: #7b9cc4; --accent: #94B4C1;
--accent-text: #7b9cc4; --accent-text: #94B4C1;
--accent-soft: rgba(123, 156, 196, 0.14); --accent-soft: rgba(148, 180, 193, 0.12);
--accent-hover: #93b0d4; --accent-hover: #a8c4cf;
--text-on-accent: #ffffff; --text-on-accent: #191919;
--danger: #f87171; --danger: #f87171;
--danger-soft: rgba(248, 113, 113, 0.12); --danger-soft: rgba(248, 113, 113, 0.12);
--warn: #f59e0b; --warn: #f59e0b;
@@ -133,11 +136,11 @@ select {
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
border-radius: 999px; border-radius: 999px;
background: rgba(60, 75, 100, 0.2); background: rgba(25, 25, 25, 0.15);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(60, 75, 100, 0.32); background: rgba(25, 25, 25, 0.25);
} }
.mono { .mono {
@@ -147,7 +150,7 @@ select {
.surface { .surface {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
box-shadow: 0 1px 3px rgba(26, 29, 36, 0.06), 0 0 0 0.5px rgba(26, 29, 36, 0.03); box-shadow: 0 1px 3px rgba(25, 25, 25, 0.06), 0 0 0 0.5px rgba(25, 25, 25, 0.03);
} }
.focus-ring { .focus-ring {
@@ -160,9 +163,19 @@ select {
.focus-ring:focus-visible { .focus-ring:focus-visible {
box-shadow: box-shadow:
0 0 0 1px rgba(74, 93, 122, 0.5), 0 0 0 1px rgba(33, 52, 72, 0.5),
0 0 0 4px rgba(74, 93, 122, 0.12); 0 0 0 4px rgba(33, 52, 72, 0.12);
border-color: rgba(74, 93, 122, 0.35); border-color: rgba(33, 52, 72, 0.35);
}
/* ─── ASCII animation ─── */
@keyframes ascii-scroll-up {
0% { transform: translateY(0); }
100% { transform: translateY(-50%); }
}
.ascii-scroll-track {
animation: ascii-scroll-up 80s linear infinite;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {