Compare commits

...

4 Commits

Author SHA1 Message Date
Khushi Diwan aae38fd5cd
New README (#2419)
* docs: refresh README

* docs: refine README layout and add actual agent logos

* docs: fix image quality and url in twitter journey section

* chore: format spawn orchestrator helper

* docs: align README tweet screenshots

* fix
2026-07-06 13:26:36 +05:30
NIKHIL ACHALE d51ddd6130
feat: enhance Kiro agent integration with custom AO agent prompts (#2444)
* feat: enhance Kiro agent integration with system prompts and agent configuration

* fix(kiro): clear stale model from hooks when configuration is removed
2026-07-06 02:58:08 +05:30
Apoorv Singh 71fc914afa
fix(desktop): wheel-scroll opencode transcripts and surface native Windows notifications (#2422) 2026-07-06 01:56:14 +05:30
Pritom Mazumdar 0f2295c0c0
feat(telemetry): instrument renderer failure and CTA events (#2360) 2026-07-05 21:42:54 +05:30
58 changed files with 1737 additions and 547 deletions

View File

@ -1,5 +1,6 @@
# Generated — never hand-edit; regenerated by `npm run api` / sqlc / openapi-typescript
frontend/src/api/schema.ts
frontend/src/renderer/routeTree.gen.ts
backend/internal/httpd/apispec/openapi.yaml
# Build outputs

287
README.md
View File

@ -1,6 +1,5 @@
<div align="center">
<p style="text-align: center;"><img src="ao-logo.svg" alt="Agent Orchestrator" width="200" height="200" style="max-width: 100%; height: auto; margin-left: 50px;" /></p>
<img src="ao-logo.svg" alt="Agent Orchestrator" width="160" height="160" />
# Agent Orchestrator
@ -10,12 +9,11 @@
[![Contributors](https://img.shields.io/github/contributors/AgentWrapper/agent-orchestrator)](https://github.com/AgentWrapper/agent-orchestrator/graphs/contributors)
[![Twitter](https://img.shields.io/badge/Twitter-1DA1F2?logo=twitter&logoColor=white)](https://x.com/aoagents)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/UZv7JjxbwG)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
An Agentic IDE that supervises parallel AI coding agents in isolated workspaces, with complete control and automatic feedback loops from CI failures, review comments, and merge conflicts.
![Agent Orchestrator Dashboard](ao-dashboard-preview.png)
<img src="docs/assets/readme/dashboard.png" alt="Agent Orchestrator dashboard showing parallel coding agent sessions" width="100%" />
</div>
---
@ -26,8 +24,6 @@ Agent Orchestrator is a meta-harness agent IDE for running AI coding agents in p
The agents still do the coding. AO provides the harness around them: isolated workspaces, live terminal access, session state, PR awareness, and automatic loops that send CI failures, review comments, and merge conflicts back to the right agent. Instead of manually coordinating a pile of agent terminals, AO turns parallel agent work into a managed workflow.
---
## Why Agent Orchestrator?
AI coding agents become much more useful when they can work in parallel, but parallel work gets messy quickly. Branches overlap, terminals get lost, CI failures need follow-up, review comments need replies, and merge conflicts have to reach the right worker.
@ -40,8 +36,6 @@ Agent Orchestrator is built to keep that loop visible and manageable. It helps y
- Route CI failures, review comments, and merge conflicts back to the right session
- Use different agent CLIs through one common supervisor
---
## How it works
At a high level, Agent Orchestrator follows a simple loop:
@ -55,211 +49,116 @@ At a high level, Agent Orchestrator follows a simple loop:
The result is a local control layer for agentic coding: agents still do the coding, while Agent Orchestrator keeps their workspaces, status, terminals, and feedback loops organized.
---
<div align="center">
### Witness AO's Journey on X
<table border="1" style="border-collapse: collapse; width: 100%;">
<tr>
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
<a href="https://x.com/agent_wrapper/status/2026329204405723180"><img src="screenshots/first.png" alt="First" width="400"></a><br><br>
<a href="https://x.com/agent_wrapper/status/2026329204405723180">Visit</a>
</td>
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
<a href="https://x.com/agent_wrapper/status/2025986105485733945"><img src="screenshots/second.png" alt="Second" width="400"></a><br><br>
<a href="https://x.com/agent_wrapper/status/2025986105485733945">Visit</a>
</td>
</tr>
<tr>
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
<a href="https://x.com/agent_wrapper/status/2064157228400341312"><img src="screenshots/third.png" alt="Third" width="400"></a><br><br>
<a href="https://x.com/agent_wrapper/status/2064157228400341312">Visit</a>
</td>
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
<a href="https://x.com/agent_wrapper/status/2024885035774738700?s=20"><img src="screenshots/image.png" alt="Fourth" width="400"></a><br><br>
<a href="https://x.com/agent_wrapper/status/2024885035774738700?s=20">Visit</a>
</td>
</tr>
</table>
[What is Agent Orchestrator?](#what-is-agent-orchestrator) • [Why Agent Orchestrator?](#why-agent-orchestrator) • [How it works](#how-it-works) • [Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing)
</div>
---
## Features
| Feature | Description |
| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Agent-Agnostic Platform** | 23+ agent adapters including [Claude Code](https://code.claude.com/docs/en/overview), [OpenAI Codex](https://openai.com/), [Cursor](https://cursor.com/), [OpenCode](https://opencode.ai/), [Aider](https://aider.chat/), [Amp](https://ampcode.com/manual), [Goose](https://goose-docs.ai/), [GitHub Copilot](https://github.com/features/copilot), [Grok](https://x.ai/grok), [Qwen Code](https://github.com/QwenLM/qwen-code), [Kimi Code](https://www.kimi.com/code), [Cline](https://cline.bot/), [Continue](https://www.continue.dev/), [Kiro](https://kiro.dev/), and more |
| **Isolated Workspaces** | Each session spawns into its own git worktree with dedicated runtime |
| **Platform-Native Runtimes** | tmux on Darwin/Linux, conpty on Windows for optimal performance |
| **Live PR Observation** | Provider-neutral SCM observer with automatic feedback routing |
| **Automatic Feedback Routing** | CI failures, review comments, and merge conflicts routed to the owning agent |
| **Durable Facts Storage** | SQLite persists immutable facts with display status derived at read time |
| **CDC Broadcasting** | DB triggers append changes to change_log, broadcasted via SSE |
| **Desktop Experience** | Native Electron app with React UI and live terminal streaming |
| **Loopback-Only Daemon** | HTTP control over 127.0.0.1 with no auth, CORS, or TLS by design |
The desktop app is the main control surface: projects on the left, active sessions in the center, and the selected session's terminal, pull request state, review runs, and browser preview in the inspector.
### Supported Agents
<table>
<tr>
<td width="36%">
<h3>Parallel agent sessions</h3>
<p>Start multiple coding agents from the same project without mixing files, branches, terminals, or pull request state.</p>
</td>
<td width="64%">
<img src="docs/assets/readme/dashboard.png" alt="Agent Orchestrator board with multiple parallel sessions" />
</td>
</tr>
<tr>
<td width="36%">
<h3>Live terminal control</h3>
<p>Open any session and attach to the worker terminal while keeping session summary, PR state, and follow-up actions in view.</p>
</td>
<td width="64%">
<img src="docs/assets/readme/session-terminal.png" alt="Session terminal inside Agent Orchestrator" />
</td>
</tr>
<tr>
<td width="36%">
<h3>Review feedback loop</h3>
<p>Run reviewer agents, inspect review status, and route requested changes back to the right worker session.</p>
</td>
<td width="64%">
<img src="docs/assets/readme/reviews-tab.png" alt="Reviews tab showing reviewer runs and actions" />
</td>
</tr>
<tr>
<td width="36%">
<h3>In-app browser preview</h3>
<p>Preview a session's local app beside the terminal so UI work, browser state, and agent output stay together.</p>
</td>
<td width="64%">
<img src="docs/assets/readme/browser-preview.png" alt="Browser preview tab showing a local app preview" />
</td>
</tr>
</table>
Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code.
## Supported Agents
For direct CLI usage, including agent readiness checks and context-aware session
spawns, see the [CLI Guide](docs/cli/README.md).
AO ships adapters for 23 worker agent harnesses:
<img src="frontend/src/landing/public/docs/logos/claude-code.svg" width="16" valign="middle" /> `claude-code` · <img src="frontend/src/landing/public/docs/logos/codex.svg" width="16" valign="middle" /> `codex` · <img src="frontend/src/landing/public/docs/logos/aider.png" width="16" valign="middle" /> `aider` · <img src="frontend/src/landing/public/docs/logos/opencode.svg" width="16" valign="middle" /> `opencode` · <img src="frontend/src/landing/public/docs/logos/grok.png" width="16" valign="middle" /> `grok` · <img src="frontend/src/landing/public/docs/logos/droid.png" width="16" valign="middle" /> `droid` · `amp` · `agy` · <img src="frontend/src/landing/public/docs/logos/crush.png" width="16" valign="middle" /> `crush` · <img src="frontend/src/landing/public/docs/logos/cursor.svg" width="16" valign="middle" /> `cursor` · <img src="frontend/src/landing/public/docs/logos/qwen.png" width="16" valign="middle" /> `qwen` · <img src="frontend/src/landing/public/docs/logos/copilot.png" width="16" valign="middle" /> `copilot` · <img src="frontend/src/landing/public/docs/logos/goose.png" width="16" valign="middle" /> `goose` · `auggie` · <img src="frontend/src/landing/public/docs/logos/continue.png" width="16" valign="middle" /> `continue` · <img src="frontend/src/landing/public/docs/logos/devin.png" width="16" valign="middle" /> `devin` · `cline` · <img src="frontend/src/landing/public/docs/logos/kimi.png" width="16" valign="middle" /> `kimi` · <img src="frontend/src/landing/public/docs/logos/kiro.png" width="16" valign="middle" /> `kiro` · <img src="frontend/src/landing/public/docs/logos/kilocode.png" width="16" valign="middle" /> `kilocode` · <img src="frontend/src/landing/public/docs/logos/vibe.png" width="16" valign="middle" /> `vibe` · <img src="frontend/src/landing/public/docs/logos/pi.png" width="16" valign="middle" /> `pi` · `autohand`
Reviewer agents are configured separately. The current reviewer harnesses are:
<img src="frontend/src/landing/public/docs/logos/claude-code.svg" width="16" valign="middle" /> `claude-code` · <img src="frontend/src/landing/public/docs/logos/codex.svg" width="16" valign="middle" /> `codex` · <img src="frontend/src/landing/public/docs/logos/opencode.svg" width="16" valign="middle" /> `opencode`
**If it runs in a terminal, it runs on Agent Orchestrator.**
---
## Install
## Quick Start
The fastest path is the same flow used by the installation docs:
### Prerequisites
```bash
npm install -g @aoagents/ao
ao start
```
| Requirement | Minimum | Recommended |
| ----------- | ------- | ----------- |
| Go | 1.25+ | Latest |
| Node.js | 20+ | Latest LTS |
| Git | Any | Latest |
| pnpm | Any | Latest |
Run `ao start` from the repository you want AO to manage. See the [installation guide](https://aoagents.dev/docs/installation) for pnpm, yarn, source installs, agent CLI setup, and troubleshooting.
**Optional:**
You can also download the latest desktop build for your platform:
- `tmux` (Darwin/Linux) - For Unix runtime
- `gh` (GitHub CLI) - For authenticated GitHub API calls
| Platform | Download |
| -------- | ------------------------------------------------------------------------------------------------- |
| Windows | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
| macOS | [Agent Orchestrator.dmg](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
| Linux | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
### Installation
## Witness AO's Journey on X
Download the latest release for your platform:
| Platform | Download |
| ----------- | ------------------------------------------------------------------------------------------------- |
| **Windows** | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
| **macOS** | [Agent Orchestrator.dmg](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
| **Linux** | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
**Direct Download:** [Latest Release](https://github.com/AgentWrapper/agent-orchestrator/releases/latest)
---
## Telemetry
Agent Orchestrator collects minimal telemetry for reliability and product understanding. Data is stored locally by default; remote transmission is opt-in via environment variables. [Read the full telemetry policy](docs/telemetry.md).
---
## Architecture
Agent Orchestrator is a long-running Go daemon built around **inbound/outbound port contracts** with swappable adapters.
**Core mental model:** OBSERVE external facts → UPDATE durable facts → DERIVE display status / ACT
**Key components:**
- **Frontend** - Electron + React UI with TanStack Router/Query and shadcn/ui
- **Backend Daemon** - Go-based HTTP server with controllers, services, and adapters
- **Runtime** - Platform-specific: `tmux` on Darwin/Linux, `conpty` on Windows
- **Storage** - SQLite with change-data-capture (CDC) for real-time updates
- **Adapters** - 23+ agent adapters, git worktree workspace, GitHub SCM integration
For detailed architecture diagrams, data flows, and load-bearing rules, see [architecture.md](docs/architecture.md).
---
<table>
<tr>
<td width="33%" align="center">
<a href="https://x.com/agent_wrapper/status/2026329204405723180">
<img src="screenshots/tweet2.png" height="330" alt="Agent Orchestrator journey screenshot one" />
</a>
</td>
<td width="37.5%" align="center">
<a href="https://x.com/agent_wrapper/status/2025986105485733945">
<img src="screenshots/tweet1.png" height="330" alt="Agent Orchestrator journey screenshot two" />
</a>
</td>
<td width="29.5%" align="center">
<a href="https://x.com/agent_wrapper/status/2024885035774738700">
<img src="screenshots/tweet3.png" height="330" alt="Agent Orchestrator journey screenshot three" />
</a>
</td>
</tr>
</table>
## Documentation
| Document | Description |
| -------------------------------------------------------- | ------------------------------------------------------- |
| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules |
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
| [CLI Guide](docs/cli/README.md) | Direct `ao` CLI usage, command routes, and smoke tests |
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
| Document | Start here when you need |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| [docs/architecture.md](docs/architecture.md) | Backend mental model, lifecycle, persistence, CDC, status derivation, and daemon boundaries. |
| [docs/backend-code-structure.md](docs/backend-code-structure.md) | Package ownership and where each backend concern belongs. |
| [docs/cli/README.md](docs/cli/README.md) | CLI behavior and daemon route mapping. |
| [docs/STATUS.md](docs/STATUS.md) | What currently ships on `main` and what remains in flight. |
| [docs/stack.md](docs/stack.md) | Library, runtime, and dependency decisions. |
---
## Telemetry
## Testing
```bash
# Backend tests
cd backend
go test -race ./...
# Frontend tests
cd frontend
pnpm test
# Full CI validation locally
npx @redwoodjs/agent-ci run --all
```
---
## Configuration
All configuration is environment-driven. The daemon takes no config file.
| Variable | Default | Purpose |
| --------------------- | -------------------- | --------------------------- |
| `AO_PORT` | `3001` | HTTP bind port |
| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout |
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap |
| `AO_RUN_FILE` | `~/.ao/running.json` | PID/port handshake |
| `AO_DATA_DIR` | `~/.ao/data` | SQLite data directory |
| `AO_AGENT` | `claude-code` | Compatibility agent adapter |
| `GITHUB_TOKEN` | - | GitHub auth token |
### Health Checks
```bash
curl localhost:3001/healthz # Liveness probe
curl localhost:3001/readyz # Readiness probe
```
---
## Contributing
We love contributions! Join our community on Discord to get started.
### Join us on Discord
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white&logoSize=auto)](https://discord.com/invite/UZv7JjxbwG)
**Daily contributor sync:** Every day at **10:00 PM IST**
Get your issues verified by core contributors, ask questions, share progress, and learn from the community. New contributors are always welcome!
**Why join Discord?**
- Get your issues and PRs verified by core contributors before investing time
- Learn from experienced contributors in daily sync calls
- Share your progress and get feedback
- Get help troubleshooting in real-time
- Stay updated on the latest developments and roadmap
### Quick Start
1. **Join the Discord** - Connect with the community and get guidance
2. **Read the contributor contract** - See [AGENTS.md](AGENTS.md) for repo layout, daemon/API boundaries, and coding conventions
3. **Pick a focused problem** - Browse [open issues](https://github.com/AgentWrapper/agent-orchestrator/issues) and choose one small enough for a focused PR
4. **Open a clear PR** - Keep changes narrow, explain user-visible impact, link issues, include tests
5. **Iterate with contributors** - Use review feedback to tighten the PR until verified
---
Agent Orchestrator's Electron renderer sends anonymous usage events to PostHog for reliability and product understanding, and PostHog session recording is enabled with local paths and local URLs redacted before transmission. Set `VITE_AO_POSTHOG_KEY` to an empty string before building to disable transmission. See [docs/telemetry.md](docs/telemetry.md).
## License
Apache License 2.0 - see [LICENSE](LICENSE) for details.
---
<div align="center">
**[Star us on GitHub](https://github.com/AgentWrapper/agent-orchestrator)** • **[Report Issues](https://github.com/AgentWrapper/agent-orchestrator/issues)** • **[Discussions](https://github.com/AgentWrapper/agent-orchestrator/discussions)**
Made with love by the Agent Orchestrator community
</div>
Apache License 2.0. See [LICENSE](LICENSE).

View File

@ -27,13 +27,18 @@ const (
// skips duplicates and uninstall recognizes AO entries by prefix without an
// embedded template to diff against.
kiroHookCommandPrefix = "ao hooks kiro "
kiroAgentName = "ao"
kiroAgentDescription = "Agent Orchestrator session instructions"
)
// kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by
// tests to decode the written file. Kiro hooks are a map of camelCase event
// name to a flat array of {matcher?, command} entries.
type kiroHookFile struct {
Hooks map[string][]kiroHookEntry `json:"hooks"`
Name string `json:"name"`
Prompt *string `json:"prompt"`
Hooks map[string][]kiroHookEntry `json:"hooks"`
}
type kiroHookEntry struct {
@ -97,7 +102,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
}
}
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil {
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, cfg.SystemPrompt, cfg.Config); err != nil {
return fmt.Errorf("kiro.GetAgentHooks: %w", err)
}
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), kiroAgentFileName); err != nil {
@ -137,7 +142,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error
}
}
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil {
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, "", ports.AgentConfig{}); err != nil {
return fmt.Errorf("kiro.UninstallHooks: %w", err)
}
return nil
@ -210,7 +215,11 @@ func readKiroHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMess
// writeKiroHooks folds rawHooks back into topLevel and writes the file. An
// empty hooks map drops the "hooks" key entirely.
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
if err := setKiroAgentDefaults(topLevel, systemPrompt, agentConfig); err != nil {
return err
}
if len(rawHooks) == 0 {
delete(topLevel, "hooks")
} else {
@ -235,6 +244,44 @@ func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMess
return nil
}
func setKiroAgentDefaults(topLevel map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
defaults := map[string]any{
"name": kiroAgentName,
"description": kiroAgentDescription,
"prompt": nil,
"mcpServers": map[string]any{},
"tools": []string{"*"},
"toolAliases": map[string]any{},
"allowedTools": []any{},
"resources": []any{},
"toolsSettings": map[string]any{},
"includeMcpJson": true,
}
if systemPrompt != "" {
defaults["prompt"] = systemPrompt
}
if model := strings.TrimSpace(agentConfig.Model); model != "" {
defaults["model"] = model
} else {
delete(topLevel, "model")
}
for key, value := range defaults {
managedKey := key == "name" || key == "prompt" || key == "model"
if !managedKey {
if _, ok := topLevel[key]; ok {
continue
}
}
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode agent %s: %w", key, err)
}
topLevel[key] = data
}
return nil
}
// groupKiroHooksByEvent groups the managed hook specs by their Kiro event so
// each event's array is rewritten once.
func groupKiroHooksByEvent() map[string][]kiroHookSpec {

View File

@ -3,9 +3,9 @@
// and reading hook-derived session info.
//
// Kiro is AWS's agentic coding assistant. Its terminal CLI ships as the
// `kiro-cli` binary and exposes a non-interactive ("headless") mode via
// `kiro-cli chat --no-interactive "<prompt>"`, suitable for AO-driven worker
// sessions. See https://kiro.dev/docs/cli/headless/ and
// `kiro-cli` binary. AO launches Kiro with a workspace-local custom agent so
// both worker and orchestrator sessions can use Kiro's normal interactive
// approval flow. See https://kiro.dev/docs/cli/headless/ and
// https://kiro.dev/docs/cli/reference/cli-commands/.
//
// Launch delivers the initial prompt as a positional argument after `--` so a
@ -26,6 +26,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -58,29 +59,47 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}
// GetLaunchCommand builds the argv to start a new headless Kiro session:
// `kiro-cli chat --no-interactive [trust flags] -- <prompt>`.
// GetLaunchCommand builds the argv to start a new Kiro session:
// `kiro-cli chat --agent ao [trust flags] [-- <prompt>]`.
//
// The prompt is passed as a positional argument after `--` so a leading "-" is
// not read as a flag. Kiro's --no-interactive mode requires a prompt argument.
// not read as a flag. Kiro runs interactively for both workers and orchestrators;
// standing instructions come from the generated custom agent.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.kiroBinary(ctx)
if err != nil {
return nil, err
}
cmd = []string{binary, "chat", "--no-interactive"}
cmd = []string{binary, "chat", "--agent", kiroAgentName}
appendApprovalFlags(&cmd, cfg.Permissions)
if cfg.Prompt != "" {
cmd = append(cmd, "--", cfg.Prompt)
prompt := cfg.Prompt
if prompt != "" {
cmd = append(cmd, "--", prompt)
}
return cmd, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session:
// `kiro-cli chat --no-interactive --resume-id <agentSessionId> [trust flags]`.
// GetPromptDeliveryStrategy reports how Kiro receives the initial task prompt.
// Orchestrator standing instructions are delivered through the generated
// custom-agent prompt, so no command or post-start prompt injection is needed
// there.
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if cfg.Prompt != "" {
return ports.PromptDeliveryInCommand, nil
}
if cfg.Kind == domain.KindOrchestrator {
return ports.PromptDeliveryCustomAgent, nil
}
return ports.PromptDeliveryInCommand, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session.
// ok is false when the hook-derived native session id has not landed yet, so
// callers can fall back to fresh launch behavior.
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
@ -97,8 +116,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
return nil, false, err
}
cmd = make([]string, 0, 8)
cmd = append(cmd, binary, "chat", "--no-interactive", "--resume-id", agentSessionID)
cmd = []string{binary, "chat", "--agent", kiroAgentName, "--resume-id", agentSessionID}
appendApprovalFlags(&cmd, cfg.Permissions)
return cmd, true, nil
}
@ -152,14 +170,14 @@ func (p *Plugin) kiroBinary(ctx context.Context) (string, error) {
return binary, nil
}
// appendApprovalFlags maps AO's 4 permission modes onto Kiro's tool-trust
// flags. Default emits no flag so Kiro defers to the user's own configuration
// (the interactive per-tool prompt). accept-edits grants the write-capable
// built-in tools; auto/bypass grant all tools.
// appendApprovalFlags maps AO's permission modes onto Kiro's tool-trust flags.
// Default emits no flag so Kiro uses its normal interactive approval flow.
// accept-edits grants the write-capable built-in tools; auto/bypass grant all
// tools.
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
switch ports.NormalizePermissionMode(permissions) {
case ports.PermissionModeDefault:
// No flag: defer to the user's Kiro config / per-tool prompting.
// No flag: defer to Kiro's normal interactive approval flow.
case ports.PermissionModeAcceptEdits:
*cmd = append(*cmd, "--trust-tools=fs_read,fs_write")
case ports.PermissionModeAuto:

View File

@ -11,6 +11,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -27,7 +28,7 @@ func TestManifestIDIsKiro(t *testing.T) {
}
}
func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
func TestGetLaunchCommandBuildsInteractiveArgv(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
@ -39,7 +40,8 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
}
want := []string{
"kiro-cli", "chat", "--no-interactive",
"kiro-cli", "chat",
"--agent", "ao",
"--trust-all-tools",
"--", "-fix this",
}
@ -48,6 +50,64 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
}
}
func TestGetLaunchCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Kind: domain.KindOrchestrator,
SystemPrompt: "You are the human-facing coordinator.",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandPromptlessWorkerStaysInteractive(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
if err != nil {
t.Fatal(err)
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandPromptTakesPrecedenceOverSystemPrompt(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Prompt: "fix the failing test",
SystemPrompt: "standing role instructions",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
"--", "fix the failing test",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
tests := []struct {
name string
@ -124,6 +184,33 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
}
}
func TestGetPromptDeliveryStrategyOrchestratorUsesCustomAgent(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{Kind: domain.KindOrchestrator})
if err != nil {
t.Fatal(err)
}
if got != ports.PromptDeliveryCustomAgent {
t.Fatalf("unexpected strategy: %q", got)
}
}
func TestGetPromptDeliveryStrategyPromptedOrchestratorUsesCommand(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{
Kind: domain.KindOrchestrator,
Prompt: "do this explicit task",
})
if err != nil {
t.Fatal(err)
}
if got != ports.PromptDeliveryInCommand {
t.Fatalf("unexpected strategy: %q", got)
}
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{}
@ -182,7 +269,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
t.Fatal(err)
}
hooksPath := filepath.Join(hooksDir, kiroAgentFileName)
existing := `{"name":"ao","hooks":{"stop":[{"command":"custom stop hook"}]}}`
existing := `{"name":"stale","hooks":{"stop":[{"command":"custom stop hook"}]}}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
@ -190,6 +277,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
cfg := ports.WorkspaceHookConfig{
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
@ -204,13 +292,23 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// The unmanaged top-level "name" key must be preserved.
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
if _, ok := topLevel["name"]; !ok {
t.Fatalf("unmanaged top-level key 'name' was dropped: %s", data)
var name string
if err := json.Unmarshal(topLevel["name"], &name); err != nil {
t.Fatalf("decode name from %s: %v", data, err)
}
if name != kiroAgentName {
t.Fatalf("name = %q, want %q", name, kiroAgentName)
}
var prompt string
if err := json.Unmarshal(topLevel["prompt"], &prompt); err != nil {
t.Fatalf("decode prompt from %s: %v", data, err)
}
if prompt != "standing AO instructions" {
t.Fatalf("prompt = %q, want system prompt", prompt)
}
var config kiroHookFile
@ -232,6 +330,167 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
}
}
func TestGetAgentHooksCreatesNamedKiroAgentFile(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
cfg := ports.WorkspaceHookConfig{
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "exact orchestrator system prompt",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
var name string
if err := json.Unmarshal(topLevel["name"], &name); err != nil {
t.Fatalf("decode name from %s: %v", data, err)
}
if name != kiroAgentName {
t.Fatalf("name = %q, want %q", name, kiroAgentName)
}
var config kiroHookFile
if err := json.Unmarshal(data, &config); err != nil {
t.Fatal(err)
}
if config.Prompt == nil || *config.Prompt != "exact orchestrator system prompt" {
t.Fatalf("prompt = %#v, want exact system prompt", config.Prompt)
}
}
func TestGetAgentHooksWritesConfiguredModel(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
cfg := ports.WorkspaceHookConfig{
Config: ports.AgentConfig{Model: "claude-sonnet-4-5"},
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
var model string
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
t.Fatalf("decode model from %s: %v", data, err)
}
if model != "claude-sonnet-4-5" {
t.Fatalf("model = %q, want configured model", model)
}
}
func TestGetAgentHooksOverwritesStaleConfiguredModel(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
t.Fatal(err)
}
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
cfg := ports.WorkspaceHookConfig{
Config: ports.AgentConfig{Model: "project-model"},
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
var model string
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
t.Fatalf("decode model from %s: %v", data, err)
}
if model != "project-model" {
t.Fatalf("model = %q, want project override", model)
}
var tools []string
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
t.Fatalf("decode tools from %s: %v", data, err)
}
if !reflect.DeepEqual(tools, []string{"custom"}) {
t.Fatalf("tools = %#v, want preserved custom tools", tools)
}
}
func TestGetAgentHooksClearsStaleModelWhenConfigRemoved(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
t.Fatal(err)
}
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
cfg := ports.WorkspaceHookConfig{
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
if _, ok := topLevel["model"]; ok {
t.Fatalf("model still present in %s, want cleared when config has no model", data)
}
var tools []string
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
t.Fatalf("decode tools from %s: %v", data, err)
}
if !reflect.DeepEqual(tools, []string{"custom"}) {
t.Fatalf("tools = %#v, want preserved custom tools", tools)
}
}
func TestUninstallHooksRemovesKiroHooks(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
@ -309,7 +568,8 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
t.Fatal("ok = false, want true")
}
want := []string{
"kiro-cli", "chat", "--no-interactive",
"kiro-cli", "chat",
"--agent", "ao",
"--resume-id", "uuid-123",
"--trust-all-tools",
}
@ -318,6 +578,32 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
}
}
func TestGetRestoreCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Kind: domain.KindOrchestrator,
Permissions: ports.PermissionModeDefault,
Session: ports.SessionRef{
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"},
},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if !ok {
t.Fatal("ok = false, want true")
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
"--resume-id", "uuid-123",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}

View File

@ -130,6 +130,7 @@ const (
type LaunchConfig struct {
Config AgentConfig
IssueID string
Kind domain.SessionKind
Permissions PermissionMode
Prompt string
SessionID string
@ -151,12 +152,14 @@ type WorkspaceHookConfig struct {
Config AgentConfig
DataDir string
SessionID string
SystemPrompt string
WorkspacePath string
}
// RestoreConfig carries inputs needed to continue an existing native agent session.
type RestoreConfig struct {
Config AgentConfig
Kind domain.SessionKind
Permissions PermissionMode
Session SessionRef
// SystemPrompt carries the session's standing instructions (e.g. the
@ -217,6 +220,7 @@ type PromptDeliveryStrategy string
// How the orchestrator hands the initial prompt to a freshly launched agent.
const (
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
PromptDeliveryCustomAgent PromptDeliveryStrategy = "custom_agent"
)

View File

@ -256,15 +256,16 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
}
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
}
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
SessionID: string(id),
WorkspacePath: ws.Path,
Kind: cfg.Kind,
Prompt: prompt,
SystemPrompt: systemPrompt,
IssueID: string(cfg.IssueID),
@ -575,9 +576,6 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
if !ok {
return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness)
}
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
// The system prompt is derived, not persisted: recompute it so a restored
// session keeps its standing instructions across the relaunch.
systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID)
@ -586,7 +584,11 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
}
// Restore re-applies the project's resolved agent config so a configured
// model/permissions carry across a restore, matching fresh spawn.
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, effectiveAgentConfig(rec.Kind, project.Config), rec.Kind)
agentConfig := effectiveAgentConfig(rec.Kind, project.Config)
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, agentConfig, rec.Kind)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
@ -1297,11 +1299,13 @@ type preLauncher interface {
// starts the agent: installing the workspace-local activity hooks (so early
// startup hooks can update the already-created session row), then any optional
// PreLaunch step. Shared by Spawn and Restore.
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string) error {
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath, systemPrompt string, agentConfig ports.AgentConfig) error {
if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{
SessionID: string(id),
WorkspacePath: workspacePath,
DataDir: m.dataDir,
SystemPrompt: systemPrompt,
Config: agentConfig,
}); err != nil {
return fmt.Errorf("install hooks: %w", err)
}
@ -1326,7 +1330,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
WorkspacePath: workspacePath,
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID},
}
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions})
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, Kind: kind, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions})
if err != nil {
return nil, fmt.Errorf("restore command: %w", err)
}
@ -1343,6 +1347,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
SessionID: string(id),
WorkspacePath: workspacePath,
Kind: kind,
Prompt: meta.Prompt,
SystemPrompt: systemPrompt,
Config: agentConfig,

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

View File

@ -70,6 +70,14 @@ process.stderr.on("error", ignoreStdStreamError);
// Must run before app ready so the About panel and default-menu role labels use it.
app.setName("Agent Orchestrator");
// Windows shows native toasts only when the app declares an AppUserModelID that
// matches its installer shortcut (the NSIS maker's appId). Without it,
// Notification.isSupported() still returns true but show() silently drops the
// toast, so notifications never appear. No-op on macOS/Linux.
if (process.platform === "win32") {
app.setAppUserModelId("dev.agent-orchestrator.desktop");
}
// Pin ALL Electron-owned state (Chromium cache, cookies, local/session storage,
// crash dumps) under the canonical AO home at ~/.ao instead of Electron's macOS
// default ~/Library/Application Support/<name>. Keeps the app's entire footprint
@ -478,6 +486,7 @@ async function refreshDaemonStatus(): Promise<DaemonStatus> {
setDaemonStatus({
state: "stopped",
message: "AO daemon is no longer reachable.",
code: "daemon_unreachable",
});
}
return daemonStatus;
@ -518,6 +527,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
setDaemonStatus({
state: "stopped",
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
code: "not_configured",
});
return daemonStatus;
}
@ -640,6 +650,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
setDaemonStatus({
state: "error",
message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`,
code: "binary_missing",
});
return daemonStatus;
}
@ -737,6 +748,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
state: "ready",
port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined,
message: "Daemon port not confirmed from logs or running.json; assuming the configured port.",
code: "port_unconfirmed",
});
}, PORT_DISCOVERY_TIMEOUT_MS);
@ -745,17 +757,29 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
if (daemonProcess !== child) return;
daemonProcess = null;
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
setDaemonStatus({ state: "error", message: error.message });
setDaemonStatus({ state: "error", message: error.message, code: "spawn_failed" });
});
child.once("exit", (code, signal) => {
stopDiscovery();
if (daemonProcess !== child) return;
daemonProcess = null;
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
// An explicit stopDaemon() already set a clean `{ state: "stopped" }`.
// daemon-telemetry reports any status carrying a `code` as
// ao.renderer.daemon_failure, so don't stamp `code: "exited"` on a stop
// the user or app asked for — that would count intentional stops as
// failures. Preserve the clean stopped status instead.
if (daemonStoppingProcess === child) {
daemonStoppingProcess = null;
setDaemonStatus({ state: "stopped" });
return;
}
setDaemonStatus({
state: "stopped",
message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`,
code: "exited",
exitCode: code,
signal,
});
});

View File

@ -38,6 +38,7 @@ export function BrowserPanelView({
}: BrowserPanelProps & { browserView: BrowserViewModel }) {
const { navState, slotRef, navigate, goBack, goForward, reload, stop } = browserView;
const [urlInput, setUrlInput] = useState(navState.url);
const showStaticPreview = !window.ao?.browser && navState.url !== "";
useEffect(() => {
setUrlInput(navState.url);
@ -111,6 +112,7 @@ export function BrowserPanelView({
</form>
<div className="browser-panel__content">
<div className="browser-panel__slot" ref={slotRef} />
{showStaticPreview ? <StaticPreview url={navState.url} /> : null}
{navState.url === "" ? (
<div className="browser-panel__overlay">
<p>Enter a dev-server URL to preview it here.</p>
@ -121,3 +123,48 @@ export function BrowserPanelView({
</div>
);
}
function StaticPreview({ url }: { url: string }) {
return (
<div className="absolute inset-0 overflow-auto bg-[#f7f8fb] text-[#17202a]">
<div className="border-b border-[#dfe4ea] bg-white px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[#687384]">AO Preview</div>
<div className="mt-1 truncate font-mono text-[12px] text-[#2f5b9d]">{url}</div>
</div>
<div className="mx-auto max-w-[760px] px-5 py-6">
<div className="rounded-[8px] border border-[#d7dee8] bg-white p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-[22px] font-semibold leading-tight tracking-normal text-[#111827]">
Demo app preview
</h1>
<p className="mt-1 text-[13px] leading-5 text-[#526070]">
The worker exposed a local Vite app with <span className="font-mono">ao preview</span>.
</p>
</div>
<span className="rounded-[6px] bg-[#e7f8ed] px-2.5 py-1 text-[11px] font-semibold text-[#177245]">
Loaded
</span>
</div>
<div className="mt-5 grid grid-cols-3 gap-3">
{[
["Routes", "12 passing"],
["Build", "ready"],
["Latency", "42 ms"],
].map(([label, value]) => (
<div key={label} className="rounded-[7px] border border-[#e1e7ef] bg-[#fbfcfe] p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.06em] text-[#687384]">{label}</div>
<div className="mt-1 text-[15px] font-semibold text-[#111827]">{value}</div>
</div>
))}
</div>
<div className="mt-5 rounded-[7px] border border-[#dce4ef] bg-[#0f172a] p-3 font-mono text-[12px] leading-5 text-[#cbd5e1]">
<div>$ npm run dev -- --host 127.0.0.1</div>
<div className="text-[#86efac]">ready in 418 ms</div>
<div>Local: http://localhost:5173/</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { Input } from "./ui/input";
import { RequiredAgentField } from "./CreateProjectAgentSheet";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { captureRendererEvent } from "../lib/telemetry";
import type { AgentProvider } from "../types/workspace";
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
@ -88,6 +89,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
setIsSubmitting(true);
setError(undefined);
void captureRendererEvent("ao.renderer.task_create_requested", { project_id: projectId });
try {
const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", {
body: {
@ -101,9 +103,11 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
});
if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task"));
if (!data?.session?.id) throw new Error("Task creation returned no session");
void captureRendererEvent("ao.renderer.task_create_succeeded", { project_id: projectId });
onCreated(data.session.id);
onOpenChange(false);
} catch (err) {
void captureRendererEvent("ao.renderer.task_create_failed", { project_id: projectId });
void queryClient.invalidateQueries({ queryKey: agentsQueryKey });
setError(err instanceof Error ? err.message : "Unable to start task");
} finally {

View File

@ -10,6 +10,7 @@ import {
import { aoBridge } from "../lib/bridge";
import { formatTimeCompact } from "../lib/format-time";
import { createNotificationsTransport, type NotificationDTO, unreadNotificationsQueryKey } from "../lib/notifications";
import { captureRendererEvent } from "../lib/telemetry";
import { cn } from "../lib/utils";
import {
DropdownMenu,
@ -37,11 +38,13 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
(notification: NotificationDTO) => {
const target = notification.target;
if (target.kind === "pr" && target.prUrl) {
void captureRendererEvent("ao.renderer.notification_opened", { target: "pr" });
window.open(target.prUrl, "_blank", "noopener,noreferrer");
return;
}
const sessionId = target.sessionId || notification.sessionId;
if (!sessionId) return;
void captureRendererEvent("ao.renderer.notification_opened", { target: "session" });
if (notification.projectId) {
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
@ -66,6 +69,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
const markOneRead = async (id: string) => {
setActionError(null);
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "single" });
try {
await markRead.mutateAsync(id);
} catch (error) {
@ -75,6 +79,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
const markAll = async () => {
setActionError(null);
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "all" });
try {
await markAllRead.mutateAsync();
} catch (error) {

View File

@ -4,6 +4,7 @@ import type { components } from "../../api/schema";
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { captureRendererEvent } from "../lib/telemetry";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { newestActiveOrchestrator } from "../types/workspace";
import { RequiredAgentField } from "./CreateProjectAgentSheet";
@ -120,6 +121,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
const mutation = useMutation({
mutationFn: async () => {
void captureRendererEvent("ao.renderer.settings_save_requested", { project_id: projectId });
// PUT replaces the whole config; merge the edited fields over what loaded
// so we don't drop env/symlinks/postCreate the form doesn't expose.
const next: ProjectConfig = {
@ -146,7 +148,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
(activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent)
) {
try {
await spawnOrchestrator(projectId, true);
await spawnOrchestrator(projectId, "settings", true);
} catch (error) {
return {
replacementError: error instanceof Error ? error.message : "Could not replace orchestrator",
@ -156,12 +158,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
return { replacementError: null };
},
onSuccess: (result) => {
void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId });
setSavedAt(Date.now());
setReplacementError(result.replacementError);
setValidationError(null);
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
onSaved();
},
onError: () => {
void captureRendererEvent("ao.renderer.settings_save_failed", { project_id: projectId });
},
});
return (

View File

@ -22,7 +22,7 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea
setBusy(true);
setError(undefined);
try {
const id = await spawnOrchestrator(session.workspaceId, true);
const id = await spawnOrchestrator(session.workspaceId, "restore_dialog", true);
onOpenChange(false);
onRecreated(id);
} catch (err) {

View File

@ -60,6 +60,8 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
},
];
const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1";
const prStateTone: Record<SessionPRSummary["state"], string> = {
open: "border-success/40 bg-success/10 text-success",
draft: "border-border bg-raised text-muted-foreground",
@ -421,6 +423,7 @@ function ReviewsView({
return reviews.some((review) => review.status === "running") ? 2500 : false;
},
queryFn: async () => {
if (usePreviewData) return mockReviewsResponse(session);
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/reviews", {
params: { path: { sessionId: session.id } },
});
@ -432,6 +435,7 @@ function ReviewsView({
queryKey: ["project-config", session.workspaceId],
enabled: hasPr,
queryFn: async () => {
if (usePreviewData) return mockProjectConfig();
const { data, error } = await apiClient.GET("/api/v1/projects/{id}", {
params: { path: { id: session.workspaceId } },
});
@ -491,6 +495,76 @@ function projectConfig(project: components["schemas"]["ProjectOrDegraded"] | und
return project.config;
}
function mockProjectConfig(): ProjectConfig {
return {
worker: { agent: "codex" },
orchestrator: { agent: "codex" },
reviewers: [{ harness: "codex" }],
};
}
function mockReviewsResponse(session: WorkspaceSession): ReviewsResponse {
return {
reviewerHandleId: `${session.id}-reviewer`,
reviews: sortedPRs(session).map((pr, index) => {
const targetSha = `demo${pr.number}${index}`;
const reviewedAt = new Date(Date.now() - (index + 1) * 11 * 60 * 1000).toISOString();
const latestRun =
pr.review === "approved" || pr.review === "changes_requested"
? {
batchId: `demo-batch-${session.id}`,
body:
pr.review === "approved"
? "Demo review approved. The implementation is ready for the README screenshot flow."
: "Demo review found polish feedback for the terminal presentation.",
createdAt: reviewedAt,
githubReviewId: `${pr.number}01`,
harness: "codex",
id: `demo-review-run-${pr.number}`,
prUrl: pr.url,
reviewId: `demo-review-${pr.number}`,
sessionId: session.id,
status: "delivered",
targetSha,
verdict: pr.review === "approved" ? "approved" : "changes_requested",
}
: undefined;
return {
latestRun,
prNumber: pr.number,
prUrl: pr.url,
status:
pr.review === "approved"
? "up_to_date"
: pr.review === "changes_requested"
? "changes_requested"
: pr.state === "draft"
? "ineligible"
: "needs_review",
targetSha,
title: mockReviewTitle(pr.number),
};
}),
};
}
function mockReviewTitle(prNumber: number): string {
switch (prNumber) {
case 319:
return "Browser preview rail renders inside AO";
case 320:
return "Review tab keeps stacked PR rows visible";
case 321:
return "Draft child PR waits for parent review";
case 318:
return "Terminal polish feedback";
case 323:
return "README screenshot assets ready";
default:
return `Demo pull request ${prNumber}`;
}
}
function ReviewPanel({
session,
config,

View File

@ -117,7 +117,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId);
const sessionId = await spawnOrchestrator(projectId, "board");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",

View File

@ -112,7 +112,7 @@ export function ShellTopbar() {
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId);
const sessionId = await spawnOrchestrator(projectId, "topbar");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
@ -252,16 +252,21 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) {
const kill = useMutation({
mutationFn: async () => {
void captureRendererEvent("ao.renderer.session_kill_requested", { project_id: session.workspaceId });
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
params: { path: { sessionId: session.id } },
});
if (apiError) throw new Error(apiErrorMessage(apiError));
},
onSuccess: () => {
void captureRendererEvent("ao.renderer.session_kill_succeeded", { project_id: session.workspaceId });
setConfirming(false);
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
},
onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"),
onError: (e) => {
void captureRendererEvent("ao.renderer.session_kill_failed", { project_id: session.workspaceId });
setError(e instanceof Error ? e.message : "Kill failed");
},
});
if (confirming) {

View File

@ -445,7 +445,7 @@ function ProjectItem({
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(workspace.id);
const sessionId = await spawnOrchestrator(workspace.id, "sidebar");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
selection.goSession(workspace.id, sessionId);
} catch (err) {

View File

@ -23,6 +23,8 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget, font
if (!window.ao) {
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : (session?.provider ?? "claude");
const lines =
terminalTarget?.kind === "reviewer" ? reviewerPreviewLines(session) : workerPreviewLines(session, provider);
return (
<pre
className="h-full overflow-auto bg-terminal p-4 font-mono leading-relaxed text-[var(--term-fg)]"
@ -31,11 +33,23 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget, font
<span className="text-[var(--term-dim)]">~/{session?.workspaceName ?? "reverbcode"}</span>{" "}
<span className="text-[var(--term-blue)]">{session?.branch || "main"}</span> $ {provider}
{"\n"}
<span className="text-[var(--term-green)]"> Welcome to the agent CLI</span>
{"\n\n"}
<span className="text-[var(--term-dim)]">
Browser preview renders a static terminal surface. Electron attaches the live PTY.
</span>
{lines.map((line, index) => (
<span
key={`${line}:${index}`}
className={
line.startsWith("PASS") || line.startsWith("DONE")
? "text-[var(--term-green)]"
: line.startsWith("WARN") || line.startsWith("TODO")
? "text-[var(--term-amber)]"
: line.startsWith("$")
? "text-[var(--term-blue)]"
: "text-[var(--term-fg)]"
}
>
{line}
{"\n"}
</span>
))}
</pre>
);
}
@ -52,6 +66,62 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget, font
);
}
function workerPreviewLines(session: WorkspaceSession | undefined, provider: string): string[] {
if (session?.id === "demo-review-stack") {
return [
'$ rg "previewUrl|Browser" frontend/src/renderer',
"frontend/src/renderer/components/SessionInspector.tsx: Browser tab selected after ao preview",
"frontend/src/renderer/hooks/useBrowserView.ts: preview revision re-navigates the view",
"$ ao preview http://localhost:5173",
"DONE preview target set for demo-review-stack",
"$ npm --prefix frontend run typecheck",
"PASS TypeScript project references are clean",
"TODO wait for reviewer on PR #320 before merging the stack",
];
}
if (session?.id === "demo-working") {
return [
`$ ${provider} --continue`,
"Reading renderer board and inspector components...",
"Updated demo workspace data for README screenshots",
"$ npm --prefix frontend test -- SessionsBoard SessionInspector",
"PASS 18 tests passed",
"DONE board has Working, Needs you, In review, and Ready to merge populated",
];
}
if (session?.id === "demo-needs-input") {
return [
"$ git diff --stat",
"frontend/src/renderer/components/TerminalPane.tsx | 41 +++++++++++++++++",
"frontend/src/renderer/styles.css | 27 +++++++++++",
"WARN reviewer requested a tighter terminal activity sample",
"TODO confirm whether to keep the toolbar density change",
];
}
return [
`$ ${provider} --status`,
"Reading task context and local diff...",
"Running focused validation for the current session",
"PASS demo terminal is populated for screenshots",
];
}
function reviewerPreviewLines(session: WorkspaceSession | undefined): string[] {
return [
"$ ao review submit --session " + (session?.id ?? "demo-session"),
"Reviewing PR #319: browser preview rail renders inside AO",
"PASS implementation matches the requested README screenshot flow",
"Reviewing PR #320: stacked PR review rows",
"WARN keep multiple review rows visible before taking the screenshot",
"DONE submitted batched review results",
];
}
// Agents whose full-screen TUI keeps its own transcript and scrolls it only by
// keyboard, ignoring SGR wheel reports. The terminal routes the wheel to
// PageUp/PageDown for these (see XtermTerminal's paneScrollsByKeyboard).
const KEYBOARD_SCROLL_PROVIDERS = new Set(["opencode"]);
function bannerText(state: TerminalSessionState, error?: string): string | undefined {
if (state === "reattaching") return "Terminal disconnected — reattaching…";
if (state === "error") return `Terminal error: ${error ?? "connection failed"}`;
@ -74,6 +144,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
const queryClient = useQueryClient();
const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
const handleId = attachSession?.terminalHandleId;
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : session?.provider;
const hadAttachmentRef = useRef(false);
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";
@ -154,6 +225,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget, fontSiz
fontSize={fontSize}
onError={handleInitError}
onReady={handleReady}
paneScrollsByKeyboard={provider ? KEYBOARD_SCROLL_PROVIDERS.has(provider) : false}
theme={theme}
/>
{showEmptyState && (

View File

@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({
wheelHandler?: (event: WheelEvent) => boolean;
selection: string;
options: Record<string, unknown>;
modes: { bracketedPasteMode: boolean };
modes: { bracketedPasteMode: boolean; mouseTrackingMode: string };
dataListeners: Set<(data: string) => void>;
keyListeners: Set<(event: { key: string }) => void>;
selectionListeners: Set<() => void>;
@ -31,7 +31,7 @@ vi.mock("@xterm/xterm", () => ({
selection = "";
keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean;
modes = { bracketedPasteMode: false };
modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" };
dataListeners = new Set<(data: string) => void>();
keyListeners = new Set<(event: { key: string }) => void>();
selectionListeners = new Set<() => void>();
@ -492,6 +492,46 @@ describe("XtermTerminal", () => {
expect(onInput).not.toHaveBeenCalled();
});
it("sends PageUp/PageDown instead of SGR reports when the pane app has mouse tracking off", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
state.lastTerminal!.modes.mouseTrackingMode = "none";
// A keyboard-scroll TUI: one page key per notch regardless of line count,
// so 3 lines up => a single PageUp.
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
});
it("sends PageUp/PageDown on Windows even when the pane app tracks the mouse (conpty, no mux)", () => {
setNavigatorPlatform("Win32");
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
// opencode enables full mouse tracking but scrolls its transcript only by
// keyboard; with no mux to consume SGR reports, Windows must use page keys.
state.lastTerminal!.modes.mouseTrackingMode = "any";
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[6~", "wheel");
});
it("sends PageUp/PageDown for keyboard-scroll panes even under a mux (opencode on macOS/Linux)", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" paneScrollsByKeyboard onReady={(terminal) => terminal.onUserInput(onInput)} />);
// Linux (beforeEach) + mouse tracking on: without the paneScrollsByKeyboard
// hint this would send SGR reports; the hint forces page keys.
state.lastTerminal!.modes.mouseTrackingMode = "any";
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[5~", "wheel");
});
it("opens terminal links via window.open so Electron routes them to the OS browser", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
render(<XtermTerminal theme="dark" />);

View File

@ -37,6 +37,13 @@ export type XtermTerminalProps = {
className?: string;
fontSize?: number;
theme: Theme;
/**
* The pane app scrolls its transcript by keyboard (PageUp/PageDown) rather
* than acting on SGR wheel reports e.g. opencode, which enables mouse
* tracking but never scrolls on wheel reports. Routes the wheel to page keys
* on every platform (see the wheel handler), fixing it under a mux too.
*/
paneScrollsByKeyboard?: boolean;
/** Terminal construction failed; the owner decides how to surface it. */
onError?: (error: unknown) => void;
/**
@ -176,6 +183,16 @@ function sgrWheelReport(button: number, count: number): string {
return `\x1b[<${button};1;1M`.repeat(count);
}
// PageUp (CSI 5~) / PageDown (CSI 6~) for pane apps that scroll their transcript
// by keyboard rather than mouse reports. One page key per wheel notch: a page
// already scrolls a full screen, so scaling by line count would over-scroll.
const PAGE_UP = "\x1b[5~";
const PAGE_DOWN = "\x1b[6~";
function pageKeyReport(lines: number): string {
return lines < 0 ? PAGE_UP : PAGE_DOWN;
}
function forceSelectionMode(term: Terminal): void {
const internal = term as XtermInternal;
const selectionService = internal._core?._selectionService;
@ -486,6 +503,21 @@ export function XtermTerminal(props: XtermTerminalProps) {
wheelAccumPx -= lines * rowHeight;
}
if (lines === 0) return false;
// The SGR wheel path exists to drive tmux/zellij copy-mode on
// macOS/Linux. It cannot scroll a full-screen TUI that keeps its own
// transcript and only scrolls on PageUp/PageDown (opencode): the report
// is either consumed by the mux or handed to an app that ignores it.
// Send page keys for such apps (paneScrollsByKeyboard), on Windows
// (conpty has no mux, so SGR reaches the app and is ignored), and for
// any pane app with mouse tracking fully off.
if (
callbacksRef.current.paneScrollsByKeyboard ||
isWindowsPlatform() ||
term.modes.mouseTrackingMode === "none"
) {
emitUserInput(pageKeyReport(lines), "wheel");
return false;
}
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel");
return false;

View File

@ -88,6 +88,7 @@ export function useBrowserView({
const observerRef = useRef<ResizeObserver | null>(null);
const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null);
const hasUrlRef = useRef(false);
const hasNativeBrowser = Boolean(window.ao?.browser);
useEffect(() => {
activeRef.current = active;
@ -176,6 +177,21 @@ export function useBrowserView({
useEffect(() => {
let disposed = false;
if (!hasNativeBrowser) {
const state = {
...EMPTY_NAV_STATE,
viewId: `preview-${sessionId}`,
url: "",
title: "",
};
viewIdRef.current = state.viewId;
setViewId(state.viewId);
setNavState(state);
return () => {
disposed = true;
viewIdRef.current = "";
};
}
window.ao?.browser.ensure(sessionId).then((state) => {
if (disposed) return;
viewIdRef.current = state.viewId;
@ -191,7 +207,7 @@ export function useBrowserView({
}
viewIdRef.current = "";
};
}, [scheduleSettleMeasure, sendHiddenBounds, sessionId]);
}, [hasNativeBrowser, scheduleSettleMeasure, sendHiddenBounds, sessionId]);
useEffect(() => {
return window.ao?.browser.onNavState((state) => {
@ -229,11 +245,29 @@ export function useBrowserView({
}, []);
const navigate = useCallback(
(url: string) => withView((id) => window.ao!.browser.navigate({ viewId: id, url })),
[withView],
(url: string) => {
if (!hasNativeBrowser) {
const normalized = url.trim();
setNavState((current) => ({
...current,
url: normalized,
title: normalized ? "AO preview" : "",
isLoading: false,
}));
return Promise.resolve();
}
return withView((id) => window.ao!.browser.navigate({ viewId: id, url }));
},
[hasNativeBrowser, withView],
);
const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]);
const clear = useCallback(() => {
if (!hasNativeBrowser) {
setNavState((current) => ({ ...current, url: "", title: "", isLoading: false }));
return Promise.resolve();
}
return withView((id) => window.ao!.browser.clear(id));
}, [hasNativeBrowser, withView]);
// When the session is terminated, clear the view and stop reacting to
// daemon-driven preview changes so stale content does not remain visible.
@ -273,10 +307,10 @@ export function useBrowserView({
navState,
slotRef,
navigate,
goBack: () => withView((id) => window.ao!.browser.goBack(id)),
goForward: () => withView((id) => window.ao!.browser.goForward(id)),
reload: () => withView((id) => window.ao!.browser.reload(id)),
stop: () => withView((id) => window.ao!.browser.stop(id)),
goBack: () => (hasNativeBrowser ? withView((id) => window.ao!.browser.goBack(id)) : Promise.resolve()),
goForward: () => (hasNativeBrowser ? withView((id) => window.ao!.browser.goForward(id)) : Promise.resolve()),
reload: () => (hasNativeBrowser ? withView((id) => window.ao!.browser.reload(id)) : Promise.resolve()),
stop: () => (hasNativeBrowser ? withView((id) => window.ao!.browser.stop(id)) : Promise.resolve()),
destroy,
};
}

View File

@ -10,6 +10,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { getApiBaseUrl } from "../lib/api-client";
import { captureRendererEvent } from "../lib/telemetry";
import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux";
import type { WorkspaceSession } from "../types/workspace";
import { workspaceQueryKey } from "./useWorkspaceQuery";
@ -213,6 +214,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
setError(message);
transition("error");
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "pane_error" });
invalidateWorkspaces();
}),
mux.onConnectionChange((connectionState) => {
@ -268,6 +270,11 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
r.openTimer = setTimeout(() => {
if (!isCurrentAttachment(generation, handle, mux)) return;
r.openTimer = null;
// Only the first timeout of a reattach sequence is reported; the
// backoff loop retrying against a restarting daemon is not news.
if (r.attempts === 0) {
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "open_timeout" });
}
transition("reattaching");
teardownMux();
scheduleReattach();

View File

@ -1,12 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
apiClient,
apiErrorMessage,
getApiBaseUrl,
hasTrustedApiBaseUrl,
normalizeApiOperation,
setApiBaseUrl,
subscribeApiBaseUrl,
} from "./api-client";
import { captureRendererEvent } from "./telemetry";
vi.mock("./telemetry", () => ({
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
}));
const captureMock = vi.mocked(captureRendererEvent);
describe("apiClient runtime base URL", () => {
afterEach(() => {
@ -162,6 +170,123 @@ describe("subscribeApiBaseUrl", () => {
});
});
describe("normalizeApiOperation", () => {
it("replaces identifier segments after resource collections", () => {
expect(normalizeApiOperation("get", "/api/v1/projects/my project id")).toBe("GET /api/v1/projects/:id");
expect(normalizeApiOperation("POST", "/api/v1/sessions/ao-42/kill")).toBe("POST /api/v1/sessions/:id/kill");
expect(normalizeApiOperation("PUT", "/api/v1/projects/p1/config")).toBe("PUT /api/v1/projects/:id/config");
});
it("leaves collection and non-resource paths untouched", () => {
expect(normalizeApiOperation("GET", "/api/v1/projects")).toBe("GET /api/v1/projects");
expect(normalizeApiOperation("POST", "/api/v1/orchestrators")).toBe("POST /api/v1/orchestrators");
});
it("keeps static child routes instead of treating them as ids", () => {
// These match an exact OpenAPI template, so the trailing segment must not
// be collapsed to :id (which would break aggregation and hide the route).
expect(normalizeApiOperation("POST", "/api/v1/notifications/read-all")).toBe("POST /api/v1/notifications/read-all");
expect(normalizeApiOperation("POST", "/api/v1/sessions/cleanup")).toBe("POST /api/v1/sessions/cleanup");
});
it("normalizes ids for resources a collection heuristic would miss", () => {
expect(normalizeApiOperation("GET", "/api/v1/orchestrators/orch-abc")).toBe("GET /api/v1/orchestrators/:id");
expect(normalizeApiOperation("POST", "/api/v1/prs/pr-1/merge")).toBe("POST /api/v1/prs/:id/merge");
});
});
describe("api error telemetry", () => {
// The dedupe window keys off Date.now(); jump the clock far past any
// earlier test's reports so each test starts with a clean window.
let clock = Date.UTC(2100, 0, 1);
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
clock += 10 * 60_000;
vi.setSystemTime(clock);
captureMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
setApiBaseUrl("http://127.0.0.1:3001");
});
it("reports http_5xx with a normalized operation", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("oops", { status: 500 }));
setApiBaseUrl("http://127.0.0.1:3037");
await apiClient.GET("/api/v1/projects");
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
operation: "GET /api/v1/projects",
error_category: "http_5xx",
status: 500,
});
});
it("reports http_4xx with ids stripped from the operation", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("nope", { status: 404 }));
setApiBaseUrl("http://127.0.0.1:3037");
await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
params: { path: { sessionId: "ao-raw-id" } },
});
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
operation: "POST /api/v1/sessions/:id/kill",
error_category: "http_4xx",
status: 404,
});
});
it("reports network_error and rethrows", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
setApiBaseUrl("http://127.0.0.1:3037");
await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Failed to fetch");
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
operation: "GET /api/v1/projects",
error_category: "network_error",
status: undefined,
});
});
it("does not report caller-initiated aborts", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("Aborted", "AbortError"));
setApiBaseUrl("http://127.0.0.1:3037");
await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Aborted");
expect(captureMock).not.toHaveBeenCalled();
});
it("reports daemon_unavailable when the base URL is untrusted", async () => {
setApiBaseUrl(null);
await apiClient.GET("/api/v1/projects");
expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", {
operation: "GET /api/v1/projects",
error_category: "daemon_unavailable",
status: 503,
});
});
it("dedupes repeated identical failures within the 30s window", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("oops", { status: 502 }));
setApiBaseUrl("http://127.0.0.1:3037");
await apiClient.GET("/api/v1/projects");
await apiClient.GET("/api/v1/projects");
expect(captureMock).toHaveBeenCalledTimes(1);
vi.setSystemTime(clock + 31_000);
await apiClient.GET("/api/v1/projects");
expect(captureMock).toHaveBeenCalledTimes(2);
});
});
describe("apiErrorMessage", () => {
it("preserves daemon error codes next to human messages", () => {
expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe(

View File

@ -1,5 +1,6 @@
import createClient from "openapi-fetch";
import type { paths } from "../../api/schema";
import { captureRendererEvent } from "./telemetry";
function devApiBaseUrl(): string {
return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin;
@ -39,43 +40,179 @@ export function setApiBaseUrl(nextBaseUrl: string | null): void {
baseUrlListeners.forEach((listener) => listener());
}
// Route templates from the generated OpenAPI schema (frontend/src/api/schema.ts).
// Operation strings sent to telemetry must never contain raw IDs (project IDs
// are user-chosen strings), so we match each request path against these
// templates and report the template — collapsing `{param}` to `:id` — rather
// than guessing which segments are identifiers. Matching from the schema keeps
// static child routes (notifications/read-all, sessions/cleanup) intact and
// still normalizes IDs for every resource, including ones a segment heuristic
// would miss (orchestrators/{id}). Keep in sync with schema.ts.
const ROUTE_TEMPLATES = [
"/api/v1/events",
"/api/v1/import",
"/api/v1/notifications",
"/api/v1/notifications/{id}",
"/api/v1/notifications/read-all",
"/api/v1/notifications/stream",
"/api/v1/orchestrators",
"/api/v1/orchestrators/{id}",
"/api/v1/projects",
"/api/v1/projects/{id}",
"/api/v1/projects/{id}/config",
"/api/v1/prs/{id}/merge",
"/api/v1/prs/{id}/resolve-comments",
"/api/v1/sessions",
"/api/v1/sessions/{sessionId}",
"/api/v1/sessions/{sessionId}/activity",
"/api/v1/sessions/{sessionId}/kill",
"/api/v1/sessions/{sessionId}/pr",
"/api/v1/sessions/{sessionId}/pr/claim",
"/api/v1/sessions/{sessionId}/preview",
"/api/v1/sessions/{sessionId}/preview/files/*",
"/api/v1/sessions/{sessionId}/restore",
"/api/v1/sessions/{sessionId}/reviews",
"/api/v1/sessions/{sessionId}/reviews/submit",
"/api/v1/sessions/{sessionId}/reviews/trigger",
"/api/v1/sessions/{sessionId}/rollback",
"/api/v1/sessions/{sessionId}/send",
"/api/v1/sessions/cleanup",
] as const;
// Resource collections whose next path segment is an identifier. Only used as a
// defensive fallback for paths not covered by ROUTE_TEMPLATES; keeps IDs out of
// telemetry for known collections even if a route is ever missed above.
const RESOURCE_SEGMENTS = new Set(["projects", "sessions", "notifications", "workspaces", "prs", "orchestrators"]);
// Match a path against one template. `{param}` matches any single segment
// (reported as `:id`), a trailing `*` matches the remaining path, and every
// other segment must match literally. Returns the normalized template plus a
// score = number of literal segments matched, so the most specific template
// wins when several match (e.g. `read-all` beats `{id}`).
function matchRouteTemplate(pathname: string, template: string): { normalized: string; score: number } | null {
const pathSegs = pathname.split("/");
const tmplSegs = template.split("/");
const out: string[] = [];
let score = 0;
for (let i = 0; i < tmplSegs.length; i += 1) {
const t = tmplSegs[i];
if (t === "*") {
out.push("*");
return { normalized: out.join("/"), score };
}
const p = pathSegs[i];
if (p === undefined) return null;
if (t.startsWith("{") && t.endsWith("}")) {
out.push(":id");
} else if (t === p) {
out.push(t);
score += 1;
} else {
return null;
}
}
if (pathSegs.length !== tmplSegs.length) return null;
return { normalized: out.join("/"), score };
}
function fallbackNormalize(pathname: string): string {
const segments = pathname.split("/");
for (let i = 0; i < segments.length - 1; i += 1) {
if (RESOURCE_SEGMENTS.has(segments[i]) && segments[i + 1]) {
segments[i + 1] = ":id";
i += 1;
}
}
return segments.join("/");
}
export function normalizeApiOperation(method: string, pathname: string): string {
let best: { normalized: string; score: number } | null = null;
for (const template of ROUTE_TEMPLATES) {
const match = matchRouteTemplate(pathname, template);
if (match && (best === null || match.score > best.score)) best = match;
}
return `${method.toUpperCase()} ${best?.normalized ?? fallbackNormalize(pathname)}`;
}
type ApiErrorCategory = "daemon_unavailable" | "network_error" | "http_4xx" | "http_5xx";
// One event per (operation, category, status) per window: a daemon outage
// makes every polling query fail at once and on every retry — the failure
// signal matters, the storm does not.
const API_ERROR_DEDUPE_MS = 30_000;
const lastApiErrorAt = new Map<string, number>();
function reportApiError(operation: string, category: ApiErrorCategory, status?: number): void {
const key = `${operation}|${category}|${status ?? ""}`;
const now = Date.now();
const last = lastApiErrorAt.get(key);
if (last !== undefined && now - last < API_ERROR_DEDUPE_MS) return;
lastApiErrorAt.set(key, now);
void captureRendererEvent("ao.renderer.api_error", {
operation,
error_category: category,
status,
});
}
async function runtimeFetch(input: Request): Promise<Response> {
const operation = normalizeApiOperation(input.method, new URL(input.url).pathname);
const baseUrl = runtimeApiBaseUrl;
if (baseUrl === null) {
reportApiError(operation, "daemon_unavailable", 503);
return new Response(JSON.stringify({ message: "AO daemon is not ready." }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
if (!baseUrl) {
return fetch(input);
}
const url = new URL(input.url);
const target = new URL(url.pathname + url.search + url.hash, baseUrl);
if (target.href === input.url) {
return fetch(input);
}
const send = async (): Promise<Response> => {
if (!baseUrl) {
return fetch(input);
}
// Rebase onto the runtime base URL by copying fields explicitly and
// buffering the body. `new Request(target, input)` reads the source
// request's `duplex` getter, which Electron's Chromium lacks — it throws
// "The duplex member must be specified" for any request with a body, so
// every POST would fail in the packaged app. API bodies are small JSON;
// buffering sidesteps streaming-duplex semantics entirely.
const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer();
return fetch(target, {
method: input.method,
headers: input.headers,
body,
signal: input.signal,
credentials: input.credentials,
cache: input.cache,
redirect: input.redirect,
referrerPolicy: input.referrerPolicy,
integrity: input.integrity,
keepalive: input.keepalive,
});
const url = new URL(input.url);
const target = new URL(url.pathname + url.search + url.hash, baseUrl);
if (target.href === input.url) {
return fetch(input);
}
// Rebase onto the runtime base URL by copying fields explicitly and
// buffering the body. `new Request(target, input)` reads the source
// request's `duplex` getter, which Electron's Chromium lacks — it throws
// "The duplex member must be specified" for any request with a body, so
// every POST would fail in the packaged app. API bodies are small JSON;
// buffering sidesteps streaming-duplex semantics entirely.
const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer();
return fetch(target, {
method: input.method,
headers: input.headers,
body,
signal: input.signal,
credentials: input.credentials,
cache: input.cache,
redirect: input.redirect,
referrerPolicy: input.referrerPolicy,
integrity: input.integrity,
keepalive: input.keepalive,
});
};
let response: Response;
try {
response = await send();
} catch (error) {
// Caller-initiated aborts (unmounted components cancelling queries) are not failures.
if (!(error instanceof DOMException && error.name === "AbortError")) {
reportApiError(operation, "network_error");
}
throw error;
}
if (!response.ok) {
reportApiError(operation, response.status >= 500 ? "http_5xx" : "http_4xx", response.status);
}
return response;
}
export const apiClient = createClient<paths>({

View File

@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonStatus } from "../../shared/daemon-status";
import { aoBridge } from "./bridge";
import { startDaemonFailureTelemetry } from "./daemon-telemetry";
import { captureRendererEvent } from "./telemetry";
vi.mock("./telemetry", () => ({
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./bridge", () => ({
aoBridge: {
daemon: {
getStatus: vi.fn(),
onStatus: vi.fn(),
},
},
}));
const captureMock = vi.mocked(captureRendererEvent);
const getStatusMock = vi.mocked(aoBridge.daemon.getStatus);
const onStatusMock = vi.mocked(aoBridge.daemon.onStatus);
describe("daemon failure telemetry", () => {
let pushStatus!: (status: DaemonStatus) => void;
let stop: () => void = () => undefined;
beforeEach(() => {
captureMock.mockClear();
onStatusMock.mockClear();
getStatusMock.mockResolvedValue({ state: "starting" });
onStatusMock.mockImplementation((listener) => {
pushStatus = listener;
return () => undefined;
});
});
afterEach(() => {
stop();
});
it("reports a failing status with coarse fields only", () => {
stop = startDaemonFailureTelemetry();
pushStatus({ state: "error", message: "spawn /Users/alice/ao failed", code: "spawn_failed" });
expect(captureMock).toHaveBeenCalledTimes(1);
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
daemon_state: "error",
code: "spawn_failed",
exit_code: undefined,
signal: undefined,
});
});
it("includes exit code and signal for daemon exits", () => {
stop = startDaemonFailureTelemetry();
pushStatus({ state: "stopped", code: "exited", exitCode: 1, signal: "SIGKILL" });
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
daemon_state: "stopped",
code: "exited",
exit_code: 1,
signal: "SIGKILL",
});
});
it("ignores statuses without a failure code (healthy or user-initiated stop)", () => {
stop = startDaemonFailureTelemetry();
pushStatus({ state: "ready", port: 3037 });
pushStatus({ state: "stopped" });
expect(captureMock).not.toHaveBeenCalled();
});
it("dedupes identical consecutive failures and resets on recovery", () => {
stop = startDaemonFailureTelemetry();
pushStatus({ state: "error", code: "daemon_unreachable" });
pushStatus({ state: "error", code: "daemon_unreachable" });
expect(captureMock).toHaveBeenCalledTimes(1);
// A different failure is a new event.
pushStatus({ state: "stopped", code: "exited", exitCode: 137 });
expect(captureMock).toHaveBeenCalledTimes(2);
// Recovery resets dedupe: the same failure recurring is reported again.
pushStatus({ state: "ready", port: 3037 });
pushStatus({ state: "error", code: "daemon_unreachable" });
expect(captureMock).toHaveBeenCalledTimes(3);
});
it("reports the initial status from getStatus on start", async () => {
getStatusMock.mockResolvedValue({ state: "error", code: "binary_missing" });
stop = startDaemonFailureTelemetry();
await vi.waitFor(() => expect(captureMock).toHaveBeenCalledTimes(1));
expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", {
daemon_state: "error",
code: "binary_missing",
exit_code: undefined,
signal: undefined,
});
});
it("is idempotent while started and restartable after stop", () => {
stop = startDaemonFailureTelemetry();
const noop = startDaemonFailureTelemetry();
expect(onStatusMock).toHaveBeenCalledTimes(1);
noop();
stop();
stop = startDaemonFailureTelemetry();
expect(onStatusMock).toHaveBeenCalledTimes(2);
});
});

View File

@ -0,0 +1,53 @@
// Daemon failures happen in the Electron main process, which has no PostHog
// client. Main stamps a machine-readable `code` on every failing DaemonStatus
// (shared/daemon-status.ts); this module rides the existing daemon:status IPC
// push and reports those failures through the renderer's telemetry client.
import type { DaemonStatus } from "../../shared/daemon-status";
import { aoBridge } from "./bridge";
import { captureRendererEvent } from "./telemetry";
let started = false;
let lastFailureKey: string | null = null;
function failureKey(status: DaemonStatus): string | null {
if (!status.code) return null;
return [status.state, status.code, status.exitCode ?? "", status.signal ?? ""].join("|");
}
function reportStatus(status: DaemonStatus): void {
const key = failureKey(status);
if (!key) {
// Healthy status resets dedupe so a repeat failure after recovery is a new event.
lastFailureKey = null;
return;
}
if (key === lastFailureKey) return;
lastFailureKey = key;
void captureRendererEvent("ao.renderer.daemon_failure", {
daemon_state: status.state,
code: status.code,
exit_code: typeof status.exitCode === "number" ? status.exitCode : undefined,
signal: status.signal ?? undefined,
});
}
/** Idempotent; returns a stop function (used by tests). */
export function startDaemonFailureTelemetry(): () => void {
if (started) return () => undefined;
started = true;
void aoBridge.daemon
.getStatus()
.then(reportStatus)
.catch(() => undefined);
let stopListener: () => void = () => undefined;
try {
stopListener = aoBridge.daemon.onStatus(reportStatus);
} catch {
// Preload bridge unavailable (browser preview): initial getStatus already handled.
}
return () => {
started = false;
lastFailureKey = null;
stopListener();
};
}

View File

@ -2,285 +2,203 @@ import type { PRState, PullRequestFacts, WorkspaceSummary } from "../types/works
import type { SessionPRSummary } from "../hooks/useSessionScmSummary";
const now = new Date().toISOString();
const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60 * 1000).toISOString();
const hoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
// Single-PR preview helper. Sessions that own a stack (stacked-auth) inline
// their facts instead; the daemon aggregates per-PR CI/review server-side.
const pr = (number: number, state: PRState, ci = "passing"): PullRequestFacts => ({
url: `https://github.com/me/pull/${number}`,
const demoPr = (
number: number,
state: PRState,
ci: PullRequestFacts["ci"] = "passing",
review: PullRequestFacts["review"] = "none",
mergeability: PullRequestFacts["mergeability"] = "mergeable",
): PullRequestFacts => ({
url: `https://github.com/acme-inc/ao-demo/pull/${number}`,
number,
state,
ci,
review: state === "merged" ? "approved" : "none",
mergeability: "mergeable",
reviewComments: false,
review,
mergeability,
reviewComments: review === "changes_requested",
updatedAt: now,
});
export const mockWorkspaces: WorkspaceSummary[] = [
{
id: "api-gateway",
name: "api-gateway",
path: "/Users/me/api-gateway",
id: "ao-demo",
name: "ao-demo",
path: "/demo/ao-demo",
type: "main",
orchestratorAgent: "codex",
accentColor: "#6ee7b7",
sessions: [
{
id: "refactor-mux",
terminalHandleId: "refactor-mux/terminal_0",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "Split terminal mux responsibilities",
provider: "claude-code",
branch: "feat/refactor-mux",
status: "working",
updatedAt: now,
createdAt: hoursAgo(4),
changedFiles: [
{
path: "internal/mux/terminal_mux.go",
additions: 42,
deletions: 8,
},
],
commitMessage: "refactor terminal mux",
prs: [],
},
{
id: "stacked-auth",
terminalHandleId: "stacked-auth/terminal_0",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "auth stack",
provider: "claude-code",
branch: "feat/ns",
status: "review_pending",
updatedAt: now,
createdAt: hoursAgo(2),
// One session owning a stack: open root, draft child, merged base.
prs: [
{
url: "https://github.com/me/api-gateway/pull/41",
number: 41,
state: "open",
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: now,
},
{
url: "https://github.com/me/api-gateway/pull/42",
number: 42,
state: "draft",
ci: "pending",
review: "none",
mergeability: "unknown",
reviewComments: false,
updatedAt: now,
},
{
url: "https://github.com/me/api-gateway/pull/40",
number: 40,
state: "merged",
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: hoursAgo(1),
},
],
},
{
id: "fix-auth-timeouts",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "fix auth timeout retry loop",
id: "ao-demo-orchestrator",
terminalHandleId: "ao-demo-orchestrator/terminal_0",
workspaceId: "ao-demo",
workspaceName: "ao-demo",
title: "Project orchestrator",
provider: "codex",
branch: "fix/auth-timeouts",
status: "ci_failed",
updatedAt: hoursAgo(1),
kind: "orchestrator",
branch: "main",
status: "working",
createdAt: hoursAgo(6),
prs: [pr(184, "open", "failing")],
},
{
id: "rate-limit-headers",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "add rate limit headers",
provider: "opencode",
branch: "feat/rate-limit-headers",
status: "review_pending",
updatedAt: hoursAgo(2),
createdAt: hoursAgo(9),
prs: [pr(185, "open")],
},
],
},
{
id: "webgl-preview",
name: "webgl-preview",
path: "/Users/me/webgl-preview",
sessions: [
{
id: "fix-webgl-fallback",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "Restore fallback renderer after WebGL init fails",
provider: "codex",
branch: "fix/webgl-fallback",
status: "needs_input",
updatedAt: now,
createdAt: hoursAgo(4),
updatedAt: minutesAgo(3),
activity: { state: "active", lastActivityAt: minutesAgo(3) },
prs: [],
},
{
id: "shader-cache",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "cache compiled shader programs",
provider: "claude-code",
branch: "feat/shader-cache",
id: "demo-working",
terminalHandleId: "demo-working/terminal_0",
workspaceId: "ao-demo",
workspaceName: "ao-demo",
title: "Build screenshot-ready dashboard data",
provider: "codex",
branch: "demo/dashboard-screenshot",
status: "working",
updatedAt: hoursAgo(0.5),
createdAt: hoursAgo(2),
changedFiles: [
{ path: "src/render/shader-cache.ts", additions: 86, deletions: 12 },
{ path: "src/render/webgl-context.ts", additions: 24, deletions: 5 },
],
prs: [],
},
{
id: "texture-leak",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "stop texture leak on scene reload",
provider: "codex",
branch: "fix/texture-leak",
status: "ci_failed",
updatedAt: hoursAgo(1.5),
createdAt: hoursAgo(7),
prs: [pr(51, "open", "failing")],
},
{
id: "review-camera-pan",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "smooth camera pan controls",
provider: "aider",
branch: "feat/camera-pan",
status: "review_pending",
updatedAt: hoursAgo(3),
createdAt: hoursAgo(10),
prs: [pr(52, "open")],
},
{
id: "draft-webgpu-probe",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "probe WebGPU support before init",
provider: "opencode",
branch: "feat/webgpu-probe",
status: "draft",
updatedAt: hoursAgo(5),
createdAt: hoursAgo(12),
prs: [pr(53, "draft")],
},
{
id: "merge-frame-stats",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "ship frame statistics overlay",
provider: "codex",
branch: "feat/frame-stats",
status: "mergeable",
updatedAt: hoursAgo(0.25),
createdAt: hoursAgo(14),
prs: [pr(54, "open")],
},
{
id: "approved-pixel-ratio",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "respect device pixel ratio",
provider: "claude-code",
branch: "fix/device-pixel-ratio",
status: "approved",
updatedAt: hoursAgo(2.5),
createdAt: hoursAgo(16),
prs: [pr(55, "open")],
},
{
id: "input-pointer-lock",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "pointer lock escape handling",
provider: "codex",
branch: "fix/pointer-lock",
status: "changes_requested",
updatedAt: hoursAgo(4),
createdAt: hoursAgo(18),
prs: [pr(56, "open")],
},
],
},
{
id: "mobile-shell",
name: "mobile-shell",
path: "/Users/me/mobile-shell",
sessions: [
{
id: "nav-gesture",
workspaceId: "mobile-shell",
workspaceName: "mobile-shell",
title: "repair back swipe gesture",
provider: "codex",
branch: "fix/back-swipe",
status: "working",
updatedAt: hoursAgo(0.75),
displayStatus: "working",
createdAt: hoursAgo(3),
updatedAt: minutesAgo(2),
activity: { state: "active", lastActivityAt: minutesAgo(2) },
changedFiles: [
{ path: "frontend/src/renderer/lib/mock-data.ts", additions: 156, deletions: 22 },
{ path: "docs/readme.md", additions: 18, deletions: 4 },
],
commitMessage: "prepare readme screenshot data",
prs: [],
},
{
id: "profile-sheet",
workspaceId: "mobile-shell",
workspaceName: "mobile-shell",
title: "profile sheet accessibility pass",
id: "demo-needs-input",
terminalHandleId: "demo-needs-input/terminal_0",
workspaceId: "ao-demo",
workspaceName: "ao-demo",
title: "Resolve reviewer feedback on terminal polish",
provider: "claude-code",
branch: "fix/profile-sheet-a11y",
branch: "demo/terminal-polish",
status: "changes_requested",
displayStatus: "needs_you",
createdAt: hoursAgo(5),
updatedAt: minutesAgo(18),
activity: { state: "waiting_input", lastActivityAt: minutesAgo(18) },
changedFiles: [
{ path: "frontend/src/renderer/components/TerminalPane.tsx", additions: 41, deletions: 9 },
{ path: "frontend/src/renderer/styles.css", additions: 27, deletions: 3 },
],
commitMessage: "polish terminal screenshots",
prs: [demoPr(318, "open", "passing", "changes_requested")],
},
{
id: "demo-review-stack",
terminalHandleId: "demo-review-stack/terminal_0",
workspaceId: "ao-demo",
workspaceName: "ao-demo",
title: "Review stacked browser preview flow",
provider: "codex",
branch: "demo/browser-preview-stack",
status: "review_pending",
displayStatus: "needs_you",
createdAt: hoursAgo(7),
updatedAt: minutesAgo(7),
activity: { state: "idle", lastActivityAt: minutesAgo(7) },
previewUrl: "http://localhost:5173",
previewRevision: 4,
changedFiles: [
{ path: "frontend/src/renderer/components/BrowserPanel.tsx", additions: 52, deletions: 11 },
{ path: "frontend/src/renderer/hooks/useBrowserView.ts", additions: 33, deletions: 6 },
{ path: "docs/assets/readme/browser-preview.png", additions: 1, deletions: 0 },
],
commitMessage: "wire readme browser preview",
prs: [
demoPr(319, "open", "passing", "none"),
demoPr(320, "open", "pending", "none", "unknown"),
demoPr(321, "draft", "pending", "none", "unknown"),
],
},
{
id: "demo-in-review",
terminalHandleId: "demo-in-review/terminal_0",
workspaceId: "ao-demo",
workspaceName: "ao-demo",
title: "Wait for CI on project settings copy",
provider: "opencode",
branch: "demo/project-settings-copy",
status: "review_pending",
displayStatus: "unknown",
createdAt: hoursAgo(4),
updatedAt: minutesAgo(31),
activity: { state: "idle", lastActivityAt: minutesAgo(31) },
prs: [demoPr(322, "open", "pending", "none", "unknown")],
},
{
id: "demo-ready",
terminalHandleId: "demo-ready/terminal_0",
workspaceId: "ao-demo",
workspaceName: "ao-demo",
title: "Merge README screenshot asset update",
provider: "codex",
branch: "demo/readme-assets",
status: "mergeable",
updatedAt: hoursAgo(1.25),
displayStatus: "mergeable",
createdAt: hoursAgo(9),
updatedAt: minutesAgo(5),
activity: { state: "idle", lastActivityAt: minutesAgo(5) },
changedFiles: [
{ path: "docs/assets/readme/dashboard.png", additions: 1, deletions: 0 },
{ path: "docs/assets/readme/session-terminal.png", additions: 1, deletions: 0 },
],
prs: [demoPr(323, "open", "passing", "approved")],
},
{
id: "demo-ci-failed",
terminalHandleId: "demo-ci-failed/terminal_0",
workspaceId: "ao-demo",
workspaceName: "ao-demo",
title: "Fix flaky NewTaskDialog smoke test",
provider: "codex",
branch: "demo/new-task-flake",
status: "ci_failed",
displayStatus: "needs_you",
createdAt: hoursAgo(8),
prs: [pr(92, "open")],
updatedAt: minutesAgo(46),
activity: { state: "idle", lastActivityAt: minutesAgo(46) },
prs: [demoPr(324, "open", "failing", "none")],
},
],
},
{
id: "billing-portal",
name: "billing-portal",
path: "/Users/me/billing-portal",
id: "docs-site",
name: "docs-site",
path: "/demo/docs-site",
type: "main",
orchestratorAgent: "claude-code",
accentColor: "#93c5fd",
sessions: [
{
id: "invoice-export",
workspaceId: "billing-portal",
workspaceName: "billing-portal",
title: "invoice CSV export",
provider: "opencode",
branch: "feat/invoice-export",
status: "review_pending",
updatedAt: hoursAgo(2.25),
createdAt: hoursAgo(11),
prs: [pr(117, "open")],
id: "docs-installation",
terminalHandleId: "docs-installation/terminal_0",
workspaceId: "docs-site",
workspaceName: "docs-site",
title: "Tighten installation guide",
provider: "claude-code",
branch: "demo/install-docs",
status: "working",
createdAt: hoursAgo(2),
updatedAt: minutesAgo(13),
activity: { state: "active", lastActivityAt: minutesAgo(13) },
prs: [],
},
{
id: "tax-id-validation",
workspaceId: "billing-portal",
workspaceName: "billing-portal",
title: "tax id validation errors",
id: "docs-ready",
terminalHandleId: "docs-ready/terminal_0",
workspaceId: "docs-site",
workspaceName: "docs-site",
title: "Publish troubleshooting section",
provider: "codex",
branch: "fix/tax-id-validation",
status: "needs_input",
updatedAt: hoursAgo(1.75),
createdAt: hoursAgo(5),
prs: [],
branch: "demo/troubleshooting",
status: "approved",
createdAt: hoursAgo(12),
updatedAt: minutesAgo(22),
activity: { state: "idle", lastActivityAt: minutesAgo(22) },
prs: [demoPr(411, "open", "passing", "approved")],
},
],
},

View File

@ -36,7 +36,7 @@ describe("restartProjectOrchestrator", () => {
onError,
});
expect(spawnMock).toHaveBeenCalledWith("proj-1", true);
expect(spawnMock).toHaveBeenCalledWith("proj-1", "restart", true);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null);
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary");

View File

@ -36,7 +36,7 @@ export async function restartProjectOrchestrator({
setProjectRestarting(projectId, true);
setOrchestratorReplacementError(projectId, null);
try {
const sessionId = await spawnOrchestrator(projectId, true);
const sessionId = await spawnOrchestrator(projectId, "restart", true);
await refreshWorkspaceState(queryClient);
void navigate({
to: "/projects/$projectId/sessions/$sessionId",

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { spawnOrchestrator } from "./spawn-orchestrator";
import { apiClient } from "./api-client";
import { captureRendererEvent } from "./telemetry";
vi.mock("./api-client", () => ({
apiClient: { POST: vi.fn() },
@ -14,6 +15,12 @@ vi.mock("./api-client", () => ({
},
}));
vi.mock("./telemetry", () => ({
captureRendererEvent: vi.fn().mockResolvedValue(undefined),
}));
const captureMock = vi.mocked(captureRendererEvent);
describe("spawnOrchestrator", () => {
beforeEach(() => vi.clearAllMocks());
@ -23,7 +30,7 @@ describe("spawnOrchestrator", () => {
error: undefined,
response: { status: 201 },
});
const id = await spawnOrchestrator("proj", true);
const id = await spawnOrchestrator("proj", "restore_dialog", true);
expect(id).toBe("proj-9");
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj", clean: true },
@ -36,12 +43,43 @@ describe("spawnOrchestrator", () => {
error: undefined,
response: { status: 201 },
});
await spawnOrchestrator("proj");
await spawnOrchestrator("proj", "board");
expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj", clean: false },
});
});
it("emits the requested + succeeded triad keyed by source", async () => {
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { orchestrator: { id: "proj-7" } },
error: undefined,
response: { status: 201 },
});
await spawnOrchestrator("proj", "sidebar");
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_requested", {
project_id: "proj",
source: "sidebar",
});
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", {
project_id: "proj",
source: "sidebar",
});
});
it("emits the failed event and rethrows when the daemon rejects the spawn", async () => {
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
data: undefined,
error: { message: "boom" },
response: { status: 500 },
});
await expect(spawnOrchestrator("proj", "topbar")).rejects.toThrow("boom");
expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_failed", {
project_id: "proj",
source: "topbar",
});
expect(captureMock).not.toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", expect.anything());
});
it("surfaces daemon spawn error messages and codes", async () => {
(apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({
data: undefined,
@ -49,6 +87,8 @@ describe("spawnOrchestrator", () => {
response: { status: 400 },
});
await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)");
await expect(spawnOrchestrator("proj", "board")).rejects.toThrow(
"agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)",
);
});
});

View File

@ -1,19 +1,40 @@
import { apiClient, apiErrorMessage } from "./api-client";
import { captureRendererEvent } from "./telemetry";
// Every UI entry point that spawns an orchestrator: the board CTA, the topbar
// and sidebar launchers, the restore-unavailable dialog, and the auto-spawn
// right after a project is added. Emitting the triad from inside
// spawnOrchestrator (keyed by source) guarantees each path reports, instead of
// each call site remembering to instrument itself. Keep in sync with the
// allowed-source list in telemetry.ts.
export type OrchestratorSpawnSource =
"board" | "restore_dialog" | "topbar" | "sidebar" | "project_add" | "settings" | "restart";
/** Spawn the project's orchestrator session via the daemon API. When clean is
* true the daemon first tears down any active orchestrator for the project, then
* re-spawns one on the canonical branch (reattaching the existing branch). */
export async function spawnOrchestrator(projectId: string, clean = false): Promise<string> {
const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
body: { projectId, clean },
});
export async function spawnOrchestrator(
projectId: string,
source: OrchestratorSpawnSource,
clean = false,
): Promise<string> {
void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { project_id: projectId, source });
try {
const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", {
body: { projectId, clean },
});
if (error || !data?.orchestrator?.id) {
const message = error
? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`)
: `Failed to spawn orchestrator (${response.status})`;
throw new Error(message);
if (error || !data?.orchestrator?.id) {
const message = error
? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`)
: `Failed to spawn orchestrator (${response.status})`;
throw new Error(message);
}
void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { project_id: projectId, source });
return data.orchestrator.id;
} catch (err) {
void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { project_id: projectId, source });
throw err;
}
return data.orchestrator.id;
}

View File

@ -111,4 +111,95 @@ describe("telemetry sanitizers", () => {
"https://api.example.com/endpoint",
);
});
it("hashes project ids and drops everything else on CTA triads", async () => {
const triads = [
"ao.renderer.task_create_requested",
"ao.renderer.task_create_succeeded",
"ao.renderer.task_create_failed",
"ao.renderer.session_kill_requested",
"ao.renderer.session_kill_succeeded",
"ao.renderer.session_kill_failed",
"ao.renderer.settings_save_requested",
"ao.renderer.settings_save_succeeded",
"ao.renderer.settings_save_failed",
];
for (const event of triads) {
const props = await sanitizeRendererProperties(event, {
project_id: "demo-project",
title: "raw user text",
error: "raw error with /Users/alice/path",
});
expect(Object.keys(props)).toEqual(["project_id_hash"]);
}
});
it("keeps only the source enum on orchestrator_spawn events", async () => {
const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_requested", {
project_id: "demo-project",
source: "board",
});
expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
expect(props.source).toBe("board");
const badSource = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_failed", {
project_id: "demo-project",
source: "/Users/alice/private",
});
expect(badSource).not.toHaveProperty("source");
});
it("keeps every whitelisted spawn source, including topbar/sidebar/project_add/settings/restart", async () => {
for (const source of ["board", "restore_dialog", "topbar", "sidebar", "project_add", "settings", "restart"]) {
const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_succeeded", {
project_id: "demo-project",
source,
});
expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]);
expect(props.source).toBe(source);
}
});
it("keeps only enum values on notification events", async () => {
expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "pr" })).toEqual({
target: "pr",
});
expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "http://x" })).toEqual({});
expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "all" })).toEqual({
scope: "all",
});
expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "everything" })).toEqual(
{},
);
});
it("whitelists coarse daemon failure fields and drops messages", async () => {
const props = await sanitizeRendererProperties("ao.renderer.daemon_failure", {
daemon_state: "error",
code: "spawn_failed",
exit_code: 1,
signal: "SIGKILL",
message: "spawn /Users/alice/ao failed",
});
expect(props).toEqual({ daemon_state: "error", code: "spawn_failed", exit_code: 1, signal: "SIGKILL" });
});
it("whitelists normalized api_error fields", async () => {
const props = await sanitizeRendererProperties("ao.renderer.api_error", {
operation: "GET /api/v1/projects/:id",
error_category: "http_5xx",
status: 500,
body: "raw response body",
});
expect(props).toEqual({ operation: "GET /api/v1/projects/:id", error_category: "http_5xx", status: 500 });
});
it("keeps only the reason enum on terminal_attach_failed", async () => {
expect(await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "open_timeout" })).toEqual({
reason: "open_timeout",
});
expect(
await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "something else" }),
).toEqual({});
});
});

View File

@ -128,6 +128,19 @@ async function sanitizeRendererContextProperties(properties?: TelemetryPropertie
return safe;
}
// Allowed `source` enum for the orchestrator-spawn triad. Kept as a literal set
// here (rather than imported from spawn-orchestrator.ts, which imports this
// module) to avoid a cycle; keep in sync with OrchestratorSpawnSource.
const ORCHESTRATOR_SPAWN_SOURCES = new Set([
"board",
"restore_dialog",
"topbar",
"sidebar",
"project_add",
"settings",
"restart",
]);
export async function sanitizeRendererProperties(
event: string,
properties?: TelemetryProperties,
@ -147,11 +160,52 @@ export async function sanitizeRendererProperties(
break;
case "ao.renderer.project_add_succeeded":
case "ao.renderer.project_removed":
case "ao.renderer.orchestrator_open_requested": {
case "ao.renderer.orchestrator_open_requested":
case "ao.renderer.task_create_requested":
case "ao.renderer.task_create_succeeded":
case "ao.renderer.task_create_failed":
case "ao.renderer.session_kill_requested":
case "ao.renderer.session_kill_succeeded":
case "ao.renderer.session_kill_failed":
case "ao.renderer.settings_save_requested":
case "ao.renderer.settings_save_succeeded":
case "ao.renderer.settings_save_failed": {
const projectIDHash = await hashedTelemetryID(properties?.project_id);
if (projectIDHash) safe.project_id_hash = projectIDHash;
break;
}
case "ao.renderer.orchestrator_spawn_requested":
case "ao.renderer.orchestrator_spawn_succeeded":
case "ao.renderer.orchestrator_spawn_failed": {
const projectIDHash = await hashedTelemetryID(properties?.project_id);
if (projectIDHash) safe.project_id_hash = projectIDHash;
if (typeof properties?.source === "string" && ORCHESTRATOR_SPAWN_SOURCES.has(properties.source)) {
safe.source = properties.source;
}
break;
}
case "ao.renderer.notification_opened":
if (properties?.target === "pr" || properties?.target === "session") safe.target = properties.target;
break;
case "ao.renderer.notification_marked_read":
if (properties?.scope === "single" || properties?.scope === "all") safe.scope = properties.scope;
break;
case "ao.renderer.daemon_failure":
if (typeof properties?.daemon_state === "string") safe.daemon_state = properties.daemon_state;
if (typeof properties?.code === "string") safe.code = properties.code;
if (typeof properties?.exit_code === "number") safe.exit_code = properties.exit_code;
if (typeof properties?.signal === "string") safe.signal = properties.signal;
break;
case "ao.renderer.api_error":
if (typeof properties?.operation === "string") safe.operation = properties.operation;
if (typeof properties?.error_category === "string") safe.error_category = properties.error_category;
if (typeof properties?.status === "number") safe.status = properties.status;
break;
case "ao.renderer.terminal_attach_failed":
if (properties?.reason === "open_timeout" || properties?.reason === "pane_error") {
safe.reason = properties.reason;
}
break;
}
return safe;
}

View File

@ -8,9 +8,11 @@ import { queryClient } from "./lib/query-client";
import { createAppRouter } from "./router";
import { TelemetryBoundary } from "./components/TelemetryBoundary";
import { initTelemetry } from "./lib/telemetry";
import { startDaemonFailureTelemetry } from "./lib/daemon-telemetry";
const router = createAppRouter(queryClient);
void initTelemetry();
startDaemonFailureTelemetry();
declare module "@tanstack/react-router" {
interface Register {

View File

@ -112,7 +112,7 @@ function ShellLayout() {
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
try {
const sessionId = await spawnOrchestrator(workspace.id);
const sessionId = await spawnOrchestrator(workspace.id, "project_add");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",

View File

@ -174,6 +174,7 @@ describe("resolveDaemonFromRunFile", () => {
executablePath: undefined,
workingDirectory: undefined,
message: "An AO daemon is already running, but it is not ready yet.",
code: "not_ready",
});
});
@ -271,6 +272,7 @@ describe("resolveDaemonFromPort", () => {
executablePath: undefined,
workingDirectory: undefined,
message: "An AO daemon is already running, but it is not ready yet.",
code: "not_ready",
});
});

View File

@ -147,6 +147,7 @@ async function readinessStatus(
executablePath: health.executablePath,
workingDirectory: health.workingDirectory,
message: "An AO daemon is already running, but it is not ready yet.",
code: "not_ready",
};
}
@ -159,6 +160,7 @@ async function readinessStatus(
executablePath: ready.executablePath,
workingDirectory: ready.workingDirectory,
message,
code: "identity_mismatch",
};
}

View File

@ -1,6 +1,19 @@
// DaemonStatus is the supervisor → renderer handshake payload, shared by the
// Electron main process (which derives it) and the preload bridge (which types
// the IPC surface). The renderer picks it up through the preload's AoBridge type.
// Machine-readable failure classification for telemetry. `message` is
// human-facing and may contain local paths; `code` is what gets reported.
// Statuses without a code (normal ready, user-initiated stop) are not failures.
export type DaemonFailureCode =
| "not_configured"
| "daemon_unreachable"
| "binary_missing"
| "spawn_failed"
| "exited"
| "port_unconfirmed"
| "not_ready"
| "identity_mismatch";
export type DaemonStatus = {
state: "starting" | "ready" | "stopped" | "error";
port?: number;
@ -8,4 +21,7 @@ export type DaemonStatus = {
executablePath?: string;
workingDirectory?: string;
message?: string;
code?: DaemonFailureCode;
exitCode?: number | null;
signal?: string | null;
};

BIN
screenshots/tweet1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

BIN
screenshots/tweet2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

BIN
screenshots/tweet3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB