Merge origin/main into remote control

This commit is contained in:
Ashish Huddar 2026-05-22 14:16:33 +05:30
commit 87e3fce535
151 changed files with 6298 additions and 1589 deletions

View File

@ -1,18 +0,0 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-web": minor
---
Wire activity events into webhook ingress and the mux WebSocket terminal server (sub-issue of #1511, follows #1620).
- `api.webhook_unverified` (warn) — signature verification failed; data includes `slug`, `remoteAddr`, `candidateCount` (never the failed signature)
- `api.webhook_rejected` (warn) — payload exceeded `maxBodyBytes`; data includes counts and `maxBodyBytes` (never the body)
- `api.webhook_received` (info|warn) — accepted webhook; data includes `projectIds`, `matchedSessions`, `parseErrorCount`, `lifecycleErrorCount` (never the body)
- `api.webhook_failed` (error) — outer pipeline crash with `errorMessage`
- `ui.terminal_connected` / `ui.terminal_disconnected` — one event per mux WS connection lifecycle
- `ui.terminal_heartbeat_lost` (warn) — fires once on 3 missed pongs (was console-only)
- `ui.terminal_pty_lost` (warn) — fires when PTY exits with subscribers attached (distinguishes "PTY died" from "user closed browser")
- `ui.terminal_protocol_error` (warn) — invalid mux client message
- `ui.session_broadcast_failed` (warn) — emitted on the healthy→failing transition only (re-arms after a successful poll), so a long outage produces one event, not 20/min
`api.webhook_unverified` is the security-audit event; treat 401s on webhooks as a signal worth retaining for the full 7-day window.

View File

@ -1,8 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-plugin-agent-claude-code": patch
---
Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"``blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903.
Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}``blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider).

View File

@ -1,17 +0,0 @@
---
"@aoagents/ao-plugin-agent-claude-code": patch
---
Harden Claude Code activity detection against five real-world edge cases identified during PR #1927's analysis:
1. **Bookkeeping types → false-active.** `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title` were falling through to the `default` switch branch and showing as `active` for 30s after Claude finished a turn. They now correctly map to `ready`/`idle` by age. Likely root cause of "Claude looks busy when it's done" reports.
2. **Multi-session disambiguation.** `findLatestSessionFile` picked newest-mtime, which is the wrong session's JSONL when two Claude sessions are running in the same workspace. Now prefers the UUID-named file (`<projectDir>/<claudeSessionUuid>.jsonl`) when `session.metadata.claudeSessionUuid` is set, falling back to newest-mtime otherwise.
3. **Symlinked workspaces.** `toClaudeProjectPath` was a pure string transform — symlink paths produced different slugs than what Claude itself wrote. Added `resolveWorkspaceForClaude(path)` that runs `realpathSync` (with fallback) and used it in all three slug-computing sites (`getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`).
4. **Process regex too narrow.** `(?:^|\/)claude(?:\s|$)` was missing several legitimate install variants — `.claude`, `claude-code`, `claude.exe`, `claude.js`, and npm shims like `node /opt/.../@anthropic-ai/claude-code/cli.js`. Broadened to `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)`; still rejects look-alikes (`claudia`, `claudine`).
5. **Silent permission-denied.** `findLatestSessionFile` was swallowing every `readdir` error silently — a missing `~/.claude/projects/<slug>/` (ENOENT) is normal, but a permission-denied (EACCES/EPERM) or fd-exhausted (EMFILE) misconfig left the session looking permanently `idle` on the dashboard with zero telemetry. Now logs a single `console.warn` for non-ENOENT errors.
193/193 plugin tests pass. No public-API change. New helper `resolveWorkspaceForClaude` is re-exported from `index.ts` for downstream consumers.

View File

@ -1,6 +0,0 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-cli": minor
---
Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.

View File

@ -37,7 +37,7 @@
]
],
"snapshot": {
"useCalculatedVersionForSnapshots": true,
"useCalculatedVersion": true,
"prereleaseTemplate": "{tag}-{commit}"
},
"access": "public",

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-web": patch
---
Fix Done/Terminated section on the dashboard not scrolling. The dashboard body now scrolls as a single page — kanban above, Done/Terminated below — so older terminated sessions are reachable when many accumulate. Restores the pre-Warm-Terminal-refresh behavior by dropping the `flex: 1` that was making `.kanban-board-wrap` greedily consume all remaining body height and defeat the body's `overflow-y: auto`.

View File

@ -1,6 +0,0 @@
---
"@aoagents/ao-cli": patch
"@aoagents/ao-core": minor
---
Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI.

View File

@ -1,8 +0,0 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-web": minor
---
feat: "Launch Orchestrator (clean context)" action on the orchestrator session page
Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-plugin-tracker-linear": patch
---
Retry transient Linear API HTTP failures in the direct transport to reduce flakes from brief 5xx/429 responses.

View File

@ -1,14 +0,0 @@
---
"@aoagents/ao-cli": patch
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
"@aoagents/ao-notifier-macos": patch
"@aoagents/ao-plugin-notifier-composio": patch
"@aoagents/ao-plugin-notifier-dashboard": patch
"@aoagents/ao-plugin-notifier-desktop": patch
"@aoagents/ao-plugin-notifier-discord": patch
"@aoagents/ao-plugin-notifier-openclaw": patch
"@aoagents/ao-plugin-notifier-slack": patch
---
Add the notifier test harness, dashboard notifications, and desktop notifier setup.

View File

@ -1,7 +0,0 @@
---
"@aoagents/ao": patch
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
---
Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.

View File

@ -1,19 +0,0 @@
---
"@aoagents/ao-web": patch
---
fix(web): show Restore button for every exited session, including pr_merged
The Restore button was hidden for sessions exited with `pr_merged` reason (legacy
status `cleanup`) on the dashboard kanban and absent altogether from the
session-detail "Terminal ended" panel. The core `isRestorable()` helper already
allowed restoring these sessions; the dashboard helpers were out of sync. Fixes
#1907.
- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES` only,
matching core's `isRestorable`. Merged-but-running sessions remain
non-restorable (lifecycle isn't terminal).
- `DoneCard` no longer hides Restore for merged sessions.
- `SessionEndedSummary` exposes a prominent `Restore session` button next to
`Open PR` / `Back to dashboard`, so users don't have to find the small
header icon.

View File

@ -112,12 +112,29 @@ jobs:
# deletes it, so no cleanup is needed.
if [ -z "$(ls .changeset/*.md 2>/dev/null | grep -vi 'README')" ]; then
node << 'SCRIPT'
const cfg = require('./.changeset/config.json');
const pkgs = cfg.linked.flat();
const fs = require('fs');
const pkgs = [];
const addPackage = (m) => {
if (!fs.existsSync(m)) return;
const d = JSON.parse(fs.readFileSync(m, 'utf8'));
if (!d.private && d.publishConfig?.access === 'public') pkgs.push(d.name);
};
const scanPackages = (dir) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) addPackage(`${dir}/${entry.name}/package.json`);
}
};
scanPackages('packages');
scanPackages('packages/plugins');
let body = '---\n';
pkgs.forEach(p => body += '"' + p + '": patch\n');
body += '---\n\nchore: canary build\n';
require('fs').writeFileSync('.changeset/canary-temp.md', body);
fs.writeFileSync('.changeset/canary-temp.md', body);
SCRIPT
fi
pnpm changeset version --snapshot nightly

View File

@ -210,6 +210,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
### ao start
- Registers in `running.json` (PID, port, projects)
- Offers to restore sessions from `last-stop.json` — includes cross-project sessions via `otherProjects` field
- `ao start --restore` restores `last-stop.json` without prompting; `ao start --no-restore` skips restore
- **Ctrl+C performs full graceful shutdown** (same as ao stop): kills all sessions, writes last-stop state, unregisters from running.json. 10s hard timeout guarantees exit.
### ao stop
@ -218,6 +219,10 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- Always loads global config (`~/.agent-orchestrator/config.yaml`) to see all projects — local config only has the cwd project
- Records `LastStopState` with `otherProjects` field for cross-project session restore
### ao update
- For package-manager installs, `ao update` pauses a running AO via `ao stop --yes`, runs the global package update, verifies `ao --version`, then restarts with `ao start --restore` (or `--no-restore` if requested)
- Failed package-manager updates must report that AO was not updated, include actionable remediation, and restart the previous installation if AO was paused
### Dashboard sidebar
- Sidebar always shows sessions from ALL projects regardless of which project page is active
- `useSessionEvents` in Dashboard.tsx is called without project filter — sidebar gets unscoped sessions

View File

@ -59,6 +59,13 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches
- Create incoming webhook: https://api.slack.com/messaging/webhooks
- Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."`
- **Public dashboard URL** - If running AO behind a reverse proxy (e.g. inside a remote dev container, on a VPS fronted by Caddy/nginx/Traefik)
- Set `AO_PUBLIC_URL` to the externally-reachable URL of the dashboard
- All console output, `ao open` browser launches, and orchestrator-prompt session links use this URL instead of `http://localhost:<port>`
- Example: `export AO_PUBLIC_URL="https://ao.example.com"`
- When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`.
- **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side.
## Installation
### Install via npm (recommended)

View File

@ -57,6 +57,21 @@ export default tseslint.config(
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-template-curly-in-string": "warn",
"no-restricted-syntax": [
"error",
{
selector:
"CallExpression[callee.name='createInterface'] Property[key.name='input'] > CallExpression[callee.name='createReadStream']",
message:
"Do not pass createReadStream() inline to createInterface(); keep the stream in a variable and close/destroy it in a finally block.",
},
{
selector:
"CallExpression[callee.name='createInterface'] Property[key.name='input'] > CallExpression[callee.property.name='createReadStream']",
message:
"Do not pass createReadStream() inline to createInterface(); keep the stream in a variable and close/destroy it in a finally block.",
},
],
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],

View File

@ -1,5 +1,25 @@
# @aoagents/ao
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-cli@0.9.1
## 0.9.0
### Patch Changes
- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
- Updated dependencies [6d48022]
- Updated dependencies [ecdf0c7]
- Updated dependencies [fcedb25]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-cli@0.9.0
## 0.8.0
### Patch Changes

93
packages/ao/README.md Normal file
View File

@ -0,0 +1,93 @@
<div align="center">
# Agent Orchestrator (`ao`)
**The orchestration layer for parallel AI coding agents.**
[![npm version](https://img.shields.io/npm/v/%40aoagents%2Fao?style=flat-square)](https://www.npmjs.com/package/@aoagents/ao)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/blob/main/LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/ComposioHQ/agent-orchestrator?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/UZv7JjxbwG)
<img width="800" alt="Agent Orchestrator" src="https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/docs/assets/agent_orchestrator_banner.png">
</div>
Spawn parallel AI coding agents, each in its own git worktree, on a single machine. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise the whole fleet from one dashboard.
**Agent-agnostic** (Claude Code, Codex, Aider, Cursor, OpenCode) · **Runtime-agnostic** (tmux, ConPTY/process, Docker) · **Tracker-agnostic** (GitHub, Linear, GitLab)
## Install
```bash
npm install -g @aoagents/ao
```
> **Nightly builds** (latest `main`): `npm install -g @aoagents/ao@nightly` — back to stable with `@latest`.
**Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), the [`gh` CLI](https://cli.github.com), and at least one coding-agent CLI (e.g. [Claude Code](https://www.anthropic.com/claude-code)).
- **macOS / Linux:** [tmux](https://github.com/tmux/tmux/wiki/Installing) — `brew install tmux` or `sudo apt install tmux`.
- **Windows:** PowerShell 7+ recommended; tmux is **not** required (AO uses native ConPTY via the `process` runtime).
## Quick start
Point it at any repo — it clones, configures, and launches the dashboard in one command:
```bash
ao start https://github.com/your-org/your-repo
```
Or from inside an existing local repo:
```bash
cd ~/your-project && ao start
```
The dashboard opens at `http://localhost:3000` and an orchestrator agent starts managing your project. Add more repos any time:
```bash
ao start ~/path/to/another-repo
```
You don't need to learn the CLI — the dashboard and the orchestrator agent drive everything. (Individual `ao` commands are documented in the [CLI Reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md) and used internally by the orchestrator.)
## How it works
1. **You start**`ao start` launches the dashboard and an orchestrator agent.
2. **Orchestrator spawns workers** — each issue gets its own agent in an isolated git worktree and branch.
3. **Agents work autonomously** — they read code, write tests, and open PRs.
4. **Reactions handle feedback** — CI failures and review comments are routed back to the responsible agent automatically.
5. **You review and merge** — you're pulled in only when human judgment is needed.
## Pluggable by design
Seven plugin slots; the lifecycle state machine stays in core:
| Slot | Default | Alternatives |
| --- | --- | --- |
| Runtime | tmux (macOS/Linux) / process (Windows) | process, docker |
| Agent | claude-code | codex, aider, cursor, opencode, kimicode |
| Workspace | worktree | clone |
| Tracker | github | linear, gitlab |
| SCM | github | gitlab |
| Notifier | desktop | slack, discord, composio, webhook, openclaw |
| Terminal | iterm2 | web |
## Why Agent Orchestrator?
Running one AI agent in a terminal is easy. Running 30 across different issues, branches, and PRs is a coordination problem: creating branches, detecting stuck agents, reading CI failures, forwarding review comments, tracking which PRs are ready, and cleaning up afterward.
Agent Orchestrator handles the isolation, feedback routing, and status tracking. You `ao start` and walk away — then review PRs and make decisions. The rest is automated.
## Documentation
- 📖 [Project README & overview](https://github.com/ComposioHQ/agent-orchestrator)
- 🛠️ [Setup guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/SETUP.md) — install, configuration, troubleshooting
- ⌨️ [CLI reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md)
- 🧩 [Development & plugin guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/DEVELOPMENT.md)
- 💬 [Discord community](https://discord.gg/UZv7JjxbwG)
## License
MIT © [ComposioHQ](https://github.com/ComposioHQ/agent-orchestrator)

View File

@ -1,7 +1,22 @@
{
"name": "@aoagents/ao",
"version": "0.8.0",
"version": "0.9.1",
"description": "Orchestrate parallel AI coding agents — global CLI wrapper",
"keywords": [
"ai",
"ai-agents",
"coding-agent",
"agent-orchestration",
"orchestrator",
"claude-code",
"codex",
"aider",
"cli",
"automation",
"devtools",
"parallel-agents",
"git-worktree"
],
"license": "MIT",
"type": "module",
"bin": {
@ -11,7 +26,8 @@
"postinstall": "node bin/postinstall.js"
},
"files": [
"bin"
"bin",
"README.md"
],
"repository": {
"type": "git",

View File

@ -1,5 +1,91 @@
# @aoagents/ao-cli
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
- @aoagents/ao-web@0.9.1
- @aoagents/ao-notifier-macos@0.9.1
- @aoagents/ao-plugin-agent-aider@0.9.1
- @aoagents/ao-plugin-agent-claude-code@0.9.1
- @aoagents/ao-plugin-agent-codex@0.9.1
- @aoagents/ao-plugin-agent-cursor@0.9.1
- @aoagents/ao-plugin-agent-grok@0.1.2
- @aoagents/ao-plugin-agent-kimicode@0.9.1
- @aoagents/ao-plugin-agent-opencode@0.9.1
- @aoagents/ao-plugin-notifier-composio@0.9.1
- @aoagents/ao-plugin-notifier-dashboard@0.9.1
- @aoagents/ao-plugin-notifier-desktop@0.9.1
- @aoagents/ao-plugin-notifier-discord@0.9.1
- @aoagents/ao-plugin-notifier-openclaw@0.9.1
- @aoagents/ao-plugin-notifier-slack@0.9.1
- @aoagents/ao-plugin-notifier-webhook@0.9.1
- @aoagents/ao-plugin-runtime-process@0.9.1
- @aoagents/ao-plugin-runtime-tmux@0.9.1
- @aoagents/ao-plugin-scm-github@0.9.1
- @aoagents/ao-plugin-terminal-iterm2@0.9.1
- @aoagents/ao-plugin-terminal-web@0.9.1
- @aoagents/ao-plugin-tracker-github@0.9.1
- @aoagents/ao-plugin-tracker-linear@0.9.1
- @aoagents/ao-plugin-workspace-clone@0.9.1
- @aoagents/ao-plugin-workspace-worktree@0.9.1
## 0.9.0
### Minor Changes
- 6d48022: Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.
- ecdf0c7: Add `AO_PUBLIC_URL` environment variable for users running AO behind a reverse proxy (remote dev containers, VPS deployments, internal tooling). When set, all user-facing dashboard URLs — `ao start` / `ao dashboard` console output, `ao open` browser launches, and `projectSessionUrl()` links surfaced to the orchestrator agent — use the public URL instead of `http://localhost:<port>`. Internal IPC (`daemon.ts` reload calls) still uses localhost since that traffic stays on the host.
Also documents the existing `TERMINAL_WS_PATH` env var and the dashboard's automatic path-based mux WebSocket routing for standard-port (HTTPS / HTTP) deployments — these together let users front AO with a single hostname and one reverse-proxy rule, no extra ports or subdomains.
### Patch Changes
- fcedb25: Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI.
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [8c71bde]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [ee3fb5d]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [6d48022]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- Updated dependencies [07c9099]
- @aoagents/ao-core@0.9.0
- @aoagents/ao-web@0.9.0
- @aoagents/ao-plugin-agent-claude-code@0.9.0
- @aoagents/ao-plugin-tracker-linear@0.9.0
- @aoagents/ao-notifier-macos@0.9.0
- @aoagents/ao-plugin-notifier-composio@0.9.0
- @aoagents/ao-plugin-notifier-dashboard@0.9.0
- @aoagents/ao-plugin-notifier-desktop@0.9.0
- @aoagents/ao-plugin-notifier-discord@0.9.0
- @aoagents/ao-plugin-notifier-openclaw@0.9.0
- @aoagents/ao-plugin-notifier-slack@0.9.0
- @aoagents/ao-plugin-agent-aider@0.9.0
- @aoagents/ao-plugin-agent-codex@0.9.0
- @aoagents/ao-plugin-agent-cursor@0.9.0
- @aoagents/ao-plugin-agent-grok@0.1.1
- @aoagents/ao-plugin-agent-kimicode@0.9.0
- @aoagents/ao-plugin-agent-opencode@0.9.0
- @aoagents/ao-plugin-notifier-webhook@0.9.0
- @aoagents/ao-plugin-runtime-process@0.9.0
- @aoagents/ao-plugin-runtime-tmux@0.9.0
- @aoagents/ao-plugin-scm-github@0.9.0
- @aoagents/ao-plugin-terminal-iterm2@0.9.0
- @aoagents/ao-plugin-terminal-web@0.9.0
- @aoagents/ao-plugin-tracker-github@0.9.0
- @aoagents/ao-plugin-workspace-clone@0.9.0
- @aoagents/ao-plugin-workspace-worktree@0.9.0
## 0.8.0
### Patch Changes

View File

@ -1810,6 +1810,46 @@ describe("stop command", () => {
expect(output).toContain("app-orchestrator-3");
});
it("does not show the project picker for no-args ao stop across multiple projects", async () => {
mockConfigRef.current = makeConfig({
"project-1": makeProject({ name: "Project 1", sessionPrefix: "p1" }),
"project-2": makeProject({ name: "Project 2", sessionPrefix: "p2" }),
});
mockSessionManager.list.mockResolvedValue([
{
id: "p1-1",
projectId: "project-1",
status: "working",
activity: "active",
metadata: {},
lastActivityAt: new Date(),
runtimeHandle: { id: "tmux-1" },
},
{
id: "p2-1",
projectId: "project-2",
status: "working",
activity: "active",
metadata: {},
lastActivityAt: new Date(),
runtimeHandle: { id: "tmux-2" },
},
]);
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
mockPromptConfirm.mockResolvedValue(true);
await program.parseAsync(["node", "test", "stop"]);
expect(mockPromptSelect).not.toHaveBeenCalledWith(
expect.stringContaining("Choose project to stop"),
expect.anything(),
expect.anything(),
);
expect(mockPromptConfirm).toHaveBeenCalledWith("Stop AO and 2 active session(s)?", false);
expect(mockSessionManager.kill).toHaveBeenCalledWith("p1-1", { purgeOpenCode: false });
expect(mockSessionManager.kill).toHaveBeenCalledWith("p2-1", { purgeOpenCode: false });
});
it("kills the most-recently-active orchestrator when multiple exist", async () => {
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
const now = Date.now();

View File

@ -5,9 +5,7 @@ import { Command } from "commander";
// Mocks
// ---------------------------------------------------------------------------
const {
mockRunRepoScript,
} = vi.hoisted(() => ({
const { mockRunRepoScript } = vi.hoisted(() => ({
mockRunRepoScript: vi.fn(),
}));
@ -55,8 +53,8 @@ vi.mock("../../src/lib/update-check.js", () => ({
isManualOnlyInstall: (m: string) => m === "homebrew",
}));
// Stub the active-session guard's dependencies so handlers don't try to load
// real config / spawn plugins. Default: no sessions, so the guard passes.
// Stub the update lifecycle planner's dependencies so handlers don't try to
// load real config / spawn plugins. Default: no sessions, so no stop is needed.
const { mockSessions } = vi.hoisted(() => ({
mockSessions: { value: [] as Array<{ id: string; status: string }> },
}));
@ -84,8 +82,7 @@ vi.mock("@aoagents/ao-core", async () => {
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args),
getGlobalConfigPath: () => "/tmp/test-global-config.yaml",
isCanonicalGlobalConfigPath: (p: string | undefined) =>
p === "/tmp/test-global-config.yaml",
isCanonicalGlobalConfigPath: (p: string | undefined) => p === "/tmp/test-global-config.yaml",
isWindows: () => mockIsWindows(),
};
});
@ -98,10 +95,8 @@ vi.mock("node:fs", async () => {
};
});
// running.json is the live signal: ensureNoActiveSessions now consults
// `getRunning()` before falling back to the global registry. Default to
// "no daemon running" so the existing global-config-driven tests keep
// exercising the fallback path. Per-test overrides simulate a live daemon.
// running.json is the live signal for update stop/start orchestration. Default
// to "no daemon running"; per-test overrides simulate a live daemon.
const { mockGetRunning } = vi.hoisted(() => ({
mockGetRunning: vi.fn<() => Promise<unknown>>(async () => null),
}));
@ -147,9 +142,28 @@ function makeNpmUpdateInfo(overrides = {}) {
};
}
function createMockChild(exitCode: number | null, signal?: NodeJS.Signals) {
function createMockChild(
exitCode: number | null,
signal?: NodeJS.Signals,
output: { stdout?: string; stderr?: string } = { stdout: "0.3.0\n" },
) {
const child = new EventEmitter();
setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0);
const stdout = new EventEmitter();
const stderr = new EventEmitter();
Object.assign(child, { stdout, stderr });
const emitResult = () => {
setTimeout(() => {
if (output.stdout) stdout.emit("data", Buffer.from(output.stdout));
if (output.stderr) stderr.emit("data", Buffer.from(output.stderr));
child.emit("exit", exitCode, signal ?? null);
}, 0);
};
const originalOn = child.on.bind(child);
child.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
const result = originalOn(event, listener);
if (event === "exit") emitResult();
return result;
}) as EventEmitter["on"];
return child;
}
@ -166,7 +180,9 @@ describe("update command", () => {
mockRunRepoScript.mockResolvedValue(0);
mockDetectInstallMethod.mockReturnValue("git");
mockCheckForUpdate.mockReset();
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }));
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }),
);
mockInvalidateCache.mockReset();
mockPromptConfirm.mockReset();
mockPromptConfirm.mockResolvedValue(false);
@ -234,9 +250,9 @@ describe("update command", () => {
it("rejects --smoke-only on npm installs with an actionable message", async () => {
mockDetectInstallMethod.mockReturnValue("npm-global");
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update", "--smoke-only"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update", "--smoke-only"])).rejects.toThrow(
"process.exit(1)",
);
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(messages).toMatch(/--smoke-only only applies to git installs/);
});
@ -306,9 +322,9 @@ describe("update command", () => {
new Error("Script not found: ao-update.sh. Expected at: /tmp/ao-update.sh"),
);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockCheckForUpdate).not.toHaveBeenCalled();
@ -353,7 +369,9 @@ describe("update command", () => {
});
it("prints already up to date when not outdated", async () => {
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }));
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }),
);
const logSpy = vi.mocked(console.log);
await program.parseAsync(["node", "test", "update"]);
@ -365,9 +383,9 @@ describe("update command", () => {
makeNpmUpdateInfo({ latestVersion: null, isOutdated: false }),
);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(vi.mocked(console.error)).toHaveBeenCalledWith(
expect.stringContaining("Could not reach npm registry"),
);
@ -375,9 +393,7 @@ describe("update command", () => {
it("forces a fresh registry fetch", async () => {
await program.parseAsync(["node", "test", "update"]);
expect(mockCheckForUpdate).toHaveBeenCalledWith(
expect.objectContaining({ force: true }),
);
expect(mockCheckForUpdate).toHaveBeenCalledWith(expect.objectContaining({ force: true }));
});
it("prints command and exits cleanly in non-TTY mode without prompting", async () => {
@ -400,7 +416,11 @@ describe("update command", () => {
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledWith("npm", expect.arrayContaining(["install"]), expect.anything());
expect(mockSpawn).toHaveBeenCalledWith(
"npm",
expect.arrayContaining(["install"]),
expect.anything(),
);
expect(mockInvalidateCache).toHaveBeenCalled();
});
@ -410,9 +430,9 @@ describe("update command", () => {
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(1));
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(mockInvalidateCache).not.toHaveBeenCalled();
});
@ -438,9 +458,9 @@ describe("update command", () => {
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(null, "SIGTERM"));
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(vi.mocked(console.error)).not.toHaveBeenCalledWith(
expect.stringContaining("exited with code null"),
@ -448,18 +468,26 @@ describe("update command", () => {
expect(mockInvalidateCache).not.toHaveBeenCalled();
});
it("handles spawn error (e.g. npm not found)", async () => {
it("handles spawn error (e.g. npm not found) with a friendly update failure", async () => {
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
mockPromptConfirm.mockResolvedValue(true);
const child = new EventEmitter();
Object.assign(child, { stdout: new EventEmitter(), stderr: new EventEmitter() });
mockSpawn.mockReturnValue(child);
setTimeout(() => child.emit("error", new Error("ENOENT: npm not found")), 0);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("ENOENT");
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
const stderr = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
expect(stderr).toContain("npm not found");
});
it("does nothing when user declines prompt", async () => {
@ -489,7 +517,9 @@ describe("update command", () => {
await program.parseAsync(["node", "test", "update"]);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Could not detect install method"));
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Could not detect install method"),
);
expect(mockRunRepoScript).not.toHaveBeenCalled();
});
@ -521,269 +551,157 @@ describe("update command", () => {
});
// -----------------------------------------------------------------------
// Active-session guard (Section C)
// Update lifecycle orchestration (#1972)
// -----------------------------------------------------------------------
describe("active-session guard", () => {
describe("update lifecycle orchestration", () => {
beforeEach(() => {
mockDetectInstallMethod.mockReturnValue("npm-global");
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
mockDetectInstallMethod.mockReturnValue("pnpm-global");
mockResolveUpdateChannel.mockReturnValue("stable");
mockGetUpdateCommand.mockImplementation((method: string) => {
if (method === "pnpm-global") return "pnpm add -g @aoagents/ao@latest";
return "npm install -g @aoagents/ao@latest";
});
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({
installMethod: "pnpm-global",
recommendedCommand: "pnpm add -g @aoagents/ao@latest",
}),
);
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
// The guard now ALWAYS loads from global config. Stage a registered
// project so the early-return ("no registry → allow") doesn't fire.
mockExistsSync.mockReturnValue(true);
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
});
mockLoadConfig.mockImplementation((path?: string) =>
path
? { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path }
: { projects: { "my-app": { path: "/tmp/foo" } }, configPath: "/cwd/agent-orchestrator.yaml" },
);
});
it("refuses to install when a session is in 'working'", async () => {
mockSessions.value = [{ id: "feat-1", status: "working" }];
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(messages).toMatch(/1 session active/);
expect(messages).toMatch(/ao stop/);
expect(mockSpawn).not.toHaveBeenCalled();
});
it.each(["working", "idle", "needs_input", "stuck"])(
"refuses for status %s",
async (status) => {
mockSessions.value = [{ id: "feat-1", status }];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
},
);
it("does NOT refuse for terminal statuses (done, terminated, killed)", async () => {
mockSessions.value = [
{ id: "old-1", status: "done" },
{ id: "old-2", status: "terminated" },
];
mockPromptConfirm.mockResolvedValue(false); // decline, no install
await program.parseAsync(["node", "test", "update"]);
// Reaches the prompt step since the guard passed.
expect(mockPromptConfirm).toHaveBeenCalled();
});
// ---------------------------------------------------------------------
// Global-config layout (review #3 / scope-gap follow-up)
// ---------------------------------------------------------------------
it("the refusal message lists active sessions from EVERY registered project, not just one (Dhruv proof)", async () => {
// Reviewer challenge: prove loadConfig(globalPath) actually enumerates
// sessions across all registered projects, not just the cwd's project.
// We register proj-a + proj-b in the global config, seed one active
// session in each, and assert BOTH ids appear in the stderr output.
mockLoadConfig.mockImplementation((path?: string) => {
// Mimic buildEffectiveConfigFromGlobalConfigPath: the global path
// returns BOTH projects; project-local would only return one.
if (!path) {
return { projects: { "proj-a": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
}
return {
projects: {
"proj-a": { path: "/repos/a" },
"proj-b": { path: "/repos/b" },
},
configPath: path,
};
});
mockLoadGlobalConfig.mockReturnValue({
projects: {
"proj-a": { path: "/repos/a" },
"proj-b": { path: "/repos/b" },
},
});
mockExistsSync.mockReturnValue(true);
// One active session per project. sm.list() is single-call (the SM
// implementation enumerates across all projectIds), so we return both
// sessions in one shot — matching real behavior. `projectId` is
// included so it's visible to anyone reading the refusal output.
mockSessions.value = [
{ id: "proj-a-feat-1", status: "working", projectId: "proj-a" },
{ id: "proj-b-feat-2", status: "needs_input", projectId: "proj-b" },
];
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
// Refusal message reports the correct total count (2, not 1).
expect(stderr).toMatch(/2 sessions active/);
// Both project's session ids appear in the listing.
expect(stderr).toMatch(/proj-a-feat-1/);
expect(stderr).toMatch(/proj-b-feat-2/);
expect(mockSpawn).not.toHaveBeenCalled();
});
it("always loads global config (never project-local), so sessions in OTHER projects fire the guard", async () => {
// Simulate running inside a project: project-local loadConfig() would
// succeed and return only THIS project's sessions. The guard must
// ignore it and still consult the global registry, otherwise active
// sessions in other projects get missed and the install would proceed.
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) {
// Project-local: would return only "this-project"'s sessions.
return { projects: { "this-project": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
}
return {
projects: {
"this-project": { path: "/cwd" },
"other-project": { path: "/other" },
},
configPath: path,
};
});
mockLoadGlobalConfig.mockReturnValue({
projects: {
"this-project": { path: "/cwd" },
"other-project": { path: "/other" },
},
});
mockExistsSync.mockReturnValue(true);
// Active session lives in the OTHER project — only visible via global.
mockSessions.value = [
{ id: "other-1", status: "working" },
];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
expect(mockLoadGlobalConfig).toHaveBeenCalled();
// Critical: we did NOT call the project-local (no-arg) loadConfig path.
const noArgCalls = mockLoadConfig.mock.calls.filter((c) => c.length === 0);
expect(noArgCalls).toHaveLength(0);
expect(mockSpawn).not.toHaveBeenCalled();
});
it("uses the global registry when running outside any project", async () => {
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) throw new Error("no config found");
return { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path };
});
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
});
mockExistsSync.mockReturnValue(true);
mockSessions.value = [{ id: "feat-1", status: "working" }];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
expect(mockLoadGlobalConfig).toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
});
it("returns early without building SessionManager when global registry is empty", async () => {
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) throw new Error("no config found");
return { projects: {}, configPath: path };
});
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
mockExistsSync.mockReturnValue(true);
// Guard returns true (allow update) without ever calling sm.list().
// No mockSessions configured, no spawn → confirms we never reached
// SessionManager construction.
mockPromptConfirm.mockResolvedValue(false); // decline soft-install
await program.parseAsync(["node", "test", "update"]);
expect(mockLoadGlobalConfig).toHaveBeenCalled();
// The decline-prompt path means the guard let us through.
expect(mockPromptConfirm).toHaveBeenCalled();
});
it("returns early without building SessionManager when global config file is missing", async () => {
mockLoadConfig.mockImplementation((path?: string) => {
if (!path) throw new Error("no config found");
return { projects: {}, configPath: path };
});
mockExistsSync.mockReturnValue(false); // no ~/.agent-orchestrator/config.yaml
mockPromptConfirm.mockResolvedValue(false);
await program.parseAsync(["node", "test", "update"]);
// We didn't even consult loadGlobalConfig — existsSync(globalPath) was false.
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
expect(mockPromptConfirm).toHaveBeenCalled();
});
it("refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)", async () => {
// The bypass: user ran `ao start` from a repo with a local
// agent-orchestrator.yaml and no global registration. running.json
// says that project is being polled, sessions live on disk, but the
// global registry is empty. Before this fix, the guard hit the
// "global has no projects → allow" branch and let `ao update`
// clobber the running daemon.
//
// Fix: consult running.json BEFORE falling back to global. When
// running.json has projects, build the SessionManager from
// running.configPath (which is the local project's yaml in this case)
// and enumerate from there.
mockGetRunning.mockResolvedValue({
mockGetRunning
.mockResolvedValueOnce({
pid: 12345,
configPath: "/repos/local-only/agent-orchestrator.yaml",
configPath: "/tmp/test-global-config.yaml",
port: 3000,
startedAt: new Date().toISOString(),
projects: ["local-only"],
projects: ["my-app"],
})
.mockResolvedValue(null);
mockLoadConfig.mockImplementation((path?: string) => ({
projects: { "my-app": { path: "/tmp/foo" } },
configPath: path ?? "/cwd/agent-orchestrator.yaml",
}));
mockSessions.value = [{ id: "feat-1", status: "working", projectId: "my-app" }];
});
// Global registry has no record of `local-only` — this is the bypass
// condition. With the old code, we'd return true here.
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
// loadConfig with the local configPath returns the local project's
// OrchestratorConfig (project-local schema is auto-wrapped).
mockLoadConfig.mockImplementation((path?: string) => {
if (path === "/repos/local-only/agent-orchestrator.yaml") {
return {
projects: { "local-only": { path: "/repos/local-only" } },
configPath: path,
};
it("runs stop → package install → version verification → start/restore when sessions are active", async () => {
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return { projects: {}, configPath: path ?? "/cwd/agent-orchestrator.yaml" };
});
mockSessions.value = [
{ id: "local-feat-1", status: "working", projectId: "local-only" },
];
const errSpy = vi.mocked(console.error);
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(stderr).toMatch(/1 session active/);
expect(stderr).toMatch(/local-feat-1/);
// We must have routed through running.configPath, NOT the global path.
expect(mockLoadConfig).toHaveBeenCalledWith("/repos/local-only/agent-orchestrator.yaml");
expect(mockSpawn).not.toHaveBeenCalled();
return createMockChild(0, undefined, { stdout: "" });
});
it("returns true (allows update) when running.json is gone and global is empty", async () => {
// No daemon running, no global projects. Genuinely safe to update.
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(4);
expect(mockSpawn.mock.calls[0]).toEqual([
"ao",
["stop", "--yes"],
expect.objectContaining({
stdio: "inherit",
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
}),
]);
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
expect(mockSpawn.mock.calls[1][1]).toEqual(["add", "-g", "@aoagents/ao@latest"]);
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
expect(mockSpawn.mock.calls[3]).toEqual([
"ao",
["start", "my-app", "--restore"],
expect.objectContaining({
stdio: "inherit",
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
}),
]);
const stopOrder = mockSpawn.mock.invocationCallOrder[0];
const installOrder = mockSpawn.mock.invocationCallOrder[1];
const startOrder = mockSpawn.mock.invocationCallOrder[3];
expect(stopOrder).toBeLessThan(installOrder);
expect(installOrder).toBeLessThan(startOrder);
});
it("does not stop/start when no daemon or active sessions exist", async () => {
mockGetRunning.mockReset();
mockGetRunning.mockResolvedValue(null);
mockExistsSync.mockReturnValue(false);
mockPromptConfirm.mockResolvedValue(false);
await program.parseAsync(["node", "test", "update"]);
expect(mockPromptConfirm).toHaveBeenCalled(); // guard passed → reached prompt
mockSessions.value = [];
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return createMockChild(0, undefined, { stdout: "" });
});
it("trusts running.json over an inconsistent global config", async () => {
// running.json says project P is being polled. Global config also
// lists P. We should use running.configPath (the live signal), and
// any active session in P fires the guard.
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn.mock.calls[0][0]).toBe("pnpm");
expect(mockSpawn.mock.calls[1][0]).toBe("ao");
expect(mockSpawn.mock.calls[1][1]).toEqual(["--version"]);
});
it("cleans up orphaned active sessions without starting a daemon that was not running", async () => {
mockGetRunning.mockReset();
mockGetRunning.mockResolvedValue(null);
mockExistsSync.mockReturnValue(true);
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
});
mockSessions.value = [{ id: "orphan-1", status: "working", projectId: "my-app" }];
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "stop") {
mockSessions.value = [];
return createMockChild(0, undefined, { stdout: "" });
}
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return createMockChild(0, undefined, { stdout: "" });
});
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
expect(mockSpawn.mock.calls[0][2]).toEqual(
expect.objectContaining({
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
}),
);
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
expect(mockSpawn.mock.calls.some((call) => call[0] === "ao" && call[1][0] === "start")).toBe(
false,
);
});
it("honors --no-restore when restarting after update", async () => {
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "ao" && args[0] === "--version") {
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
}
return createMockChild(0, undefined, { stdout: "" });
});
await program.parseAsync(["node", "test", "update", "--no-restore"]);
expect(mockSpawn.mock.calls.at(-1)).toEqual([
"ao",
["start", "my-app", "--no-restore"],
expect.objectContaining({
stdio: "inherit",
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
}),
]);
});
it("aborts before install if ao stop exits 0 but AO still appears active", async () => {
mockGetRunning.mockResolvedValue({
pid: 12345,
configPath: "/tmp/test-global-config.yaml",
@ -791,23 +709,46 @@ describe("update command", () => {
startedAt: new Date().toISOString(),
projects: ["my-app"],
});
mockLoadConfig.mockImplementation((path?: string) => ({
projects: { "my-app": { path: "/tmp/foo" } },
configPath: path ?? "/cwd/agent-orchestrator.yaml",
}));
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "" }));
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
expect(mockSpawn).toHaveBeenCalledTimes(1);
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
expect(mockSpawn.mock.calls.some((call) => call[0] === "pnpm")).toBe(false);
const stderr = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(stderr).toContain("AO still appears to be running after `ao stop --yes`");
});
mockSessions.value = [{ id: "feat-1", status: "working" }];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
it("prints a friendly pnpm diagnostic and npm fallback when pnpm fails", async () => {
mockGetRunning.mockReset();
mockGetRunning.mockResolvedValue(null);
mockExistsSync.mockReturnValue(false);
mockSpawn.mockReturnValue(
createMockChild(1, undefined, {
stderr: "ERR_PNPM_UNEXPECTED_VIRTUAL_STORE Unexpected virtual store location\n",
}),
);
// Because getRunning() returned a daemon, we went straight to its
// configPath — we should NOT have fallen back to loadGlobalConfig.
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
"process.exit(1)",
);
const stderr = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
expect(stderr).toContain("pnpm's global store metadata is inconsistent");
expect(stderr).toContain("You can also try: npm install -g @aoagents/ao@latest");
expect(stderr).toContain("ERR_PNPM_UNEXPECTED_VIRTUAL_STORE");
expect(mockInvalidateCache).not.toHaveBeenCalled();
});
});
@ -874,7 +815,7 @@ describe("update command", () => {
}),
);
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(0));
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
await program.parseAsync(["node", "test", "update"]);
@ -990,7 +931,7 @@ describe("update command", () => {
}),
);
mockPromptConfirm.mockResolvedValue(true);
mockSpawn.mockReturnValue(createMockChild(0));
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
await program.parseAsync(["node", "test", "update"]);
@ -1035,9 +976,7 @@ describe("update command", () => {
beforeEach(() => {
mockDetectInstallMethod.mockReturnValue("npm-global");
mockResolveUpdateChannel.mockReturnValue("stable");
mockCheckForUpdate.mockResolvedValue(
makeNpmUpdateInfo({ installMethod: "npm-global" }),
);
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
mockExistsSync.mockReturnValue(true);
mockLoadGlobalConfig.mockReturnValue({
projects: { "my-app": { path: "/tmp/foo" } },
@ -1069,7 +1008,11 @@ describe("update command", () => {
// would run. Asserting spawn was called proves the install actually
// happens in the API-invoked path.
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(1);
expect(mockSpawn).toHaveBeenCalledWith(
"npm",
expect.arrayContaining(["install"]),
expect.anything(),
);
// And without a TTY, we MUST NOT have prompted — that would hang the
// detached child forever.
expect(mockPromptConfirm).not.toHaveBeenCalled();
@ -1084,12 +1027,25 @@ describe("update command", () => {
expect(mockSpawn).not.toHaveBeenCalled();
});
it("still refuses on active sessions even when API-invoked (the API's own guard isn't a single point of trust)", async () => {
it("orchestrates stop/install/verify/start when active sessions exist and update is API-invoked", async () => {
mockSessions.value = [{ id: "feat-1", status: "working" }];
await expect(
program.parseAsync(["node", "test", "update"]),
).rejects.toThrow("process.exit(1)");
expect(mockSpawn).not.toHaveBeenCalled();
mockExistsSync.mockReturnValue(false);
mockGetRunning
.mockResolvedValueOnce({
pid: 12345,
configPath: "/tmp/test-global-config.yaml",
port: 3000,
startedAt: new Date().toISOString(),
projects: ["my-app"],
})
.mockResolvedValue(null);
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
expect(mockSpawn.mock.calls.at(-1)?.[0]).toBe("ao");
expect(mockSpawn.mock.calls.at(-1)?.[1]).toEqual(["start", "my-app", "--restore"]);
});
});
@ -1128,17 +1084,15 @@ describe("update command", () => {
it("passes shell:true and windowsHide:true on Windows so PATHEXT resolves npm.cmd", async () => {
mockIsWindows.mockReturnValue(true);
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(1);
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
expect(opts.shell).toBe(true);
expect(opts.windowsHide).toBe(true);
expect(opts.stdio).toBe("inherit");
expect(opts.stdio).toEqual(["inherit", "pipe", "pipe"]);
});
it("passes shell:false on macOS / Linux (no shell wrap needed)", async () => {
mockIsWindows.mockReturnValue(false);
await program.parseAsync(["node", "test", "update"]);
expect(mockSpawn).toHaveBeenCalledTimes(1);
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
expect(opts.shell).toBe(false);
});

View File

@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { dashboardUrl } from "../../src/lib/dashboard-url.js";
describe("dashboardUrl", () => {
const original = process.env.AO_PUBLIC_URL;
beforeEach(() => {
delete process.env.AO_PUBLIC_URL;
});
afterEach(() => {
if (original === undefined) {
delete process.env.AO_PUBLIC_URL;
} else {
process.env.AO_PUBLIC_URL = original;
}
});
it("falls back to localhost when AO_PUBLIC_URL is unset", () => {
expect(dashboardUrl(3000)).toBe("http://localhost:3000");
});
it("falls back to localhost when AO_PUBLIC_URL is empty", () => {
process.env.AO_PUBLIC_URL = "";
expect(dashboardUrl(8094)).toBe("http://localhost:8094");
});
it("falls back to localhost when AO_PUBLIC_URL is whitespace only", () => {
process.env.AO_PUBLIC_URL = " ";
expect(dashboardUrl(8094)).toBe("http://localhost:8094");
});
it("uses AO_PUBLIC_URL when set", () => {
process.env.AO_PUBLIC_URL = "https://ao.example.com";
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
});
it("ignores the port argument when AO_PUBLIC_URL is set", () => {
process.env.AO_PUBLIC_URL = "https://ao.example.com";
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
expect(dashboardUrl(8094)).toBe("https://ao.example.com");
});
it("strips a trailing slash from AO_PUBLIC_URL", () => {
process.env.AO_PUBLIC_URL = "https://ao.example.com/";
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
});
it("strips multiple trailing slashes from AO_PUBLIC_URL", () => {
process.env.AO_PUBLIC_URL = "https://ao.example.com///";
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
});
it("preserves a sub-path in AO_PUBLIC_URL", () => {
process.env.AO_PUBLIC_URL = "https://example.com/ao";
expect(dashboardUrl(3000)).toBe("https://example.com/ao");
});
it("trims surrounding whitespace from AO_PUBLIC_URL", () => {
process.env.AO_PUBLIC_URL = " https://ao.example.com ";
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
});
it("supports a non-default port in AO_PUBLIC_URL", () => {
process.env.AO_PUBLIC_URL = "http://192.168.1.5:9000";
expect(dashboardUrl(3000)).toBe("http://192.168.1.5:9000");
});
});

View File

@ -84,6 +84,7 @@ import {
maybeShowUpdateNotice,
scheduleBackgroundRefresh,
isVersionOutdated,
isOutdatedForChannel,
resolveUpdateChannel,
resolveInstallMethodOverride,
isManualOnlyInstall,
@ -115,6 +116,42 @@ describe("update-check", () => {
mockGlobalConfig.value = null;
});
// -----------------------------------------------------------------------
// isOutdatedForChannel
// -----------------------------------------------------------------------
describe("isOutdatedForChannel", () => {
it("treats any nightly dist-tag identity change as an update in both lexical directions", () => {
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-def", "nightly")).toBe(true);
expect(isOutdatedForChannel("0.0.0-nightly-def", "0.0.0-nightly-abc", "nightly")).toBe(true);
expect(
isOutdatedForChannel("0.0.0-nightly-f00d123", "0.0.0-nightly-0dead01", "nightly"),
).toBe(true);
});
it("does not update nightly when the exact dist-tag version is already installed", () => {
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-abc", "nightly")).toBe(false);
});
it("uses semver for stable-to-nightly channel switches", () => {
expect(isOutdatedForChannel("0.7.0", "0.0.0-nightly-abc", "nightly")).toBe(false);
expect(isOutdatedForChannel("0.7.0", "0.8.0-nightly-abc", "nightly")).toBe(true);
expect(isOutdatedForChannel("0.8.0", "0.8.0-nightly-abc", "nightly")).toBe(false);
});
it("treats nightly-to-stable fallback on the nightly channel as an update when the dist-tag differs", () => {
// `fetchLatestVersion("nightly")` can fall back to `latest` if the
// nightly dist-tag is absent; prerelease-to-stable is a normal
// semver upgrade.
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.8.0", "nightly")).toBe(true);
});
it("keeps stable comparisons numeric instead of lexical", () => {
expect(isOutdatedForChannel("0.9.0", "0.10.0", "stable")).toBe(true);
expect(isOutdatedForChannel("0.7.0", "0.8.0", "stable")).toBe(true);
});
});
// -----------------------------------------------------------------------
// isVersionOutdated
// -----------------------------------------------------------------------
@ -1186,24 +1223,14 @@ describe("update-check", () => {
describe("getUpdateCommand — channel-aware (Section B)", () => {
it("uses @nightly tag for nightly channel", () => {
expect(getUpdateCommand("npm-global", "nightly")).toBe(
"npm install -g @aoagents/ao@nightly",
);
expect(getUpdateCommand("pnpm-global", "nightly")).toBe(
"pnpm add -g @aoagents/ao@nightly",
);
expect(getUpdateCommand("bun-global", "nightly")).toBe(
"bun add -g @aoagents/ao@nightly",
);
expect(getUpdateCommand("npm-global", "nightly")).toBe("npm install -g @aoagents/ao@nightly");
expect(getUpdateCommand("pnpm-global", "nightly")).toBe("pnpm add -g @aoagents/ao@nightly");
expect(getUpdateCommand("bun-global", "nightly")).toBe("bun add -g @aoagents/ao@nightly");
});
it("uses @latest tag for stable + manual channels", () => {
expect(getUpdateCommand("npm-global", "stable")).toBe(
"npm install -g @aoagents/ao@latest",
);
expect(getUpdateCommand("npm-global", "manual")).toBe(
"npm install -g @aoagents/ao@latest",
);
expect(getUpdateCommand("npm-global", "stable")).toBe("npm install -g @aoagents/ao@latest");
expect(getUpdateCommand("npm-global", "manual")).toBe("npm install -g @aoagents/ao@latest");
});
it("returns the brew upgrade notice for homebrew installs", () => {

View File

@ -7,6 +7,7 @@ import {
readFileSync,
rmSync,
symlinkSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
@ -34,7 +35,14 @@ function createHealthyRepo(tempRoot: string): string {
mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true });
mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true });
mkdirSync(join(fakeRepo, "packages", "web"), { recursive: true });
writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), "export {};\n");
writeFileSync(
join(fakeRepo, "packages", "core", "package.json"),
JSON.stringify({ type: "module", main: "dist/index.js" }, null, 2),
);
writeFileSync(
join(fakeRepo, "packages", "core", "dist", "index.js"),
'export function getNodePtyPrebuildsSubdir() { return process.platform + "-" + process.arch; }\n',
);
writeFileSync(join(fakeRepo, "packages", "cli", "dist", "index.js"), "export {};\n");
writeFileSync(
join(fakeRepo, "packages", "ao", "bin", "ao.js"),
@ -61,7 +69,7 @@ function createHealthyPath(binDir: string): void {
createFakeBinary(
binDir,
"node",
'if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexit 0',
`if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexec ${JSON.stringify(process.execPath)} "$@"`,
);
createFakeBinary(
binDir,
@ -252,6 +260,67 @@ exit 0`,
expect(npmCommands).toContain("link --force");
});
it("warns about and repairs a non-executable node-pty spawn-helper", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-node-pty-helper-"));
const fakeRepo = createHealthyRepo(tempRoot);
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });
createHealthyPath(binDir);
const helperPath = join(
fakeRepo,
"node_modules",
"node-pty",
"prebuilds",
`${process.platform}-${process.arch}`,
"spawn-helper",
);
mkdirSync(dirname(helperPath), { recursive: true });
writeFileSync(join(fakeRepo, "node_modules", "node-pty", "package.json"), "{}\n");
writeFileSync(helperPath, "#!/bin/sh\nexit 0\n");
chmodSync(helperPath, 0o644);
const configPath = join(tempRoot, "agent-orchestrator.yaml");
const dataDir = join(tempRoot, "data");
const worktreeDir = join(tempRoot, "worktrees");
mkdirSync(dataDir, { recursive: true });
mkdirSync(worktreeDir, { recursive: true });
writeFileSync(
configPath,
[`dataDir: ${dataDir}`, `worktreeDir: ${worktreeDir}`, "projects: {}"].join("\n"),
);
const warnResult = spawnSync("bash", [scriptPath], {
env: {
...process.env,
PATH: `${binDir}:/bin:/usr/bin`,
AO_REPO_ROOT: fakeRepo,
AO_CONFIG_PATH: configPath,
},
encoding: "utf8",
});
const fixResult = spawnSync("bash", [scriptPath, "--fix"], {
env: {
...process.env,
PATH: `${binDir}:/bin:/usr/bin`,
AO_REPO_ROOT: fakeRepo,
AO_CONFIG_PATH: configPath,
},
encoding: "utf8",
});
const statMode = (statSync(helperPath).mode & 0o111) !== 0;
rmSync(tempRoot, { recursive: true, force: true });
expect(warnResult.status).toBe(0);
expect(warnResult.stdout).toContain("WARN node-pty spawn-helper is not executable");
expect(warnResult.stdout).toContain("posix_spawnp failed");
expect(fixResult.status).toBe(0);
expect(fixResult.stdout).toContain("FIXED chmod +x applied to node-pty spawn-helper");
expect(statMode).toBe(true);
});
it("reports a healthy packaged install without source-checkout failures", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-package-"));
const fakeInstall = createHealthyPackageInstall(tempRoot);

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-cli",
"version": "0.8.0",
"version": "0.9.1",
"description": "CLI for agent-orchestrator — the `ao` command",
"license": "MIT",
"type": "module",
@ -39,6 +39,7 @@
"@aoagents/ao-plugin-agent-claude-code": "workspace:*",
"@aoagents/ao-plugin-agent-codex": "workspace:*",
"@aoagents/ao-plugin-agent-cursor": "workspace:*",
"@aoagents/ao-plugin-agent-grok": "workspace:*",
"@aoagents/ao-plugin-agent-kimicode": "workspace:*",
"@aoagents/ao-plugin-agent-opencode": "workspace:*",
"@aoagents/ao-plugin-notifier-composio": "workspace:*",

View File

@ -13,10 +13,10 @@ while [ $# -gt 0 ]; do
cat <<'EOF'
Usage: ao doctor [--fix]
Checks install, PATH, binaries, service health, stale temp files, and runtime sanity.
Checks install, PATH, binaries, service health, web terminal support, stale temp files, and runtime sanity.
Options:
--fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files
--fix Apply safe fixes for missing launcher links, missing support dirs, node-pty spawn-helper permissions, and stale temp files
EOF
exit 0
;;
@ -398,6 +398,136 @@ check_stale_temp_files() {
warn "$stale_count stale temp files older than 60 minutes found under $temp_root. Fix: rerun ao doctor --fix"
}
file_mode_octal() {
case "$(uname -s 2>/dev/null || true)" in
Darwin|FreeBSD|OpenBSD|NetBSD)
stat -f '%Lp' "$1" 2>/dev/null || printf 'unknown'
;;
*)
stat -c '%a' "$1" 2>/dev/null || printf 'unknown'
;;
esac
}
resolve_node_pty_spawn_helper() {
node - "$REPO_ROOT" <<'NODE'
const fs = require("node:fs");
const { createRequire } = require("node:module");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const repoRoot = process.argv[2];
function resolvePackageJson(fromDir) {
try {
return createRequire(path.join(fromDir, "ao-doctor.js")).resolve("node-pty/package.json");
} catch {
return null;
}
}
function findPackageUp(startDir, ...segments) {
let dir = path.resolve(startDir);
while (true) {
const candidate = path.resolve(dir, "node_modules", ...segments);
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function resolveNodeModulesPackage(fromDir, ...segments) {
const packageDir = path.resolve(fromDir, "node_modules", ...segments);
return fs.existsSync(path.resolve(packageDir, "package.json")) ? packageDir : null;
}
function resolveCoreEntrypoint() {
const sourceCoreDir = path.resolve(repoRoot, "packages", "core");
const coreDir =
(fs.existsSync(path.join(sourceCoreDir, "package.json")) ? sourceCoreDir : null) ??
findPackageUp(repoRoot, "@aoagents", "ao-core") ??
resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-core");
if (!coreDir) return null;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(coreDir, "package.json"), "utf8"));
const entry = pkg.exports?.["."]?.import ?? pkg.module ?? pkg.main ?? "dist/index.js";
return path.resolve(coreDir, entry);
} catch {
return null;
}
}
async function getNodePtyPrebuildsSubdir() {
const coreEntrypoint = resolveCoreEntrypoint();
if (!coreEntrypoint || !fs.existsSync(coreEntrypoint)) return null;
const core = await import(pathToFileURL(coreEntrypoint).href);
return typeof core.getNodePtyPrebuildsSubdir === "function"
? core.getNodePtyPrebuildsSubdir()
: null;
}
(async () => {
const packageJsonPath =
resolvePackageJson(repoRoot) ??
(() => {
const directNodePtyDir = findPackageUp(repoRoot, "node-pty");
if (directNodePtyDir) return path.join(directNodePtyDir, "package.json");
const sourceWebDir = path.resolve(repoRoot, "packages", "web");
const webDir =
(fs.existsSync(path.join(sourceWebDir, "package.json")) ? sourceWebDir : null) ??
findPackageUp(repoRoot, "@aoagents", "ao-web") ??
resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-web");
if (!webDir) return null;
const webNodePtyDir =
resolveNodeModulesPackage(webDir, "node-pty") ?? findPackageUp(webDir, "node-pty");
return webNodePtyDir ? path.join(webNodePtyDir, "package.json") : null;
})();
const prebuildsSubdir = await getNodePtyPrebuildsSubdir();
if (!packageJsonPath || !prebuildsSubdir) process.exit(0);
console.log(path.join(path.dirname(packageJsonPath), "prebuilds", prebuildsSubdir, "spawn-helper"));
})().catch(() => process.exit(0));
NODE
}
check_node_pty_spawn_helper() {
if ! command -v node >/dev/null 2>&1; then
warn "node-pty spawn-helper check skipped because node is unavailable"
return
fi
local helper_path mode
helper_path="$(resolve_node_pty_spawn_helper 2>/dev/null || true)"
if [ -z "$helper_path" ] || [ ! -f "$helper_path" ]; then
pass "node-pty spawn-helper check skipped because no helper was found for this platform"
return
fi
mode="$(file_mode_octal "$helper_path")"
if [ -x "$helper_path" ]; then
pass "node-pty spawn-helper is executable at $helper_path (mode 0o$mode)"
return
fi
if [ "$FIX_MODE" = true ]; then
if chmod 755 "$helper_path"; then
fixed "chmod +x applied to node-pty spawn-helper at $helper_path (was 0o$mode)"
return
fi
warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode), and chmod failed. Web dashboard terminals can fail with posix_spawnp failed. Fix: chmod +x $helper_path"
return
fi
warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode). Web dashboard terminals can fail with posix_spawnp failed. Fix: run ao doctor --fix or chmod +x $helper_path. See ao#1770."
}
printf 'Agent Orchestrator Doctor\n\n'
check_node
@ -409,6 +539,7 @@ check_gh
check_config_dirs
check_stale_temp_files
check_install_layout
check_node_pty_spawn_helper
check_runtime_sanity
printf '\nResults: %s PASS, %s WARN, %s FAIL, %s FIXED\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$FIX_COUNT"

View File

@ -12,6 +12,7 @@ import {
} from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";
import { DEFAULT_PORT } from "../lib/constants.js";
import { dashboardUrl } from "../lib/dashboard-url.js";
export function registerDashboard(program: Command): void {
program
@ -42,7 +43,7 @@ export function registerDashboard(program: Command): void {
const webDir = localWebDir;
console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`));
console.log(chalk.bold(`Starting dashboard on ${dashboardUrl(port)}\n`));
const env = await buildDashboardEnv(
port,
@ -90,7 +91,7 @@ export function registerDashboard(program: Command): void {
if (opts.open !== false) {
openAbort = new AbortController();
void waitForPortAndOpen(port, `http://localhost:${port}`, openAbort.signal);
void waitForPortAndOpen(port, dashboardUrl(port), openAbort.signal);
}
child.on("exit", (code) => {

View File

@ -32,7 +32,7 @@ async function resolveSessionContext(sessionName: string): Promise<{
if (session) {
const tmuxTarget = session.runtimeHandle?.id ?? sessionName;
const project = config.projects[session.projectId];
const agentName = session.metadata["agent"] ?? project?.agent ?? config.defaults.agent;
const agentName = session.metadata["agent"]!;
const runtimeName =
session.runtimeHandle?.runtimeName ?? project?.runtime ?? config.defaults.runtime;
return {

View File

@ -10,11 +10,7 @@
*/
import { type ChildProcess } from "node:child_process";
import {
existsSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve, basename, dirname } from "node:path";
import { cwd } from "node:process";
import chalk from "chalk";
@ -91,6 +87,7 @@ import {
type DetectedAgent,
} from "../lib/detect-agent.js";
import { detectDefaultBranch } from "../lib/git-utils.js";
import { dashboardUrl } from "../lib/dashboard-url.js";
import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js";
import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js";
import {
@ -824,12 +821,7 @@ async function startDashboard(
directTerminalPort?: number,
devMode?: boolean,
): Promise<ChildProcess> {
const env = await buildDashboardEnv(
port,
configPath,
terminalPort,
directTerminalPort,
);
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);
// Detect monorepo vs npm install: the `server/` source directory only exists
// in the monorepo. Published npm packages only have `dist-server/`.
@ -895,6 +887,8 @@ async function runStartup(
orchestrator?: boolean;
rebuild?: boolean;
dev?: boolean;
/** true = restore without prompting, false = skip restore, undefined = prompt for humans */
restore?: boolean;
},
): Promise<StartupResult> {
await runtimePreflight(config);
@ -954,7 +948,7 @@ async function runStartup(
config.directTerminalPort,
opts?.dev,
);
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`);
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
}
@ -1028,11 +1022,14 @@ async function runStartup(
}
}
// Check for sessions from last `ao stop` and offer to restore them
if (isHumanCaller()) {
// Check for sessions from last `ao stop` and restore/prompt/skip based on caller intent.
if (opts?.restore !== false && (opts?.restore === true || isHumanCaller())) {
try {
const lastStop = await readLastStop();
if (lastStop && lastStop.sessionIds.length > 0) {
const totalLastStopSessions =
(lastStop?.sessionIds.length ?? 0) +
(lastStop?.otherProjects ?? []).reduce((sum, p) => sum + p.sessionIds.length, 0);
if (lastStop && totalLastStopSessions > 0) {
const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`;
const otherProjects = lastStop.otherProjects ?? [];
const restoreProjectBySessionId = new Map<string, string>();
@ -1073,7 +1070,8 @@ async function runStartup(
}
if (allRestoreSessions.length > 0) {
const shouldRestore = await promptConfirm("Restore these sessions?", true);
const shouldRestore =
opts?.restore === true ? true : await promptConfirm("Restore these sessions?", true);
if (shouldRestore) {
recordActivityEvent({
projectId,
@ -1206,7 +1204,7 @@ async function runStartup(
console.log(chalk.bold.green("\n✓ Startup complete\n"));
if (opts?.dashboard !== false) {
console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`);
console.log(chalk.cyan("Dashboard:"), dashboardUrl(port));
console.log(chalk.dim(" Use the dashboard Remote button to enable mobile access at runtime."));
}
@ -1241,7 +1239,7 @@ async function runStartup(
openAbort = new AbortController();
const orchestratorUrl = selectedOrchestratorId
? projectSessionUrl(port, projectId, selectedOrchestratorId)
: `http://localhost:${port}`;
: dashboardUrl(port);
void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal);
}
@ -1438,10 +1436,10 @@ async function attachAndSpawnOrchestrator(opts: {
}
if (isHumanCaller()) {
console.log(chalk.dim(` Opening dashboard: http://localhost:${daemon.port}\n`));
openUrl(`http://localhost:${daemon.port}`);
console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`));
openUrl(dashboardUrl(daemon.port));
} else {
console.log(`Dashboard: http://localhost:${daemon.port}`);
console.log(`Dashboard: ${dashboardUrl(daemon.port)}`);
}
}
@ -1461,6 +1459,8 @@ export function registerStart(program: Command): void {
.option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)")
.option("--interactive", "Prompt to configure config settings")
.option("--reap-orphans", "Kill orphaned AO child processes before starting")
.option("--restore", "Restore sessions from last ao stop without prompting")
.option("--no-restore", "Skip restoring sessions from last ao stop")
.action(
async (
projectArg?: string,
@ -1471,6 +1471,7 @@ export function registerStart(program: Command): void {
dev?: boolean;
interactive?: boolean;
reapOrphans?: boolean;
restore?: boolean;
},
) => {
recordActivityEvent({
@ -1523,7 +1524,7 @@ export function registerStart(program: Command): void {
// exit. Project-id args fall through to attach+spawn so
// automation can `ao start <id>` against a live daemon.
console.log(`AO is already running.`);
console.log(`Dashboard: http://localhost:${running.port}`);
console.log(`Dashboard: ${dashboardUrl(running.port)}`);
console.log(`PID: ${running.pid}`);
console.log(`Projects: ${running.projects.join(", ")}`);
console.log(`To restart: ao stop && ao start`);
@ -1533,7 +1534,7 @@ export function registerStart(program: Command): void {
if (isHumanCaller() && !projectArg) {
console.log(chalk.cyan(`\n AO is already running.`));
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`);
console.log(` Projects: ${running.projects.join(", ")}\n`);
@ -1580,7 +1581,7 @@ export function registerStart(program: Command): void {
);
if (choice === "open") {
openUrl(`http://localhost:${running.port}`);
openUrl(dashboardUrl(running.port));
unlockStartup();
process.exit(0);
} else if (choice === "quit") {
@ -1612,7 +1613,7 @@ export function registerStart(program: Command): void {
),
);
}
openUrl(`http://localhost:${running.port}`);
openUrl(dashboardUrl(running.port));
unlockStartup();
process.exit(0);
} else if (choice === "new") {
@ -1698,9 +1699,9 @@ export function registerStart(program: Command): void {
running.projects.includes(projectId)
) {
console.log(chalk.cyan(`\n AO is already running.`));
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
console.log(` Project "${projectId}" is already registered and running.\n`);
openUrl(`http://localhost:${running.port}`);
openUrl(dashboardUrl(running.port));
unlockStartup();
process.exit(0);
}
@ -1868,7 +1869,12 @@ export function registerStop(program: Command): void {
.description("Stop orchestrator agent and dashboard")
.option("--purge-session", "Delete mapped OpenCode session when stopping")
.option("--all", "Stop all running AO instances")
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => {
.option("-y, --yes", "Confirm stopping active sessions without prompting")
.action(
async (
projectArg?: string,
opts: { purgeSession?: boolean; all?: boolean; yes?: boolean } = {},
) => {
recordActivityEvent({
source: "cli",
kind: "cli.stop_invoked",
@ -1917,10 +1923,28 @@ export function registerStop(program: Command): void {
config = loadConfig(globalPath);
}
}
const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop");
let _projectId: string;
let project: ProjectConfig;
if (projectArg) {
({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"));
} else {
const projectIds = Object.keys(config.projects);
if (projectIds.length === 0) {
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
}
const currentDir = resolve(cwd());
const cwdProjectId = findProjectForDirectory(config.projects, currentDir);
_projectId =
running?.projects.find((id) => config.projects[id]) ?? cwdProjectId ?? projectIds[0];
project = config.projects[_projectId];
}
const port = config.port ?? DEFAULT_PORT;
if (projectArg) {
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
} else {
console.log(chalk.bold(`\nStopping AO across all projects\n`));
}
const sm = await getSessionManager(config);
try {
@ -1946,6 +1970,16 @@ export function registerStop(program: Command): void {
const otherByProject = new Map<string, string[]>();
if (activeSessions.length > 0) {
if (!projectArg && opts.yes !== true && isHumanCaller()) {
const confirmed = await promptConfirm(
`Stop AO and ${activeSessions.length} active session(s)?`,
false,
);
if (!confirmed) {
console.log(chalk.yellow("Stop cancelled."));
return;
}
}
const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start();
const purgeOpenCode = opts?.purgeSession === true;
const warnings: string[] = [];
@ -2136,5 +2170,6 @@ export function registerStop(program: Command): void {
}
process.exit(1);
}
});
},
);
}

View File

@ -3,7 +3,6 @@ import type { Command } from "commander";
import {
createInitialCanonicalLifecycle,
createActivitySignal,
type Agent,
type SCM,
type Session,
type PRInfo,
@ -13,6 +12,7 @@ import {
type Tracker,
type ProjectConfig,
type AgentReportAuditEntry,
type PluginRegistry,
isOrchestratorSession,
isTerminalSession,
isWindows,
@ -127,7 +127,7 @@ function gatherProjectReviewStatus(projectId: string): ProjectReviewStatus {
async function gatherSessionInfo(
session: Session,
agent: Agent,
registry: PluginRegistry,
scm: SCM,
projectConfig: ReturnType<typeof loadConfig>,
reportsLimit: number = 0,
@ -160,11 +160,17 @@ async function gatherSessionInfo(
lastActivity = activityTs ? formatAge(activityTs) : "-";
}
// Get agent's auto-generated summary via introspection
// Get agent's auto-generated summary via introspection. The SessionManager
// normalizes session.metadata.agent on read, so never infer the session agent
// from current project/default config here.
let claudeSummary: string | null = null;
try {
const agentName = session.metadata["agent"];
if (agentName) {
const agent = getAgentByNameFromRegistry(registry, agentName);
const introspection = await agent.getSessionInfo(session);
claudeSummary = introspection?.summary ?? null;
}
} catch {
// Summary extraction failed — not critical
}
@ -500,9 +506,9 @@ export function registerStatus(program: Command): void {
totalActiveReviewRuns += reviewStatus.activeRunCount;
totalOpenReviewFindings += reviewStatus.openFindingCount;
// Resolve agent and SCM for this project via the shared registry
const agentName = projectConfig.agent ?? config.defaults.agent;
const agent = getAgentByNameFromRegistry(registry, agentName);
// Resolve SCM for this project via the shared registry. Agents are
// resolved per session because historical metadata may record a
// different agent than the current project default.
const scm = getSCMFromRegistry(registry, config, projectId);
if (!opts.json) {
@ -519,7 +525,9 @@ export function registerStatus(program: Command): void {
}
// Gather all session info in parallel
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config, reportsLimit));
const infoPromises = projectSessions.map((s) =>
gatherSessionInfo(s, registry, scm, config, reportsLimit),
);
const sessionInfos = await Promise.all(infoPromises);
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");

View File

@ -68,12 +68,16 @@ export function registerUpdate(program: Command): void {
.option("--skip-smoke", "Skip smoke tests after rebuilding (git installs only)")
.option("--smoke-only", "Run smoke tests without fetching or rebuilding (git installs only)")
.option("--check", "Print version info as JSON without upgrading")
.option("--no-restore", "Restart AO after updating but do not restore stopped sessions")
.action(
async (opts: { skipSmoke?: boolean; smokeOnly?: boolean; check?: boolean }) => {
async (opts: {
skipSmoke?: boolean;
smokeOnly?: boolean;
check?: boolean;
restore?: boolean;
}) => {
if (opts.skipSmoke && opts.smokeOnly) {
console.error(
"`ao update` does not allow `--skip-smoke` together with `--smoke-only`.",
);
console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`.");
process.exit(1);
}
@ -113,7 +117,7 @@ export function registerUpdate(program: Command): void {
case "npm-global":
case "pnpm-global":
case "bun-global":
await handleNpmUpdate(method);
await handleNpmUpdate(method, { restore: opts.restore !== false });
break;
case "unknown":
await handleUnknownUpdate();
@ -133,21 +137,26 @@ async function handleCheck(): Promise<void> {
}
// ---------------------------------------------------------------------------
// Active-session guard
// Update lifecycle planning
// ---------------------------------------------------------------------------
/**
* Refuse to update when the user has live sessions.
*
* Auto-stopping would lose the agent's in-flight context (and potentially
* uncommitted work). The release doc is explicit: refuse, surface the
* `ao stop` command, let the user decide.
*
* Best-effort when no config is reachable (fresh install, broken yaml)
* we skip the guard rather than blocking the update on a missing dependency.
* Best-effort snapshot used by `ao update` to pause and resume AO around the
* package-manager install. Missing/broken config should not block an update;
* in that case we proceed without attempting a stop/start round trip.
*/
async function ensureNoActiveSessions(): Promise<boolean> {
interface UpdateLifecyclePlan {
runningBeforeUpdate: boolean;
configPath?: string;
primaryProjectId?: string;
activeSessions: Session[];
}
async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
let sessions: Session[];
let configPath: string | undefined;
let primaryProjectId: string | undefined;
let runningBeforeUpdate = false;
try {
// Live signal first: running.json lists whichever projects the active
// `ao start` daemon is currently polling. That can include local-only
@ -160,6 +169,9 @@ async function ensureNoActiveSessions(): Promise<boolean> {
// truth for "which sessions does the running ao instance own?"
const running = await getRunning();
if (running && running.projects.length > 0) {
runningBeforeUpdate = true;
configPath = running.configPath;
primaryProjectId = running.projects[0];
// running.configPath could be local-wrapped (a project's
// agent-orchestrator.yaml) OR the canonical global path. loadConfig
// dispatches based on the path shape — both cases produce a full
@ -174,15 +186,19 @@ async function ensureNoActiveSessions(): Promise<boolean> {
// SessionManager's enrichment will reconcile any stale-runtime
// sessions to `killed`, so terminal statuses don't block the update.
const globalPath = getGlobalConfigPath();
if (!existsSync(globalPath)) return true;
if (!existsSync(globalPath)) {
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
const globalConfig = loadGlobalConfig(globalPath);
if (!globalConfig || Object.keys(globalConfig.projects).length === 0) {
return true;
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
if (!isCanonicalGlobalConfigPath(globalPath)) {
return true;
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
configPath = globalPath;
const config = loadConfig(globalPath);
primaryProjectId = Object.keys(config.projects)[0];
const sm = await getSessionManager(config);
sessions = await sm.list();
}
@ -190,29 +206,132 @@ async function ensureNoActiveSessions(): Promise<boolean> {
// If we can't enumerate sessions, don't pretend there are zero — but
// also don't block the upgrade indefinitely. Surface a soft warning.
console.error(
chalk.yellow(
"⚠ Could not check for active sessions before updating. Proceeding anyway.",
),
chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."),
);
return true;
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
}
const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
if (active.length === 0) return true;
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: active };
}
const noun = active.length === 1 ? "session" : "sessions";
console.error(
chalk.red(
`\n✗ ${active.length} ${noun} active. Run \`ao stop\` first, then \`ao update\`.\n`,
async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
const shouldStop = plan.runningBeforeUpdate || plan.activeSessions.length > 0;
if (!shouldStop) return false;
if (plan.activeSessions.length > 0) {
const noun = plan.activeSessions.length === 1 ? "session" : "sessions";
console.log(
chalk.yellow(
`\n${plan.activeSessions.length} active ${noun} will be paused and restored after the update.`,
),
);
for (const s of active.slice(0, 5)) {
for (const s of plan.activeSessions.slice(0, 5)) {
console.log(chalk.dim(`${s.id} (${s.status})`));
}
if (plan.activeSessions.length > 5) {
console.log(chalk.dim(` … and ${plan.activeSessions.length - 5} more`));
}
} else {
console.log(chalk.dim("\nAO is running; it will be restarted after the update."));
}
const stopExit = await runAoLifecycleCommand(["stop", "--yes"], {
configPath: plan.configPath,
});
if (stopExit !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update failed: internal ao stop exited non-zero`,
data: { exitCode: stopExit },
});
console.error(chalk.red(`\nAO update could not stop the running daemon (exit ${stopExit}).`));
process.exit(stopExit);
}
const afterStop = await getUpdateLifecyclePlan();
if (afterStop.runningBeforeUpdate || afterStop.activeSessions.length > 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update failed: AO still appears active after internal ao stop`,
data: {
runningAfterStop: afterStop.runningBeforeUpdate,
activeSessionCount: afterStop.activeSessions.length,
activeSessionIds: afterStop.activeSessions.map((s) => s.id).slice(0, 20),
},
});
console.error(
chalk.red(
"\nAO update stopped before installing because AO still appears to be running after `ao stop --yes`.",
),
);
if (afterStop.activeSessions.length > 0) {
console.error(chalk.dim("Still-active sessions:"));
for (const s of afterStop.activeSessions.slice(0, 5)) {
console.error(chalk.dim(`${s.id} (${s.status})`));
}
if (active.length > 5) {
console.error(chalk.dim(` … and ${active.length - 5} more`));
if (afterStop.activeSessions.length > 5) {
console.error(chalk.dim(` … and ${afterStop.activeSessions.length - 5} more`));
}
return false;
}
console.error(chalk.dim("Run `ao stop` and retry `ao update` after AO is fully stopped."));
process.exit(1);
}
return plan.runningBeforeUpdate;
}
async function restartAoAfterUpdate(
plan: UpdateLifecyclePlan,
opts: { restore: boolean },
): Promise<void> {
const args = ["start"];
if (plan.primaryProjectId) args.push(plan.primaryProjectId);
args.push(opts.restore ? "--restore" : "--no-restore");
console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`));
const exitCode = await runAoLifecycleCommand(args, { configPath: plan.configPath });
if (exitCode !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_restart_failed",
level: "error",
summary: `ao update could not restart AO after install`,
data: { exitCode, args },
});
console.error(
chalk.yellow(
`\nAO was updated, but \`ao ${args.join(" ")}\` failed with exit ${exitCode}. ` +
`Run it manually to restore your sessions.`,
),
);
process.exit(exitCode);
}
}
function runAoLifecycleCommand(
args: string[],
opts: { configPath?: string } = {},
): Promise<number> {
return new Promise<number>((resolveExit) => {
const child = spawn("ao", args, {
stdio: "inherit",
shell: isWindows(),
windowsHide: true,
env: opts.configPath ? { ...process.env, AO_CONFIG_PATH: opts.configPath } : process.env,
});
child.on("error", (error) => {
console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`));
resolveExit(1);
});
child.on("exit", (code, signal) => {
resolveExit(signal ? 1 : (code ?? 1));
});
});
}
// ---------------------------------------------------------------------------
@ -222,10 +341,10 @@ async function ensureNoActiveSessions(): Promise<boolean> {
async function handleGitUpdate(opts: {
skipSmoke?: boolean;
smokeOnly?: boolean;
restore?: boolean;
}): Promise<void> {
if (!(await ensureNoActiveSessions())) {
process.exit(1);
}
const lifecyclePlan = await getUpdateLifecyclePlan();
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
const args: string[] = [];
if (opts.skipSmoke) args.push("--skip-smoke");
@ -241,14 +360,17 @@ async function handleGitUpdate(opts: {
summary: `ao update (git) failed: ao-update.sh exited non-zero`,
data: { method: "git", exitCode },
});
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(exitCode);
}
invalidateCache();
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
} catch (error) {
if (
error instanceof Error &&
error.message.includes("Script not found: ao-update.sh")
) {
if (error instanceof Error && error.message.includes("Script not found: ao-update.sh")) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
@ -263,6 +385,9 @@ async function handleGitUpdate(opts: {
"If you're on a package install, reinstall the package.",
),
);
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(1);
}
@ -277,6 +402,9 @@ async function handleGitUpdate(opts: {
},
});
console.error(error instanceof Error ? error.message : String(error));
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
}
process.exit(1);
}
}
@ -285,7 +413,7 @@ async function handleGitUpdate(opts: {
// npm / pnpm / bun global install
// ---------------------------------------------------------------------------
async function handleNpmUpdate(method: InstallMethod): Promise<void> {
async function handleNpmUpdate(method: InstallMethod, opts: { restore: boolean }): Promise<void> {
const channel = resolveUpdateChannel();
// Snapshot the previously cached channel BEFORE we force a refresh, so we
@ -332,9 +460,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
// is the right UX for a channel transition, and the install command we'd
// run is genuinely different even if the version-compare says "no".
const isChannelSwitch =
!info.isOutdated &&
previousChannel !== undefined &&
previousChannel !== channel;
!info.isOutdated && previousChannel !== undefined && previousChannel !== channel;
// First-channel opt-in. previousChannel === undefined means we've never
// installed via the auto-updater. A user who just ran `ao config set
@ -363,9 +489,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
console.log(`Channel: ${chalk.cyan(channel)}`);
if (isChannelSwitch) {
console.log(
chalk.yellow(
`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`,
),
chalk.yellow(`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`),
);
console.log(
chalk.dim(
@ -384,15 +508,12 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
const command = getUpdateCommand(method, channel);
const apiInvoked = isApiInvoked();
const interactive = isTTY() && !apiInvoked;
const lifecyclePlan = await getUpdateLifecyclePlan();
// Non-interactive path: API-invoked OR piped output. We still gate on the
// active-session guard (refusing returns true/false), but we never bail
// out just because there's no terminal — the dashboard's "Update" click
// must actually install. The only thing we skip is the confirm prompt.
if (!(await ensureNoActiveSessions())) {
process.exit(1);
}
// Non-interactive path: API-invoked OR piped output. We still plan the
// stop/start lifecycle, but we never bail out just because there's no
// terminal — the dashboard's "Update" click must actually install. The
// only thing we skip is the confirm prompt.
if (interactive) {
// Soft auto-install: when the user has opted into stable or nightly we
// skip the confirm prompt — they've already said "keep me on this channel."
@ -404,10 +525,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
isChannelSwitch || isFirstChannelOptIn
? `Switch to ${channel} via ${chalk.cyan(command)}?`
: `Run ${chalk.cyan(command)}?`;
const confirmed = await promptConfirm(
promptText,
!(isChannelSwitch || isFirstChannelOptIn),
);
const confirmed = await promptConfirm(promptText, !(isChannelSwitch || isFirstChannelOptIn));
if (!confirmed) return;
} else {
console.log(chalk.dim(`Updating: ${command}`));
@ -422,25 +540,95 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
return;
}
const exitCode = await runNpmInstall(command);
if (exitCode === 0) {
invalidateCache();
console.log(chalk.green("\nUpdate complete."));
} else {
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
const installResult = await runNpmInstall(command);
if (installResult.exitCode !== 0) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: install command exited non-zero`,
data: { method, command, exitCode },
data: {
method,
command,
exitCode: installResult.exitCode,
classification: classifyInstallFailure(installResult.output).kind,
},
});
process.exit(exitCode);
printInstallFailure({
method,
command,
channel,
currentVersion: info.currentVersion,
exitCode: installResult.exitCode,
output: installResult.output,
});
if (shouldRestart) {
console.log(chalk.dim("\nRestarting AO with the existing installation..."));
await restartAoAfterUpdate(lifecyclePlan, opts);
}
process.exit(1);
}
function runNpmInstall(command: string): Promise<number> {
const verification = await verifyInstalledVersion(info.latestVersion, info.currentVersion);
if (!verification.ok) {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: installed version verification failed`,
data: {
method,
command,
expectedVersion: info.latestVersion,
actualVersion: verification.actualVersion,
output: verification.output,
},
});
console.error(chalk.red(`\nAO was not verified after install.`));
console.error(chalk.yellow(verification.message));
console.error(chalk.dim(`Expected: ${info.latestVersion}`));
console.error(chalk.dim(`Current before update: ${info.currentVersion}`));
if (shouldRestart) {
console.log(chalk.dim("\nRestarting AO before exiting..."));
await restartAoAfterUpdate(lifecyclePlan, opts);
}
process.exit(1);
}
invalidateCache();
if (shouldRestart) {
await restartAoAfterUpdate(lifecyclePlan, opts);
}
console.log(
chalk.green(
`\nUpdate complete: ${info.currentVersion}${verification.actualVersion}.` +
(shouldRestart ? " AO restarted." : ""),
),
);
}
interface CommandResult {
exitCode: number;
output: string;
}
function runNpmInstall(command: string): Promise<CommandResult> {
const [cmd, ...args] = command.split(" ");
return new Promise<number>((resolveExit, reject) => {
return runCommandCapture(cmd!, args, { echo: true }).then((result) => {
if (result.exitCode !== 0) {
console.error(chalk.yellow(`\n${cmd} exited with code ${result.exitCode}.`));
}
return result;
});
}
function runCommandCapture(
cmd: string,
args: string[],
opts: { echo?: boolean } = {},
): Promise<CommandResult> {
return new Promise<CommandResult>((resolveExit) => {
// `shell: isWindows()` is required so PATHEXT gets consulted on Windows —
// npm/pnpm/bun install as `*.cmd` shims, and Node.js does not look at
// PATHEXT for non-shell spawns, so a bare `npm` / `pnpm` / `bun` lookup
@ -448,23 +636,160 @@ function runNpmInstall(command: string): Promise<number> {
// keeps the shell window from flashing. Same fix that landed for the
// dashboard's /api/update spawn in commit 9f29131d.
const child = spawn(cmd!, args, {
stdio: "inherit",
stdio: ["inherit", "pipe", "pipe"],
shell: isWindows(),
windowsHide: true,
});
child.on("error", reject);
let output = "";
const collect = (chunk: Buffer | string, stream: NodeJS.WriteStream): void => {
const text = chunk.toString();
output += text;
if (opts.echo) stream.write(chunk);
};
child.stdout?.on("data", (chunk: Buffer | string) => collect(chunk, process.stdout));
child.stderr?.on("data", (chunk: Buffer | string) => collect(chunk, process.stderr));
child.on("error", (error) => {
output += `${error.name}: ${error.message}`;
resolveExit({ exitCode: 1, output });
});
child.on("exit", (code, signal) => {
if (signal) {
resolveExit(1);
resolveExit({ exitCode: 1, output: `${output}\nTerminated by signal ${signal}` });
return;
}
if (code !== 0) {
console.error(chalk.yellow(`\n${cmd} exited with code ${code}.`));
resolveExit({ exitCode: code ?? 1, output });
});
});
}
resolveExit(code ?? 1);
});
});
interface VerificationResult {
ok: boolean;
actualVersion?: string;
output: string;
message: string;
}
async function verifyInstalledVersion(
expectedVersion: string,
previousVersion: string,
): Promise<VerificationResult> {
const result = await runCommandCapture("ao", ["--version"]);
const output = result.output.trim();
const actualVersion = parseAoVersion(output);
if (result.exitCode !== 0) {
return {
ok: false,
output,
message: `\`ao --version\` failed with exit ${result.exitCode}.`,
};
}
if (!actualVersion) {
return {
ok: false,
output,
message: `Could not parse \`ao --version\` output: ${output || "<empty>"}`,
};
}
if (actualVersion !== expectedVersion) {
return {
ok: false,
actualVersion,
output,
message:
actualVersion === previousVersion
? `The install command exited successfully, but AO is still on ${previousVersion}.`
: `The install command exited successfully, but AO reports ${actualVersion}.`,
};
}
return { ok: true, actualVersion, output, message: "verified" };
}
function parseAoVersion(output: string): string | undefined {
const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
return match?.[1];
}
function classifyInstallFailure(output: string): { kind: string; guidance: string } {
if (/ERR_PNPM_UNEXPECTED_VIRTUAL_STORE/i.test(output)) {
return {
kind: "pnpm_virtual_store",
guidance:
"pnpm's global store metadata is inconsistent. Try `pnpm store prune`, then retry `ao update`. " +
"If pnpm remains stuck, use the npm fallback below.",
};
}
if (/(?:EACCES|EPERM|permission denied|access denied)/i.test(output)) {
return {
kind: "permission",
guidance:
"The package manager could not write to the global install location. Fix your npm/pnpm global prefix permissions, or retry from a shell with access to that directory.",
};
}
if (/(?:ENETUNREACH|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|socket hang up)/i.test(output)) {
return {
kind: "network",
guidance:
"The registry request failed due to a network error. Check connectivity/VPN/proxy settings and retry `ao update`.",
};
}
if (
/(?:lockfile|ERR_PNPM_LOCKFILE|ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_BROKEN_LOCKFILE)/i.test(
output,
)
) {
return {
kind: "lockfile",
guidance:
"pnpm reported lockfile state problems. Clear the affected global install metadata or retry with the npm fallback below.",
};
}
if (
/(?:registry|ERR_PNPM_FETCH|ERR_PNPM_META_FETCH_FAIL|E401|E403|E404|404 Not Found|401 Unauthorized|403 Forbidden)/i.test(
output,
)
) {
return {
kind: "registry",
guidance:
"The npm registry rejected or failed the package request. Check registry configuration, auth tokens, and the selected AO update channel.",
};
}
return {
kind: "unknown",
guidance:
"The package manager failed before AO could verify the new version. Retry `ao update` after addressing the package-manager error below.",
};
}
function printInstallFailure(opts: {
method: InstallMethod;
command: string;
channel: ReturnType<typeof resolveUpdateChannel>;
currentVersion: string;
exitCode: number;
output: string;
}): void {
const classification = classifyInstallFailure(opts.output);
const fallbackCommand = getUpdateCommand("npm-global", opts.channel);
console.error(
chalk.red(`\nAO was not updated. You are still on version ${opts.currentVersion}.`),
);
console.error(
chalk.yellow(
`The package manager (${opts.method.replace("-global", "")}) failed with exit ${opts.exitCode}.`,
),
);
console.error(chalk.yellow(classification.guidance));
console.error(chalk.dim(`\nTo retry: ao update`));
if (opts.command !== fallbackCommand) {
console.error(chalk.dim(`You can also try: ${fallbackCommand}`));
}
console.error(chalk.dim("\nPackage manager output:"));
console.error(opts.output.trim() || "<no output>");
}
// ---------------------------------------------------------------------------
@ -480,9 +805,7 @@ async function handleHomebrewUpdate(): Promise<void> {
console.log(`Latest version: ${chalk.green(info.latestVersion)}`);
}
console.log();
console.log(
`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`,
);
console.log(`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`);
console.log(
chalk.dim(
" (AO does not auto-install for brew installs because it would clobber brew's symlinks.)",

View File

@ -29,7 +29,7 @@ observability:
defaults:
runtime: tmux # tmux | process
agent: claude-code # claude-code | aider | codex | cursor | kimicode | opencode
agent: claude-code # claude-code | aider | codex | cursor | kimicode | grok | opencode
workspace: worktree # worktree | clone
notifiers:
- desktop # desktop | discord | slack | webhook | composio | openclaw
@ -182,7 +182,7 @@ notificationRouting:
# Available plugins
#
# Agent: claude-code, aider, codex, cursor, kimicode, opencode
# Agent: claude-code, aider, codex, cursor, kimicode, grok, opencode
# Runtime: tmux, process
# Workspace: worktree, clone
# SCM: github, gitlab

View File

@ -0,0 +1,24 @@
/**
* Returns the user-facing base URL of the dashboard.
*
* When `AO_PUBLIC_URL` is set in the environment, AO is being fronted by a
* reverse proxy (e.g. when running inside a remote dev container or behind
* Caddy/nginx). All console output, `ao open` browser launches, and session
* URLs surfaced to humans should use that public URL instead of localhost.
*
* The trailing slash is stripped for consistency so callers can append paths
* without producing `//`.
*
* Internal IPC (the daemon hitting its own dashboard's API) is intentionally
* **not** routed through this helper those calls always use localhost since
* they happen on the same host as the dashboard process.
*
* @param port - the local dashboard port; only used in the localhost fallback
*/
export function dashboardUrl(port: number): string {
const publicUrl = process.env.AO_PUBLIC_URL?.trim();
if (publicUrl) {
return publicUrl.replace(/\/+$/, "");
}
return `http://localhost:${port}`;
}

View File

@ -20,6 +20,7 @@ const AGENT_PLUGINS: Array<{ name: string; pkg: string }> = [
{ name: "codex", pkg: "@aoagents/ao-plugin-agent-codex" },
{ name: "cursor", pkg: "@aoagents/ao-plugin-agent-cursor" },
{ name: "kimicode", pkg: "@aoagents/ao-plugin-agent-kimicode" },
{ name: "grok", pkg: "@aoagents/ao-plugin-agent-grok" },
{ name: "opencode", pkg: "@aoagents/ao-plugin-agent-opencode" },
];

View File

@ -4,6 +4,7 @@ import codexPlugin from "@aoagents/ao-plugin-agent-codex";
import aiderPlugin from "@aoagents/ao-plugin-agent-aider";
import cursorPlugin from "@aoagents/ao-plugin-agent-cursor";
import kimicodePlugin from "@aoagents/ao-plugin-agent-kimicode";
import grokPlugin from "@aoagents/ao-plugin-agent-grok";
import opencodePlugin from "@aoagents/ao-plugin-agent-opencode";
import githubSCMPlugin from "@aoagents/ao-plugin-scm-github";
@ -13,6 +14,7 @@ const agentPlugins: Record<string, { create(): Agent }> = {
aider: aiderPlugin,
cursor: cursorPlugin,
kimicode: kimicodePlugin,
grok: grokPlugin,
opencode: opencodePlugin,
};

View File

@ -1,3 +1,5 @@
import { dashboardUrl } from "./dashboard-url.js";
export function projectSessionUrl(port: number, projectId: string, sessionId: string): string {
return `http://localhost:${port}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`;
return `${dashboardUrl(port)}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`;
}

View File

@ -16,6 +16,7 @@ import { promisify } from "node:util";
import {
getInstalledAoVersion,
getUpdateCheckCachePath,
isVersionOutdatedForChannel as coreIsVersionOutdatedForChannel,
isVersionOutdated as coreIsVersionOutdated,
loadGlobalConfig,
type UpdateChannel,
@ -181,8 +182,7 @@ export function classifyInstallPath(resolvedPath: string): InstallMethod {
if (isPnpmGlobal) return "pnpm-global";
const isNpmGlobal =
resolvedPath.includes("/lib/node_modules/") ||
resolvedPath.includes("\\lib\\node_modules\\");
resolvedPath.includes("/lib/node_modules/") || resolvedPath.includes("\\lib\\node_modules\\");
if (isNpmGlobal) return "npm-global";
return "unknown";
@ -256,10 +256,7 @@ export function getGitUpdateRef(): string {
* a notice so the user runs it themselves auto-running `npm install -g`
* inside a brew prefix overwrites brew's symlinks.
*/
export function getUpdateCommand(
method: InstallMethod,
channel: UpdateChannel = "stable",
): string {
export function getUpdateCommand(method: InstallMethod, channel: UpdateChannel = "stable"): string {
// "manual" channel maps to "stable" for the install command — the channel
// affects when we check, not which tag manual installers should pick.
const tag = channel === "nightly" ? "nightly" : "latest";
@ -284,6 +281,14 @@ export function isManualOnlyInstall(method: InstallMethod): boolean {
return method === "homebrew";
}
export function isOutdatedForChannel(
currentVersion: string,
latestVersion: string,
channel: UpdateChannel,
): boolean {
return coreIsVersionOutdatedForChannel(currentVersion, latestVersion, channel);
}
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
@ -493,7 +498,7 @@ export async function checkForUpdate(opts?: {
isOutdated:
cached.installMethod === "git"
? cached.isOutdated === true
: isVersionOutdated(currentVersion, cached.latestVersion),
: isOutdatedForChannel(currentVersion, cached.latestVersion, channel),
installMethod,
recommendedCommand,
checkedAt: cached.checkedAt,
@ -540,14 +545,16 @@ export async function checkForUpdate(opts?: {
currentVersionAtCheck: currentVersion,
installMethod,
channel,
isOutdated: isVersionOutdated(currentVersion, latestVersion),
isOutdated: isOutdatedForChannel(currentVersion, latestVersion, channel),
});
}
return {
currentVersion,
latestVersion,
isOutdated: latestVersion ? isVersionOutdated(currentVersion, latestVersion) : false,
isOutdated: latestVersion
? isOutdatedForChannel(currentVersion, latestVersion, channel)
: false,
installMethod,
recommendedCommand,
checkedAt: latestVersion ? now : null,
@ -585,7 +592,7 @@ export function maybeShowUpdateNotice(): void {
const isOutdated =
installMethod === "git"
? cached.isOutdated === true
: isVersionOutdated(currentVersion, cached.latestVersion);
: isOutdatedForChannel(currentVersion, cached.latestVersion, channel);
if (!isOutdated) return;
const channelSuffix = channel === "nightly" ? " (nightly)" : "";

View File

@ -1,5 +1,90 @@
# @aoagents/ao-core
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
## 0.9.0
### Minor Changes
- 73bed33: Wire activity events into webhook ingress and the mux WebSocket terminal server (sub-issue of #1511, follows #1620).
- `api.webhook_unverified` (warn) — signature verification failed; data includes `slug`, `remoteAddr`, `candidateCount` (never the failed signature)
- `api.webhook_rejected` (warn) — payload exceeded `maxBodyBytes`; data includes counts and `maxBodyBytes` (never the body)
- `api.webhook_received` (info|warn) — accepted webhook; data includes `projectIds`, `matchedSessions`, `parseErrorCount`, `lifecycleErrorCount` (never the body)
- `api.webhook_failed` (error) — outer pipeline crash with `errorMessage`
- `ui.terminal_connected` / `ui.terminal_disconnected` — one event per mux WS connection lifecycle
- `ui.terminal_heartbeat_lost` (warn) — fires once on 3 missed pongs (was console-only)
- `ui.terminal_pty_lost` (warn) — fires when PTY exits with subscribers attached (distinguishes "PTY died" from "user closed browser")
- `ui.terminal_protocol_error` (warn) — invalid mux client message
- `ui.session_broadcast_failed` (warn) — emitted on the healthy→failing transition only (re-arms after a successful poll), so a long outage produces one event, not 20/min
`api.webhook_unverified` is the security-audit event; treat 401s on webhooks as a signal worth retaining for the full 7-day window.
- 7d9b862: Replace Claude Code terminal-regex activity detection with platform-event hooks (#1941).
Claude Code emits a lifecycle hook on every state transition that matters
(`PermissionRequest`, `StopFailure`, `Notification`, `Stop`, `PreToolUse`,
…). Until now, AO ignored all but one of them and tried to infer the
same information by regex-matching Claude's rendered terminal output —
fragile by construction. Every Claude UI tweak (footer wording, status
verb, spinner glyph) broke a heuristic; PR #1932 spent 15 commits
patching the sharpest edges.
This release pivots:
**`@aoagents/ao-plugin-agent-claude-code`** now installs two scripts per
workspace:
- `metadata-updater` — unchanged; PostToolUse(Bash) extracts gh/git
side-effects (PR URL, branch, merge status).
- `activity-updater` — new; registered on every hook that carries
activity information (SessionStart, UserPromptSubmit, PreToolUse,
PostToolUse, PostToolUseFailure, PostToolBatch, Notification,
PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop,
PreCompact, PostCompact). The script reads the JSON payload from
stdin, maps `hook_event_name` to an activity state, and appends a
JSONL entry to `{workspace}/.ao/activity.jsonl` with `source: "hook"`.
Notification is filtered by `notification_type` so `auth_success` /
`elicitation_*` no longer false-fire `waiting_input` (the RFC's blanket
"Notification → waiting_input" would have regressed here).
The terminal-regex layer (`classifyTerminalOutput`, ~80 LOC of
patterns + `agent.recordActivity`) is retired. `detectActivity` stays on
the Agent interface for other agents but is now a stable `return "idle"`
stub for Claude — the JSONL-backed cascade is the only source of truth
for active / ready / waiting_input / blocked.
**`@aoagents/ao-core`** extends `ActivityLogEntry.source` and
`ActivitySignalSource` with a `"hook"` value so the new entries are
parseable and their provenance is visible in telemetry. No downstream
consumer needs changes — the cascade has always read whatever source
appeared in the JSONL, and the new tests assert hook-sourced entries
flow through `checkActivityLogState` / `getActivityFallbackState`
identically to terminal-sourced ones.
Idempotent install: calling `setupWorkspaceHooks` twice keeps exactly
one entry per event and preserves user-installed hooks alongside ours.
Cross-platform: bash + Node (.cjs) variants behave identically against a
shared 52-case scenario table.
- 6d48022: Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.
- fcedb25: Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI.
- 94981dc: feat: "Launch Orchestrator (clean context)" action on the orchestrator session page
Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080.
### Patch Changes
- a610601: Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"``blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903.
Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}``blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider).
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
## 0.8.0
### Minor Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-core",
"version": "0.8.0",
"version": "0.9.1",
"description": "Core library — types, config, session manager, lifecycle manager, event bus",
"license": "MIT",
"type": "module",

View File

@ -240,6 +240,15 @@ describe("readLastActivityEntry", () => {
expect(result).toBeNull();
});
it("accepts entries with source: hook", async () => {
await appendActivityEntry(tmpDir, "waiting_input", "hook", "PermissionRequest");
const result = await readLastActivityEntry(tmpDir);
expect(result).not.toBeNull();
expect(result!.entry.state).toBe("waiting_input");
expect(result!.entry.source).toBe("hook");
expect(result!.entry.trigger).toBe("PermissionRequest");
});
it("falls back to the previous complete line when a read races a truncated tail", async () => {
await mkdir(join(tmpDir, ".ao"), { recursive: true });
const completeEntry: ActivityLogEntry = {

View File

@ -76,6 +76,7 @@ function persistSession(
branch: session.branch ?? "main",
status: session.status,
project: "my-app",
agent: session.metadata["agent"] ?? "mock-agent",
runtimeHandle: session.runtimeHandle ?? undefined,
...metaOverrides,
};

View File

@ -149,6 +149,7 @@ function setupCheck(
branch: opts.session.branch ?? "main",
status: opts.session.status,
project: "my-app",
agent: opts.session.metadata["agent"] ?? "mock-agent",
runtimeHandle: opts.session.runtimeHandle ?? undefined,
...opts.metaOverrides,
};
@ -225,6 +226,7 @@ function setupPollCheck(
branch: opts.session.branch ?? "main",
status: opts.session.status,
project: "my-app",
agent: opts.session.metadata["agent"] ?? "mock-agent",
runtimeHandle: opts.session.runtimeHandle ?? undefined,
...opts.metaOverrides,
};
@ -530,7 +532,7 @@ describe("check (single session)", () => {
expect(lm.getStates().get("app-1")).toBe("detecting");
});
it("uses worker-specific agent fallback when metadata does not persist an agent", async () => {
it("uses persisted session agent even when worker config differs", async () => {
const codexAgent: Agent = {
...plugins.agent,
name: "codex",
@ -557,13 +559,13 @@ describe("check (single session)", () => {
"my-app": {
...config.projects["my-app"],
agent: "mock-agent",
worker: { agent: "codex" },
worker: { agent: "mock-agent" },
},
},
};
const lm = setupCheck("app-1", {
session: makeSession({ status: "working", metadata: {} }),
session: makeSession({ status: "working", metadata: { agent: "codex" } }),
registry: registryWithMultipleAgents,
configOverride: configWithWorkerAgent,
});

View File

@ -44,6 +44,14 @@ describe("platform adapter", () => {
});
});
describe("getNodePtyPrebuildsSubdir", () => {
it("centralizes node-pty prebuild platform/arch naming", async () => {
setPlatform("darwin");
const mod = await import("../platform.js");
expect(mod.getNodePtyPrebuildsSubdir()).toBe(`darwin-${process.arch}`);
});
});
describe("getShell", () => {
it("always returns /bin/sh on unix (ignores $SHELL)", async () => {
setPlatform("linux");

View File

@ -151,11 +151,13 @@ describe("loadBuiltins", () => {
const fakeClaudeCode = makePlugin("agent", "claude-code");
const fakeCodex = makePlugin("agent", "codex");
const fakeGrok = makePlugin("agent", "grok");
const fakeOpenCode = makePlugin("agent", "opencode");
await registry.loadBuiltins(undefined, async (pkg: string) => {
if (pkg === "@aoagents/ao-plugin-agent-claude-code") return fakeClaudeCode;
if (pkg === "@aoagents/ao-plugin-agent-codex") return fakeCodex;
if (pkg === "@aoagents/ao-plugin-agent-grok") return fakeGrok;
if (pkg === "@aoagents/ao-plugin-agent-opencode") return fakeOpenCode;
throw new Error(`Not found: ${pkg}`);
});
@ -163,10 +165,12 @@ describe("loadBuiltins", () => {
const agents = registry.list("agent");
expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" }));
expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" }));
expect(agents).toContainEqual(expect.objectContaining({ name: "grok", slot: "agent" }));
expect(agents).toContainEqual(expect.objectContaining({ name: "opencode", slot: "agent" }));
expect(registry.get("agent", "codex")).not.toBeNull();
expect(registry.get("agent", "claude-code")).not.toBeNull();
expect(registry.get("agent", "grok")).not.toBeNull();
expect(registry.get("agent", "opencode")).not.toBeNull();
});

View File

@ -103,6 +103,7 @@ function makeAssessment(overrides: Partial<RecoveryAssessment> = {}): RecoveryAs
metadataStatus: "working",
rawMetadata: {
project: "app",
agent: "claude-code",
branch: "feat/test",
issue: "123",
pr: "https://github.com/org/repo/pull/42",
@ -169,6 +170,7 @@ describe("recoverSession", () => {
expect(result.session?.restoredAt).toBeInstanceOf(Date);
expect(metadata?.["restoredAt"]).toBeDefined();
expect(metadata?.["recoveredAt"]).toBeUndefined();
expect(metadata?.["agent"]).toBe("claude-code");
});
it("preserves project ownership when legacy metadata omits the project field", async () => {

View File

@ -119,6 +119,7 @@ describe("recovery validator", () => {
const assessment = await validateSession(scanned, config, registry);
expect(assessment.agentProcessRunning).toBe(true);
expect(assessment.rawMetadata["agent"]).toBe("codex");
expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled();
expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled();
});

View File

@ -598,6 +598,7 @@ describe("remap", () => {
expect(mapped).toBe("ses_project_agent");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["agent"]).toBe("opencode");
expect(meta?.["opencodeSessionId"]).toBe("ses_project_agent");
});
});

View File

@ -64,6 +64,21 @@ describe("list", () => {
expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]);
});
it("backfills missing legacy agent metadata on read", async () => {
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp/w1",
branch: "feat/a",
status: "working",
project: "my-app",
});
const sm = createSessionManager({ config, registry: mockRegistry });
const sessions = await sm.list();
expect(sessions[0]?.metadata["agent"]).toBe("mock-agent");
expect(readMetadataRaw(sessionsDir, "app-1")?.["agent"]).toBe("mock-agent");
});
it("skips dead-runtime agent metadata discovery when native restore metadata is already persisted", async () => {
writeMetadata(sessionsDir, "app-1", {
worktree: config.projects["my-app"]!.path,
@ -474,6 +489,7 @@ describe("list", () => {
branch: "a",
status: "working",
project: "my-app",
agent: "mock-agent",
runtimeHandle,
lifecycle,
});

View File

@ -1113,6 +1113,18 @@ describe("spawn", () => {
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
});
it("delivers prompt after launch for post-launch prompt agents", async () => {
(mockAgent as Agent & { promptDelivery: "post-launch" }).promptDelivery = "post-launch";
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
makeHandle("rt-1"),
expect.stringContaining("Fix the bug"),
);
});
it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
@ -1444,6 +1456,17 @@ describe("spawn", () => {
expect(meta?.["displayName"]).toBe("Add rate limiting to /api/upload");
});
it("strips a markdown heading marker from prompt-derived displayName", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawn({
projectId: "my-app",
prompt: "### Add rate limiting to /api/upload\n\nUse a sliding-window counter.",
});
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["displayName"]).toBe("Add rate limiting to /api/upload");
});
it("truncates long displayName values with an ellipsis", async () => {
const longPrompt =
"Implement a comprehensive rate-limiter that supports sliding windows, token buckets, and per-route overrides with distributed counters";
@ -2385,6 +2408,18 @@ describe("spawn", () => {
expect(readFileSync(promptFile, "utf-8")).toBe("You are the orchestrator.");
});
it("delivers only a begin trigger after launch for post-launch orchestrator agents", async () => {
(mockAgent as Agent & { promptDelivery: "post-launch" }).promptDelivery = "post-launch";
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawnOrchestrator({
projectId: "my-app",
systemPrompt: "You are the orchestrator.",
});
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "Begin.");
});
it("persists displayName derived from the orchestrator system prompt", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });

View File

@ -1,12 +1,47 @@
import { describe, it, expect } from "vitest";
import { isVersionOutdated } from "../version-compare.js";
import { isVersionOutdated, isVersionOutdatedForChannel } from "../version-compare.js";
// Both `packages/cli/src/lib/update-check.ts` and
// `packages/web/src/app/api/version/route.ts` import this implementation
// from core. These tests are the single source of truth for the comparison
// rules — if either consumer's behavior diverges, fix the consumer, not this.
describe("isVersionOutdatedForChannel", () => {
it("treats nightly dist-tag changes as updates by identity in both lexical directions", () => {
expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-def", "nightly")).toBe(
true,
);
expect(isVersionOutdatedForChannel("0.0.0-nightly-def", "0.0.0-nightly-abc", "nightly")).toBe(
true,
);
expect(
isVersionOutdatedForChannel("0.0.0-nightly-f00d123", "0.0.0-nightly-0dead01", "nightly"),
).toBe(true);
});
it("does not update nightly when the exact dist-tag version is already installed", () => {
expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-abc", "nightly")).toBe(
false,
);
});
it("uses semver for stable-to-nightly channel switches", () => {
expect(isVersionOutdatedForChannel("0.7.0", "0.0.0-nightly-abc", "nightly")).toBe(false);
expect(isVersionOutdatedForChannel("0.7.0", "0.8.0-nightly-abc", "nightly")).toBe(true);
expect(isVersionOutdatedForChannel("0.8.0", "0.8.0-nightly-abc", "nightly")).toBe(false);
});
it("treats nightly-to-stable fallback on the nightly channel as an update when the dist-tag differs", () => {
expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.8.0", "nightly")).toBe(true);
});
it("keeps stable comparisons numeric instead of lexical", () => {
expect(isVersionOutdatedForChannel("0.9.0", "0.10.0", "stable")).toBe(true);
expect(isVersionOutdatedForChannel("0.7.0", "0.8.0", "stable")).toBe(true);
});
});
describe("isVersionOutdated (shared core implementation)", () => {
describe("numeric major/minor/patch", () => {
it("treats lower major as older", () => {

View File

@ -35,7 +35,7 @@ export function getActivityLogPath(workspacePath: string): string {
export async function appendActivityEntry(
workspacePath: string,
state: ActivityState,
source: "terminal" | "native",
source: "terminal" | "native" | "hook",
trigger?: string,
): Promise<void> {
const logPath = getActivityLogPath(workspacePath);
@ -98,7 +98,7 @@ export async function readLastActivityEntry(
const record = parsed as Record<string, unknown>;
const validStates = new Set(["active", "ready", "idle", "waiting_input", "blocked", "exited"]);
const validSources = new Set(["terminal", "native"]);
const validSources = new Set(["terminal", "native", "hook"]);
if (
typeof record.ts !== "string" ||
typeof record.state !== "string" ||

View File

@ -29,6 +29,31 @@ export function resolveSessionRole(
: "worker";
}
/**
* Resolve the agent identity for a session metadata record. Normalized Session
* objects are expected to carry metadata.agent; fallback resolution exists only
* for legacy raw metadata read/repair boundaries.
*/
export function resolveAgentSelectionForSession(params: {
sessionId: string;
metadata?: Record<string, string>;
project: ProjectConfig;
defaults: DefaultPlugins;
allSessionPrefixes?: string[];
}): ResolvedAgentSelection {
return resolveAgentSelection({
role: resolveSessionRole(
params.sessionId,
params.metadata,
params.project.sessionPrefix,
params.allSessionPrefixes,
),
project: params.project,
defaults: params.defaults,
persistedAgent: params.metadata?.["agent"],
});
}
export function resolveAgentSelection(params: {
role: SessionRole;
project: ProjectConfig;

View File

@ -41,6 +41,13 @@ export {
listMetadata,
} from "./metadata.js";
export { createInitialCanonicalLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
export {
resolveAgentSelection,
resolveAgentSelectionForSession,
resolveSessionRole,
type ResolvedAgentSelection,
type SessionRole,
} from "./agent-selection.js";
export { sessionFromMetadata } from "./utils/session-from-metadata.js";
// AO-local code review store
@ -366,6 +373,7 @@ export {
isMac,
isLinux,
getDefaultRuntime,
getNodePtyPrebuildsSubdir,
getShell,
killProcessTree,
findPidByPort,
@ -425,7 +433,7 @@ export { UpdateChannelSchema, InstallMethodOverrideSchema } from "./global-confi
// Channel-aware semver comparison shared by the CLI's update-check and the
// dashboard's /api/version route.
export { isVersionOutdated } from "./version-compare.js";
export { isVersionOutdated, isVersionOutdatedForChannel } from "./version-compare.js";
// Cache-layer primitives for the update pipeline. Both the CLI and the
// dashboard's /api/version route read the same cache file; centralising the

View File

@ -70,7 +70,7 @@ import {
import { createCorrelationId, createProjectObserver } from "./observability.js";
import { resolveNotifierTarget } from "./notifier-resolution.js";
import { recordNotificationDelivery } from "./notification-observability.js";
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
import { resolveSessionRole } from "./agent-selection.js";
import {
DETECTING_MAX_ATTEMPTS,
createDetectingDecision,
@ -910,20 +910,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const lifecycle = cloneLifecycle(session.lifecycle);
const nowIso = new Date().toISOString();
const allSessionPrefixes = Object.values(config.projects).map((p) => p.sessionPrefix);
const sessionRole = resolveSessionRole(
session.id,
session.metadata,
project.sessionPrefix,
allSessionPrefixes,
);
const agentName = resolveAgentSelection({
role: sessionRole,
project,
defaults: config.defaults,
persistedAgent: session.metadata["agent"],
}).agentName;
const agent = registry.get<Agent>("agent", agentName);
const agentName = session.metadata["agent"];
const agent = agentName ? registry.get<Agent>("agent", agentName) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("scm", project.scm.plugin) : null;
let detectedIdleTimestamp: Date | null = null;
let idleWasBlocked = false;

View File

@ -28,6 +28,10 @@ export function getDefaultRuntime(): "tmux" | "process" {
return isWindows() ? "process" : "tmux";
}
export function getNodePtyPrebuildsSubdir(): string {
return `${process.platform}-${process.arch}`;
}
// -- Shell resolution --
interface ShellInfo {

View File

@ -46,6 +46,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> =
{ slot: "agent", name: "aider", pkg: "@aoagents/ao-plugin-agent-aider" },
{ slot: "agent", name: "cursor", pkg: "@aoagents/ao-plugin-agent-cursor" },
{ slot: "agent", name: "kimicode", pkg: "@aoagents/ao-plugin-agent-kimicode" },
{ slot: "agent", name: "grok", pkg: "@aoagents/ao-plugin-agent-grok" },
{ slot: "agent", name: "opencode", pkg: "@aoagents/ao-plugin-agent-opencode" },
// Workspaces
{ slot: "workspace", name: "worktree", pkg: "@aoagents/ao-plugin-workspace-worktree" },

View File

@ -51,6 +51,12 @@ function buildLifecycleRecoveryPatch(
return buildLifecycleMetadataPatch(updated);
}
function preserveSessionAgentPatch(
rawMetadata: Record<string, string>,
): Partial<Record<string, string>> {
return rawMetadata["agent"] ? { agent: rawMetadata["agent"] } : {};
}
export async function recoverSession(
assessment: RecoveryAssessment,
config: OrchestratorConfig,
@ -101,6 +107,7 @@ export async function recoverSession(
escalatedAt: now,
escalationReason: `Exceeded max recovery attempts (${context.recoveryConfig.maxRecoveryAttempts})`,
recoveryCount: String(recoveryCount),
...preserveSessionAgentPatch(rawMetadata),
...buildLifecycleRecoveryPatch(rawMetadata, {
state: "stuck",
reason: "probe_failure",
@ -121,6 +128,7 @@ export async function recoverSession(
status: preservedStatus,
restoredAt: now,
recoveryCount: String(recoveryCount),
...preserveSessionAgentPatch(rawMetadata),
});
context.invalidateCache?.();
@ -225,6 +233,7 @@ export async function cleanupSession(
status: "terminated",
terminatedAt: cleanupAt,
terminationReason: "cleanup",
...preserveSessionAgentPatch(rawMetadata),
...buildLifecycleRecoveryPatch(rawMetadata, {
state: "terminated",
reason: "auto_cleanup",
@ -284,6 +293,7 @@ export async function escalateSession(
status: "stuck",
escalatedAt: new Date().toISOString(),
escalationReason: reason,
...preserveSessionAgentPatch(rawMetadata),
...buildLifecycleRecoveryPatch(rawMetadata, {
state: "stuck",
reason: "probe_failure",

View File

@ -21,7 +21,7 @@ import {
type RecoveryAction,
type RecoveryConfig,
} from "./types.js";
import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js";
import { resolveAgentSelectionForSession } from "../agent-selection.js";
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
import { createActivitySignal } from "../activity-signal.js";
@ -44,27 +44,26 @@ export async function validateSession(
const { sessionId, projectId, project, rawMetadata } = scanned;
const runtimeName = project.runtime ?? config.defaults.runtime;
const agentName = resolveAgentSelection({
role: resolveSessionRole(
const agentName = resolveAgentSelectionForSession({
sessionId,
rawMetadata,
project.sessionPrefix,
Object.values(config.projects).map((p) => p.sessionPrefix),
),
metadata: rawMetadata,
project,
defaults: config.defaults,
persistedAgent: rawMetadata["agent"],
allSessionPrefixes: Object.values(config.projects).map((p) => p.sessionPrefix),
}).agentName;
const normalizedMetadata = rawMetadata["agent"]
? rawMetadata
: { ...rawMetadata, agent: agentName };
const workspaceName = project.workspace ?? config.defaults.workspace;
const runtime = registry.get<Runtime>("runtime", runtimeName);
const agent = registry.get<Agent>("agent", agentName);
const workspace = registry.get<Workspace>("workspace", workspaceName);
const workspacePath = rawMetadata["worktree"] || null;
const runtimeHandleStr = rawMetadata["runtimeHandle"];
const workspacePath = normalizedMetadata["worktree"] || null;
const runtimeHandleStr = normalizedMetadata["runtimeHandle"];
const runtimeHandle = runtimeHandleStr ? safeJsonParse<RuntimeHandle>(runtimeHandleStr) : null;
const metadataStatus = validateStatus(rawMetadata["status"]);
const metadataStatus = validateStatus(normalizedMetadata["status"]);
const recoveryConfig: RecoveryConfig = {
...DEFAULT_RECOVERY_CONFIG,
...(recoveryConfigInput ?? {}),
@ -120,15 +119,15 @@ export async function validateSession(
activity: null,
activitySignal: createActivitySignal("unavailable"),
lifecycle,
branch: rawMetadata["branch"] ?? null,
issueId: rawMetadata["issue"] ?? null,
branch: normalizedMetadata["branch"] ?? null,
issueId: normalizedMetadata["issue"] ?? null,
pr: null,
workspacePath,
runtimeHandle,
agentInfo: null,
createdAt: new Date(rawMetadata["createdAt"] ?? Date.now()),
lastActivityAt: new Date(rawMetadata["lastActivityAt"] ?? Date.now()),
metadata: rawMetadata,
createdAt: new Date(normalizedMetadata["createdAt"] ?? Date.now()),
lastActivityAt: new Date(normalizedMetadata["lastActivityAt"] ?? Date.now()),
metadata: normalizedMetadata,
};
const detection = await agent.getActivityState(probeSession, config.readyThresholdMs);
agentActivity = detection?.state ?? null;
@ -147,7 +146,7 @@ export async function validateSession(
}
}
const metadataValid = Object.keys(rawMetadata).length > 0;
const metadataValid = Object.keys(normalizedMetadata).length > 0;
const classification = classifySession(
runtimeAlive,
workspaceExists,
@ -195,7 +194,7 @@ export async function validateSession(
agentActivity,
metadataValid,
metadataStatus,
rawMetadata,
rawMetadata: normalizedMetadata,
};
}

View File

@ -94,7 +94,7 @@ import {
import { sessionFromMetadata } from "./utils/session-from-metadata.js";
import { safeJsonParse, validateStatus } from "./utils/validation.js";
import { isGitBranchNameSafe } from "./utils.js";
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
import { resolveAgentSelection, resolveAgentSelectionForSession } from "./agent-selection.js";
import {
buildAgentPath,
setupPathWrapperWorkspace,
@ -272,7 +272,7 @@ function deriveDisplayName(input: { issueTitle?: string; prompt?: string }): str
}
if (input.prompt && input.prompt.trim()) {
const line = pickLine(input.prompt);
const line = pickLine(input.prompt).replace(/^#{1,6}\s+/, "");
if (line) return truncate(line);
}
@ -538,6 +538,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
sessionCache = null;
}
function repairSessionAgentMetadataOnRead(
sessionsDir: string,
record: ActiveSessionRecord,
project: ProjectConfig,
): ActiveSessionRecord {
if (record.raw["agent"]) return record;
const agent = resolveSelectionForSession(project, record.sessionName, record.raw).agentName;
updateMetadataPreservingMtime(sessionsDir, record.sessionName, { agent }, record.modifiedAt);
return {
...record,
raw: applyMetadataUpdatesToRaw(record.raw, { agent }),
};
}
function repairSingleSessionMetadataOnRead(
sessionsDir: string,
record: ActiveSessionRecord,
@ -599,7 +614,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
function repairSessionMetadataOnRead(
sessionsDir: string,
records: ActiveSessionRecord[],
sessionPrefix?: string,
project: ProjectConfig,
): ActiveSessionRecord[] {
const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } }));
const duplicatePRAttachments = new Map<string, ActiveSessionRecord[]>();
@ -611,7 +626,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
sessionId: record.sessionName,
status: validateStatus(record.raw["status"]),
createdAt: record.raw["createdAt"] ? new Date(record.raw["createdAt"]) : undefined,
sessionKind: isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)
sessionKind: isOrchestratorSessionRecord(
record.sessionName,
record.raw,
project.sessionPrefix,
)
? "orchestrator"
: "worker",
}),
@ -626,11 +645,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
record.raw = applyMetadataUpdatesToRaw(record.raw, canonicalUpdates);
}
if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) {
record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw;
if (isOrchestratorSessionRecord(record.sessionName, record.raw, project.sessionPrefix)) {
record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, project.sessionPrefix).raw;
record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw;
continue;
}
record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw;
const prUrl = record.raw["pr"];
if (!prUrl) continue;
@ -705,7 +727,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return [{ sessionName, raw, modifiedAt } satisfies ActiveSessionRecord];
});
return repairSessionMetadataOnRead(sessionsDir, records, project.sessionPrefix);
return repairSessionMetadataOnRead(sessionsDir, records, project);
}
function sortSessionIdsForReuse(ids: string[]): string[] {
@ -900,16 +922,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
sessionId: string,
metadata: Record<string, string>,
) {
return resolveAgentSelection({
role: resolveSessionRole(
return resolveAgentSelectionForSession({
sessionId,
metadata,
project.sessionPrefix,
Object.values(config.projects).map((p) => p.sessionPrefix),
),
project,
defaults: config.defaults,
persistedAgent: metadata["agent"],
allSessionPrefixes: Object.values(config.projects).map((p) => p.sessionPrefix),
});
}
@ -947,10 +965,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
modifiedAt = undefined;
}
const repaired = repairSingleSessionMetadataOnRead(
const repaired = repairSessionAgentMetadataOnRead(
sessionsDir,
repairSingleSessionMetadataOnRead(
sessionsDir,
{ sessionName: sessionId, raw, modifiedAt },
project.sessionPrefix,
),
project,
);
return { raw: repaired.raw, sessionsDir, project, projectId };
@ -1504,6 +1526,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
await plugins.agent.postLaunchSetup(session);
}
if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) {
await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt);
}
if (
plugins.agent.name === "opencode" &&
opencodeIssueSessionStrategy === "reuse" &&
@ -1979,6 +2005,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
await plugins.agent.postLaunchSetup(session);
}
if (plugins.agent.promptDelivery === "post-launch" && orchestratorConfig.systemPrompt) {
// The orchestrator prompt is already passed via systemPromptFile in the launch command.
// Send only a minimal trigger so interactive post-launch agents start without
// receiving their system instructions again as a user message.
await plugins.runtime.sendMessage(handle, "Begin.");
}
if (
plugins.agent.name === "opencode" &&
orchestratorSessionStrategy === "reuse" &&
@ -2369,10 +2402,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// If stat fails, timestamps will fall back to current time
}
const repaired = repairSingleSessionMetadataOnRead(
const repaired = repairSessionAgentMetadataOnRead(
sessionsDir,
repairSingleSessionMetadataOnRead(
sessionsDir,
{ sessionName: sessionId, raw, modifiedAt },
project.sessionPrefix,
),
project,
);
const session = metadataToSession(
@ -3539,6 +3576,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
updateMetadata(sessionsDir, sessionId, {
...buildLifecycleMetadataPatch(restoredLifecycle),
agent: selection.agentName,
restoredAt: now,
mergedPendingCleanupSince: "",
});

View File

@ -157,7 +157,7 @@ export const ACTIVITY_STATE = {
export type ActivitySignalState = "valid" | "stale" | "null" | "unavailable" | "probe_failure";
export type ActivitySignalSource = "native" | "terminal" | "runtime" | "none";
export type ActivitySignalSource = "native" | "terminal" | "hook" | "runtime" | "none";
export interface ActivitySignal {
/** Confidence bucket for the activity probe result. */
@ -183,11 +183,16 @@ export interface ActivityDetection {
export interface ActivityLogEntry {
/** ISO 8601 timestamp */
ts: string;
/** Activity state derived from terminal output or agent-native data */
/** Activity state derived from terminal output, agent-native data, or a platform-event hook */
state: ActivityState;
/** What triggered this state classification */
source: "terminal" | "native";
/** Raw terminal snippet that caused waiting_input/blocked (for debugging) */
/**
* Provenance of this entry:
* - "terminal": classified from terminal output (regex/heuristic; deprecated for hook-capable agents)
* - "native": read from the agent's own JSONL/API
* - "hook": emitted by an agent lifecycle hook (e.g. Claude Code's PermissionRequest, Stop, StopFailure)
*/
source: "terminal" | "native" | "hook";
/** Raw terminal snippet, hook event name, or other context that caused waiting_input/blocked (for debugging) */
trigger?: string;
}
@ -475,6 +480,13 @@ export interface Agent {
/** Process name to look for (e.g. "claude", "codex", "aider") */
readonly processName: string;
/**
* How the initial user prompt is delivered.
* Defaults to inline, meaning the agent embeds the prompt in getLaunchCommand().
* Use post-launch for interactive CLIs that must start first and receive input over stdin.
*/
readonly promptDelivery?: "inline" | "post-launch";
/** Get the shell command to launch this agent */
getLaunchCommand(config: AgentLaunchConfig): string;

View File

@ -8,6 +8,34 @@
* Spec: release-process.html §07 (Auto-update mechanics).
*/
import type { UpdateChannel } from "./global-config.js";
export function isVersionOutdatedForChannel(
current: string,
latest: string,
channel: UpdateChannel,
): boolean {
if (channel === "nightly") {
const currentParsed = parseVersion(current);
const latestParsed = parseVersion(latest);
if (
isNightlyPrerelease(currentParsed.prerelease) &&
isNightlyPrerelease(latestParsed.prerelease)
) {
return current !== latest;
}
}
return isVersionOutdated(current, latest);
}
function isNightlyPrerelease(prerelease: string | undefined): boolean {
return (
prerelease === "nightly" ||
prerelease?.startsWith("nightly-") === true ||
prerelease?.startsWith("nightly.") === true
);
}
/**
* Returns true if `current` is an older version than `latest`.
*

View File

@ -0,0 +1,13 @@
# @aoagents/ao-notifier-macos
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
## 0.9.0
### Patch Changes
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-notifier-macos",
"version": "0.6.0",
"version": "0.9.1",
"description": "Native macOS notification helper app for AO",
"license": "MIT",
"type": "module",

View File

@ -1,5 +1,27 @@
# @aoagents/ao-plugin-agent-aider
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Minor Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-agent-aider",
"version": "0.8.0",
"version": "0.9.1",
"description": "Agent plugin: Aider",
"license": "MIT",
"type": "module",

View File

@ -1,5 +1,89 @@
# @aoagents/ao-plugin-agent-claude-code
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Minor Changes
- 7d9b862: Replace Claude Code terminal-regex activity detection with platform-event hooks (#1941).
Claude Code emits a lifecycle hook on every state transition that matters
(`PermissionRequest`, `StopFailure`, `Notification`, `Stop`, `PreToolUse`,
…). Until now, AO ignored all but one of them and tried to infer the
same information by regex-matching Claude's rendered terminal output —
fragile by construction. Every Claude UI tweak (footer wording, status
verb, spinner glyph) broke a heuristic; PR #1932 spent 15 commits
patching the sharpest edges.
This release pivots:
**`@aoagents/ao-plugin-agent-claude-code`** now installs two scripts per
workspace:
- `metadata-updater` — unchanged; PostToolUse(Bash) extracts gh/git
side-effects (PR URL, branch, merge status).
- `activity-updater` — new; registered on every hook that carries
activity information (SessionStart, UserPromptSubmit, PreToolUse,
PostToolUse, PostToolUseFailure, PostToolBatch, Notification,
PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop,
PreCompact, PostCompact). The script reads the JSON payload from
stdin, maps `hook_event_name` to an activity state, and appends a
JSONL entry to `{workspace}/.ao/activity.jsonl` with `source: "hook"`.
Notification is filtered by `notification_type` so `auth_success` /
`elicitation_*` no longer false-fire `waiting_input` (the RFC's blanket
"Notification → waiting_input" would have regressed here).
The terminal-regex layer (`classifyTerminalOutput`, ~80 LOC of
patterns + `agent.recordActivity`) is retired. `detectActivity` stays on
the Agent interface for other agents but is now a stable `return "idle"`
stub for Claude — the JSONL-backed cascade is the only source of truth
for active / ready / waiting_input / blocked.
**`@aoagents/ao-core`** extends `ActivityLogEntry.source` and
`ActivitySignalSource` with a `"hook"` value so the new entries are
parseable and their provenance is visible in telemetry. No downstream
consumer needs changes — the cascade has always read whatever source
appeared in the JSONL, and the new tests assert hook-sourced entries
flow through `checkActivityLogState` / `getActivityFallbackState`
identically to terminal-sourced ones.
Idempotent install: calling `setupWorkspaceHooks` twice keeps exactly
one entry per event and preserves user-installed hooks alongside ours.
Cross-platform: bash + Node (.cjs) variants behave identically against a
shared 52-case scenario table.
### Patch Changes
- a610601: Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"``blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903.
Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}``blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider).
- 8c71bde: Harden Claude Code activity detection against five real-world edge cases identified during PR #1927's analysis:
1. **Bookkeeping types → false-active.** `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title` were falling through to the `default` switch branch and showing as `active` for 30s after Claude finished a turn. They now correctly map to `ready`/`idle` by age. Likely root cause of "Claude looks busy when it's done" reports.
2. **Multi-session disambiguation.** `findLatestSessionFile` picked newest-mtime, which is the wrong session's JSONL when two Claude sessions are running in the same workspace. Now prefers the UUID-named file (`<projectDir>/<claudeSessionUuid>.jsonl`) when `session.metadata.claudeSessionUuid` is set, falling back to newest-mtime otherwise.
3. **Symlinked workspaces.** `toClaudeProjectPath` was a pure string transform — symlink paths produced different slugs than what Claude itself wrote. Added `resolveWorkspaceForClaude(path)` that runs `realpathSync` (with fallback) and used it in all three slug-computing sites (`getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`).
4. **Process regex too narrow.** `(?:^|\/)claude(?:\s|$)` was missing several legitimate install variants — `.claude`, `claude-code`, `claude.exe`, `claude.js`, and npm shims like `node /opt/.../@anthropic-ai/claude-code/cli.js`. Broadened to `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)`; still rejects look-alikes (`claudia`, `claudine`).
5. **Silent permission-denied.** `findLatestSessionFile` was swallowing every `readdir` error silently — a missing `~/.claude/projects/<slug>/` (ENOENT) is normal, but a permission-denied (EACCES/EPERM) or fd-exhausted (EMFILE) misconfig left the session looking permanently `idle` on the dashboard with zero telemetry. Now logs a single `console.warn` for non-ENOENT errors.
193/193 plugin tests pass. No public-API change. New helper `resolveWorkspaceForClaude` is re-exported from `index.ts` for downstream consumers.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Minor Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-agent-claude-code",
"version": "0.8.0",
"version": "0.9.1",
"description": "Agent plugin: Claude Code",
"license": "MIT",
"type": "module",

View File

@ -14,7 +14,6 @@ import { toClaudeProjectPath, create } from "../index.js";
import { resetWarnedReaddirPaths } from "../activity-detection.js";
import {
createActivitySignal,
readLastActivityEntry,
type ActivityState,
type Session,
type RuntimeHandle,
@ -72,13 +71,20 @@ function writeJsonl(
}
}
function writeActivityLog(state: ActivityState, ageMs = 0): void {
function writeActivityLog(
state: ActivityState,
ageMs = 0,
source: "terminal" | "native" | "hook" = "terminal",
trigger?: string,
): void {
const ts = new Date(Date.now() - ageMs).toISOString();
const aoDir = join(workspacePath, ".ao");
mkdirSync(aoDir, { recursive: true });
const entry: Record<string, unknown> = { ts, state, source };
if (trigger !== undefined) entry.trigger = trigger;
writeFileSync(
join(aoDir, "activity.jsonl"),
JSON.stringify({ ts, state, source: "terminal" }) + "\n",
JSON.stringify(entry) + "\n",
);
}
@ -288,21 +294,16 @@ describe("Claude Code Activity Detection", () => {
expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull();
});
it("recordActivity writes to .ao/activity.jsonl when workspacePath is set", async () => {
await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o");
const result = await readLastActivityEntry(workspacePath);
expect(result?.entry.state).toBe("waiting_input");
expect(result?.entry.source).toBe("terminal");
expect(result?.entry.trigger).toContain("Do you want to proceed?");
it("recordActivity is intentionally not implemented (#1941 — hooks write activity-JSONL directly)", () => {
// Lifecycle manager calls agent.recordActivity? only if defined.
// For Claude, hooks are the source of truth so this method is
// omitted — guarding here surfaces accidental re-introduction.
expect(agent.recordActivity).toBeUndefined();
});
it("recordActivity is a no-op when workspacePath is null", async () => {
await agent.recordActivity?.(
makeSession({ workspacePath: null }),
"Do you want to proceed?\n(Y)es / (N)o",
);
it("does NOT write to .ao/activity.jsonl on its own (hook-only producer)", () => {
// Without recordActivity, the plugin no longer derives anything from
// terminal output. .ao/activity.jsonl stays empty until a hook fires.
expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false);
});
@ -313,8 +314,11 @@ describe("Claude Code Activity Detection", () => {
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("falls back to AO JSONL waiting_input when native session lookup is unavailable", async () => {
await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o");
it("falls back to AO JSONL waiting_input when native session lookup is unavailable (#1941 hook entry)", async () => {
// PermissionRequest hook fires → activity-updater appends a JSONL entry
// with source: "hook". The cascade picks it up exactly like the old
// terminal-derived entry.
writeActivityLog("waiting_input", 0, "hook", "PermissionRequest (Bash)");
expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input");
});
@ -323,11 +327,21 @@ describe("Claude Code Activity Detection", () => {
writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000);
const session = makeSession({ createdAt: new Date() });
await agent.recordActivity?.(session, "Do you want to proceed?\n(Y)es / (N)o");
writeActivityLog("waiting_input", 0, "hook", "PermissionRequest");
expect((await agent.getActivityState(session))?.state).toBe("waiting_input");
});
it("surfaces blocked from a StopFailure hook entry in AO JSONL", async () => {
// StopFailure → activity-updater appends `{state: blocked, source: hook,
// trigger: "StopFailure (rate_limit)"}`. With no Claude native JSONL
// present, the cascade must surface it through checkActivityLogState.
writeActivityLog("blocked", 0, "hook", "StopFailure (rate_limit)");
const result = await agent.getActivityState(makeSession());
expect(result?.state).toBe("blocked");
});
it("returns idle for stale native session entry when AO JSONL is unavailable", async () => {
writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000);
const session = makeSession({ createdAt: new Date() });

View File

@ -276,86 +276,27 @@ export async function isClaudeProcessAlive(handle: RuntimeHandle): Promise<Proce
}
// =============================================================================
// Terminal output classification
// Terminal output classification — retired (#1941)
// =============================================================================
/** Classify Claude Code's activity state from terminal output (pure, sync). */
export function classifyTerminalOutput(terminalOutput: string): ActivityState {
if (!terminalOutput.trim()) return "idle";
const lines = terminalOutput.trim().split("\n");
const lastLine = lines[lines.length - 1]?.trim() ?? "";
// Empty prompt on the last line is unambiguously idle.
if (/^[>$#]\s*$/.test(lastLine)) return "idle";
// Use a wider window (last 12 lines) than the bottom-of-buffer prompt
// check above because Claude's spinner+status line, ⎿ tool-result lines,
// and api-error text often sit 6-8 lines above the input area + footer.
// All multi-line state checks (blocked, active) use this window — full
// `terminalOutput` would let scrolled-off error text falsely return
// "blocked" forever after a successful retry pushes the error out of
// view but not out of scrollback.
const wideTail = lines.slice(-12).join("\n");
// Check for blocked. Claude's persistent UI footer contains
// "bypass permissions on (shift+tab to cycle)" on every session — the
// tightened waiting_input regex no longer matches it, and the blocked
// patterns below are specific to api-error retry text.
//
// Patterns observed empirically by capturing tmux output during a real
// api-blocked retry loop (see PR #1932 description):
// ⎿ Unable to connect to API (ConnectionRefused)
// Retrying in 19s · attempt 7/10
if (/Unable to connect to API/i.test(wideTail)) return "blocked";
if (/Retrying in \d+s.*attempt \d+\/\d+/i.test(wideTail)) return "blocked";
// Check the bottom of the buffer for permission prompts. Historical
// "Thinking"/"Reading" text earlier in the buffer must not override a
// current permission prompt at the bottom.
const tail = lines.slice(-5).join("\n");
if (/Do you want to proceed\?/i.test(tail)) return "waiting_input";
if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input";
// Match the ACTUAL permission-bypass prompt, NOT the static footer toggle.
if (/bypass\s+all\s+future\s+permissions/i.test(tail)) return "waiting_input";
// Active: only when an explicit active-work indicator is present. Default
// is IDLE — Claude's tmux pane has a persistent input area + footer that
// looks identical between "just finished" and "working". Treating
// unrecognized output as active caused dormant sessions (ao-160 etc.) to
// get an "active" written to AO activity-JSONL every poll cycle, which the
// age-decayed fallback then surfaced as ready forever.
// Strongest active signal: gerund (present-participle) status verb
// followed by the trailing ellipsis "…". Claude cycles through many
// status words (Germinating, Fluttering, Pondering, Mulling, Crafting,
// Thinking, Reasoning, ...) and many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.)
// depending on animation frame. The gerund+ellipsis combo is the
// consistent signal that survives glyph rotation. Past-tense lines like
// "✻ Worked for 11s" or "✻ Crunched for 11s" lack the ellipsis and are
// turn-complete summaries — they must NOT match (ao-143 repro).
if (/\b\w+ing…/.test(wideTail)) return "active";
// NOTE — these patterns look "active-ish" but are NOT, and should never
// go back as active indicators:
// - /\bCrunched\s+for\s+\d+s/ — past-tense turn summary (ao-154 repro:
// "✻ Crunched for 22s" was making sessions perpetually "active")
// - /\bWorked\s+for\s+\d+s/ — past-tense turn summary (ao-143 repro)
// - /^\s*⎿\s+/ — prefixes past tool-results too
// - /esc to interrupt/ — in the persistent UI footer
// - bare /\bWorking\b/ — matches "working on issue #N" in recap
// The gerund+ellipsis above catches present-tense forms across all
// status words regardless of spinner glyph rotation.
// Word-based fallbacks for synthetic test inputs and rare cases where
// the ellipsis isn't captured. Tight patterns to avoid false-firing on
// benign text.
if (/\bGerminating/i.test(wideTail)) return "active";
if (/\b(?:Thinking|Working)\s*(?:…|\.\.\.)/i.test(wideTail)) return "active";
if (/\bReading\s+file/i.test(wideTail)) return "active";
if (/\bWriting\s+to\b/i.test(wideTail)) return "active";
if (/\bSearching\s+codebase/i.test(wideTail)) return "active";
if (/Press\s+up\s+to\s+edit\s+queued/i.test(wideTail)) return "active";
/**
* Retained as a stable no-signal stub for the deprecated
* `Agent.detectActivity` method on the Claude plugin.
*
* Claude activity is now derived from platform-event hooks
* (PermissionRequest / StopFailure / Notification / Stop / PreToolUse / ...)
* which write directly to `{workspace}/.ao/activity.jsonl`. The previous
* implementation regex-matched Claude's rendered terminal output, which
* regressed every time Claude's UI footer or status-line wording changed
* (15-commit churn in #1932 motivated the rewrite).
*
* The function is preserved so the Claude agent's `detectActivity` can
* delegate to a stable export rather than inlining `() => "idle"`, and
* because the hard-deprecated `detectActivity` method on the `Agent`
* interface still has callers outside this plugin (lifecycle-manager's
* terminal-output fallback, used by agents that haven't moved to hooks).
*/
export function classifyTerminalOutput(_terminalOutput: string): ActivityState {
return "idle";
}

View File

@ -0,0 +1,256 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { isWindows } from "@aoagents/ao-core";
import { ACTIVITY_UPDATER_SCRIPT, ACTIVITY_UPDATER_SCRIPT_NODE } from "./index.js";
// ---------------------------------------------------------------------------
// Integration tests for the activity-updater hook script (#1941).
// Pipes synthetic Claude Code hook JSON payloads into the real script and
// asserts the JSONL line written to {workspace}/.ao/activity.jsonl matches.
//
// Both the bash variant (Unix) and the Node variant (Windows) are exercised
// against the same scenario table to keep them in lockstep.
// ---------------------------------------------------------------------------
let scratchDir: string;
let bashScript: string;
let nodeScript: string;
beforeAll(() => {
scratchDir = mkdtempSync(join(tmpdir(), "ao-activity-hook-"));
bashScript = join(scratchDir, "activity-updater.sh");
nodeScript = join(scratchDir, "activity-updater.cjs");
writeFileSync(bashScript, ACTIVITY_UPDATER_SCRIPT, { mode: 0o755 });
writeFileSync(nodeScript, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8");
});
afterAll(() => {
rmSync(scratchDir, { recursive: true, force: true });
});
interface HookInput {
hook_event_name: string;
// Common-payload fields are optional in this synthetic shape; runtime payloads always have them.
session_id?: string;
cwd?: string;
notification_type?: string;
tool_name?: string;
error_type?: string;
error_message?: string;
}
interface HookResult {
stdout: string;
lastEntry: Record<string, unknown> | null;
rawJsonl: string;
}
type Variant = "bash" | "node";
function runHook(variant: Variant, payload: HookInput): HookResult {
const workspace = mkdtempSync(join(scratchDir, "ws-"));
const input = JSON.stringify(payload);
let stdout: string;
try {
const cmd = variant === "bash" ? `bash "${bashScript}"` : `node "${nodeScript}"`;
stdout = execSync(cmd, {
input,
env: { ...process.env, CLAUDE_PROJECT_DIR: workspace },
encoding: "utf-8",
timeout: 5000,
});
} catch (err: unknown) {
const e = err as { stdout?: string; stderr?: string };
stdout = e.stdout ?? "";
}
const logFile = join(workspace, ".ao", "activity.jsonl");
let rawJsonl = "";
let lastEntry: Record<string, unknown> | null = null;
if (existsSync(logFile)) {
rawJsonl = readFileSync(logFile, "utf-8");
const lines = rawJsonl.split("\n").filter((l) => l.trim());
if (lines.length > 0) {
try {
lastEntry = JSON.parse(lines[lines.length - 1]!) as Record<string, unknown>;
} catch {
lastEntry = null;
}
}
}
return { stdout, lastEntry, rawJsonl };
}
// Each scenario is executed against both the bash and the Node variant so
// drift between the two implementations is caught immediately. The bash
// suite is skipped on Windows — bash isn't a native shell there, so
// `execSync('bash "..."')` would throw ENOENT for every case. The Node
// variant is the Windows-supported path (and is exercised on Unix too,
// guarding against drift). Matches the `describe.skipIf` pattern used by
// `packages/core/src/__tests__/migration-storage-v2.test.ts`.
const variants: Variant[] = ["bash", "node"];
for (const variant of variants) {
describe.skipIf(variant === "bash" && isWindows())(`activity-updater (${variant})`, () => {
// -----------------------------------------------------------------------
// active states — turn-in-progress markers
// -----------------------------------------------------------------------
it.each([
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PreCompact",
"PostCompact",
"SubagentStart",
"PostToolBatch",
])("writes active for %s", (event) => {
const { lastEntry } = runHook(variant, { hook_event_name: event });
expect(lastEntry).not.toBeNull();
expect(lastEntry!.state).toBe("active");
expect(lastEntry!.source).toBe("hook");
expect(lastEntry).not.toHaveProperty("trigger");
});
// -----------------------------------------------------------------------
// ready states — turn boundaries
// -----------------------------------------------------------------------
it.each(["SessionStart", "Stop", "SubagentStop"])("writes ready for %s", (event) => {
const { lastEntry } = runHook(variant, { hook_event_name: event });
expect(lastEntry!.state).toBe("ready");
expect(lastEntry!.source).toBe("hook");
});
// -----------------------------------------------------------------------
// waiting_input — PermissionRequest is the authoritative signal
// -----------------------------------------------------------------------
it("writes waiting_input for PermissionRequest with tool_name in trigger", () => {
const { lastEntry } = runHook(variant, {
hook_event_name: "PermissionRequest",
tool_name: "Bash",
});
expect(lastEntry!.state).toBe("waiting_input");
expect(lastEntry!.source).toBe("hook");
expect(lastEntry!.trigger).toBe("PermissionRequest (Bash)");
});
it("writes waiting_input for PermissionRequest without tool_name", () => {
const { lastEntry } = runHook(variant, { hook_event_name: "PermissionRequest" });
expect(lastEntry!.state).toBe("waiting_input");
expect(lastEntry!.trigger).toBe("PermissionRequest");
});
// -----------------------------------------------------------------------
// Notification — MUST filter by notification_type so auth_success /
// elicitation_* don't false-fire waiting_input.
// -----------------------------------------------------------------------
it("writes waiting_input for Notification(permission_prompt)", () => {
const { lastEntry } = runHook(variant, {
hook_event_name: "Notification",
notification_type: "permission_prompt",
});
expect(lastEntry!.state).toBe("waiting_input");
expect(lastEntry!.trigger).toBe("Notification (permission_prompt)");
});
it("writes waiting_input for Notification(idle_prompt)", () => {
const { lastEntry } = runHook(variant, {
hook_event_name: "Notification",
notification_type: "idle_prompt",
});
expect(lastEntry!.state).toBe("waiting_input");
expect(lastEntry!.trigger).toBe("Notification (idle_prompt)");
});
it("skips Notification(auth_success) — not a stuck-on-the-user state", () => {
const { lastEntry, stdout } = runHook(variant, {
hook_event_name: "Notification",
notification_type: "auth_success",
});
expect(lastEntry).toBeNull();
expect(stdout.trim()).toBe("{}");
});
it("skips Notification(elicitation_response)", () => {
const { lastEntry } = runHook(variant, {
hook_event_name: "Notification",
notification_type: "elicitation_response",
});
expect(lastEntry).toBeNull();
});
// -----------------------------------------------------------------------
// blocked — StopFailure is the authoritative API-error signal
// -----------------------------------------------------------------------
it("writes blocked for StopFailure with error_type in trigger", () => {
const { lastEntry } = runHook(variant, {
hook_event_name: "StopFailure",
error_type: "rate_limit",
error_message: "Rate limited",
});
expect(lastEntry!.state).toBe("blocked");
expect(lastEntry!.source).toBe("hook");
expect(lastEntry!.trigger).toBe("StopFailure (rate_limit)");
});
it("writes blocked for StopFailure without error_type", () => {
const { lastEntry } = runHook(variant, { hook_event_name: "StopFailure" });
expect(lastEntry!.state).toBe("blocked");
expect(lastEntry!.trigger).toBe("StopFailure");
});
// -----------------------------------------------------------------------
// No-ops — unknown event names + ignored events
// -----------------------------------------------------------------------
it.each(["SessionEnd", "TaskCreated", "FileChanged", "UnknownFutureEvent"])(
"ignores unhandled event %s",
(event) => {
const { lastEntry, stdout } = runHook(variant, { hook_event_name: event });
expect(lastEntry).toBeNull();
expect(stdout.trim()).toBe("{}");
},
);
it("returns {} stdout so Claude doesn't surface a hook decision", () => {
const { stdout } = runHook(variant, { hook_event_name: "Stop" });
expect(stdout.trim()).toBe("{}");
});
it("creates .ao/ directory on first write", () => {
const { rawJsonl } = runHook(variant, { hook_event_name: "Stop" });
expect(rawJsonl.length).toBeGreaterThan(0);
});
it("emits a parseable JSON line with a valid ISO timestamp", () => {
const { lastEntry } = runHook(variant, { hook_event_name: "Stop" });
expect(lastEntry).not.toBeNull();
const ts = lastEntry!.ts as string;
expect(typeof ts).toBe("string");
// ISO 8601 with millisecond precision and Z suffix
expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/);
});
it("escapes control chars in trigger so the JSONL line stays parseable", () => {
// Bounded by Claude's enums today (`error_type`, `tool_name` never contain
// \n/\r/\t), but if a future hook payload field flows into `trigger`
// unescaped, a literal newline would split one entry into two and break
// the JSONL parser. This locks in JSON-style escaping across both
// bash and Node variants.
const { lastEntry, rawJsonl } = runHook(variant, {
hook_event_name: "StopFailure",
// Smuggle a multi-line value through error_type — covers \n/\t/\r/\\/".
error_type: 'multi\nline\twith\rmixed\\\\and"quotes',
});
// Exactly one JSONL line (control chars escaped, not literal)
expect(rawJsonl.split("\n").filter((l) => l.trim())).toHaveLength(1);
// Parsed entry round-trips the original string
expect(lastEntry!.state).toBe("blocked");
expect(lastEntry!.trigger).toBe(
'StopFailure (multi\nline\twith\rmixed\\\\and"quotes)',
);
});
});
}

View File

@ -78,6 +78,8 @@ import {
toClaudeProjectPath,
METADATA_UPDATER_SCRIPT,
METADATA_UPDATER_SCRIPT_NODE,
ACTIVITY_UPDATER_SCRIPT,
ACTIVITY_UPDATER_SCRIPT_NODE,
} from "./index.js";
// ---------------------------------------------------------------------------
@ -493,7 +495,18 @@ describe("isProcessRunning", () => {
// =========================================================================
// detectActivity — terminal output classification
// =========================================================================
describe("detectActivity", () => {
describe("detectActivity (retired — see #1941)", () => {
// Claude activity is derived from platform-event hooks (PermissionRequest,
// StopFailure, Notification, Stop, ...) which write directly to
// .ao/activity.jsonl with source: "hook". The terminal-regex layer was
// structurally fragile (every Claude UI tweak regressed it; #1932 spent
// 15 commits patching its sharpest edges) and has been retired.
//
// The `detectActivity` method is kept on the Agent interface for other
// plugins (Aider, OpenCode, Codex fallback) but is a stable no-signal
// stub for Claude — returns "idle" for every input so the lifecycle
// manager's terminal-output path stays neutral and the JSONL-backed
// cascade is the only source of truth for active/ready/waiting_input/blocked.
const agent = create();
it("returns idle for empty terminal output", () => {
@ -504,210 +517,20 @@ describe("detectActivity", () => {
expect(agent.detectActivity(" \n \n ")).toBe("idle");
});
it("returns active when 'esc to interrupt' is visible", () => {
expect(agent.detectActivity("Working... esc to interrupt\n")).toBe("active");
});
it("returns active when Thinking indicator is visible", () => {
expect(agent.detectActivity("Thinking...\n")).toBe("active");
});
it("returns active when Reading indicator is visible", () => {
expect(agent.detectActivity("Reading file src/index.ts\n")).toBe("active");
});
it("returns active when Writing indicator is visible", () => {
expect(agent.detectActivity("Writing to src/main.ts\n")).toBe("active");
});
it("returns active when Searching indicator is visible", () => {
expect(agent.detectActivity("Searching codebase...\n")).toBe("active");
});
it("returns waiting_input for permission prompt (Y/N)", () => {
expect(agent.detectActivity("Do you want to proceed? (Y)es / (N)o\n")).toBe("waiting_input");
});
it("returns waiting_input for 'Do you want to proceed?' prompt", () => {
expect(agent.detectActivity("Do you want to proceed?\n")).toBe("waiting_input");
});
it("returns waiting_input for bypass permissions prompt", () => {
expect(agent.detectActivity("bypass all future permissions for this session\n")).toBe(
"waiting_input",
);
});
it("does NOT match Claude's persistent UI footer 'bypass permissions on (shift+tab to cycle)'", () => {
// Regression test: the old `/bypass.*permissions/i` regex matched this
// footer toggle (visible on EVERY Claude session) and falsely fired
// waiting_input for every session that fell through to the AO JSONL
// pipeline. ao-143/144/151 all flipped to waiting_input on dormant
// sessions until this was tightened to require "all future".
const footerOnly = [
"✻ Crunched for 11s",
"",
"──────────────────────────────────────────────────────────",
" ",
"──────────────────────────────────────────────────────────",
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
].join("\n");
expect(agent.detectActivity(footerOnly)).not.toBe("waiting_input");
});
it("returns active when queued message indicator is visible", () => {
expect(agent.detectActivity("Press up to edit queued messages\n")).toBe("active");
});
it("returns idle when shell prompt is visible", () => {
expect(agent.detectActivity("some output\n> ")).toBe("idle");
expect(agent.detectActivity("some output\n$ ")).toBe("idle");
});
it("returns idle when prompt follows historical activity indicators", () => {
// Key regression test: historical "Reading file..." output in the buffer
// should NOT override an idle prompt on the last line.
expect(agent.detectActivity("Reading file src/index.ts\nWriting to out.ts\n ")).toBe("idle");
expect(agent.detectActivity("Thinking...\nSearching codebase...\n$ ")).toBe("idle");
});
it("returns waiting_input when permission prompt follows historical activity", () => {
// Permission prompt at the bottom should NOT be overridden by historical
// "Reading"/"Thinking" output higher in the buffer.
expect(
agent.detectActivity("Reading file src/index.ts\nThinking...\nDo you want to proceed?\n"),
).toBe("waiting_input");
expect(agent.detectActivity("Searching codebase...\n(Y)es / (N)o\n")).toBe("waiting_input");
expect(
agent.detectActivity("Writing to out.ts\nbypass all future permissions for this session\n"),
).toBe("waiting_input");
});
it("returns idle for non-empty output with no active-work indicators", () => {
// Default-to-idle (changed from default-to-active in this PR). Claude's
// tmux pane has a persistent input area + footer that looks identical
// between "just finished" and "currently working". Treating
// unrecognized output as active caused dormant sessions to get an
// "active" written to AO activity-JSONL every poll cycle, which the
// age-decayed fallback then surfaced as ready forever (ao-160 repro).
expect(agent.detectActivity("some random terminal output\n")).toBe("idle");
});
it("returns idle for dormant session showing only Claude's input area + footer", () => {
// Real captured output from a dormant session (ao-143 style): assistant
// output above, separator, empty prompt line, separator, footer toggle.
// The empty prompt is NOT the LAST line (footer is) so the existing
// lastLine check misses it, and previously the default-to-active sent
// every dormant session into the AO-JSONL active-loop.
const dormant = [
"※ recap: working on issue #143; next: wait for review",
"",
"──────────────────────────────────────────────────────────",
" ",
"──────────────────────────────────────────────────────────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(dormant)).toBe("idle");
});
it("returns active when spinner+ellipsis is in the tail (✻ Fluttering…)", () => {
// Real captured output from ao-161 mid-active-turn. The ✻ spinner
// followed by a verb and trailing ellipsis is the canonical Claude
// active indicator across all turn-status words (Germinating,
// Fluttering, Thinking, Pondering, etc).
const active = [
"✻ Fluttering… (6m 49s · ↓ 26.9k tokens)",
" ⎿ Tip: Use /feedback to help us improve!",
"",
"──────",
" ",
"──────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(active)).toBe("active");
});
it("returns idle for past-tense spinner status like '✻ Worked for 11s' (no ellipsis)", () => {
// Real captured output from ao-143 dormant. The ✻ glyph appears in
// past-tense turn summaries too — without the trailing ellipsis,
// Claude is done, not active.
const dormant = [
"⏺ Posted: https://github.com/owner/repo/pull/1#comment-1",
"",
"✻ Worked for 11s",
"",
"※ recap: working on issue #143; next: wait for review",
"──────",
" ",
"──────",
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
].join("\n");
expect(agent.detectActivity(dormant)).toBe("idle");
});
// Blocked detection from terminal regex — empirically captured from real
// Claude output during api.anthropic.com block (see PR #1932).
it("returns blocked for 'Unable to connect to API' error line", () => {
const real = [
" what is 2+2? answer in one word.",
" ⎿ Unable to connect to API (ConnectionRefused)",
" Retrying in 19s · attempt 7/10",
"",
"✽ Germinating… (56s)",
"",
].join("\n");
expect(agent.detectActivity(real)).toBe("blocked");
});
it("returns blocked for FailedToOpenSocket error variant", () => {
expect(
agent.detectActivity(" ⎿ Unable to connect to API (FailedToOpenSocket)\n"),
).toBe("blocked");
});
it("returns blocked for retry counter alone (Retrying in Ns · attempt N/M)", () => {
// If only the retry line is in the visible window (error scrolled off),
// the retry counter is still a sufficient signal.
expect(agent.detectActivity(" Retrying in 30s · attempt 9/10\n")).toBe("blocked");
});
it("does NOT return blocked when API error has scrolled out of the visible window after a successful retry", () => {
// Regression test: blocked detection must be bounded to the last 12
// lines (wideTail), NOT the full terminalOutput buffer. Otherwise an
// api_error that scrolled off the visible area after a successful
// retry but stayed in scrollback would falsely return "blocked"
// forever (Greptile review on PR #1932).
const recoveredAndContinued = [
" ⎿ Unable to connect to API (ConnectionRefused)",
" Retrying in 1s · attempt 1/10",
" ⎿ ✓ Connected, retry succeeded",
"",
"(many lines of work output below pushing the error off the visible area)",
...Array.from({ length: 15 }, (_, i) => ` line ${i + 1} of subsequent work`),
"",
"✻ Fluttering… (2m 14s)",
" ⎿ Tip: Use /feedback to help us improve!",
"",
"──────",
" ",
"──────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(recoveredAndContinued)).toBe("active");
});
it("blocked takes precedence over waiting_input when both 'bypass permissions' footer and api-error are present", () => {
// Claude's static UI footer always contains "bypass permissions on …",
// which the existing waiting_input regex matches. A real blocked state
// must win over that incidental match.
const real = [
" ⎿ Unable to connect to API (ConnectionRefused)",
" Retrying in 1s · attempt 5/10",
"",
"────────────────────────────────────────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(real)).toBe("blocked");
it.each([
"Working... esc to interrupt\n",
"Thinking...\n",
"Reading file src/index.ts\n",
"Writing to src/main.ts\n",
"Searching codebase...\n",
"Do you want to proceed? (Y)es / (N)o\n",
"bypass all future permissions for this session\n",
" ⎿ Unable to connect to API (ConnectionRefused)\n",
" Retrying in 19s · attempt 7/10\n",
"✻ Fluttering… (6m 49s · ↓ 26.9k tokens)\n",
"some random terminal output\n",
])("returns idle for ALL non-empty input (no terminal-regex active/waiting_input/blocked): %s", (input) => {
expect(agent.detectActivity(input)).toBe("idle");
});
});
@ -1182,6 +1005,269 @@ describe("hook setup — relative path (symlink-safe)", () => {
});
});
// =========================================================================
// setupWorkspaceHooks — activity-updater registration (#1941)
// =========================================================================
describe("setupWorkspaceHooks — activity-updater (#1941)", () => {
const agent = create();
function getParsedSettings(): Record<string, unknown> {
const settingsWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
expect(settingsWrite).toBeDefined();
return JSON.parse(settingsWrite![1] as string) as Record<string, unknown>;
}
/** Activity-updater command paths (unix vs win32) */
const ACTIVITY_CMD_UNIX = ".claude/activity-updater.sh";
const ACTIVITY_CMD_WIN = "node .claude/activity-updater.cjs";
/**
* Every Claude Code hook event the script knows how to translate into an
* activity state. The dashboard / lifecycle reducer relies on these firing
* so platform events replace terminal-output regex.
*/
const ACTIVITY_EVENTS = [
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PostToolBatch",
"Notification",
"PermissionRequest",
"Stop",
"StopFailure",
"SubagentStart",
"SubagentStop",
"PreCompact",
"PostCompact",
] as const;
it("writes the activity-updater script to .claude/", async () => {
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const scriptWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"),
);
expect(scriptWrite).toBeDefined();
expect(scriptWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT);
});
it("makes the activity-updater script executable on unix (chmod 0o755)", async () => {
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const chmodCall = mockChmod.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"),
);
expect(chmodCall).toBeDefined();
expect(chmodCall![1]).toBe(0o755);
});
it.each(ACTIVITY_EVENTS)(
"registers the activity-updater hook on %s",
async (event) => {
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const settings = getParsedSettings();
const hookGroup = (settings.hooks as Record<string, unknown>)[event] as Array<{
matcher: string;
hooks: Array<{ command: string; timeout?: number }>;
}>;
expect(hookGroup).toBeDefined();
const activity = hookGroup.flatMap((g) => g.hooks).find((h) => h.command === ACTIVITY_CMD_UNIX);
expect(activity).toBeDefined();
// The script does a single JSON parse + append — short timeout keeps a
// stuck hook from slowing the turn down.
expect(activity!.timeout).toBe(2000);
},
);
it("registers activity-updater PostToolUse alongside metadata-updater", async () => {
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const settings = getParsedSettings();
const postToolUse = (settings.hooks as Record<string, unknown>)["PostToolUse"] as Array<{
matcher: string;
hooks: Array<{ command: string }>;
}>;
expect(postToolUse.length).toBeGreaterThanOrEqual(2);
const metadataEntry = postToolUse.find((g) =>
g.hooks.some((h) => h.command.includes("metadata-updater")),
);
const activityEntry = postToolUse.find((g) =>
g.hooks.some((h) => h.command.includes("activity-updater")),
);
expect(metadataEntry).toBeDefined();
expect(metadataEntry!.matcher).toBe("Bash"); // unchanged from before #1941
expect(activityEntry).toBeDefined();
expect(activityEntry!.matcher).toBe(""); // fires on every PostToolUse, not just Bash
});
it("is idempotent — calling twice keeps exactly one activity-updater entry per event", async () => {
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const firstSettings = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
mockExistsSync.mockReturnValueOnce(true);
mockReadFile.mockResolvedValueOnce(firstSettings![1] as string);
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const settings = getParsedSettings();
for (const event of ACTIVITY_EVENTS) {
const hookGroup = (settings.hooks as Record<string, unknown>)[event] as Array<{
hooks: Array<{ command: string }>;
}>;
const activityHooks = hookGroup.flatMap((g) => g.hooks).filter(
(h) => h.command === ACTIVITY_CMD_UNIX,
);
expect(activityHooks).toHaveLength(1);
}
});
it("preserves a user-installed Stop hook when adding our activity-updater", async () => {
const existingSettings = {
hooks: {
Stop: [
{
matcher: "",
hooks: [{ type: "command", command: "echo user-hook", timeout: 1000 }],
},
],
},
};
mockExistsSync.mockReturnValueOnce(true);
mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings));
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const settings = getParsedSettings();
const stopGroup = (settings.hooks as Record<string, unknown>)["Stop"] as Array<{
hooks: Array<{ command: string }>;
}>;
const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command);
expect(commands).toContain("echo user-hook"); // user hook preserved
expect(commands).toContain(ACTIVITY_CMD_UNIX); // our hook added
});
it("tolerates malformed hooks.<event> (object instead of array)", async () => {
// A user could hand-edit settings.json or an older plugin could have
// written a non-array shape there. We must not crash — start fresh.
const malformed = {
hooks: {
// Object where an array is expected
Stop: { matcher: "", command: "broken" },
},
};
mockExistsSync.mockReturnValueOnce(true);
mockReadFile.mockResolvedValueOnce(JSON.stringify(malformed));
mockWriteFile.mockClear();
await expect(
agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig),
).resolves.not.toThrow();
const settings = getParsedSettings();
const stopGroup = (settings.hooks as Record<string, unknown>)["Stop"] as Array<{
hooks: Array<{ command: string }>;
}>;
expect(Array.isArray(stopGroup)).toBe(true);
const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command);
expect(commands).toContain(ACTIVITY_CMD_UNIX);
});
it("preserves matcher of an entry where user co-located their own def alongside ours", async () => {
// User has added their own hook def into the SAME { matcher, hooks: [...] }
// object that contains our activity-updater. If we naively reset
// entry.matcher to ours (""), the user's def starts firing on every
// PreToolUse event instead of only "Edit|Write".
const existingSettings = {
hooks: {
PreToolUse: [
{
matcher: "Edit|Write",
hooks: [
{ type: "command", command: ".claude/activity-updater.sh", timeout: 2000 },
{ type: "command", command: "echo user-edits-only", timeout: 1000 },
],
},
],
},
};
mockExistsSync.mockReturnValueOnce(true);
mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings));
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const settings = getParsedSettings();
const pre = (settings.hooks as Record<string, unknown>)["PreToolUse"] as Array<{
matcher: string;
hooks: Array<{ command: string }>;
}>;
const sharedEntry = pre.find((g) =>
g.hooks.some((h) => h.command === "echo user-edits-only"),
);
expect(sharedEntry).toBeDefined();
// Matcher must NOT be overwritten — user's hook keeps firing on "Edit|Write"
expect(sharedEntry!.matcher).toBe("Edit|Write");
// Both defs still present
expect(sharedEntry!.hooks.map((h) => h.command)).toEqual([
ACTIVITY_CMD_UNIX,
"echo user-edits-only",
]);
});
it("on Windows writes activity-updater.cjs (not .sh) and uses node invocation", async () => {
mockIsWindows.mockReturnValue(true);
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
const cjsWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"),
);
expect(cjsWrite).toBeDefined();
expect(cjsWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT_NODE);
const shWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"),
);
expect(shWrite).toBeUndefined();
const settings = getParsedSettings();
const stopGroup = (settings.hooks as Record<string, unknown>)["Stop"] as Array<{
hooks: Array<{ command: string }>;
}>;
expect(stopGroup.flatMap((g) => g.hooks).some((h) => h.command === ACTIVITY_CMD_WIN)).toBe(true);
mockIsWindows.mockReturnValue(false);
});
it("does not chmod on Windows (Windows uses extension for executability)", async () => {
mockIsWindows.mockReturnValue(true);
mockChmod.mockClear();
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
const chmodCalls = mockChmod.mock.calls.filter(
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"),
);
expect(chmodCalls).toHaveLength(0);
mockIsWindows.mockReturnValue(false);
});
});
// =========================================================================
// setupWorkspaceHooks on win32 — Node.js hook script
// =========================================================================

View File

@ -2,7 +2,6 @@ import {
shellEscape,
normalizeAgentPermissionMode,
isWindows,
recordTerminalActivity,
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
@ -385,6 +384,237 @@ process.stdout.write("{}\\n");
process.exit(0);
`;
// =============================================================================
// Activity Updater Hook Script
// =============================================================================
/**
* Bash hook script that translates Claude Code lifecycle hooks into AO activity
* JSONL entries. Registered on every event whose firing carries activity
* information (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
* PermissionRequest, Notification, Stop, SubagentStop, StopFailure, PreCompact,
* PostCompact, SubagentStart, PostToolBatch).
*
* Reads the JSON payload from stdin, parses `hook_event_name`, maps it to an
* activity state, and appends a single JSONL entry to
* `$CLAUDE_PROJECT_DIR/.ao/activity.jsonl` with `source: "hook"`.
*
* Notification is filtered by `notification_type` only `permission_prompt`
* and `idle_prompt` map to `waiting_input`; `auth_success`/`elicitation_*` etc.
* are skipped because they don't represent a stuck-on-the-user transition.
*
* The script always exits 0 (never blocks Claude). Unknown events exit
* silently. Exported for integration testing.
*/
export const ACTIVITY_UPDATER_SCRIPT = `#!/usr/bin/env bash
# Activity Updater Hook for Agent Orchestrator
#
# Records Claude Code lifecycle events to {workspace}/.ao/activity.jsonl so
# the dashboard / lifecycle reducer derives activity state from authoritative
# platform events instead of regex over rendered terminal output. (#1941)
set -uo pipefail
input=$(cat)
if command -v jq &>/dev/null; then
event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty')
notif_type=$(printf '%s' "$input" | jq -r '.notification_type // empty')
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty')
error_type=$(printf '%s' "$input" | jq -r '.error_type // empty')
else
event=$(printf '%s' "$input" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
notif_type=$(printf '%s' "$input" | grep -o '"notification_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
tool_name=$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
error_type=$(printf '%s' "$input" | grep -o '"error_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
fi
state=""
trigger=""
case "$event" in
SessionStart|Stop|SubagentStop)
state="ready"
trigger="$event"
;;
UserPromptSubmit|PreToolUse|PostToolUse|PostToolUseFailure|PreCompact|PostCompact|SubagentStart|PostToolBatch)
state="active"
trigger="$event"
;;
PermissionRequest)
state="waiting_input"
if [[ -n "$tool_name" ]]; then
trigger="PermissionRequest ($tool_name)"
else
trigger="PermissionRequest"
fi
;;
Notification)
if [[ "$notif_type" == "permission_prompt" || "$notif_type" == "idle_prompt" ]]; then
state="waiting_input"
trigger="Notification ($notif_type)"
else
# auth_success / elicitation_* / unrecognized not an activity transition
echo '{}'
exit 0
fi
;;
StopFailure)
state="blocked"
if [[ -n "$error_type" ]]; then
trigger="StopFailure ($error_type)"
else
trigger="StopFailure"
fi
;;
*)
echo '{}'
exit 0
;;
esac
workspace="\${CLAUDE_PROJECT_DIR:-$(pwd)}"
log_dir="$workspace/.ao"
log_file="$log_dir/activity.jsonl"
mkdir -p "$log_dir" 2>/dev/null || { echo '{}'; exit 0; }
# Node is a hard runtime dep of Claude Code, so node -p is always available
# and gives millisecond-precision ISO timestamps matching the rest of the
# activity-JSONL log. Fall back to seconds-precision date for the unlikely
# case where node is unavailable (still valid ISO 8601).
ts=$(node -p 'new Date().toISOString()' 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
# Escape JSON-special characters in the trigger value. Triggers are bounded
# today to event/tool/error names (no control chars in practice) but escape
# defensively \\ and " for content, plus the five common control chars
# (\\n \\r \\t \\b \\f) so the JSONL line stays parseable for any future
# trigger source. Matches what Node's JSON.stringify produces in the .cjs
# variant so both implementations stay in lockstep.
escape_json() {
local s="$1"
s="\${s//\\\\/\\\\\\\\}"
s="\${s//\\"/\\\\\\"}"
s="\${s//$'\\n'/\\\\n}"
s="\${s//$'\\r'/\\\\r}"
s="\${s//$'\\t'/\\\\t}"
s="\${s//$'\\b'/\\\\b}"
s="\${s//$'\\f'/\\\\f}"
printf '%s' "$s"
}
if [[ "$state" == "waiting_input" || "$state" == "blocked" ]]; then
esc_trigger=$(escape_json "$trigger")
printf '{"ts":"%s","state":"%s","source":"hook","trigger":"%s"}\\n' "$ts" "$state" "$esc_trigger" >> "$log_file"
else
printf '{"ts":"%s","state":"%s","source":"hook"}\\n' "$ts" "$state" >> "$log_file"
fi
echo '{}'
exit 0
`;
/**
* Node.js equivalent of ACTIVITY_UPDATER_SCRIPT for Windows. No bash, no jq,
* no shebang interpretation; relies only on Node built-ins. Exported for
* testing.
*/
export const ACTIVITY_UPDATER_SCRIPT_NODE = `#!/usr/bin/env node
// Activity Updater Hook for Agent Orchestrator (Node.js — Windows). See
// ACTIVITY_UPDATER_SCRIPT for the canonical bash version. (#1941)
const { appendFileSync, mkdirSync, readFileSync } = require("node:fs");
const { join } = require("node:path");
let inputRaw = "";
try {
inputRaw = readFileSync(0, "utf-8");
} catch {
process.stdout.write("{}\\n");
process.exit(0);
}
let payload;
try {
payload = JSON.parse(inputRaw || "{}");
} catch {
process.stdout.write("{}\\n");
process.exit(0);
}
const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
const notifType = typeof payload.notification_type === "string" ? payload.notification_type : "";
const toolName = typeof payload.tool_name === "string" ? payload.tool_name : "";
const errorType = typeof payload.error_type === "string" ? payload.error_type : "";
let state = "";
let trigger = "";
switch (event) {
case "SessionStart":
case "Stop":
case "SubagentStop":
state = "ready";
trigger = event;
break;
case "UserPromptSubmit":
case "PreToolUse":
case "PostToolUse":
case "PostToolUseFailure":
case "PreCompact":
case "PostCompact":
case "SubagentStart":
case "PostToolBatch":
state = "active";
trigger = event;
break;
case "PermissionRequest":
state = "waiting_input";
trigger = toolName ? \`PermissionRequest (\${toolName})\` : "PermissionRequest";
break;
case "Notification":
if (notifType === "permission_prompt" || notifType === "idle_prompt") {
state = "waiting_input";
trigger = \`Notification (\${notifType})\`;
} else {
process.stdout.write("{}\\n");
process.exit(0);
}
break;
case "StopFailure":
state = "blocked";
trigger = errorType ? \`StopFailure (\${errorType})\` : "StopFailure";
break;
default:
process.stdout.write("{}\\n");
process.exit(0);
}
const workspace = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const logDir = join(workspace, ".ao");
const logFile = join(logDir, "activity.jsonl");
try {
mkdirSync(logDir, { recursive: true });
} catch {
process.stdout.write("{}\\n");
process.exit(0);
}
const ts = new Date().toISOString();
const entry =
state === "waiting_input" || state === "blocked"
? { ts, state, source: "hook", trigger }
: { ts, state, source: "hook" };
try {
appendFileSync(logFile, JSON.stringify(entry) + "\\n", "utf-8");
} catch {
// Best-effort — never block Claude on log append failure
}
process.stdout.write("{}\\n");
process.exit(0);
`;
// =============================================================================
// Plugin Manifest
// =============================================================================
@ -567,41 +797,188 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined {
// =============================================================================
/**
* Shared helper to setup PostToolUse hooks in a workspace.
* Writes metadata-updater.sh script and updates settings.json.
* Single hook registration: which event, which variant (matcher), which
* command to invoke, and a substring used to find-and-update an existing
* entry so repeated setup calls are idempotent.
*/
interface HookRegistration {
event: string;
matcher: string;
command: string;
timeout: number;
/** Substring(s) of `command` that identify a pre-existing entry to update. */
identifiers: ReadonlyArray<string>;
}
/**
* Set the registration's hook in the `event`'s hook array, updating any
* existing entry whose command contains one of `identifiers` (idempotent).
*
* @param workspacePath - Path to the workspace directory
* @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR)
* Tolerates malformed pre-existing settings: if `hooks[event]` is not an
* array (object, string, missing) we start a fresh array rather than
* throwing on `.push`.
*
* Only refreshes the entry-level `matcher` when the entry contains a single
* hook def (ours). When a user has co-located their own hook def in the
* same `{ matcher, hooks: [...] }` object, we leave their matcher alone and
* only update our def's `command`/`timeout` so their hook keeps firing on
* the matchers they chose.
*/
function upsertHookEntry(
hooks: Record<string, unknown>,
reg: HookRegistration,
): void {
const existing = hooks[reg.event];
const entries: Array<unknown> = Array.isArray(existing) ? existing : [];
let foundEntryIdx = -1;
let foundDefIdx = -1;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
const hooksList = (entry as Record<string, unknown>)["hooks"];
if (!Array.isArray(hooksList)) continue;
for (let j = 0; j < hooksList.length; j++) {
const def = hooksList[j];
if (typeof def !== "object" || def === null || Array.isArray(def)) continue;
const cmd = (def as Record<string, unknown>)["command"];
if (typeof cmd === "string" && reg.identifiers.some((id) => cmd.includes(id))) {
foundEntryIdx = i;
foundDefIdx = j;
break;
}
}
if (foundEntryIdx >= 0) break;
}
if (foundEntryIdx === -1) {
entries.push({
matcher: reg.matcher,
hooks: [{ type: "command", command: reg.command, timeout: reg.timeout }],
});
} else {
const entry = entries[foundEntryIdx] as Record<string, unknown>;
const hooksList = entry["hooks"] as Array<Record<string, unknown>>;
hooksList[foundDefIdx]!["command"] = reg.command;
hooksList[foundDefIdx]!["timeout"] = reg.timeout;
// Only refresh the matcher when the entry is clearly owned by AO
// (single hook def == ours). With multiple defs the entry is shared
// with a user hook; changing the matcher would change when their hook
// fires.
if (hooksList.length === 1) {
entry["matcher"] = reg.matcher;
}
}
hooks[reg.event] = entries;
}
/**
* Build the list of hooks to register for this workspace. Two scripts are
* installed:
* - metadata-updater: PostToolUse(Bash) only extracts gh/git side-effects.
* - activity-updater: every event that carries activity information, so
* dashboard / lifecycle reducer state derives from platform events
* instead of regex over rendered terminal output (#1941).
*
* Activity events use matcher "" match every variant. PermissionRequest's
* tool-name and Notification's notification_type are filtered inside the
* script itself so the registered set stays small.
*/
function buildHookRegistrations(
metadataCommand: string,
activityCommand: string,
): HookRegistration[] {
const METADATA_IDS = [
"metadata-updater.sh",
"metadata-updater.cjs",
"metadata-updater.js",
] as const;
const ACTIVITY_IDS = ["activity-updater.sh", "activity-updater.cjs"] as const;
const regs: HookRegistration[] = [
{
event: "PostToolUse",
matcher: "Bash",
command: metadataCommand,
timeout: 5000,
identifiers: METADATA_IDS,
},
];
// Activity-updater events. Every event that the activity-updater script
// knows how to map (see ACTIVITY_UPDATER_SCRIPT) must be registered here;
// unregistered events fire no hook, so unrecognized hooks waste no time.
const activityEvents = [
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PostToolBatch",
"Notification",
"PermissionRequest",
"Stop",
"StopFailure",
"SubagentStart",
"SubagentStop",
"PreCompact",
"PostCompact",
];
for (const event of activityEvents) {
regs.push({
event,
matcher: "",
command: activityCommand,
// Hook execution is best-effort and the activity-updater is intentionally
// O(few ms): JSON parse, one append, exit. A short timeout keeps a stuck
// hook from slowing a turn down.
timeout: 2000,
identifiers: ACTIVITY_IDS,
});
}
return regs;
}
/**
* Install Claude Code workspace hooks. Writes both helper scripts
* (metadata-updater + activity-updater) and merges hook registrations into
* `.claude/settings.json` preserving any user-installed hooks, updating our
* own in place on repeated calls.
*/
async function setupHookInWorkspace(workspacePath: string): Promise<void> {
const claudeDir = join(workspacePath, ".claude");
const settingsPath = join(claudeDir, "settings.json");
// Create .claude directory if it doesn't exist
try {
await mkdir(claudeDir, { recursive: true });
} catch {
// Directory might already exist
// Directory may already exist; ignore
}
// On Windows: write a Node.js hook script, skip chmod (not needed).
// On Unix: write the bash hook script and make it executable.
let hookCommand: string;
let metadataCommand: string;
let activityCommand: string;
if (isWindows()) {
const hookScriptPath = join(claudeDir, "metadata-updater.cjs");
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8");
// No chmod — Windows uses file extension for executability
// Use `node` to invoke the script (Windows won't run .js via shebang)
// Use .cjs extension to force CJS mode regardless of workspace package.json "type" field
hookCommand = "node .claude/metadata-updater.cjs";
const metadataPath = join(claudeDir, "metadata-updater.cjs");
const activityPath = join(claudeDir, "activity-updater.cjs");
await writeFile(metadataPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8");
await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8");
// .cjs forces CJS regardless of workspace package.json "type"; node
// invocation is required on Windows because shebangs aren't honoured.
metadataCommand = "node .claude/metadata-updater.cjs";
activityCommand = "node .claude/activity-updater.cjs";
} else {
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
await chmod(hookScriptPath, 0o755); // Make executable
hookCommand = ".claude/metadata-updater.sh";
const metadataPath = join(claudeDir, "metadata-updater.sh");
const activityPath = join(claudeDir, "activity-updater.sh");
await writeFile(metadataPath, METADATA_UPDATER_SCRIPT, "utf-8");
await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT, "utf-8");
await chmod(metadataPath, 0o755);
await chmod(activityPath, 0o755);
metadataCommand = ".claude/metadata-updater.sh";
activityCommand = ".claude/activity-updater.sh";
}
// Read existing settings if present
let existingSettings: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
@ -612,61 +989,12 @@ async function setupHookInWorkspace(workspacePath: string): Promise<void> {
}
}
// Merge hooks configuration
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
// Check if our hook is already configured
let hookIndex = -1;
let hookDefIndex = -1;
for (let i = 0; i < postToolUse.length; i++) {
const hook = postToolUse[i];
if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue;
const h = hook as Record<string, unknown>;
const hooksList = h["hooks"];
if (!Array.isArray(hooksList)) continue;
for (let j = 0; j < hooksList.length; j++) {
const hDef = hooksList[j];
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
const def = hDef as Record<string, unknown>;
if (
typeof def["command"] === "string" &&
(def["command"].includes("metadata-updater.sh") ||
def["command"].includes("metadata-updater.js") ||
def["command"].includes("metadata-updater.cjs"))
) {
hookIndex = i;
hookDefIndex = j;
break;
for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) {
upsertHookEntry(hooks, reg);
}
}
if (hookIndex >= 0) break;
}
// Add or update our hook
if (hookIndex === -1) {
// No metadata hook exists, add it
postToolUse.push({
matcher: "Bash",
hooks: [
{
type: "command",
command: hookCommand,
timeout: 5000,
},
],
});
} else {
// Hook exists, update the command
const hook = postToolUse[hookIndex] as Record<string, unknown>;
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
hooksList[hookDefIndex]["command"] = hookCommand;
}
hooks["PostToolUse"] = postToolUse;
existingSettings["hooks"] = hooks;
// Write updated settings
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
}
@ -741,15 +1069,28 @@ function createClaudeCodeAgent(): Agent {
},
detectActivity(terminalOutput: string): ActivityState {
// #1941: Claude activity is derived from platform-event hooks
// (PermissionRequest / StopFailure / Notification / Stop / ...) which
// write directly to {workspace}/.ao/activity.jsonl. The terminal-regex
// layer was structurally fragile (every UI tweak in Claude regressed
// it; see the 15-commit churn in #1932) so it has been retired in
// favour of those authoritative events.
//
// detectActivity is kept on the Agent interface for other plugins
// (Aider, OpenCode, Codex fallback) that still rely on terminal output.
// For Claude, classifyTerminalOutput is a stable "idle" stub — the
// lifecycle manager only consults this method when getActivityState
// returned null (no Claude process / no JSONL / no hook entry yet),
// and in that no-signal case "idle" is the correct conservative
// answer (we don't write it back to JSONL — recordActivity is also
// intentionally omitted for Claude).
return classifyTerminalOutput(terminalOutput);
},
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
if (!session.workspacePath) return;
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) =>
this.detectActivity(output),
);
},
// recordActivity is intentionally NOT implemented for the Claude agent
// (#1941). Hooks write activity entries directly via the activity-updater
// script, so polling-driven terminal-output classification would only add
// stale duplicates to .ao/activity.jsonl.
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
return isClaudeProcessAlive(handle);

View File

@ -1,5 +1,27 @@
# @aoagents/ao-plugin-agent-codex
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Minor Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-agent-codex",
"version": "0.8.0",
"version": "0.9.1",
"description": "Agent plugin: OpenAI Codex CLI",
"license": "MIT",
"type": "module",

View File

@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as Readline from "node:readline";
import {
createActivitySignal,
type Session,
@ -21,6 +22,7 @@ const {
mockLstat,
mockOpen,
mockCreateReadStream,
mockCreateInterface,
mockHomedir,
mockReadLastJsonlEntry,
mockIsWindows,
@ -35,6 +37,7 @@ const {
mockLstat: vi.fn(),
mockOpen: vi.fn(),
mockCreateReadStream: vi.fn(),
mockCreateInterface: vi.fn(),
mockHomedir: vi.fn(() => "/mock/home"),
mockReadLastJsonlEntry: vi.fn(),
mockIsWindows: vi.fn(() => false),
@ -67,6 +70,17 @@ vi.mock("node:fs", () => ({
createReadStream: mockCreateReadStream,
}));
vi.mock("node:readline", async (importOriginal) => {
const actual = await importOriginal<typeof Readline>();
mockCreateInterface.mockImplementation((...args: Parameters<typeof actual.createInterface>) =>
actual.createInterface(...args),
);
return {
...actual,
createInterface: mockCreateInterface,
};
});
vi.mock("node:os", () => ({
homedir: mockHomedir,
}));
@ -677,6 +691,57 @@ describe("getActivityState", () => {
expect(await agent.getActivityState(session)).toBeNull();
});
it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => {
mockTmuxWithProcess("codex");
mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl"]);
const modifiedAt = new Date();
mockReadLastJsonlEntry.mockResolvedValue({
lastType: "assistant_message",
modifiedAt,
});
const session = makeSession({
runtimeHandle: makeTmuxHandle(),
workspacePath: "/workspace/test",
metadata: { codexThreadId: "thread-fast-activity" },
});
const result = await agent.getActivityState(session);
expect(result?.state).toBe("ready");
expect(mockReadLastJsonlEntry).toHaveBeenCalledWith(
pathJoin(
"/mock/home",
".codex",
"sessions",
"rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl",
),
);
expect(mockOpen).not.toHaveBeenCalled();
});
it("falls back to cwd-prefix scanning when codexThreadId lookup misses", async () => {
mockTmuxWithProcess("codex");
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
mockReaddir.mockResolvedValue(["rollout-other-thread.jsonl"]);
setupMockOpen(content);
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
mockReadLastJsonlEntry.mockResolvedValue({
lastType: "assistant_message",
modifiedAt: new Date(),
});
const session = makeSession({
runtimeHandle: makeTmuxHandle(),
workspacePath: "/workspace/test",
metadata: { codexThreadId: "missing-thread" },
});
const result = await agent.getActivityState(session);
expect(result?.state).toBe("ready");
expect(mockReaddir).toHaveBeenCalledTimes(1);
expect(mockOpen).toHaveBeenCalled();
});
it("returns active when session file was recently modified", async () => {
mockTmuxWithProcess("codex");
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
@ -980,6 +1045,121 @@ describe("getSessionInfo", () => {
expect(await agent.getSessionInfo(makeSession())).toBeNull();
});
it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => {
const sessionContent = jsonl(
{
type: "session_meta",
payload: {
id: "thread-fast-info",
},
},
{
type: "turn_context",
payload: {
model: "gpt-5.5",
},
},
);
mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-info.jsonl"]);
setupMockStream(sessionContent);
const result = await agent.getSessionInfo(
makeSession({
workspacePath: "/workspace/test",
metadata: { codexThreadId: "thread-fast-info" },
}),
);
expect(result).not.toBeNull();
expect(result!.agentSessionId).toBe("rollout-2026-05-22T00-00-00-thread-fast-info");
expect(result!.summary).toBe("Codex session (gpt-5.5)");
expect(mockOpen).not.toHaveBeenCalled();
});
it("caches codexThreadId filename lookups by thread id", async () => {
const sessionContent = jsonl({
type: "session_meta",
payload: {
id: "thread-cached-info",
},
});
mockReaddir.mockResolvedValue(["rollout-thread-cached-info.jsonl"]);
mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent));
const first = await agent.getSessionInfo(
makeSession({
workspacePath: "/workspace/first",
metadata: { codexThreadId: "thread-cached-info" },
}),
);
const second = await agent.getSessionInfo(
makeSession({
workspacePath: "/workspace/second",
metadata: { codexThreadId: "thread-cached-info" },
}),
);
expect(first).not.toBeNull();
expect(second).not.toBeNull();
expect(mockReaddir).toHaveBeenCalledTimes(1);
expect(mockOpen).not.toHaveBeenCalled();
});
it("chooses the newest duplicate codexThreadId filename match by mtime", async () => {
const oldContent = jsonl({
type: "session_meta",
payload: { id: "thread-dupe", model: "old-model" },
});
const newContent = jsonl({
type: "session_meta",
payload: { id: "thread-dupe", model: "new-model" },
});
mockReaddir.mockResolvedValue([
"rollout-old-thread-dupe.jsonl",
"rollout-new-thread-dupe.jsonl",
]);
mockStat.mockImplementation((path: string) => {
if (path.includes("rollout-old-thread-dupe")) return Promise.resolve({ mtimeMs: 1000 });
if (path.includes("rollout-new-thread-dupe")) return Promise.resolve({ mtimeMs: 2000 });
return Promise.reject(new Error("ENOENT"));
});
mockCreateReadStream.mockImplementation((path: string) => {
if (path.includes("rollout-old-thread-dupe")) return makeContentStream(oldContent);
if (path.includes("rollout-new-thread-dupe")) return makeContentStream(newContent);
return makeContentStream("");
});
const result = await agent.getSessionInfo(
makeSession({
workspacePath: "/workspace/test",
metadata: { codexThreadId: "thread-dupe" },
}),
);
expect(result).not.toBeNull();
expect(result!.agentSessionId).toBe("rollout-new-thread-dupe");
expect(result!.summary).toBe("Codex session (new-model)");
expect(mockOpen).not.toHaveBeenCalled();
});
it("does not treat infix filename matches as thread-id hits", async () => {
mockReaddir.mockResolvedValue(["rollout-thread-fast-extra.jsonl"]);
const result = await agent.getSessionInfo(
makeSession({
workspacePath: null,
metadata: { codexThreadId: "fast" },
}),
);
expect(result).toBeNull();
expect(mockOpen).not.toHaveBeenCalled();
expect(mockCreateReadStream).not.toHaveBeenCalled();
});
it("returns null when no session files match the workspace cwd", async () => {
mockReaddir.mockResolvedValue(["session-abc.jsonl"]);
const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" });
@ -990,6 +1170,25 @@ describe("getSessionInfo", () => {
).toBeNull();
});
it("falls back to cwd-prefix scanning when codexThreadId is absent", async () => {
const sessionContent = jsonl({
type: "session_meta",
cwd: "/workspace/test",
model: "gpt-5.4",
});
mockReaddir.mockResolvedValue(["rollout-cwd-fallback.jsonl"]);
setupMockOpen(sessionContent);
setupMockStream(sessionContent);
mockStat.mockResolvedValue({ mtimeMs: 1000 });
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
expect(result).not.toBeNull();
expect(result!.summary).toBe("Codex session (gpt-5.4)");
expect(mockOpen).toHaveBeenCalled();
});
it("returns session info with cost and model when matching session found", async () => {
const sessionContent = jsonl(
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
@ -1212,6 +1411,31 @@ describe("getSessionInfo", () => {
).toBeNull();
});
it("closes readline and destroys the stream when JSONL streaming is interrupted", async () => {
const content = jsonl({ type: "session_meta", cwd: "/workspace/test" });
mockReaddir.mockResolvedValue(["sess.jsonl"]);
setupMockOpen(content);
mockStat.mockResolvedValue({ mtimeMs: 1000 });
const stream = makeContentStream(content);
const destroySpy = vi.spyOn(stream, "destroy");
const closeSpy = vi.fn();
mockCreateReadStream.mockReturnValue(stream);
mockCreateInterface.mockImplementationOnce(() => ({
close: closeSpy,
async *[Symbol.asyncIterator]() {
yield JSON.stringify({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
throw new Error("aborted");
},
}));
expect(
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
).toBeNull();
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
it("skips session files when stat throws", async () => {
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
mockReaddir.mockResolvedValue(["sess.jsonl"]);
@ -1371,6 +1595,7 @@ describe("getRestoreCommand", () => {
expect(cmd).toContain("--model 'gpt-5.3-codex'");
expect(cmd).toContain("persisted-thread");
expect(mockReaddir).not.toHaveBeenCalled();
expect(mockOpen).not.toHaveBeenCalled();
});
it("builds native resume command from payload-wrapped Codex session id", async () => {

View File

@ -229,8 +229,11 @@ async function sessionFileMatchesCwd(filePath: string, workspacePath: string): P
* Recursively scans ~/.codex/sessions/ (date-sharded: YYYY/MM/DD/rollout-*.jsonl).
* Returns the path to the most recently modified matching file, or null.
*/
async function findCodexSessionFile(workspacePath: string): Promise<string | null> {
const jsonlFiles = await collectJsonlFiles(CODEX_SESSIONS_DIR);
async function findCodexSessionFile(
workspacePath: string,
jsonlFiles?: string[],
): Promise<string | null> {
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
if (jsonlFiles.length === 0) return null;
let bestMatch: { path: string; mtime: number } | null = null;
@ -252,6 +255,39 @@ async function findCodexSessionFile(workspacePath: string): Promise<string | nul
return bestMatch?.path ?? null;
}
/**
* Find a Codex session file by persisted native thread id. Codex rollout
* filenames include the thread id, so this path only inspects filenames and
* avoids opening historical JSONL files to match session_meta.cwd.
*/
async function findCodexSessionFileByThreadId(
threadId: string,
jsonlFiles?: string[],
): Promise<string | null> {
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
const matches = jsonlFiles.filter((filePath) =>
basename(filePath).endsWith(`-${threadId}.jsonl`),
);
if (matches.length === 0) return null;
if (matches.length === 1) return matches[0] ?? null;
let bestMatch: { path: string; mtime: number } | null = null;
let fallback: string | null = null;
for (const filePath of matches) {
fallback ??= filePath;
try {
const s = await stat(filePath);
if (!bestMatch || s.mtimeMs > bestMatch.mtime) {
bestMatch = { path: filePath, mtime: s.mtimeMs };
}
} catch {
// Keep a filename match as fallback; thread id in the filename is enough.
}
}
return bestMatch?.path ?? fallback;
}
/** Aggregated data extracted from a Codex session file via streaming */
interface CodexSessionData {
model: string | null;
@ -268,6 +304,9 @@ interface CodexSessionData {
* into memory. This is critical because Codex rollout files can be 100 MB+.
*/
async function streamCodexSessionData(filePath: string): Promise<CodexSessionData | null> {
let stream: ReturnType<typeof createReadStream> | null = null;
let rl: ReturnType<typeof createInterface> | null = null;
try {
const data: CodexSessionData = {
model: null,
@ -277,8 +316,9 @@ async function streamCodexSessionData(filePath: string): Promise<CodexSessionDat
cachedTokens: 0,
reasoningTokens: 0,
};
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf-8" }),
stream = createReadStream(filePath, { encoding: "utf-8" });
rl = createInterface({
input: stream,
crlfDelay: Infinity,
});
@ -352,6 +392,9 @@ async function streamCodexSessionData(filePath: string): Promise<CodexSessionDat
return data;
} catch {
return null;
} finally {
rl?.close();
stream?.destroy();
}
}
@ -492,23 +535,52 @@ function appendNoUpdateCheckFlag(parts: string[]): void {
const SESSION_FILE_CACHE_TTL_MS = 30_000;
/** Module-level session file cache shared across the agent instance lifetime.
* Keyed by workspace path, stores the resolved file path and an expiry timestamp. */
* Keyed by Codex thread id when available, otherwise workspace path. */
const sessionFileCache = new Map<string, { path: string | null; expiry: number }>();
/** Find session file with caching to avoid double scans per refresh cycle */
async function findCodexSessionFileCached(workspacePath: string): Promise<string | null> {
const cached = sessionFileCache.get(workspacePath);
function getSessionMetadataString(session: Session, key: string): string | null {
const value = session.metadata?.[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
async function getCachedSessionFile(
cacheKey: string,
resolve: () => Promise<string | null>,
): Promise<string | null> {
const cached = sessionFileCache.get(cacheKey);
if (cached && Date.now() < cached.expiry) {
return cached.path;
}
const result = await findCodexSessionFile(workspacePath);
sessionFileCache.set(workspacePath, {
const result = await resolve();
sessionFileCache.set(cacheKey, {
path: result,
expiry: Date.now() + SESSION_FILE_CACHE_TTL_MS,
});
return result;
}
/** Find session file with caching to avoid double scans per refresh cycle */
async function findCodexSessionFileCached(session: Session): Promise<string | null> {
let jsonlFiles: string[] | null = null;
const getJsonlFiles = async (): Promise<string[]> => {
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
return jsonlFiles;
};
const threadId = getSessionMetadataString(session, "codexThreadId");
if (threadId) {
const byThreadId = await getCachedSessionFile(`thread:${threadId}`, async () =>
findCodexSessionFileByThreadId(threadId, await getJsonlFiles()),
);
if (byThreadId) return byThreadId;
}
if (!session.workspacePath) return null;
return getCachedSessionFile(`cwd:${toComparablePath(session.workspacePath)}`, async () =>
findCodexSessionFile(session.workspacePath!, await getJsonlFiles()),
);
}
/**
* Format a launch command for the host shell. On Windows the resolved binary
* path is single-quoted by shellEscape (e.g. `'C:\Users\...\codex.cmd'`), and
@ -604,11 +676,13 @@ function createCodexAgent(): Agent {
if (running === PROCESS_PROBE_INDETERMINATE) return null;
if (!running) return { state: "exited", timestamp: exitedAt };
if (!session.workspacePath) return null;
if (!session.workspacePath && !getSessionMetadataString(session, "codexThreadId")) {
return null;
}
// 1. Try Codex's native JSONL first — it has richer 6-state detection
// (approval_request, error, tool_call, etc.) that terminal parsing can't match.
const sessionFile = await findCodexSessionFileCached(session.workspacePath);
const sessionFile = await findCodexSessionFileCached(session);
if (sessionFile) {
const entry = await readLastJsonlEntry(sessionFile);
if (entry) {
@ -669,7 +743,9 @@ function createCodexAgent(): Agent {
// 2. Fallback: check AO activity JSONL (terminal-derived) for waiting_input/blocked
// that the native JSONL may not have captured.
const activityResult = await readLastActivityEntry(session.workspacePath);
const activityResult = session.workspacePath
? await readLastActivityEntry(session.workspacePath)
: null;
const activityState = checkActivityLogState(activityResult);
if (activityState) return activityState;
@ -758,9 +834,7 @@ function createCodexAgent(): Agent {
},
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
if (!session.workspacePath) return null;
const sessionFile = await findCodexSessionFileCached(session.workspacePath);
const sessionFile = await findCodexSessionFileCached(session);
if (!sessionFile) return null;
// Stream the file line-by-line to avoid loading potentially huge
@ -799,13 +873,13 @@ function createCodexAgent(): Agent {
},
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
let threadId = session.metadata?.["codexThreadId"]?.trim();
let model: string | null = session.metadata?.["codexModel"]?.trim() || null;
let threadId = getSessionMetadataString(session, "codexThreadId");
let model: string | null = getSessionMetadataString(session, "codexModel");
if (!threadId) {
if (!session.workspacePath) return null;
// Find the Codex session file for this workspace
const sessionFile = await findCodexSessionFileCached(session.workspacePath);
const sessionFile = await findCodexSessionFileCached(session);
if (!sessionFile) return null;
// Stream the file line-by-line to avoid loading potentially huge
@ -833,7 +907,10 @@ function createCodexAgent(): Agent {
return formatLaunchCommand(parts);
},
async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
async setupWorkspaceHooks(
_workspacePath: string,
_config: WorkspaceHooksConfig,
): Promise<void> {
// PATH wrappers are installed by session-manager for all agents.
},

View File

@ -1,5 +1,27 @@
# @aoagents/ao-plugin-agent-cursor
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Patch Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-agent-cursor",
"version": "0.8.0",
"version": "0.9.1",
"description": "Agent plugin: Cursor CLI",
"license": "MIT",
"type": "module",

View File

@ -0,0 +1,23 @@
# @aoagents/ao-plugin-agent-grok
## 0.1.2
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0

View File

@ -0,0 +1,46 @@
{
"name": "@aoagents/ao-plugin-agent-grok",
"version": "0.1.2",
"description": "Agent plugin: Grok",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/ComposioHQ/agent-orchestrator.git",
"directory": "packages/plugins/agent-grok"
},
"homepage": "https://github.com/ComposioHQ/agent-orchestrator",
"bugs": {
"url": "https://github.com/ComposioHQ/agent-orchestrator/issues"
},
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@aoagents/ao-core": "workspace:*",
"which": "^6.0.1"
},
"devDependencies": {
"@types/which": "^3.0.4",
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,519 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AgentLaunchConfig, RuntimeHandle, Session } from "@aoagents/ao-core";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const packageJson = require("../../package.json") as {
name: string;
version: string;
description: string;
};
const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-";
const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX)
? packageJson.name.slice(PACKAGE_NAME_PREFIX.length)
: packageJson.name;
const {
mockReadLastActivityEntry,
mockRecordTerminalActivity,
mockSetupPathWrapperWorkspace,
mockExecFileAsync,
mockWhichSync,
mockIsWindows,
} = vi.hoisted(() => ({
mockReadLastActivityEntry: vi.fn().mockResolvedValue(null),
mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined),
mockSetupPathWrapperWorkspace: vi.fn().mockResolvedValue(undefined),
mockExecFileAsync: vi.fn(),
mockWhichSync: vi.fn(),
mockIsWindows: vi.fn(() => false),
}));
vi.mock("@aoagents/ao-core", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
readLastActivityEntry: mockReadLastActivityEntry,
recordTerminalActivity: mockRecordTerminalActivity,
setupPathWrapperWorkspace: mockSetupPathWrapperWorkspace,
isWindows: mockIsWindows,
};
});
vi.mock("which", () => ({
default: {
sync: mockWhichSync,
},
sync: mockWhichSync,
}));
vi.mock("node:child_process", () => ({
execFile: (...args: unknown[]) => {
const callback = args[args.length - 1];
const result = mockExecFileAsync(...args.slice(0, -1));
if (typeof callback === "function" && result && typeof result.then === "function") {
result.then(
(value: { stdout: string; stderr: string }) => callback(null, value),
(err: Error) => callback(err),
);
}
},
}));
import { create, detect, manifest, default as defaultExport } from "../index.js";
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "test-1",
projectId: "test-project",
status: "working",
activity: "active",
activitySignal: {
state: "valid",
activity: "active",
timestamp: new Date(),
source: "runtime",
},
lifecycle: {} as Session["lifecycle"],
branch: "feat/test",
issueId: null,
pr: null,
workspacePath: "/workspace/test",
runtimeHandle: null,
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
};
}
function makeTmuxHandle(id = "test-session"): RuntimeHandle {
return { id, runtimeName: "tmux", data: {} };
}
function makeProcessHandle(pid?: number | string): RuntimeHandle {
return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} };
}
function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): AgentLaunchConfig {
return {
sessionId: "sess-1",
projectConfig: {
name: "my-project",
repo: "owner/repo",
path: "/workspace/repo",
defaultBranch: "main",
sessionPrefix: "my",
agentConfig: {
model: "grok-4.1-fast",
},
},
...overrides,
};
}
function makeActivityResult(
state: "active" | "ready" | "idle" | "waiting_input" | "blocked",
ts: Date,
): {
entry: {
state: "active" | "ready" | "idle" | "waiting_input" | "blocked";
ts: string;
source: string;
};
modifiedAt: Date;
} {
return {
entry: {
state,
ts: ts.toISOString(),
source: "terminal",
},
modifiedAt: ts,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockWhichSync.mockReset();
mockExecFileAsync.mockReset();
mockIsWindows.mockReset();
mockIsWindows.mockReturnValue(false);
});
describe("manifest", () => {
it("has correct Grok manifest", () => {
expect(manifest).toEqual({
name: pluginName,
slot: "agent",
description: packageJson.description,
version: packageJson.version,
displayName: "Grok",
});
});
});
describe("create", () => {
it("uses grok as process name and post-launch prompt mode", () => {
const agent = create();
expect(agent.name).toBe(pluginName);
expect(agent.processName).toBe(pluginName);
expect(agent.promptDelivery).toBe("post-launch");
});
it("exports plugin module shape", () => {
expect(defaultExport.manifest).toBe(manifest);
expect(typeof defaultExport.create).toBe("function");
});
});
describe("detect", () => {
it("returns true when which resolves", () => {
mockWhichSync.mockReturnValue("/usr/local/bin/grok");
expect(detect()).toBe(true);
});
it("returns false when which fails", () => {
mockWhichSync.mockImplementation(() => {
throw new Error("not found");
});
expect(detect()).toBe(false);
});
});
describe("getLaunchCommand", () => {
const agent = create();
it("uses interactive launch without a session id", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ projectConfig: { ...makeLaunchConfig().projectConfig, agentConfig: {} } }),
);
expect(cmd).toBe("grok --no-alt-screen --worktree");
});
it("uses configured model and rules file when provided", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({
model: "grok-4.1",
systemPromptFile: "/tmp/ao prompt.md",
}),
);
expect(cmd).toBe(
"grok --no-alt-screen --worktree --model 'grok-4.1' --rules '@/tmp/ao prompt.md'",
);
});
it("uses --resume when a configured Grok session id is present", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({
projectConfig: {
...makeLaunchConfig().projectConfig,
agentConfig: { grokSessionId: "01HXGROKSESSION" },
},
}),
);
expect(cmd).toBe("grok --no-alt-screen --resume '01HXGROKSESSION'");
});
it("does not include prompt flags when prompt delivery is post-launch", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({
prompt: "Do work",
systemPrompt: "You are helpful",
}),
);
expect(cmd).toContain("--rules 'You are helpful'");
expect(cmd).not.toContain("-p");
expect(cmd).not.toContain("--single");
expect(cmd).not.toContain("--prompt");
});
});
describe("getEnvironment", () => {
const agent = create();
it("writes AO session keys and leaves shared wrapper paths to session-manager", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_ISSUE_ID"]).toBeUndefined();
expect(env["PATH"]).toBeUndefined();
expect(env["GH_PATH"]).toBeUndefined();
expect(env["GROK_SANDBOX"]).toBeUndefined();
});
it("includes AO_ISSUE_ID and GROK_SANDBOX when provided", () => {
const env = agent.getEnvironment(
makeLaunchConfig({
issueId: "INT-42",
projectConfig: {
...makeLaunchConfig().projectConfig,
agentConfig: { grokSandbox: "workspace-write" },
},
}),
);
expect(env["AO_ISSUE_ID"]).toBe("INT-42");
expect(env["GROK_SANDBOX"]).toBe("workspace-write");
});
});
describe("isProcessRunning", () => {
const agent = create();
it("returns true when grok is on tmux pane", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
if (cmd === "ps") {
return Promise.resolve({
stdout: " PID TT ARGS\n 789 ttys003 /Users/me/.grok/bin/grok\n",
stderr: "",
});
}
return Promise.reject(new Error("unexpected"));
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
it("returns false when tmux process is missing", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
if (cmd === "ps")
return Promise.resolve({ stdout: " PID TT ARGS\n 789 ttys003 bash\n", stderr: "" });
return Promise.reject(new Error("unexpected"));
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("returns indeterminate when tmux probing fails", async () => {
mockExecFileAsync.mockRejectedValue(new Error("tmux unavailable"));
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
});
it("returns indeterminate for tmux handles on Windows", async () => {
mockIsWindows.mockReturnValue(true);
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
expect(mockExecFileAsync).not.toHaveBeenCalled();
});
it("returns true when process handle pid is alive", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
expect(killSpy).toHaveBeenCalledWith(123, 0);
killSpy.mockRestore();
});
it("treats EPERM as running for process handles", async () => {
const err = Object.assign(new Error("EPERM"), { code: "EPERM" });
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw err;
});
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
killSpy.mockRestore();
});
it("returns false when process handle pid is dead", async () => {
const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw err;
});
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(false);
killSpy.mockRestore();
});
});
describe("recordActivity", () => {
const agent = create();
it("classifies idle terminal output when recording activity", async () => {
await agent.recordActivity?.(makeSession(), "foo\n ");
expect(mockRecordTerminalActivity).toHaveBeenCalledWith(
"/workspace/test",
"foo\n ",
expect.any(Function),
);
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
| ((output: string) => string)
| undefined;
expect(classify?.("foo\n ")).toBe("idle");
});
it("classifies active terminal output when recording activity", async () => {
await agent.recordActivity?.(makeSession(), "Thinking through the plan");
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
| ((output: string) => string)
| undefined;
expect(classify?.("Thinking through the plan")).toBe("active");
});
it("classifies waiting_input terminal output when recording activity", async () => {
await agent.recordActivity?.(
makeSession(),
"Signing in with Grok...\n\nOpen this URL to sign in:\n https://auth.x.ai/oauth2/authorize?...",
);
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
| ((output: string) => string)
| undefined;
expect(classify?.("Allow this command? y/N:")).toBe("waiting_input");
expect(classify?.("Open this URL to sign in: https://auth.x.ai/oauth2/authorize")).toBe(
"waiting_input",
);
});
it("classifies blocked terminal output when recording activity", async () => {
await agent.recordActivity?.(makeSession(), "Error: Device not configured");
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
| ((output: string) => string)
| undefined;
expect(classify?.("Error: Device not configured")).toBe("blocked");
});
it("skips recording when workspacePath is missing", async () => {
await agent.recordActivity?.(makeSession({ workspacePath: null }), "still running");
expect(mockRecordTerminalActivity).not.toHaveBeenCalled();
});
});
describe("getActivityState", () => {
const agent = create();
it("falls back to exited when runtime handle is missing", async () => {
const state = await agent.getActivityState(makeSession({ runtimeHandle: null }));
expect(state).toMatchObject({ state: "exited" });
});
it("returns waiting_input from recent activity JSONL", async () => {
const activityAt = new Date();
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("waiting_input", activityAt));
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const state = await agent.getActivityState(
makeSession({ runtimeHandle: makeProcessHandle(101) }),
);
expect(state?.state).toBe("waiting_input");
killSpy.mockRestore();
});
it("returns blocked from recent activity JSONL", async () => {
const activityAt = new Date();
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("blocked", activityAt));
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const state = await agent.getActivityState(
makeSession({ runtimeHandle: makeProcessHandle(101) }),
);
expect(state?.state).toBe("blocked");
expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString());
killSpy.mockRestore();
});
it("returns active from JSONL fallback", async () => {
const activityAt = new Date();
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("active", activityAt));
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const state = await agent.getActivityState(
makeSession({ runtimeHandle: makeProcessHandle(101) }),
);
expect(state?.state).toBe("active");
expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString());
killSpy.mockRestore();
});
it("decays JSONL fallback state to idle when the entry is stale", async () => {
const activityAt = new Date(Date.now() - 24 * 60 * 60 * 1000);
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("active", activityAt));
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const state = await agent.getActivityState(
makeSession({ runtimeHandle: makeProcessHandle(101) }),
);
expect(state?.state).toBe("idle");
expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString());
killSpy.mockRestore();
});
it("returns null when no activity data is available", async () => {
mockReadLastActivityEntry.mockResolvedValue(null);
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const state = await agent.getActivityState(
makeSession({ runtimeHandle: makeProcessHandle(101) }),
);
expect(state).toBeNull();
killSpy.mockRestore();
});
it("treats indeterminate process probes as no process verdict", async () => {
mockReadLastActivityEntry.mockResolvedValue(null);
mockExecFileAsync.mockRejectedValue(new Error("tmux unavailable"));
const state = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() }));
expect(state).toBeNull();
});
});
describe("getSessionInfo", () => {
const agent = create();
it("returns null without Grok session metadata", async () => {
expect(await agent.getSessionInfo(makeSession())).toBeNull();
});
it("returns a known Grok session id from metadata", async () => {
const info = await agent.getSessionInfo(
makeSession({ metadata: { grokSessionId: "01HXGROKSESSION" } }),
);
expect(info).toEqual({
agentSessionId: "01HXGROKSESSION",
summary: null,
});
});
});
describe("getRestoreCommand", () => {
const agent = create();
it("returns null without Grok session metadata", async () => {
const cmd = await agent.getRestoreCommand?.(makeSession(), makeLaunchConfig().projectConfig);
expect(cmd).toBeNull();
});
it("builds restore command using the Grok session id", async () => {
const cmd = await agent.getRestoreCommand?.(
makeSession({ metadata: { grokSessionId: "01HXGROKSESSION" } }),
makeLaunchConfig({ model: "grok-4.1" }).projectConfig,
);
expect(cmd).toBe("grok --no-alt-screen --model 'grok-4.1-fast' --resume '01HXGROKSESSION'");
});
});
describe("workspace hooks", () => {
const agent = create();
it("sets up PATH wrapper workspace hooks", async () => {
await agent.setupWorkspaceHooks?.("/workspace/test", { dataDir: "/sessions" });
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
});
it("runs post-launch workspace hook setup", async () => {
await agent.postLaunchSetup?.(makeSession({ workspacePath: "/workspace/test" }));
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
});
it("waits for Grok worktree readiness before post-launch prompt delivery", async () => {
mockExecFileAsync.mockResolvedValue({
stdout: "Worktree ready: /Users/me/.grok/worktrees/project/smoke\n",
stderr: "",
});
await agent.postLaunchSetup?.(
makeSession({ workspacePath: "/workspace/test", runtimeHandle: makeTmuxHandle("ao-smoke") }),
);
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
expect(mockExecFileAsync).toHaveBeenCalledWith(
"tmux",
["capture-pane", "-t", "ao-smoke", "-p", "-S", "-120"],
{ timeout: 5_000 },
);
});
});

View File

@ -0,0 +1,316 @@
import {
DEFAULT_READY_THRESHOLD_MS,
DEFAULT_ACTIVE_WINDOW_MS,
PROCESS_PROBE_INDETERMINATE,
shellEscape,
readLastActivityEntry,
checkActivityLogState,
getActivityFallbackState,
recordTerminalActivity,
setupPathWrapperWorkspace,
isWindows,
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
type ActivityDetection,
type ActivityState,
type PluginModule,
type ProcessProbeResult,
type ProjectConfig,
type RuntimeHandle,
type Session,
type WorkspaceHooksConfig,
} from "@aoagents/ao-core";
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { setTimeout as sleep } from "node:timers/promises";
import { promisify } from "node:util";
import which from "which";
const require = createRequire(import.meta.url);
const packageJson = require("../package.json") as {
name: string;
version: string;
description: string;
};
const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-";
const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX)
? packageJson.name.slice(PACKAGE_NAME_PREFIX.length)
: packageJson.name;
const execFileAsync = promisify(execFile);
const GROK_EXECUTABLE = "grok";
const GROK_STARTUP_READY_TIMEOUT_MS = 30_000;
const GROK_STARTUP_POLL_MS = 500;
const ANSI_ESCAPE_RE = new RegExp(
`${String.fromCharCode(27)}(?:[@-Z\\-_]|\\[[0-?]*[ -/]*[@-~])`,
"g",
);
const GROK_CONFIRM_PROMPT_RE =
/(?:allow|approve|do you want to continue|continue anyway|proceed).*(?:y\/n|yes\/no|\[[YyNn]\/ ?[YyNn]\]|\([YyNn]\/ ?[YyNn]\)|\?)\s*:?$/i;
const GROK_AUTH_PROMPT_RE = /(?:sign in with grok|open this url to sign in|oauth2\/authorize)/i;
interface GrokAgentConfig {
grokSessionId?: unknown;
model?: unknown;
grokSandbox?: unknown;
}
function asGrokSessionId(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed) return null;
if (/\p{C}/u.test(trimmed)) return null;
return trimmed.length <= 512 ? trimmed : null;
}
function getConfiguredGrokSessionId(config: AgentLaunchConfig): string | null {
return asGrokSessionId(
(config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.grokSessionId,
);
}
function getConfiguredModel(config: AgentLaunchConfig): string | null {
const model =
config.model ?? (config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.model;
return typeof model === "string" && model.trim() ? model.trim() : null;
}
function getConfiguredSandbox(config: AgentLaunchConfig): string | null {
const sandbox = (config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.grokSandbox;
return typeof sandbox === "string" && sandbox.trim() ? sandbox.trim() : null;
}
function buildGrokCommand(config: AgentLaunchConfig, sessionId?: string | null): string {
const restoreSessionId = sessionId ?? getConfiguredGrokSessionId(config);
const parts = [GROK_EXECUTABLE, "--no-alt-screen"];
if (!restoreSessionId) {
parts.push("--worktree");
}
const model = getConfiguredModel(config);
if (model) {
parts.push("--model", shellEscape(model));
}
if (config.systemPromptFile) {
parts.push("--rules", shellEscape(`@${config.systemPromptFile}`));
} else if (config.systemPrompt) {
parts.push("--rules", shellEscape(config.systemPrompt));
}
if (restoreSessionId) {
parts.push("--resume", shellEscape(restoreSessionId));
}
return parts.join(" ");
}
async function captureTmuxOutput(handle: RuntimeHandle): Promise<string> {
const { stdout } = await execFileAsync(
"tmux",
["capture-pane", "-t", handle.id, "-p", "-S", "-120"],
{ timeout: 5_000 },
);
return stdout;
}
async function waitForGrokWorktreeReady(session: Session): Promise<void> {
const handle = session.runtimeHandle;
if (!handle || handle.runtimeName !== "tmux" || !handle.id) return;
if (isWindows()) return;
if (asGrokSessionId(session.metadata?.grokSessionId)) return;
const startedAt = Date.now();
while (Date.now() - startedAt < GROK_STARTUP_READY_TIMEOUT_MS) {
const output = await captureTmuxOutput(handle);
if (/Worktree ready:/i.test(output)) return;
await sleep(GROK_STARTUP_POLL_MS);
}
}
function classifyGrokTerminalOutput(terminalOutput: string): ActivityState {
const normalizedOutput = terminalOutput.replaceAll(ANSI_ESCAPE_RE, "").trim();
if (!normalizedOutput) return "idle";
const lines = normalizedOutput.split("\n").map((line) => line.trim());
const lastLine = lines[lines.length - 1] ?? "";
const lastNonEmptyLine = [...lines].reverse().find(Boolean) ?? "";
if (/^[>$#]\s*$/.test(lastLine)) return "idle";
if (GROK_AUTH_PROMPT_RE.test(normalizedOutput)) return "waiting_input";
if (GROK_CONFIRM_PROMPT_RE.test(lastNonEmptyLine)) return "waiting_input";
if (/\b(error|failed|exception|not authenticated|device not configured)\b/i.test(lastLine))
return "blocked";
return "active";
}
export const manifest = {
name: pluginName,
slot: "agent" as const,
description: packageJson.description,
version: packageJson.version,
displayName: "Grok",
};
function createGrokAgent(): Agent {
return {
name: pluginName,
processName: pluginName,
promptDelivery: "post-launch",
getLaunchCommand(config: AgentLaunchConfig): string {
return buildGrokCommand(config);
},
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
const env: Record<string, string> = {};
env["AO_SESSION_ID"] = config.sessionId;
if (config.issueId) {
env["AO_ISSUE_ID"] = config.issueId;
}
const sandbox = getConfiguredSandbox(config);
if (sandbox) {
env["GROK_SANDBOX"] = sandbox;
}
return env;
},
detectActivity(terminalOutput: string): ActivityState {
return classifyGrokTerminalOutput(terminalOutput);
},
async getActivityState(
session: Session,
readyThresholdMs?: number,
): Promise<ActivityDetection | null> {
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
const exitedAt = new Date();
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
const running = await this.isProcessRunning(session.runtimeHandle);
if (running === false) return { state: "exited", timestamp: exitedAt };
let activityResult: Awaited<ReturnType<typeof readLastActivityEntry>> = null;
if (session.workspacePath) {
activityResult = await readLastActivityEntry(session.workspacePath);
const activityState = checkActivityLogState(activityResult);
if (activityState) return activityState;
}
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
if (fallback) return fallback;
return null;
},
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
if (!session.workspacePath) return;
await recordTerminalActivity(session.workspacePath, terminalOutput, (output: string) =>
classifyGrokTerminalOutput(output),
);
},
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
try {
if (handle.runtimeName === "tmux" && handle.id) {
if (isWindows()) return PROCESS_PROBE_INDETERMINATE;
const { stdout: ttyOut } = await execFileAsync(
"tmux",
["list-panes", "-t", handle.id, "-F", "#{pane_tty}"],
{ timeout: 30_000 },
);
const ttys = ttyOut
.trim()
.split("\n")
.map((t) => t.trim())
.filter(Boolean);
if (ttys.length === 0) return false;
const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], {
timeout: 30_000,
});
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
const processRe = /(?:^|[\s/])\.?grok(?:\s|$)/;
for (const line of psOut.split("\n")) {
const cols = line.trimStart().split(/\s+/);
if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue;
const args = cols.slice(2).join(" ");
if (processRe.test(args)) {
return true;
}
}
return false;
}
const rawPid = handle.data["pid"];
const pid = typeof rawPid === "number" ? rawPid : Number(rawPid);
if (Number.isFinite(pid) && pid > 0) {
try {
process.kill(pid, 0);
return true;
} catch (err: unknown) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "EPERM") {
return true;
}
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ESRCH") {
return false;
}
return PROCESS_PROBE_INDETERMINATE;
}
}
return false;
} catch {
return PROCESS_PROBE_INDETERMINATE;
}
},
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
const grokSessionId = asGrokSessionId(session.metadata?.grokSessionId);
if (!grokSessionId) return null;
return {
agentSessionId: grokSessionId,
summary: null,
};
},
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
const grokSessionId = asGrokSessionId(session.metadata?.grokSessionId);
if (!grokSessionId) return null;
return buildGrokCommand(
{
sessionId: session.id,
projectConfig: project,
workspacePath: session.workspacePath ?? undefined,
issueId: session.issueId ?? undefined,
},
grokSessionId,
);
},
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
await setupPathWrapperWorkspace(workspacePath);
},
async postLaunchSetup(session: Session): Promise<void> {
if (session.workspacePath) {
await setupPathWrapperWorkspace(session.workspacePath);
}
await waitForGrokWorktreeReady(session);
},
};
}
export function create(): Agent {
return createGrokAgent();
}
export function detect(): boolean {
try {
return Boolean(which.sync(GROK_EXECUTABLE));
} catch {
return false;
}
}
export default { manifest, create, detect } satisfies PluginModule<Agent>;

View File

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -1,5 +1,27 @@
# @aoagents/ao-plugin-agent-kimicode
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Patch Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-agent-kimicode",
"version": "0.8.0",
"version": "0.9.1",
"description": "Agent plugin: Kimi Code CLI (MoonshotAI)",
"license": "MIT",
"type": "module",

View File

@ -52,9 +52,12 @@ async function extractKimiSummary(sessionDir: string): Promise<string | null> {
// still be planted as symlinks pointing at /etc/passwd or /dev/zero.
if (!(await isKimiSessionFile(wirePath))) return null;
let summary: string | null = null;
let stream: ReturnType<typeof createReadStream> | null = null;
let rl: ReturnType<typeof createInterface> | null = null;
try {
const rl = createInterface({
input: createReadStream(wirePath, { encoding: "utf-8" }),
stream = createReadStream(wirePath, { encoding: "utf-8" });
rl = createInterface({
input: stream,
crlfDelay: Infinity,
});
let bytes = 0;
@ -85,9 +88,11 @@ async function extractKimiSummary(sessionDir: string): Promise<string | null> {
// Skip malformed line
}
}
rl.close();
} catch {
return null;
} finally {
rl?.close();
stream?.destroy();
}
return summary;
}

View File

@ -1,5 +1,27 @@
# @aoagents/ao-plugin-agent-opencode
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Minor Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-agent-opencode",
"version": "0.8.0",
"version": "0.9.1",
"description": "Agent plugin: OpenCode",
"license": "MIT",
"type": "module",

View File

@ -1,5 +1,28 @@
# @aoagents/ao-plugin-notifier-composio
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Patch Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-notifier-composio",
"version": "0.8.0",
"version": "0.9.1",
"description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)",
"license": "MIT",
"type": "module",

View File

@ -0,0 +1,24 @@
# @aoagents/ao-plugin-notifier-dashboard
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-notifier-dashboard",
"version": "0.6.0",
"version": "0.9.1",
"description": "Notifier plugin: AO dashboard notifications",
"license": "MIT",
"type": "module",

View File

@ -1,5 +1,28 @@
# @aoagents/ao-plugin-notifier-desktop
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Patch Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-notifier-desktop",
"version": "0.8.0",
"version": "0.9.1",
"description": "Notifier plugin: OS desktop notifications",
"license": "MIT",
"type": "module",

View File

@ -1,5 +1,28 @@
# @aoagents/ao-plugin-notifier-discord
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Patch Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-notifier-discord",
"version": "0.8.0",
"version": "0.9.1",
"description": "Notifier plugin: Discord webhook notifications",
"license": "MIT",
"type": "module",

View File

@ -1,5 +1,28 @@
# @aoagents/ao-plugin-notifier-openclaw
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Patch Changes

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-plugin-notifier-openclaw",
"version": "0.8.0",
"version": "0.9.1",
"description": "Notifier plugin: OpenClaw webhook notifications",
"license": "MIT",
"type": "module",

View File

@ -1,5 +1,28 @@
# @aoagents/ao-plugin-notifier-slack
## 0.9.1
### Patch Changes
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
- Updated dependencies [2d4c457]
- @aoagents/ao-core@0.9.1
## 0.9.0
### Patch Changes
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
- Updated dependencies [73bed33]
- Updated dependencies [a610601]
- Updated dependencies [7d9b862]
- Updated dependencies [6d48022]
- Updated dependencies [fcedb25]
- Updated dependencies [94981dc]
- Updated dependencies [2980570]
- Updated dependencies [d5d0f07]
- @aoagents/ao-core@0.9.0
## 0.8.0
### Patch Changes

Some files were not shown because too many files have changed in this diff Show More