diff --git a/.changeset/activity-events-webhooks-mux.md b/.changeset/activity-events-webhooks-mux.md deleted file mode 100644 index 6f4dcc73f..000000000 --- a/.changeset/activity-events-webhooks-mux.md +++ /dev/null @@ -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. diff --git a/.changeset/claude-activity-detection-split.md b/.changeset/claude-activity-detection-split.md deleted file mode 100644 index 043b65f52..000000000 --- a/.changeset/claude-activity-detection-split.md +++ /dev/null @@ -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). diff --git a/.changeset/claude-activity-edge-cases.md b/.changeset/claude-activity-edge-cases.md deleted file mode 100644 index b44c5686f..000000000 --- a/.changeset/claude-activity-edge-cases.md +++ /dev/null @@ -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 (`/.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//` (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. diff --git a/.changeset/cli-activity-events.md b/.changeset/cli-activity-events.md deleted file mode 100644 index 7528033c4..000000000 --- a/.changeset/cli-activity-events.md +++ /dev/null @@ -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. diff --git a/.changeset/config.json b/.changeset/config.json index 0f83c3b95..c8468bd06 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -37,7 +37,7 @@ ] ], "snapshot": { - "useCalculatedVersionForSnapshots": true, + "useCalculatedVersion": true, "prereleaseTemplate": "{tag}-{commit}" }, "access": "public", diff --git a/.changeset/fix-done-bar-scroll.md b/.changeset/fix-done-bar-scroll.md deleted file mode 100644 index f049da4a8..000000000 --- a/.changeset/fix-done-bar-scroll.md +++ /dev/null @@ -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`. diff --git a/.changeset/issue-1660-recovery-metadata-events.md b/.changeset/issue-1660-recovery-metadata-events.md deleted file mode 100644 index 4ad89f8b0..000000000 --- a/.changeset/issue-1660-recovery-metadata-events.md +++ /dev/null @@ -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. diff --git a/.changeset/launch-orchestrator-clean.md b/.changeset/launch-orchestrator-clean.md deleted file mode 100644 index a13c2f708..000000000 --- a/.changeset/launch-orchestrator-clean.md +++ /dev/null @@ -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. diff --git a/.changeset/linear-transient-retry.md b/.changeset/linear-transient-retry.md deleted file mode 100644 index cb1ab2764..000000000 --- a/.changeset/linear-transient-retry.md +++ /dev/null @@ -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. diff --git a/.changeset/notifier-release-links.md b/.changeset/notifier-release-links.md deleted file mode 100644 index 24ffdf344..000000000 --- a/.changeset/notifier-release-links.md +++ /dev/null @@ -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. diff --git a/.changeset/quiet-sqlite-rebuild.md b/.changeset/quiet-sqlite-rebuild.md deleted file mode 100644 index 79dbfd120..000000000 --- a/.changeset/quiet-sqlite-rebuild.md +++ /dev/null @@ -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. diff --git a/.changeset/restore-button-pr-merged.md b/.changeset/restore-button-pr-merged.md deleted file mode 100644 index 125ad6b7a..000000000 --- a/.changeset/restore-button-pr-merged.md +++ /dev/null @@ -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. diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index ef89b8915..9cdfe5dce 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 731ee041f..69dc6b580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/SETUP.md b/SETUP.md index 81782df10..08402f832 100644 --- a/SETUP.md +++ b/SETUP.md @@ -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:` + - 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) diff --git a/eslint.config.js b/eslint.config.js index c6dcc531e..47a9e06ba 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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"], diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 8c7303359..fea3cf3f3 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -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 diff --git a/packages/ao/README.md b/packages/ao/README.md new file mode 100644 index 000000000..ad402d540 --- /dev/null +++ b/packages/ao/README.md @@ -0,0 +1,93 @@ +
+ +# 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) + +Agent Orchestrator + +
+ +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) diff --git a/packages/ao/package.json b/packages/ao/package.json index ab301e4e8..037281e98 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -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", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index eab75ff1e..fc7839348 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -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:`. 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 diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index b821d3637..964f0a674 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -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(); diff --git a/packages/cli/__tests__/commands/update.test.ts b/packages/cli/__tests__/commands/update.test.ts index 72cb31047..843bcc1bb 100644 --- a/packages/cli/__tests__/commands/update.test.ts +++ b/packages/cli/__tests__/commands/update.test.ts @@ -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>(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. + mockGetRunning + .mockResolvedValueOnce({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + 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" }]; + }); + + 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 createMockChild(0, undefined, { stdout: "" }); + }); + + 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); + 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: "" }); + }); + + 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" } }, }); - mockLoadConfig.mockImplementation((path?: string) => - path - ? { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path } - : { projects: { "my-app": { path: "/tmp/foo" } }, configPath: "/cwd/agent-orchestrator.yaml" }, + 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("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" }; + 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 { - projects: { - "proj-a": { path: "/repos/a" }, - "proj-b": { path: "/repos/b" }, - }, - configPath: path, - }; + return createMockChild(0, undefined, { stdout: "" }); }); - 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)"); + await program.parseAsync(["node", "test", "update", "--no-restore"]); - 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(); + 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("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({ - pid: 12345, - configPath: "/repos/local-only/agent-orchestrator.yaml", - port: 3000, - startedAt: new Date().toISOString(), - projects: ["local-only"], - }); - // 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, - }; - } - 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(); - }); - - 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. - mockGetRunning.mockResolvedValue(null); - mockExistsSync.mockReturnValue(false); - mockPromptConfirm.mockResolvedValue(false); - await program.parseAsync(["node", "test", "update"]); - expect(mockPromptConfirm).toHaveBeenCalled(); // guard passed → reached prompt - }); - - 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. + 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" } }, - }); - mockSessions.value = [{ id: "feat-1", status: "working" }]; + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "" })); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); - // 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(); + 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`"); + }); + + 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", + }), + ); + + 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; 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; expect(opts.shell).toBe(false); }); diff --git a/packages/cli/__tests__/lib/dashboard-url.test.ts b/packages/cli/__tests__/lib/dashboard-url.test.ts new file mode 100644 index 000000000..e97b83b3f --- /dev/null +++ b/packages/cli/__tests__/lib/dashboard-url.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/__tests__/lib/update-check.test.ts b/packages/cli/__tests__/lib/update-check.test.ts index 4b160d426..889712372 100644 --- a/packages/cli/__tests__/lib/update-check.test.ts +++ b/packages/cli/__tests__/lib/update-check.test.ts @@ -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", () => { diff --git a/packages/cli/__tests__/scripts/doctor-script.test.ts b/packages/cli/__tests__/scripts/doctor-script.test.ts index 6315d48ef..514100cac 100644 --- a/packages/cli/__tests__/scripts/doctor-script.test.ts +++ b/packages/cli/__tests__/scripts/doctor-script.test.ts @@ -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); diff --git a/packages/cli/package.json b/packages/cli/package.json index 803046b21..bbb9b6adb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:*", diff --git a/packages/cli/src/assets/scripts/ao-doctor.sh b/packages/cli/src/assets/scripts/ao-doctor.sh index bd3fd8895..2b20735bf 100755 --- a/packages/cli/src/assets/scripts/ao-doctor.sh +++ b/packages/cli/src/assets/scripts/ao-doctor.sh @@ -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" diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index addd9bd30..f9abbf817 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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) => { diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index fa79cb9a1..57d84e97b 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -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 { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 844eea724..5c951758c 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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 { - 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 { 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(); @@ -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 ` 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,273 +1869,307 @@ 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 } = {}) => { - recordActivityEvent({ - source: "cli", - kind: "cli.stop_invoked", - level: "info", - summary: "ao stop invoked", - data: { - projectArg: projectArg ?? null, - all: opts.all === true, - purgeSession: opts.purgeSession === true, - }, - }); - try { - // Check running.json first - const running = await getRunning(); - - if (opts.all) { - // --all: kill via running.json if available, then fallback to config - if (running) { - // Sweep detached Windows pty-hosts BEFORE killing the parent. - // detached:true puts them outside the parent's process tree, so - // taskkill /T cannot reach them. The sweep speaks the named-pipe - // protocol so node-pty disposes ConPTY gracefully (avoids WER - // 0x800700e8). No-op on non-Windows. - await sweepWindowsPtyHostsBeforeParentKill(); - await sweepRegisteredDaemonChildren(running.pid); - // killProcessTree handles process trees on Windows (taskkill /T /F) - // and process groups on Unix; it swallows "already dead" internally. - await killProcessTree(running.pid, "SIGTERM"); - await unregister(); - console.log(chalk.green(`\n✓ Stopped AO on port ${running.port}`)); - console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`)); - } else { - console.log(chalk.yellow("No running AO instance found in running.json.")); - } - return; - } - - let config = loadConfig(); - // ao stop affects all projects (it kills the parent ao start process), - // so load the global config which has all registered projects. - // When a specific project is targeted, only fall back to global if - // the project isn't in the local config. - if (!projectArg || !config.projects[projectArg]) { - const globalPath = getGlobalConfigPath(); - if (existsSync(globalPath)) { - config = loadConfig(globalPath); - } - } - const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"); - const port = config.port ?? DEFAULT_PORT; - - console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); - - const sm = await getSessionManager(config); + .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", + level: "info", + summary: "ao stop invoked", + data: { + projectArg: projectArg ?? null, + all: opts.all === true, + purgeSession: opts.purgeSession === true, + }, + }); try { - // When no explicit project is given, list ALL sessions — ao stop - // kills the parent process which affects all projects. When a - // specific project is targeted, scope to that project only. - const stopAll = !projectArg; - const rawSessions = await sm.list(stopAll ? undefined : _projectId); - // Defensive consumer-side filter. `sm.list(projectId)` already scopes - // to the named project, but the kill loop hard-stops processes — a - // contract regression here would silently kill another project's - // work. When a project arg is given, drop anything that isn't ours. - const allSessions = stopAll - ? rawSessions - : rawSessions.filter((s) => s.projectId === _projectId); - const activeSessions = allSessions.filter((s) => !isTerminalSession(s)); - const killedSessionIds: string[] = []; + // Check running.json first + const running = await getRunning(); - // Separate sessions by project for display and recording - const targetActive = activeSessions.filter((s) => s.projectId === _projectId); - const otherActive = activeSessions.filter((s) => s.projectId !== _projectId); - // Group other-project sessions by projectId (used for display + recording) - const otherByProject = new Map(); + if (opts.all) { + // --all: kill via running.json if available, then fallback to config + if (running) { + // Sweep detached Windows pty-hosts BEFORE killing the parent. + // detached:true puts them outside the parent's process tree, so + // taskkill /T cannot reach them. The sweep speaks the named-pipe + // protocol so node-pty disposes ConPTY gracefully (avoids WER + // 0x800700e8). No-op on non-Windows. + await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); + // killProcessTree handles process trees on Windows (taskkill /T /F) + // and process groups on Unix; it swallows "already dead" internally. + await killProcessTree(running.pid, "SIGTERM"); + await unregister(); + console.log(chalk.green(`\n✓ Stopped AO on port ${running.port}`)); + console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`)); + } else { + console.log(chalk.yellow("No running AO instance found in running.json.")); + } + return; + } - if (activeSessions.length > 0) { - const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start(); - const purgeOpenCode = opts?.purgeSession === true; - const warnings: string[] = []; - for (const session of activeSessions) { - try { - const result = await sm.kill(session.id, { purgeOpenCode }); - if (result.cleaned || result.alreadyTerminated) { - killedSessionIds.push(session.id); + let config = loadConfig(); + // ao stop affects all projects (it kills the parent ao start process), + // so load the global config which has all registered projects. + // When a specific project is targeted, only fall back to global if + // the project isn't in the local config. + if (!projectArg || !config.projects[projectArg]) { + const globalPath = getGlobalConfigPath(); + if (existsSync(globalPath)) { + config = loadConfig(globalPath); + } + } + 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 { + // When no explicit project is given, list ALL sessions — ao stop + // kills the parent process which affects all projects. When a + // specific project is targeted, scope to that project only. + const stopAll = !projectArg; + const rawSessions = await sm.list(stopAll ? undefined : _projectId); + // Defensive consumer-side filter. `sm.list(projectId)` already scopes + // to the named project, but the kill loop hard-stops processes — a + // contract regression here would silently kill another project's + // work. When a project arg is given, drop anything that isn't ours. + const allSessions = stopAll + ? rawSessions + : rawSessions.filter((s) => s.projectId === _projectId); + const activeSessions = allSessions.filter((s) => !isTerminalSession(s)); + const killedSessionIds: string[] = []; + + // Separate sessions by project for display and recording + const targetActive = activeSessions.filter((s) => s.projectId === _projectId); + const otherActive = activeSessions.filter((s) => s.projectId !== _projectId); + // Group other-project sessions by projectId (used for display + recording) + const otherByProject = new Map(); + + 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[] = []; + for (const session of activeSessions) { + try { + const result = await sm.kill(session.id, { purgeOpenCode }); + if (result.cleaned || result.alreadyTerminated) { + killedSessionIds.push(session.id); + } + } catch (err) { + recordActivityEvent({ + projectId: session.projectId ?? _projectId, + sessionId: session.id, + source: "cli", + kind: "cli.stop_session_failed", + level: "warn", + summary: `failed to kill session during ao stop`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); + warnings.push( + ` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + if (killedSessionIds.length === 0) { + spinner.fail("Failed to stop any sessions"); + } else if (killedSessionIds.length < activeSessions.length) { + spinner.warn( + `Stopped ${killedSessionIds.length}/${activeSessions.length} session(s)`, + ); + } else { + spinner.succeed(`Stopped ${killedSessionIds.length} session(s)`); + } + for (const w of warnings) { + console.log(chalk.yellow(w)); + } + // Show stopped sessions grouped by project + const killedTarget = targetActive + .filter((s) => killedSessionIds.includes(s.id)) + .map((s) => s.id); + if (killedTarget.length > 0) { + console.log(chalk.green(` ${project.name}: ${killedTarget.join(", ")}`)); + } + for (const s of otherActive) { + if (!killedSessionIds.includes(s.id)) continue; + const list = otherByProject.get(s.projectId ?? "unknown") ?? []; + list.push(s.id); + otherByProject.set(s.projectId ?? "unknown", list); + } + for (const [pid, ids] of otherByProject) { + console.log(chalk.green(` ${pid}: ${ids.join(", ")}`)); + } + } else { + console.log(chalk.yellow(`No active sessions found`)); + } + + // Record stopped sessions for restore on next `ao start` + if (killedSessionIds.length > 0) { + const otherProjects: Array<{ projectId: string; sessionIds: string[] }> = []; + for (const [pid, ids] of otherByProject) { + otherProjects.push({ projectId: pid, sessionIds: ids }); + } + + const targetSessionIds = killedSessionIds.filter((id) => + targetActive.some((s) => s.id === id), + ); + try { + await writeLastStop({ + stoppedAt: new Date().toISOString(), + projectId: _projectId, + sessionIds: targetSessionIds, + otherProjects: otherProjects.length > 0 ? otherProjects : undefined, + }); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.last_stop_written", + level: "info", + summary: `last-stop state written with ${killedSessionIds.length} session(s)`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + }, + }); } catch (err) { recordActivityEvent({ - projectId: session.projectId ?? _projectId, - sessionId: session.id, + projectId: _projectId, source: "cli", - kind: "cli.stop_session_failed", - level: "warn", - summary: `failed to kill session during ao stop`, - data: { errorMessage: err instanceof Error ? err.message : String(err) }, + kind: "cli.last_stop_write_failed", + level: "error", + summary: `failed to write last-stop state during ao stop`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + errorMessage: err instanceof Error ? err.message : String(err), + }, }); - warnings.push( - ` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`, + console.log( + chalk.yellow( + ` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`, + ), ); } } - if (killedSessionIds.length === 0) { - spinner.fail("Failed to stop any sessions"); - } else if (killedSessionIds.length < activeSessions.length) { - spinner.warn( - `Stopped ${killedSessionIds.length}/${activeSessions.length} session(s)`, - ); - } else { - spinner.succeed(`Stopped ${killedSessionIds.length} session(s)`); - } - for (const w of warnings) { - console.log(chalk.yellow(w)); - } - // Show stopped sessions grouped by project - const killedTarget = targetActive - .filter((s) => killedSessionIds.includes(s.id)) - .map((s) => s.id); - if (killedTarget.length > 0) { - console.log(chalk.green(` ${project.name}: ${killedTarget.join(", ")}`)); - } - for (const s of otherActive) { - if (!killedSessionIds.includes(s.id)) continue; - const list = otherByProject.get(s.projectId ?? "unknown") ?? []; - list.push(s.id); - otherByProject.set(s.projectId ?? "unknown", list); - } - for (const [pid, ids] of otherByProject) { - console.log(chalk.green(` ${pid}: ${ids.join(", ")}`)); - } - } else { - console.log(chalk.yellow(`No active sessions found`)); + } catch (err) { + console.log( + chalk.yellow( + ` Could not list sessions: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } - // Record stopped sessions for restore on next `ao start` - if (killedSessionIds.length > 0) { - const otherProjects: Array<{ projectId: string; sessionIds: string[] }> = []; - for (const [pid, ids] of otherByProject) { - otherProjects.push({ projectId: pid, sessionIds: ids }); + // Only kill the parent `ao start` process and dashboard when stopping + // everything (no project arg). When targeting a specific project, the + // parent process and dashboard serve all projects and must stay alive. + if (!projectArg) { + // Lifecycle polling runs in-process inside the `ao start` process + // (registered via `running.json`). Sending SIGTERM to that PID below + // triggers the shared shutdown handler in `lifecycle-service`, which + // stops every per-project loop. No explicit stop call needed here — + // this CLI invocation is a separate process with an empty active map. + if (running) { + // Sweep detached Windows pty-hosts BEFORE killing the parent. + // detached:true puts them outside the parent's process tree, so + // taskkill /T cannot reach them. The sweep speaks the named-pipe + // protocol so node-pty disposes ConPTY gracefully (avoids WER + // 0x800700e8). No-op on non-Windows. + await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); + try { + await killProcessTree(running.pid, "SIGTERM"); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.daemon_killed", + level: "info", + summary: `SIGTERM sent to parent ao start`, + data: { pid: running.pid, port: running.port }, + }); + } catch (err) { + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.daemon_killed", + level: "warn", + summary: `parent ao start was already dead`, + data: { + pid: running.pid, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + } + await unregister(); + } else { + await sweepRegisteredDaemonChildren(); } + await stopDashboard(running?.port ?? port); + } + // Targeted stop deliberately does NOT edit `running.json` from this + // child CLI process. The long-lived parent supervises lifecycle + // workers and will remove the project from `running.projects` after + // it observes that the last session became terminal. - const targetSessionIds = killedSessionIds.filter((id) => - targetActive.some((s) => s.id === id), - ); - try { - await writeLastStop({ - stoppedAt: new Date().toISOString(), - projectId: _projectId, - sessionIds: targetSessionIds, - otherProjects: otherProjects.length > 0 ? otherProjects : undefined, - }); - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.last_stop_written", - level: "info", - summary: `last-stop state written with ${killedSessionIds.length} session(s)`, - data: { - targetSessionCount: targetSessionIds.length, - otherProjectCount: otherProjects.length, - totalKilled: killedSessionIds.length, - }, - }); - } catch (err) { - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.last_stop_write_failed", - level: "error", - summary: `failed to write last-stop state during ao stop`, - data: { - targetSessionCount: targetSessionIds.length, - otherProjectCount: otherProjects.length, - totalKilled: killedSessionIds.length, - errorMessage: err instanceof Error ? err.message : String(err), - }, - }); - console.log( - chalk.yellow( - ` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`, - ), - ); - } + if (projectArg) { + console.log(chalk.bold.green(`\n✓ Stopped sessions for ${project.name}\n`)); + } else { + console.log(chalk.bold.green("\n✓ Orchestrator stopped\n")); + console.log(chalk.dim(` Uptime: since ${running?.startedAt ?? "unknown"}`)); + console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`)); } } catch (err) { - console.log( - chalk.yellow( - ` Could not list sessions: ${err instanceof Error ? err.message : String(err)}`, - ), - ); - } - - // Only kill the parent `ao start` process and dashboard when stopping - // everything (no project arg). When targeting a specific project, the - // parent process and dashboard serve all projects and must stay alive. - if (!projectArg) { - // Lifecycle polling runs in-process inside the `ao start` process - // (registered via `running.json`). Sending SIGTERM to that PID below - // triggers the shared shutdown handler in `lifecycle-service`, which - // stops every per-project loop. No explicit stop call needed here — - // this CLI invocation is a separate process with an empty active map. - if (running) { - // Sweep detached Windows pty-hosts BEFORE killing the parent. - // detached:true puts them outside the parent's process tree, so - // taskkill /T cannot reach them. The sweep speaks the named-pipe - // protocol so node-pty disposes ConPTY gracefully (avoids WER - // 0x800700e8). No-op on non-Windows. - await sweepWindowsPtyHostsBeforeParentKill(); - await sweepRegisteredDaemonChildren(running.pid); - try { - await killProcessTree(running.pid, "SIGTERM"); - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.daemon_killed", - level: "info", - summary: `SIGTERM sent to parent ao start`, - data: { pid: running.pid, port: running.port }, - }); - } catch (err) { - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.daemon_killed", - level: "warn", - summary: `parent ao start was already dead`, - data: { - pid: running.pid, - errorMessage: err instanceof Error ? err.message : String(err), - }, - }); - } - await unregister(); + recordActivityEvent({ + source: "cli", + kind: "cli.stop_failed", + level: "error", + summary: `ao stop action failed`, + data: { + projectArg: projectArg ?? null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + if (err instanceof Error) { + console.error(chalk.red("\nError:"), err.message); } else { - await sweepRegisteredDaemonChildren(); + console.error(chalk.red("\nError:"), String(err)); } - await stopDashboard(running?.port ?? port); + process.exit(1); } - // Targeted stop deliberately does NOT edit `running.json` from this - // child CLI process. The long-lived parent supervises lifecycle - // workers and will remove the project from `running.projects` after - // it observes that the last session became terminal. - - if (projectArg) { - console.log(chalk.bold.green(`\n✓ Stopped sessions for ${project.name}\n`)); - } else { - console.log(chalk.bold.green("\n✓ Orchestrator stopped\n")); - console.log(chalk.dim(` Uptime: since ${running?.startedAt ?? "unknown"}`)); - console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`)); - } - } catch (err) { - recordActivityEvent({ - source: "cli", - kind: "cli.stop_failed", - level: "error", - summary: `ao stop action failed`, - data: { - projectArg: projectArg ?? null, - errorMessage: err instanceof Error ? err.message : String(err), - }, - }); - if (err instanceof Error) { - console.error(chalk.red("\nError:"), err.message); - } else { - console.error(chalk.red("\nError:"), String(err)); - } - process.exit(1); - } - }); + }, + ); } diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 72b3327f9..df1804213 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -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, 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 introspection = await agent.getSessionInfo(session); - claudeSummary = introspection?.summary ?? null; + 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"); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 8e3d37dd9..b181d4370 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -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 { } // --------------------------------------------------------------------------- -// 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 { +interface UpdateLifecyclePlan { + runningBeforeUpdate: boolean; + configPath?: string; + primaryProjectId?: string; + activeSessions: Session[]; +} + +async function getUpdateLifecyclePlan(): Promise { 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 { // 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 { // 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 { // 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`, - ), - ); - for (const s of active.slice(0, 5)) { - console.error(chalk.dim(` • ${s.id} (${s.status})`)); +async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise { + 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 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.")); } - if (active.length > 5) { - console.error(chalk.dim(` … and ${active.length - 5} more`)); + + 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); } - return false; + + 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 (afterStop.activeSessions.length > 5) { + console.error(chalk.dim(` … and ${afterStop.activeSessions.length - 5} more`)); + } + } + 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 { + 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 { + return new Promise((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 { async function handleGitUpdate(opts: { skipSmoke?: boolean; smokeOnly?: boolean; + restore?: boolean; }): Promise { - 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 { +async function handleNpmUpdate(method: InstallMethod, opts: { restore: boolean }): Promise { 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 { // 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 { 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 { 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 { 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 { 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); } + + 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." : ""), + ), + ); } -function runNpmInstall(command: string): Promise { +interface CommandResult { + exitCode: number; + output: string; +} + +function runNpmInstall(command: string): Promise { const [cmd, ...args] = command.split(" "); - return new Promise((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 { + return new Promise((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,25 +636,162 @@ function runNpmInstall(command: string): Promise { // 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(code ?? 1); + resolveExit({ exitCode: code ?? 1, output }); }); }); } +interface VerificationResult { + ok: boolean; + actualVersion?: string; + output: string; + message: string; +} + +async function verifyInstalledVersion( + expectedVersion: string, + previousVersion: string, +): Promise { + 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 || ""}`, + }; + } + 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; + 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() || ""); +} + // --------------------------------------------------------------------------- // homebrew install (notice only) // --------------------------------------------------------------------------- @@ -480,9 +805,7 @@ async function handleHomebrewUpdate(): Promise { 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.)", diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index e02095c44..a526f9a5b 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -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 diff --git a/packages/cli/src/lib/dashboard-url.ts b/packages/cli/src/lib/dashboard-url.ts new file mode 100644 index 000000000..a27c2c2a2 --- /dev/null +++ b/packages/cli/src/lib/dashboard-url.ts @@ -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}`; +} diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index b29f7ee6f..c52c7d4be 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -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" }, ]; diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts index fc17acede..3e7f2f311 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -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 = { aider: aiderPlugin, cursor: cursorPlugin, kimicode: kimicodePlugin, + grok: grokPlugin, opencode: opencodePlugin, }; diff --git a/packages/cli/src/lib/routes.ts b/packages/cli/src/lib/routes.ts index 959b76424..1aab21280 100644 --- a/packages/cli/src/lib/routes.ts +++ b/packages/cli/src/lib/routes.ts @@ -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)}`; } diff --git a/packages/cli/src/lib/update-check.ts b/packages/cli/src/lib/update-check.ts index 80ec8f586..7dc72019f 100644 --- a/packages/cli/src/lib/update-check.ts +++ b/packages/cli/src/lib/update-check.ts @@ -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)" : ""; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 31a717031..a47c946fa 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -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 diff --git a/packages/core/package.json b/packages/core/package.json index 3ac2696d8..e44e109ad 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/__tests__/activity-log.test.ts b/packages/core/src/__tests__/activity-log.test.ts index 6d8288b8d..675441475 100644 --- a/packages/core/src/__tests__/activity-log.test.ts +++ b/packages/core/src/__tests__/activity-log.test.ts @@ -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 = { diff --git a/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts index d1d844899..2bbb9ca43 100644 --- a/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts @@ -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, }; diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index e78146fa1..68edb12fe 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -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, }); diff --git a/packages/core/src/__tests__/platform.test.ts b/packages/core/src/__tests__/platform.test.ts index 06667db02..0e3fcfb80 100644 --- a/packages/core/src/__tests__/platform.test.ts +++ b/packages/core/src/__tests__/platform.test.ts @@ -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"); diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index a66bf3744..880e96c18 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -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(); }); diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index 307ef55bb..6e489a8cb 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -103,6 +103,7 @@ function makeAssessment(overrides: Partial = {}): 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 () => { diff --git a/packages/core/src/__tests__/recovery-validator.test.ts b/packages/core/src/__tests__/recovery-validator.test.ts index c0e418152..2ceb85455 100644 --- a/packages/core/src/__tests__/recovery-validator.test.ts +++ b/packages/core/src/__tests__/recovery-validator.test.ts @@ -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(); }); diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index 0faf93c79..8e2724cd6 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -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"); }); }); diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index 17300fca6..39bb68b60 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -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, }); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index d92c1a4f1..d06dbbe3a 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -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 }); diff --git a/packages/core/src/__tests__/version-compare.test.ts b/packages/core/src/__tests__/version-compare.test.ts index 7eeedd9e4..859a49f32 100644 --- a/packages/core/src/__tests__/version-compare.test.ts +++ b/packages/core/src/__tests__/version-compare.test.ts @@ -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", () => { diff --git a/packages/core/src/activity-log.ts b/packages/core/src/activity-log.ts index 56a1e02c3..a661387eb 100644 --- a/packages/core/src/activity-log.ts +++ b/packages/core/src/activity-log.ts @@ -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 { const logPath = getActivityLogPath(workspacePath); @@ -98,7 +98,7 @@ export async function readLastActivityEntry( const record = parsed as Record; 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" || diff --git a/packages/core/src/agent-selection.ts b/packages/core/src/agent-selection.ts index e017e6e62..7878d564d 100644 --- a/packages/core/src/agent-selection.ts +++ b/packages/core/src/agent-selection.ts @@ -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; + 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; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a8a7f228e..4ca21c112 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 9fa1a928f..3cae2f77e 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -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", agentName); + const agentName = session.metadata["agent"]; + const agent = agentName ? registry.get("agent", agentName) : null; const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; let detectedIdleTimestamp: Date | null = null; let idleWasBlocked = false; diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index 5cde1af87..2f4323555 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -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 { diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index ed8db891a..304d8a893 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -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" }, diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index f7b57980a..c9d7728ff 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -51,6 +51,12 @@ function buildLifecycleRecoveryPatch( return buildLifecycleMetadataPatch(updated); } +function preserveSessionAgentPatch( + rawMetadata: Record, +): Partial> { + 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", diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index dff4fc7b7..2f9a472ef 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -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( - sessionId, - rawMetadata, - project.sessionPrefix, - Object.values(config.projects).map((p) => p.sessionPrefix), - ), + const agentName = resolveAgentSelectionForSession({ + sessionId, + 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", runtimeName); const agent = registry.get("agent", agentName); const workspace = registry.get("workspace", workspaceName); - const workspacePath = rawMetadata["worktree"] || null; - const runtimeHandleStr = rawMetadata["runtimeHandle"]; + const workspacePath = normalizedMetadata["worktree"] || null; + const runtimeHandleStr = normalizedMetadata["runtimeHandle"]; const runtimeHandle = runtimeHandleStr ? safeJsonParse(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, }; } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 30e7dc12e..86807c47b 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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(); @@ -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, ) { - return resolveAgentSelection({ - role: resolveSessionRole( - sessionId, - metadata, - project.sessionPrefix, - Object.values(config.projects).map((p) => p.sessionPrefix), - ), + return resolveAgentSelectionForSession({ + sessionId, + metadata, 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, - { sessionName: sessionId, raw, modifiedAt }, - project.sessionPrefix, + 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, - { sessionName: sessionId, raw, modifiedAt }, - project.sessionPrefix, + 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: "", }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 66b1ec0ef..87cbd6b0c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; diff --git a/packages/core/src/version-compare.ts b/packages/core/src/version-compare.ts index 224678656..a6145e16c 100644 --- a/packages/core/src/version-compare.ts +++ b/packages/core/src/version-compare.ts @@ -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`. * diff --git a/packages/notifier-macos/CHANGELOG.md b/packages/notifier-macos/CHANGELOG.md new file mode 100644 index 000000000..00ecdead5 --- /dev/null +++ b/packages/notifier-macos/CHANGELOG.md @@ -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. diff --git a/packages/notifier-macos/package.json b/packages/notifier-macos/package.json index 5b5a760b1..ee4f1d17d 100644 --- a/packages/notifier-macos/package.json +++ b/packages/notifier-macos/package.json @@ -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", diff --git a/packages/plugins/agent-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 2d281b453..135d02427 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -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 diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index 0d099bab4..d4bd5f51f 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -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", diff --git a/packages/plugins/agent-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index 00a33ac2e..71efb9f55 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -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 (`/.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//` (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 diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index ec4b77b30..4145cc1b5 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -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", diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index 332018d41..a93f7d74d 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -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 = { 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() }); diff --git a/packages/plugins/agent-claude-code/src/activity-detection.ts b/packages/plugins/agent-claude-code/src/activity-detection.ts index 940a4591f..af64c6cb5 100644 --- a/packages/plugins/agent-claude-code/src/activity-detection.ts +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -276,86 +276,27 @@ export async function isClaudeProcessAlive(handle: RuntimeHandle): Promise$#]\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"; } diff --git a/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts b/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts new file mode 100644 index 000000000..56ee653d4 --- /dev/null +++ b/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts @@ -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 | 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 | 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; + } 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)', + ); + }); + }); +} diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index be8955c55..8b65084b3 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -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 { + 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; + } + + /** 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)[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)["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)[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)["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. (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)["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)["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)["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 // ========================================================================= diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 29e5ce5e3..934fe3be2 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -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; +} + +/** + * 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, + reg: HookRegistration, +): void { + const existing = hooks[reg.event]; + const entries: Array = 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)["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)["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; + const hooksList = entry["hooks"] as Array>; + 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 { 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 = {}; if (existsSync(settingsPath)) { try { @@ -612,61 +989,12 @@ async function setupHookInWorkspace(workspacePath: string): Promise { } } - // Merge hooks configuration const hooks = (existingSettings["hooks"] as Record) ?? {}; - const postToolUse = (hooks["PostToolUse"] as Array) ?? []; - - // 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; - 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; - 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; - } - } - if (hookIndex >= 0) break; + for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) { + upsertHookEntry(hooks, reg); } - - // 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; - const hooksList = hook["hooks"] as Array>; - 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 { - 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 { return isClaudeProcessAlive(handle); diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index 0149854f8..2828522ea 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -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 diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index 66baf3267..1f4b2f2d5 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -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", diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 880b20a03..5499873a1 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -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(); + mockCreateInterface.mockImplementation((...args: Parameters) => + 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 () => { diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 93179d6d3..770c6a505 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -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 { - const jsonlFiles = await collectJsonlFiles(CODEX_SESSIONS_DIR); +async function findCodexSessionFile( + workspacePath: string, + jsonlFiles?: string[], +): Promise { + 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 { + 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 { + let stream: ReturnType | null = null; + let rl: ReturnType | null = null; + try { const data: CodexSessionData = { model: null, @@ -277,8 +316,9 @@ async function streamCodexSessionData(filePath: string): Promise(); -/** Find session file with caching to avoid double scans per refresh cycle */ -async function findCodexSessionFileCached(workspacePath: string): Promise { - 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, +): Promise { + 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 { + let jsonlFiles: string[] | null = null; + const getJsonlFiles = async (): Promise => { + 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 { - 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 { - 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 { + async setupWorkspaceHooks( + _workspacePath: string, + _config: WorkspaceHooksConfig, + ): Promise { // PATH wrappers are installed by session-manager for all agents. }, diff --git a/packages/plugins/agent-cursor/CHANGELOG.md b/packages/plugins/agent-cursor/CHANGELOG.md index 2d60850e0..bf14c6a4f 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -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 diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index 906630c79..41a665032 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -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", diff --git a/packages/plugins/agent-grok/CHANGELOG.md b/packages/plugins/agent-grok/CHANGELOG.md new file mode 100644 index 000000000..0b21db2a3 --- /dev/null +++ b/packages/plugins/agent-grok/CHANGELOG.md @@ -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 diff --git a/packages/plugins/agent-grok/package.json b/packages/plugins/agent-grok/package.json new file mode 100644 index 000000000..ba2e93aaa --- /dev/null +++ b/packages/plugins/agent-grok/package.json @@ -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" + } +} diff --git a/packages/plugins/agent-grok/src/__tests__/index.test.ts b/packages/plugins/agent-grok/src/__tests__/index.test.ts new file mode 100644 index 000000000..6e181e7e3 --- /dev/null +++ b/packages/plugins/agent-grok/src/__tests__/index.test.ts @@ -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; + 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 { + 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 { + 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 }, + ); + }); +}); diff --git a/packages/plugins/agent-grok/src/index.ts b/packages/plugins/agent-grok/src/index.ts new file mode 100644 index 000000000..084fac296 --- /dev/null +++ b/packages/plugins/agent-grok/src/index.ts @@ -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 { + const { stdout } = await execFileAsync( + "tmux", + ["capture-pane", "-t", handle.id, "-p", "-S", "-120"], + { timeout: 5_000 }, + ); + return stdout; +} + +async function waitForGrokWorktreeReady(session: Session): Promise { + 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 { + const env: Record = {}; + 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 { + 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> = 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 { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output: string) => + classifyGrokTerminalOutput(output), + ); + }, + + async isProcessRunning(handle: RuntimeHandle): Promise { + 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 { + const grokSessionId = asGrokSessionId(session.metadata?.grokSessionId); + if (!grokSessionId) return null; + return { + agentSessionId: grokSessionId, + summary: null, + }; + }, + + async getRestoreCommand(session: Session, project: ProjectConfig): Promise { + 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 { + await setupPathWrapperWorkspace(workspacePath); + }, + + async postLaunchSetup(session: Session): Promise { + 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; diff --git a/packages/plugins/agent-grok/tsconfig.json b/packages/plugins/agent-grok/tsconfig.json new file mode 100644 index 000000000..556dd3e4e --- /dev/null +++ b/packages/plugins/agent-grok/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index c2eeaab2c..3f75d3475 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -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 diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index 30c09fa89..e2190bc0d 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -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", diff --git a/packages/plugins/agent-kimicode/src/index.ts b/packages/plugins/agent-kimicode/src/index.ts index a0b26b9f6..2711bafa4 100644 --- a/packages/plugins/agent-kimicode/src/index.ts +++ b/packages/plugins/agent-kimicode/src/index.ts @@ -52,9 +52,12 @@ async function extractKimiSummary(sessionDir: string): Promise { // 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 | null = null; + let rl: ReturnType | 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 { // Skip malformed line } } - rl.close(); } catch { return null; + } finally { + rl?.close(); + stream?.destroy(); } return summary; } diff --git a/packages/plugins/agent-opencode/CHANGELOG.md b/packages/plugins/agent-opencode/CHANGELOG.md index db22d59f2..ae38ec215 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -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 diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index 16e3ba5a6..8a8f7ef7b 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -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", diff --git a/packages/plugins/notifier-composio/CHANGELOG.md b/packages/plugins/notifier-composio/CHANGELOG.md index ec8d20f5c..b5c236dbd 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -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 diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index 81a08f554..d72f6ec8d 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -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", diff --git a/packages/plugins/notifier-dashboard/CHANGELOG.md b/packages/plugins/notifier-dashboard/CHANGELOG.md new file mode 100644 index 000000000..307650914 --- /dev/null +++ b/packages/plugins/notifier-dashboard/CHANGELOG.md @@ -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 diff --git a/packages/plugins/notifier-dashboard/package.json b/packages/plugins/notifier-dashboard/package.json index 5bb7ac9c0..78ac44c4a 100644 --- a/packages/plugins/notifier-dashboard/package.json +++ b/packages/plugins/notifier-dashboard/package.json @@ -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", diff --git a/packages/plugins/notifier-desktop/CHANGELOG.md b/packages/plugins/notifier-desktop/CHANGELOG.md index 79b929fde..a1ccdd7e1 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -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 diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index 003280b7c..d347e8567 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -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", diff --git a/packages/plugins/notifier-discord/CHANGELOG.md b/packages/plugins/notifier-discord/CHANGELOG.md index e067ee0e1..68c0f568c 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -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 diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index 21c0ef29e..8c2ae9a60 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -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", diff --git a/packages/plugins/notifier-openclaw/CHANGELOG.md b/packages/plugins/notifier-openclaw/CHANGELOG.md index f392d06a9..36493b62c 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -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 diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json index 2b03b6369..c9406c5cb 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -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", diff --git a/packages/plugins/notifier-slack/CHANGELOG.md b/packages/plugins/notifier-slack/CHANGELOG.md index b0ddc06b1..3b6539509 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -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 diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index cbf23bd85..006e0178c 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-slack", - "version": "0.8.0", + "version": "0.9.1", "description": "Notifier plugin: Slack", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-webhook/CHANGELOG.md b/packages/plugins/notifier-webhook/CHANGELOG.md index 6288a0aa3..5846d1b8c 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-notifier-webhook +## 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 diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index c6a0b545f..22653f1dc 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-webhook", - "version": "0.8.0", + "version": "0.9.1", "description": "Notifier plugin: generic webhook", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-process/CHANGELOG.md b/packages/plugins/runtime-process/CHANGELOG.md index bda98972b..1ef7cb90d 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-runtime-process +## 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 diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 692ffb065..23252c2ca 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-process", - "version": "0.8.0", + "version": "0.9.1", "description": "Runtime plugin: child processes", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-tmux/CHANGELOG.md b/packages/plugins/runtime-tmux/CHANGELOG.md index 87ad7b266..e6762330f 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-runtime-tmux +## 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 diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index d32591794..271e9c229 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-tmux", - "version": "0.8.0", + "version": "0.9.1", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index f989e12f2..4701bbf01 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-scm-github +## 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 diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index 75f262e7f..fd4a933d7 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-github", - "version": "0.8.0", + "version": "0.9.1", "description": "SCM plugin: GitHub (PRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-gitlab/CHANGELOG.md b/packages/plugins/scm-gitlab/CHANGELOG.md index 4e55944ea..c7c953753 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-scm-gitlab +## 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 diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index 9367e5177..840a47919 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-gitlab", - "version": "0.8.0", + "version": "0.9.1", "description": "SCM plugin: GitLab (MRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-iterm2/CHANGELOG.md b/packages/plugins/terminal-iterm2/CHANGELOG.md index f11718dc8..57c66bd99 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-terminal-iterm2 +## 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 diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index b8472a40b..697281d89 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-iterm2", - "version": "0.8.0", + "version": "0.9.1", "description": "Terminal plugin: macOS iTerm2", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-web/CHANGELOG.md b/packages/plugins/terminal-web/CHANGELOG.md index d99d4c95e..86a063a89 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-terminal-web +## 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 diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index b0743fa88..7316b26ca 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-web", - "version": "0.8.0", + "version": "0.9.1", "description": "Terminal plugin: xterm.js web terminal", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-github/CHANGELOG.md b/packages/plugins/tracker-github/CHANGELOG.md index 012d22bc3..16708df8e 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-tracker-github +## 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 diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index 501e2d014..90d3bb572 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-github", - "version": "0.8.0", + "version": "0.9.1", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index 67aa2b0be..94cc5ab56 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,29 @@ # @aoagents/ao-plugin-tracker-gitlab +## 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-plugin-scm-gitlab@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 + - @aoagents/ao-plugin-scm-gitlab@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index 0275eb208..718dd3594 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-gitlab", - "version": "0.8.0", + "version": "0.9.1", "description": "Tracker plugin: GitLab Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/CHANGELOG.md b/packages/plugins/tracker-linear/CHANGELOG.md index 57c285650..555300ea6 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,28 @@ # @aoagents/ao-plugin-tracker-linear +## 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 + +- 6d48022: Retry transient Linear API HTTP failures in the direct transport to reduce flakes from brief 5xx/429 responses. +- 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 diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index baee198ec..7ab614f1f 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-linear", - "version": "0.8.0", + "version": "0.9.1", "description": "Tracker plugin: Linear", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-clone/CHANGELOG.md b/packages/plugins/workspace-clone/CHANGELOG.md index fc4342852..38b7c729b 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-workspace-clone +## 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 diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index bb8668e84..aeba1bedf 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-clone", - "version": "0.8.0", + "version": "0.9.1", "description": "Workspace plugin: git clone", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-worktree/CHANGELOG.md b/packages/plugins/workspace-worktree/CHANGELOG.md index 776b1a984..a83722ae1 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,27 @@ # @aoagents/ao-plugin-workspace-worktree +## 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 diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index 61b2459e0..7a9428f29 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-worktree", - "version": "0.8.0", + "version": "0.9.1", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index cd79e9e9b..8db0a8269 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,89 @@ # @aoagents/ao-web +## 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-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-runtime-process@0.9.1 + - @aoagents/ao-plugin-runtime-tmux@0.9.1 + - @aoagents/ao-plugin-scm-github@0.9.1 + - @aoagents/ao-plugin-tracker-github@0.9.1 + - @aoagents/ao-plugin-tracker-linear@0.9.1 + - @aoagents/ao-plugin-workspace-worktree@0.9.1 + +## 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. + +- 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 + +- ee3fb5d: 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`. +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- 07c9099: 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. + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [8c71bde] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [6d48022] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + - @aoagents/ao-plugin-agent-claude-code@0.9.0 + - @aoagents/ao-plugin-tracker-linear@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-runtime-process@0.9.0 + - @aoagents/ao-plugin-runtime-tmux@0.9.0 + - @aoagents/ao-plugin-scm-github@0.9.0 + - @aoagents/ao-plugin-tracker-github@0.9.0 + - @aoagents/ao-plugin-workspace-worktree@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 5923fedff..2639442dc 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -9,7 +9,6 @@ const homeDir = os.homedir().replace(/\\/g, "/"); const nextConfig = { outputFileTracingRoot: path.join(__dirname, "../.."), transpilePackages: [ - "@aoagents/ao-core", "@aoagents/ao-plugin-agent-claude-code", "@aoagents/ao-plugin-agent-codex", "@aoagents/ao-plugin-agent-opencode", @@ -22,6 +21,8 @@ const nextConfig = { serverExternalPackages: [ "yaml", "zod", + "@aoagents/ao-core", + "better-sqlite3", ], webpack: (config, { isServer }) => { if (process.platform === "win32") { diff --git a/packages/web/package.json b/packages/web/package.json index 238c9b42b..847c21835 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-web", - "version": "0.8.0", + "version": "0.9.1", "description": "Web dashboard for agent-orchestrator", "license": "MIT", "type": "module", @@ -47,6 +47,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-runtime-process": "workspace:*", diff --git a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts index 032a23753..bf63490aa 100644 --- a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts +++ b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts @@ -200,6 +200,19 @@ describeWithTmux("WebSocket upgrade routing", () => { ws.close(); }); + it("accepts connections on /ao-terminal-mux (alias for /mux)", async () => { + // The dashboard's MuxProvider uses this path on standard-port deployments + // so a path-routing reverse proxy can forward it here without a rewrite. + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://localhost:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WebSocket connect timeout")), 5000); + }); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + it("requires auth_token on /mux when remote auth is enabled", async () => { vi.stubEnv("AO_REMOTE_AUTH_USER", "ao"); vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret"); diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index c8472f08c..2154af7d2 100644 --- a/packages/web/server/__tests__/mux-websocket.test.ts +++ b/packages/web/server/__tests__/mux-websocket.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Socket } from "node:net"; import { WebSocket } from "ws"; -import { appendDashboardNotification, type OrchestratorEvent } from "@aoagents/ao-core"; +import { appendDashboardNotification, isWindows, type OrchestratorEvent } from "@aoagents/ao-core"; import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket"; // vi.mock factories run before module-level statements. Hoist the mock @@ -988,6 +988,41 @@ describe("TerminalManager.open — tmux target args (regression for #1714)", () const [, args] = mockPtySpawn.mock.calls[0]; expect(args).toEqual(["attach-session", "-t", "=ao-177"]); }); + + it("repairs node-pty spawn-helper when applicable and retries once after posix_spawnp failure", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-mux-spawn-helper-")); + const helperPath = join(tempRoot, "spawn-helper"); + writeFileSync(helperPath, "#!/bin/sh\nexit 0\n"); + chmodSync(helperPath, 0o644); + process.env.AO_NODE_PTY_SPAWN_HELPER_PATH = helperPath; + + const pty = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + }; + + mockPtySpawn + .mockImplementationOnce(() => { + throw new Error("posix_spawnp failed."); + }) + .mockImplementationOnce(() => pty); + + try { + const mgr = new TerminalManager("/usr/bin/tmux"); + mgr.open("ao-177"); + + expect(mockPtySpawn).toHaveBeenCalledTimes(2); + if (!isWindows()) { + expect((statSync(helperPath).mode & 0o111) !== 0).toBe(true); + } + } finally { + delete process.env.AO_NODE_PTY_SPAWN_HELPER_PATH; + rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); describe("TerminalManager.open — re-attach skipped when tmux session is gone (regression for #1756)", () => { diff --git a/packages/web/server/__tests__/single-port-server.integration.test.ts b/packages/web/server/__tests__/single-port-server.integration.test.ts new file mode 100644 index 000000000..249f4f039 --- /dev/null +++ b/packages/web/server/__tests__/single-port-server.integration.test.ts @@ -0,0 +1,308 @@ +/** + * Integration tests for single-port-server — the opt-in HTTP + WebSocket + * proxy used when AO_PATH_BASED_MUX=1. + * + * These pin the four behaviours surfaced in review of PR #1757: + * 1. Hop-by-hop request headers are stripped before reaching the upstream. + * 2. X-Forwarded-For/-Proto/-Host are added so the upstream sees the client. + * 3. A non-101 response on the WS upgrade path is relayed, not left to hang. + * 4. shutdown() closes promptly even with a live connection open. + * + * The proxy is pointed at lightweight fake upstreams, so no tmux / Next.js is + * needed — these run everywhere, including CI on Windows. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { + createServer as createHttpServer, + request, + type Server, + type IncomingMessage, +} from "node:http"; +import { connect as netConnect, type AddressInfo } from "node:net"; +import { randomBytes } from "node:crypto"; +import { WebSocketServer, WebSocket } from "ws"; +import { createSinglePortServer, type SinglePortServer } from "../single-port-server.js"; + +// ============================================================================= +// Teardown registry — every server/proxy created by a test is closed here. +// ============================================================================= + +const closers: Array<() => void | Promise> = []; + +afterEach(async () => { + while (closers.length > 0) { + const close = closers.pop(); + if (close) await close(); + } +}); + +function portOf(server: Server): number { + const addr = server.address() as AddressInfo | null; + return addr ? addr.port : 0; +} + +/** A fake "Next.js" upstream that echoes the request it received as JSON. */ +async function startEchoUpstream(): Promise { + const server = createHttpServer((req, res) => { + let body = ""; + req.on("data", (c: Buffer) => (body += c.toString())); + req.on("end", () => { + // Set an explicit Content-Length (not chunked) so the raw-socket test + // can read the body without decoding chunk framing. + const payload = JSON.stringify({ + method: req.method, + url: req.url, + headers: req.headers, + body, + }); + res.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => new Promise((r) => server.close(() => r()))); + return portOf(server); +} + +/** A fake direct-terminal-ws upstream: real WS server on /mux that echoes. */ +async function startWsUpstream(): Promise { + const server = createHttpServer(); + const wss = new WebSocketServer({ server, path: "/mux" }); + wss.on("connection", (socket) => { + socket.on("message", (data) => socket.send(data)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => { + wss.close(); + return new Promise((r) => server.close(() => r())); + }); + return portOf(server); +} + +/** + * A fake upstream that answers a WS upgrade with a plain non-101 response — + * the case fix #3 exists for. + */ +async function startNon101Upstream(): Promise { + const server = createHttpServer(); + server.on("upgrade", (_req, socket) => { + socket.end( + "HTTP/1.1 503 Service Unavailable\r\n" + + "content-type: text/plain\r\n" + + "content-length: 11\r\n" + + "connection: close\r\n\r\n" + + "unavailable", + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => new Promise((r) => server.close(() => r()))); + return portOf(server); +} + +/** Start the proxy in front of the given upstream ports. */ +async function startProxy(opts: { + nextInternalPort: number; + directTerminalPort: number; +}): Promise<{ proxy: SinglePortServer; port: number }> { + const proxy = createSinglePortServer({ + port: 0, + nextInternalPort: opts.nextInternalPort, + directTerminalPort: opts.directTerminalPort, + }); + await proxy.listen(); + closers.push(() => proxy.shutdown()); + return { proxy, port: portOf(proxy.server) }; +} + +/** + * Write a raw HTTP request and collect the full raw response. `closedByPeer` + * reports whether the server closed the connection on its own — used to prove + * the proxy honoured the client's `Connection: close` instead of forwarding + * the upstream's keep-alive. + */ +function rawHttpRequest( + port: number, + requestLines: string[], +): Promise<{ raw: string; closedByPeer: boolean }> { + return new Promise((resolve, reject) => { + const socket = netConnect({ port, host: "127.0.0.1" }, () => { + socket.write(requestLines.join("\r\n") + "\r\n\r\n"); + }); + let raw = ""; + let closedByPeer = false; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (raw += chunk)); + socket.on("error", reject); + socket.on("end", () => { + closedByPeer = true; + resolve({ raw, closedByPeer }); + }); + setTimeout(() => { + socket.destroy(); + resolve({ raw, closedByPeer }); + }, 2000); + }); +} + +// ============================================================================= +// HTTP forwarding — header sanitisation +// ============================================================================= + +describe("single-port HTTP forwarding", () => { + it("strips hop-by-hop headers and adds X-Forwarded-* before reaching upstream", async () => { + const nextPort = await startEchoUpstream(); + const { port } = await startProxy({ nextInternalPort: nextPort, directTerminalPort: 1 }); + + const { raw, closedByPeer } = await rawHttpRequest(port, [ + "GET /echo HTTP/1.1", + "Host: dashboard.example", + // "close" ends our client connection; "x-custom-hop" is named in + // Connection so it is hop-by-hop too — both must be stripped. + "Connection: close, X-Custom-Hop", + "X-Custom-Hop: should-be-stripped", + "Keep-Alive: timeout=99", + "X-Forwarded-For: 1.2.3.4", + ]); + + // The proxy must honour the client's Connection: close and not forward + // the upstream's keep-alive — otherwise the connection lingers. + expect(closedByPeer).toBe(true); + + const body = raw.slice(raw.indexOf("\r\n\r\n") + 4); + const seen = JSON.parse(body) as { headers: Record }; + + // The client's hop-by-hop headers must not leak to the upstream. + // X-Custom-Hop is listed in the client's Connection header, marking it + // hop-by-hop — its absence proves Connection-token parsing works. + expect(seen.headers["x-custom-hop"]).toBeUndefined(); + // The standard hop-by-hop Keep-Alive header is dropped. + expect(seen.headers["keep-alive"]).not.toBe("timeout=99"); + // The client's Connection value ("close") must not propagate. The + // proxy↔upstream hop has its own Connection header, managed by Node — + // that is correct and expected, so only assert the client's value is gone. + expect(String(seen.headers.connection ?? "")).not.toMatch(/close|x-custom-hop/); + + // X-Forwarded-For preserves the prior value and appends the proxy client. + expect(seen.headers["x-forwarded-for"]).toMatch(/^1\.2\.3\.4, /); + expect(seen.headers["x-forwarded-proto"]).toBe("http"); + expect(seen.headers["x-forwarded-host"]).toBe("dashboard.example"); + }); + + it("returns 502 when the Next.js upstream is unreachable", async () => { + // Point at a port nothing is listening on. + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: 1 }); + + const status = await new Promise((resolve, reject) => { + const req = request( + { host: "127.0.0.1", port, path: "/", method: "GET" }, + (res: IncomingMessage) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on("error", reject); + req.end(); + }); + + expect(status).toBe(502); + }); +}); + +// ============================================================================= +// WebSocket upgrade path +// ============================================================================= + +describe("single-port WebSocket upgrade", () => { + it("tunnels /ao-terminal-mux to the terminal upstream's /mux", async () => { + const wsPort = await startWsUpstream(); + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: wsPort }); + + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WS connect timeout")), 3000); + }); + + const echoed = await new Promise((resolve, reject) => { + ws.on("message", (data) => resolve(data.toString())); + ws.on("error", reject); + ws.send("ping-through-proxy"); + setTimeout(() => reject(new Error("WS echo timeout")), 3000); + }); + + expect(echoed).toBe("ping-through-proxy"); + ws.close(); + }); + + it("relays a non-101 upstream response instead of hanging the client", async () => { + // Regression test for the WS-upgrade hang: before the `response` handler, + // a non-101 upstream answer left the client socket open until TCP timeout. + const badPort = await startNon101Upstream(); + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: badPort }); + + const result = await new Promise<{ type: string; status?: number }>((resolve) => { + const req = request({ + host: "127.0.0.1", + port, + path: "/ao-terminal-mux", + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + // Generated rather than hardcoded — a literal base64 nonce trips + // the repo's gitleaks pre-commit hook as a high-entropy string. + "Sec-WebSocket-Key": randomBytes(16).toString("base64"), + }, + }); + req.on("upgrade", (res) => resolve({ type: "upgrade", status: res.statusCode })); + req.on("response", (res) => { + res.resume(); + resolve({ type: "response", status: res.statusCode }); + }); + req.on("error", () => resolve({ type: "error" })); + // If the proxy hangs, none of the above fire and this surfaces it. + setTimeout(() => resolve({ type: "hang" }), 3000); + req.end(); + }); + + expect(result.type).toBe("response"); + expect(result.status).toBe(503); + }); +}); + +// ============================================================================= +// Shutdown +// ============================================================================= + +describe("single-port shutdown", () => { + it("closes promptly with a live WebSocket connection open", async () => { + // Regression test for shutdown hitting the force-exit timer: server.close() + // alone waits forever for the piped WS tunnel; closeAllConnections() fixes it. + const wsPort = await startWsUpstream(); + const { proxy, port } = await startProxy({ + nextInternalPort: 1, + directTerminalPort: wsPort, + }); + + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WS connect timeout")), 3000); + }); + + const start = Date.now(); + await proxy.shutdown(); + const elapsed = Date.now() - start; + + // Comfortably under the 5s force-exit timer the entrypoint arms. + expect(elapsed).toBeLessThan(1000); + + ws.close(); + }); +}); diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index d8ebb63b0..8809f7748 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -79,11 +79,20 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm // Manual upgrade routing — ws library doesn't support multiple WebSocketServer // instances with different `path` options on the same HTTP server. + // `/ao-terminal-mux` is accepted as an alias of `/mux` so deployments fronted + // by a path-routing reverse proxy (e.g. cloudflared, nginx) can forward the + // dashboard's path-based mux URL straight at this port without needing a + // path-rewrite rule. The dashboard's MuxProvider already constructs that + // path when accessed on a standard HTTPS port; see `packages/web/src/providers/MuxProvider.tsx`. server.on("upgrade", (request, socket, head) => { const url = new URL(request.url ?? "/", "ws://localhost"); const pathname = url.pathname; - if (pathname === "/mux" && muxWss && isRemoteAuthAllowed(url, initialConfiguredAuth)) { + if ( + (pathname === "/mux" || pathname === "/ao-terminal-mux") && + muxWss && + isRemoteAuthAllowed(url, initialConfiguredAuth) + ) { muxWss.handleUpgrade(request, socket, head, (ws) => { muxWss!.emit("connection", ws, request); }); diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 4e4249342..b34b05a98 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -8,11 +8,15 @@ import { WebSocketServer, WebSocket } from "ws"; import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; import { type Socket, connect as netConnect } from "node:net"; +import { dirname, join } from "node:path"; import { DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, getEnvDefaults, getDashboardNotificationStorePath, + getNodePtyPrebuildsSubdir, isWindows, loadConfig, normalizeDashboardNotificationLimit, @@ -386,8 +390,38 @@ export class NotificationBroadcaster { // node-pty is an optionalDependency — load dynamically /* eslint-disable @typescript-eslint/consistent-type-imports -- node-pty is optional; static import would crash if missing */ type IPty = import("node-pty").IPty; -let ptySpawn: typeof import("node-pty").spawn | undefined; +type PtySpawn = typeof import("node-pty").spawn; +type PtySpawnOptions = Parameters[2]; +let ptySpawn: PtySpawn | undefined; /* eslint-enable @typescript-eslint/consistent-type-imports */ +const nodePtyRequire = createRequire(import.meta.url); + +export function resolveNodePtySpawnHelperPath(): string | null { + const override = process.env.AO_NODE_PTY_SPAWN_HELPER_PATH; + if (override) return override; + + try { + const packageJsonPath = nodePtyRequire.resolve("node-pty/package.json"); + return join(dirname(packageJsonPath), "prebuilds", getNodePtyPrebuildsSubdir(), "spawn-helper"); + } catch { + return null; + } +} + +function isPosixSpawnpFailure(err: unknown): boolean { + return err instanceof Error && err.message.includes("posix_spawnp"); +} + +function repairNodePtySpawnHelper(): string | null { + if (isWindows()) return null; + + const spawnHelperPath = resolveNodePtySpawnHelperPath(); + if (!spawnHelperPath || !fs.existsSync(spawnHelperPath)) return null; + + fs.chmodSync(spawnHelperPath, 0o755); + return spawnHelperPath; +} + try { const nodePty = await import("node-pty"); ptySpawn = nodePty.spawn; @@ -438,6 +472,7 @@ const REATTACH_RESET_GRACE_MS = 5_000; export class TerminalManager { private terminals = new Map(); private TMUX: string; + private spawnHelperRepairAttempted = false; constructor(tmuxPath?: string) { const resolved = tmuxPath ?? findTmux(); @@ -451,6 +486,41 @@ export class TerminalManager { return projectId ? `${projectId}:${id}` : id; } + private spawnTmuxPty(args: string[], options: PtySpawnOptions): IPty { + if (!ptySpawn) { + throw new Error("node-pty not available"); + } + + try { + return ptySpawn(this.TMUX, args, options); + } catch (err) { + if (this.spawnHelperRepairAttempted || !isPosixSpawnpFailure(err)) { + throw err; + } + + this.spawnHelperRepairAttempted = true; + try { + const repairedPath = repairNodePtySpawnHelper(); + if (repairedPath) { + console.warn( + `[MuxServer] node-pty posix_spawnp failed; set executable bit on ${repairedPath} and retrying once.`, + ); + } else { + console.warn( + "[MuxServer] node-pty posix_spawnp failed; spawn-helper was not found, retrying once.", + ); + } + } catch (repairErr) { + const message = repairErr instanceof Error ? repairErr.message : String(repairErr); + console.warn( + `[MuxServer] node-pty posix_spawnp failed; chmod spawn-helper failed (${message}), retrying once.`, + ); + } + + return ptySpawn(this.TMUX, args, options); + } + } + /** * Open/attach to a terminal. If already open, just return. * If has subscribers but PTY crashed, re-attach. @@ -521,14 +591,10 @@ export class TerminalManager { TMPDIR: platformDefaults.TMPDIR, }; - if (!ptySpawn) { - throw new Error("node-pty not available"); - } - // Spawn PTY — use `=`-prefixed exact-match target so we never attach to // a session whose name happens to be a prefix of the requested id. const exactTmuxTarget = `=${tmuxSessionId}`; - const pty = ptySpawn(this.TMUX, ["attach-session", "-t", exactTmuxTarget], { + const pty = this.spawnTmuxPty(["attach-session", "-t", exactTmuxTarget], { name: "xterm-256color", cols: 80, rows: 24, diff --git a/packages/web/server/single-port-server.ts b/packages/web/server/single-port-server.ts new file mode 100644 index 000000000..d344d0df6 --- /dev/null +++ b/packages/web/server/single-port-server.ts @@ -0,0 +1,378 @@ +/** + * Single-port server (opt-in) — a thin HTTP + WebSocket proxy that puts + * Next.js and the `/ao-terminal-mux` WebSocket upgrade on the same public + * port. Spawned by start-all.ts when AO_PATH_BASED_MUX=1, in front of a + * Next.js process that has shifted to an internal port. + * + * ┌──────────────────────┐ HTTP ┌──────────────────────┐ + * │ proxy on PORT │───────▶│ next start │ + * │ (this file) │ │ on NEXT_INTERNAL_PORT │ + * │ │ └──────────────────────┘ + * │ │ WS upgrade /ao-terminal-mux + * │ │───────▶┌──────────────────────┐ + * │ │ │ direct-terminal-ws │ + * │ │ │ on DIRECT_TERMINAL │ + * │ │ └──────────────────────┘ + * └──────────────────────┘ + * + * The default flow (AO_PATH_BASED_MUX unset) is unchanged: Next.js runs on + * PORT directly, direct-terminal-ws runs on DIRECT_TERMINAL_PORT, and the + * dashboard JS picks one of three URLs at connection time + * (see `packages/web/src/providers/MuxProvider.tsx`): + * + * 1. proxyWsPath (TERMINAL_WS_PATH) — explicit path-based routing + * 2. standard port (loc.port "" / 443 / 80) — `/ao-terminal-mux` on same host + * 3. fallback — direct connection to `:DIRECT_TERMINAL_PORT/mux` + * + * Path #1 and #3 require the operator to do something at the proxy layer + * (path rewrite or per-port routing). Path #2 only works if *something* is + * listening for the `/ao-terminal-mux` upgrade on the dashboard port. Until + * now, nothing was — Next.js doesn't handle upgrades, so the request fell + * through to its 404 handler. This server is that something. + * + * Use this when the reverse proxy in front of AO can only forward one + * hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at one + * `service:` URL with no path-based ingress). With this enabled, a single + * proxy rule pointing at PORT is sufficient — the WS path is multiplexed + * onto the same TCP port and demuxed here. + * + * `createSinglePortServer()` is exported so the proxy behaviour can be + * exercised in tests; the bottom-of-file entrypoint wires it to env vars and + * process signals when this file is run directly (as start-all.ts spawns it). + */ + +import { + createServer, + request as httpRequest, + type IncomingHttpHeaders, + type IncomingMessage, + type OutgoingHttpHeaders, + type Server, +} from "node:http"; +import type { Socket } from "node:net"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const MUX_PATH = "/ao-terminal-mux"; +const SHUTDOWN_TIMEOUT_MS = 5_000; + +/** + * Hop-by-hop headers (RFC 9110 §7.6.1) are meaningful only on a single + * transport connection and must not be forwarded by an intermediary. + * Forwarding e.g. a client's `Connection: close` would tear down the + * keep-alive socket to the upstream; a stray `Transfer-Encoding` would + * desync framing once the body is re-encoded. + */ +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +export interface SinglePortConfig { + /** Public-facing port this proxy listens on. */ + port: number; + /** Internal port Next.js has been shifted to. */ + nextInternalPort: number; + /** Port direct-terminal-ws listens on. */ + directTerminalPort: number; +} + +export interface SinglePortServer { + /** The underlying HTTP server — exposed for `.address()` in tests. */ + server: Server; + /** Begin listening on the configured port; resolves once bound. */ + listen(): Promise; + /** + * Stop listening and force-close every live connection, resolving once the + * server is fully closed. `server.close()` alone waits for keep-alive HTTP + * sockets and piped WS tunnels to drain on their own, which they never do. + */ + shutdown(): Promise; +} + +/** + * Build the header set for an upstream request: strip hop-by-hop headers + * (including any extra ones named in the client's `Connection` header) and + * append the standard `X-Forwarded-*` trio so the upstream still sees the + * real client IP / proto / host instead of `127.0.0.1`. + * + * On the WebSocket upgrade path, `keepUpgrade` retains `Connection` and + * `Upgrade` — the handshake is exactly the case where those headers are + * load-bearing rather than hop-by-hop noise. + */ +function buildUpstreamHeaders( + req: IncomingMessage, + opts: { keepUpgrade: boolean }, +): OutgoingHttpHeaders { + const drop = new Set(HOP_BY_HOP); + + const connection = req.headers.connection; + if (connection) { + const tokens = Array.isArray(connection) ? connection : connection.split(","); + for (const token of tokens) { + const name = token.trim().toLowerCase(); + if (name) drop.add(name); + } + } + if (opts.keepUpgrade) { + drop.delete("connection"); + drop.delete("upgrade"); + } + + const headers: OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (drop.has(key.toLowerCase())) continue; + headers[key] = value; + } + + // X-Forwarded-*: preserve anything an outer proxy already set, then add ours. + const clientIp = req.socket.remoteAddress ?? ""; + const priorFor = headers["x-forwarded-for"]; + headers["x-forwarded-for"] = priorFor + ? `${Array.isArray(priorFor) ? priorFor.join(", ") : String(priorFor)}, ${clientIp}` + : clientIp; + // This proxy itself terminates plain HTTP; an outer TLS proxy would have + // set x-forwarded-proto already, so only fill it in when absent. + if (headers["x-forwarded-proto"] === undefined) { + headers["x-forwarded-proto"] = "http"; + } + if (headers["x-forwarded-host"] === undefined && req.headers.host) { + headers["x-forwarded-host"] = req.headers.host; + } + return headers; +} + +/** + * Drop hop-by-hop headers from an upstream *response* before relaying it to + * the client. Without this the upstream's `Connection`/`Keep-Alive` would + * override the proxy↔client connection's own semantics — e.g. forwarding the + * upstream's `Connection: keep-alive` ignores a client that asked for `close`. + * Framing headers (`transfer-encoding`) are dropped here too so the client's + * `ServerResponse` re-derives framing for its own hop. + */ +function filterResponseHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders { + const out: OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (HOP_BY_HOP.has(key.toLowerCase())) continue; + out[key] = value; + } + return out; +} + +function tunnelUpgrade( + req: IncomingMessage, + clientSocket: Socket, + clientHead: Buffer, + target: { host: string; port: number; path: string }, +): void { + const proxyReq = httpRequest({ + host: target.host, + port: target.port, + method: "GET", + path: target.path, + headers: buildUpstreamHeaders(req, { keepUpgrade: true }), + }); + + proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => { + const lines = [ + `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}`, + ]; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) continue; + lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`); + } + lines.push("\r\n"); + clientSocket.write(lines.join("\r\n")); + + if (proxyHead.length > 0) clientSocket.write(proxyHead); + if (clientHead.length > 0) proxySocket.write(clientHead); + + clientSocket.pipe(proxySocket); + proxySocket.pipe(clientSocket); + + const teardown = (): void => { + clientSocket.destroy(); + proxySocket.destroy(); + }; + proxySocket.on("error", teardown); + proxySocket.on("close", teardown); + clientSocket.on("error", teardown); + clientSocket.on("close", teardown); + }); + + // Upstream answered the upgrade with an ordinary response (404, 502, + // mid-restart, path not in its allow-list, …) instead of a 101. Without + // this handler the `upgrade` event never fires and the client socket + // hangs until its TCP timeout. Relay the response and close cleanly. + proxyReq.on("response", (proxyRes) => { + if (clientSocket.writableEnded || clientSocket.destroyed) { + proxyRes.destroy(); + return; + } + const lines = [`HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? ""}`]; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) continue; + const lower = key.toLowerCase(); + // Body is delimited by connection close below, so drop framing headers. + if (HOP_BY_HOP.has(lower) || lower === "content-length") continue; + lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`); + } + lines.push("connection: close"); + lines.push("\r\n"); + clientSocket.write(lines.join("\r\n")); + proxyRes.pipe(clientSocket); + proxyRes.on("end", () => clientSocket.end()); + }); + + proxyReq.on("error", (err) => { + console.error( + `[single-port] upstream upgrade error (${target.host}:${target.port}${target.path}): ${err.message}`, + ); + clientSocket.destroy(); + }); + + proxyReq.end(); +} + +/** + * Create the single-port proxy. The returned server is not yet listening — + * call `listen()`. + */ +export function createSinglePortServer(config: SinglePortConfig): SinglePortServer { + const { port, nextInternalPort, directTerminalPort } = config; + + // Sockets handed off via the 'upgrade' event are no longer tracked by the + // HTTP server, so `server.closeAllConnections()` does not destroy them and + // `server.close()`'s callback would wait on them forever. Track them here. + const upgradedSockets = new Set(); + + const server = createServer((req, res) => { + const proxyReq = httpRequest( + { + host: "127.0.0.1", + port: nextInternalPort, + method: req.method, + path: req.url, + headers: buildUpstreamHeaders(req, { keepUpgrade: false }), + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, filterResponseHeaders(proxyRes.headers)); + proxyRes.pipe(res); + }, + ); + + proxyReq.on("error", (err) => { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "text/plain" }); + } + res.end(`Bad gateway: ${err.message}`); + }); + + req.pipe(proxyReq); + }); + + server.on("upgrade", (req, socket, head) => { + const clientSocket = socket as Socket; + upgradedSockets.add(clientSocket); + clientSocket.once("close", () => upgradedSockets.delete(clientSocket)); + + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const target = + pathname === MUX_PATH + ? { host: "127.0.0.1", port: directTerminalPort, path: "/mux" } + : { host: "127.0.0.1", port: nextInternalPort, path: req.url ?? "/" }; + + tunnelUpgrade(req, clientSocket, head, target); + }); + + return { + server, + listen() { + return new Promise((resolve) => server.listen(port, () => resolve())); + }, + shutdown() { + return new Promise((resolve) => { + server.close(() => resolve()); + // closeAllConnections() handles keep-alive HTTP sockets; the upgraded + // WS tunnels are tracked separately and destroyed here. Destroying the + // client socket triggers tunnelUpgrade's teardown for the upstream side. + server.closeAllConnections(); + for (const socket of upgradedSockets) socket.destroy(); + }); + }, + }; +} + +/** Parse and validate the proxy config from env vars, exiting on bad input. */ +function configFromEnv(): SinglePortConfig { + const port = parseInt(process.env.PORT ?? "3000", 10); + const directTerminalPort = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10); + const nextInternalPort = parseInt(process.env.NEXT_INTERNAL_PORT ?? "0", 10); + + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + console.error(`[single-port] Invalid PORT: ${process.env.PORT}`); + process.exit(1); + } + if ( + !Number.isInteger(directTerminalPort) || + directTerminalPort < 1 || + directTerminalPort > 65_535 + ) { + console.error( + `[single-port] Invalid DIRECT_TERMINAL_PORT: ${process.env.DIRECT_TERMINAL_PORT}`, + ); + process.exit(1); + } + if ( + !Number.isInteger(nextInternalPort) || + nextInternalPort < 1 || + nextInternalPort > 65_535 || + nextInternalPort === port + ) { + console.error( + `[single-port] Invalid NEXT_INTERNAL_PORT (must differ from PORT): ${process.env.NEXT_INTERNAL_PORT}`, + ); + process.exit(1); + } + return { port, nextInternalPort, directTerminalPort }; +} + +function main(): void { + const config = configFromEnv(); + const proxy = createSinglePortServer(config); + + void proxy.listen().then(() => { + console.log( + `[single-port] listening on ${config.port}; HTTP → 127.0.0.1:${config.nextInternalPort}; ${MUX_PATH} → 127.0.0.1:${config.directTerminalPort}/mux`, + ); + }); + + const onSignal = (): void => { + void proxy.shutdown().then(() => process.exit(0)); + setTimeout(() => process.exit(1), SHUTDOWN_TIMEOUT_MS).unref(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); +} + +/** True when this file was run directly (`node single-port-server.js`). */ +function isMainModule(): boolean { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { + main(); +} diff --git a/packages/web/server/start-all.ts b/packages/web/server/start-all.ts index b36ba5bcc..8aaa7f641 100644 --- a/packages/web/server/start-all.ts +++ b/packages/web/server/start-all.ts @@ -105,6 +105,23 @@ function spawnProcess( const port = process.env["PORT"] || "3000"; const hostname = process.env["HOST"] || "0.0.0.0"; process.env["AO_TRUST_REMOTE_ADDRESS_HEADER"] = "1"; +const pathBasedMux = process.env["AO_PATH_BASED_MUX"] === "1"; + +// When AO_PATH_BASED_MUX=1, single-port-server.js owns PORT and Next.js is +// shifted to PORT + 1000 (overridable via NEXT_INTERNAL_PORT). The proxy +// forwards HTTP to Next.js and tunnels `/ao-terminal-mux` WS upgrades to +// direct-terminal-ws. Default off — Next.js stays on PORT directly. +const NEXT_INTERNAL_OFFSET = 1000; +const nextPort = pathBasedMux + ? (process.env["NEXT_INTERNAL_PORT"] ?? String(parseInt(port, 10) + NEXT_INTERNAL_OFFSET)) + : port; + +if (pathBasedMux) { + // Surface the internal port to the child so it doesn't have to re-derive + // the offset; pin it explicitly. + process.env["NEXT_INTERNAL_PORT"] = nextPort; + spawnProcess("single-port", process.execPath, [resolve(__dirname, "single-port-server.js")]); +} // Start direct terminal WebSocket server (auto-restart on crash) spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws.js")], { @@ -112,7 +129,7 @@ spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws. }); async function startNextServer(): Promise { - const app = next({ dev: false, dir: pkgRoot, hostname, port: Number.parseInt(port, 10) }); + const app = next({ dev: false, dir: pkgRoot, hostname, port: Number.parseInt(nextPort, 10) }); const handle = app.getRequestHandler(); await app.prepare(); @@ -127,8 +144,8 @@ async function startNextServer(): Promise { } }); - nextServer.listen(Number.parseInt(port, 10), hostname, () => { - log("next", `ready on http://${hostname}:${port}`); + nextServer.listen(Number.parseInt(nextPort, 10), hostname, () => { + log("next", `ready on http://${hostname}:${nextPort}`); }); } diff --git a/packages/web/src/__tests__/services.test.ts b/packages/web/src/__tests__/services.test.ts index 8d9274670..427d7bbf9 100644 --- a/packages/web/src/__tests__/services.test.ts +++ b/packages/web/src/__tests__/services.test.ts @@ -10,6 +10,7 @@ const { tmuxPlugin, claudePlugin, codexPlugin, + grokPlugin, opencodePlugin, worktreePlugin, scmPlugin, @@ -44,6 +45,7 @@ const { tmuxPlugin: { manifest: { name: "tmux" } }, claudePlugin: { manifest: { name: "claude-code" } }, codexPlugin: { manifest: { name: "codex" } }, + grokPlugin: { manifest: { name: "grok" } }, opencodePlugin: { manifest: { name: "opencode" } }, worktreePlugin: { manifest: { name: "worktree" } }, scmPlugin: { manifest: { name: "github" } }, @@ -70,6 +72,7 @@ vi.mock("@aoagents/ao-core", () => ({ vi.mock("@aoagents/ao-plugin-runtime-tmux", () => ({ default: tmuxPlugin })); vi.mock("@aoagents/ao-plugin-agent-claude-code", () => ({ default: claudePlugin })); vi.mock("@aoagents/ao-plugin-agent-codex", () => ({ default: codexPlugin })); +vi.mock("@aoagents/ao-plugin-agent-grok", () => ({ default: grokPlugin })); vi.mock("@aoagents/ao-plugin-agent-opencode", () => ({ default: opencodePlugin })); vi.mock("@aoagents/ao-plugin-workspace-worktree", () => ({ default: worktreePlugin })); vi.mock("@aoagents/ao-plugin-scm-github", () => ({ default: scmPlugin })); @@ -120,6 +123,14 @@ describe("services", () => { expect(mockRegister).toHaveBeenCalledWith(codexPlugin); }); + it("registers the Grok agent plugin with web services", async () => { + const { getServices } = await import("../lib/services"); + + await getServices(); + + expect(mockRegister).toHaveBeenCalledWith(grokPlugin); + }); + it("caches initialized services across repeated calls", async () => { const { getServices } = await import("../lib/services"); diff --git a/packages/web/src/app/api/version/route.ts b/packages/web/src/app/api/version/route.ts index 768820ed8..2613a7d0a 100644 --- a/packages/web/src/app/api/version/route.ts +++ b/packages/web/src/app/api/version/route.ts @@ -13,7 +13,7 @@ import { NextResponse } from "next/server"; import { getInstalledAoVersion, - isVersionOutdated, + isVersionOutdatedForChannel, loadGlobalConfig, readUpdateCheckCacheRaw, type UpdateChannel, @@ -53,7 +53,8 @@ export async function GET() { const latest = cache?.latestVersion && cacheMatchesChannel ? cache.latestVersion : null; // Git installs cache `latestVersion: "origin/main"` (a ref, not a semver), - // so `isVersionOutdated(current, "origin/main")` would always return false. + // so `isVersionOutdatedForChannel(current, "origin/main", channel)` would + // always return false. // The CLI works around this by trusting the precomputed `cached.isOutdated` // for git installs — mirror that here so the dashboard banner actually // appears when a git-installed user is behind origin/main. @@ -62,7 +63,7 @@ export async function GET() { isOutdated = cache?.installMethod === "git" ? cache.isOutdated === true - : isVersionOutdated(current, latest); + : isVersionOutdatedForChannel(current, latest, channel); } const body: VersionResponse = { diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index e7e6d9b16..77bd405a3 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1206,6 +1206,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .dashboard-app-header__project { font-size: 12px; font-weight: 400; + line-height: 1; color: var(--color-text-secondary); } @@ -1346,6 +1347,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 999px; font-size: 11px; font-weight: 500; + line-height: 1; white-space: nowrap; color: var(--color-text-secondary); } @@ -1372,6 +1374,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { height: 6px; border-radius: 50%; flex-shrink: 0; + background: var(--color-text-muted); +} +.topbar-status-pill--active .topbar-status-pill__dot { + background: var(--color-status-working); +} +.topbar-status-pill--ready .topbar-status-pill__dot { + background: var(--color-status-ready); +} +.topbar-status-pill--idle .topbar-status-pill__dot { + background: var(--color-text-tertiary); +} +.topbar-status-pill--waiting-for-input .topbar-status-pill__dot { + background: var(--color-status-attention); +} +.topbar-status-pill--blocked .topbar-status-pill__dot, +.topbar-status-pill--exited .topbar-status-pill__dot { + background: var(--color-status-error); } .topbar-status-pill__label { color: inherit; @@ -2657,15 +2676,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .session-detail-mode-badge { display: inline-flex; align-items: center; + gap: 4px; padding: 2px 8px; border: 1px solid var(--color-accent-amber-border); background: var(--color-accent-amber-dim); color: var(--color-accent-amber); font-size: 10px; font-weight: 600; + line-height: 1; letter-spacing: 0.05em; } +.session-detail-mode-badge--neutral { + border-color: var(--color-border-default); + background: var(--color-bg-subtle); + color: var(--color-text-secondary); +} + .session-detail-link-pill { display: inline-flex; align-items: center; @@ -8551,7 +8578,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { margin-right: 2px; } - /* Stack: session name on top, status+XDA row below */ + /* Stack: session name on top, status row below */ .terminal-chrome-identity { flex-direction: column; align-items: flex-start; @@ -8600,11 +8627,17 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { so this inner .topbar-project-line only exists to group them on mobile. */ .topbar-project-line { display: inline-flex; - align-items: baseline; + align-items: center; gap: 6px; min-width: 0; } +.topbar-identity-sep { + color: var(--color-text-tertiary); + font-size: 12px; + line-height: 1; +} + /* topbar-mobile-only is the inverse of topbar-desktop-only; hidden on desktop, shown below the mobile breakpoint. */ .topbar-mobile-only { @@ -8673,6 +8706,14 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 1px 5px; gap: 3px; } + .topbar-session-pills .topbar-fleet-pills { + gap: 3px; + padding-left: 6px; + margin-left: 3px; + } + .topbar-session-pills .topbar-fleet-pills__label { + font-size: 9px; + } /* Hide zone pill labels on mobile — count + color is enough */ .topbar-session-pills .topbar-zone-pill__label { display: none; @@ -8681,6 +8722,21 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { /* Orchestrator agent-zone pills — compact topbar variant. Replaces the stacked status strip that used to sit above the terminal. */ +.topbar-fleet-pills { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding-left: 10px; + border-left: 1px solid var(--color-border-default); +} +.topbar-fleet-pills__label { + font-size: 11px; + font-weight: 600; + line-height: 1; + color: var(--color-text-muted); + white-space: nowrap; +} .topbar-zone-pill { display: inline-flex; align-items: center; @@ -8689,6 +8745,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 999px; font-size: 11px; font-weight: 500; + line-height: 1; white-space: nowrap; color: var(--color-text-secondary); } diff --git a/packages/web/src/app/projects/[projectId]/page.test.tsx b/packages/web/src/app/projects/[projectId]/page.test.tsx index 8ba4b4d21..6cdb15ce8 100644 --- a/packages/web/src/app/projects/[projectId]/page.test.tsx +++ b/packages/web/src/app/projects/[projectId]/page.test.tsx @@ -80,5 +80,7 @@ describe("ProjectPage", () => { expect(screen.getByText("This project's config failed to load")).toBeInTheDocument(); expect(screen.getByText("Local config failed validation")).toBeInTheDocument(); expect(screen.queryByTestId("dashboard")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/"); + expect(screen.queryByRole("link", { name: "Edit settings" })).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/projects/[projectId]/settings/page.test.tsx b/packages/web/src/app/projects/[projectId]/settings/page.test.tsx index 23686b24c..ae26393c6 100644 --- a/packages/web/src/app/projects/[projectId]/settings/page.test.tsx +++ b/packages/web/src/app/projects/[projectId]/settings/page.test.tsx @@ -77,5 +77,7 @@ describe("ProjectSettingsPage", () => { ).toBeInTheDocument(); expect(screen.getByText("Local config failed validation")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/"); + expect(screen.queryByRole("link", { name: "Edit settings" })).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/components/DegradedProjectState.tsx b/packages/web/src/components/DegradedProjectState.tsx index c1b147f63..c02c734a5 100644 --- a/packages/web/src/components/DegradedProjectState.tsx +++ b/packages/web/src/components/DegradedProjectState.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { projectDashboardPath } from "@/lib/routes"; + import { RepairDegradedProjectButton } from "./RepairDegradedProjectButton"; interface DegradedProjectStateProps { @@ -61,16 +61,10 @@ export function DegradedProjectState({
- Back to project - - - Open dashboard view + Back to dashboard
diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index a47797729..5ea560e43 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -54,7 +54,6 @@ export function SessionDetail({ const sidebarCtx = useSidebarContext(); const startFullscreen = searchParams.get("fullscreen") === "true"; const [showTerminal, setShowTerminal] = useState(false); - const [relaunchError, setRelaunchError] = useState(null); const pr = session.pr; const terminalEnded = isDashboardSessionTerminal(session); const isRestorable = isDashboardSessionRestorable(session); @@ -108,47 +107,6 @@ export function SessionDetail({ } }, [session.id]); - const handleRelaunchClean = useCallback(async () => { - const confirmed = window.confirm( - "This will discard the current orchestrator's conversation and state. Continue?", - ); - if (!confirmed) return; - setRelaunchError(null); - try { - const res = await fetch("/api/orchestrators", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId: session.projectId, clean: true }), - }); - if (!res.ok) { - // Surface server-side errors. Note: a failure here may indicate the - // existing orchestrator was killed but respawn failed — the banner - // tells the user the previous session was terminated so they don't - // assume the page they're looking at is still live. - let message = ""; - try { - const data = (await res.json()) as { error?: string }; - message = data.error ?? ""; - } catch { - message = await res.text().catch(() => ""); - } - throw new Error(message || `HTTP ${res.status}`); - } - // Hard-navigate to the freshly spawned orchestrator's session page. - // Orchestrator session IDs are fixed per project, so this is the same - // path in practice — but reading from the response keeps us correct if - // the contract ever changes (and a hard nav forces the terminal - // WebSocket to reconnect against the new tmux session). - const data = (await res.json()) as { orchestrator?: { id: string } }; - const newId = data.orchestrator?.id ?? session.id; - window.location.href = projectSessionPath(session.projectId, newId); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator"; - console.error("Failed to relaunch orchestrator:", err); - setRelaunchError(message); - } - }, [session.id, session.projectId]); - const orchestratorHref = useMemo(() => { if (isOrchestrator) return null; if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId); @@ -179,34 +137,8 @@ export function SessionDetail({ onToggleSidebar={sidebarCtx?.onToggleSidebar ?? (() => {})} onRestore={handleRestore} onKill={handleKill} - onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined} />
- {relaunchError ? ( -
-
-
- Relaunch failed: {relaunchError} -
- The previous orchestrator may already be terminated. Try again from the - project dashboard. -
-
- -
-
- ) : null}
{!showTerminal ? (
diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx index 91746af8d..d685a26ce 100644 --- a/packages/web/src/components/SessionDetailHeader.tsx +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -10,6 +10,7 @@ import { RemoteAccessQR } from "./RemoteAccessQR"; import { SessionDetailPRCard } from "./SessionDetailPRCard"; import { askAgentToFix } from "./session-detail-agent-actions"; import { buildGitHubBranchUrl } from "./session-detail-utils"; +import { projectDashboardPath } from "@/lib/routes"; export interface OrchestratorZones { merge: number; @@ -34,7 +35,6 @@ interface SessionDetailHeaderProps { onToggleSidebar: () => void; onRestore: () => void; onKill: () => void; - onRelaunchClean?: () => void; } function normalizeActivityLabelForClass(activityLabel: string): string { @@ -54,14 +54,15 @@ function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) { if (stats.length === 0) return null; return ( - <> + + Fleet {stats.map((s) => ( {s.value} {s.label} ))} - + ); } @@ -79,7 +80,6 @@ export function SessionDetailHeader({ onToggleSidebar, onRestore, onKill, - onRelaunchClean, }: SessionDetailHeaderProps) { const pr = session.pr; const allGreen = pr ? isPRMergeReady(pr) : false; @@ -107,6 +107,10 @@ export function SessionDetailHeader({ const headerProjectLabel = projects.find((project) => project.id === session.projectId)?.name ?? session.projectId; const showHeaderProjectLabel = headerProjectLabel.trim().toLowerCase() !== "agent orchestrator"; + const showProductBrand = !isOrchestrator; + const showProjectLabel = isOrchestrator || showHeaderProjectLabel; + const showDesktopTitle = !isOrchestrator; + const showDesktopHeaderSep = showProductBrand && showProjectLabel; return (
@@ -145,19 +149,49 @@ export function SessionDetailHeader({ )} ) : null} -
- Agent Orchestrator -
- {showHeaderProjectLabel && ( + {showProductBrand ? ( +
+ Agent Orchestrator +
+ ) : null} + {showDesktopHeaderSep && (
); diff --git a/packages/web/src/components/__tests__/DegradedProjectState.test.tsx b/packages/web/src/components/__tests__/DegradedProjectState.test.tsx new file mode 100644 index 000000000..561af112f --- /dev/null +++ b/packages/web/src/components/__tests__/DegradedProjectState.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DegradedProjectState } from "@/components/DegradedProjectState"; + +vi.mock("next/link", () => ({ + default: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), +})); + +vi.mock("@/components/RepairDegradedProjectButton", () => ({ + RepairDegradedProjectButton: ({ projectId }: { projectId: string }) => ( + + ), +})); + +const baseProps = { + projectId: "my-project", + resolveError: "Local config at /tmp/my-project/agent-orchestrator.yaml failed validation: bad field", + projectPath: "/tmp/my-project", +}; + +describe("DegradedProjectState", () => { + it("renders the default heading", () => { + render(); + expect(screen.getByText("This project's config failed to load")).toBeInTheDocument(); + }); + + it("renders a custom heading when provided", () => { + render(); + expect(screen.getByText("Custom heading")).toBeInTheDocument(); + }); + + it("displays the resolve error", () => { + render(); + expect(screen.getByText(baseProps.resolveError)).toBeInTheDocument(); + }); + + it("extracts and displays the config path from the resolve error", () => { + render(); + expect( + screen.getByText("/tmp/my-project/agent-orchestrator.yaml"), + ).toBeInTheDocument(); + }); + + it("falls back to the project path when the config path cannot be parsed from the error", () => { + render( + , + ); + expect( + screen.getByText("/tmp/my-project/agent-orchestrator.yaml or .yml"), + ).toBeInTheDocument(); + }); + + it("renders 'Back to dashboard' link pointing to /", () => { + render(); + const link = screen.getByRole("link", { name: "Back to dashboard" }); + expect(link).toHaveAttribute("href", "/"); + }); + + it("does not render an 'Edit settings' link", () => { + render(); + expect(screen.queryByRole("link", { name: "Edit settings" })).not.toBeInTheDocument(); + }); + + it("does not show the repair button for a generic validation error", () => { + render(); + expect(screen.queryByRole("button", { name: /repair/i })).not.toBeInTheDocument(); + }); + + it("shows the repair button when the error indicates a wrapped projects format", () => { + render( + , + ); + expect(screen.getByRole("button", { name: /repair/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx b/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx index d234bba79..cc7e43c46 100644 --- a/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx +++ b/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx @@ -154,7 +154,7 @@ describe("DirectTerminal render", () => { await waitFor(() => expect(screen.getByText("Connected")).toBeInTheDocument()); expect(screen.getByText("ao-orchestrator")).toHaveStyle({ color: "var(--color-accent)" }); - expect(screen.getByText("XDA")).toHaveStyle({ color: "var(--color-accent)" }); + expect(screen.queryByText("XDA")).toBeNull(); }); it("keeps restart and fullscreen actions available in chromeless mode", async () => { diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index 7f310bb56..bffbbc54a 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -351,7 +351,7 @@ describe("SessionDetail desktop layout", () => { ).toBeInTheDocument(); }); - it("shows restore for restorable orchestrator sessions", () => { + it("keeps restore in the ended summary but not the top bar for restorable orchestrators", () => { render( { />, ); - expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toHaveClass( - "dashboard-app-btn--restore", - ); + expect( + within(screen.getByRole("banner")).queryByRole("button", { name: "Restore" }), + ).not.toBeInTheDocument(); + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); + expect( + within(screen.getByRole("banner")).getByRole("link", { name: "Open Kanban" }), + ).toHaveAttribute("href", "/projects/my-app"); + expect( + within(screen.getByRole("banner")).queryByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ).not.toBeInTheDocument(); expect( within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" }), ).not.toBeInTheDocument(); }); - it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); - const hrefSetter = vi.fn(); - Object.defineProperty(window, "location", { - value: { - ...window.location, - set href(value: string) { - hrefSetter(value); - }, - }, - writable: true, - }); - vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url === "/api/orchestrators") { - return { - ok: true, - json: async () => ({ - orchestrator: { id: "my-app-orchestrator", projectId: "my-app" }, - }), - } as Response; - } - return { ok: true, json: async () => ({}), text: async () => "" } as Response; - }); - + it("renders a scoped, non-repetitive orchestrator top bar", () => { render( { projectId: "my-app", status: "working", activity: "active", + branch: "orchestrator/my-app-orchestrator", summary: "Project orchestrator", + displayName: "# My App Orchestrator", + pr: makePR({ number: 777 }), })} isOrchestrator orchestratorZones={{ merge: 0, respond: 0, - review: 0, - pending: 0, - working: 0, - done: 0, + review: 1, + pending: 2, + working: 1, + done: 4, }} projectOrchestratorId="my-app-orchestrator" projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]} />, ); - const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", { - name: /launch orchestrator \(clean context\)/i, - }); - fireEvent.click(relaunchBtn); + const banner = within(screen.getByRole("banner")); - expect(confirmSpy).toHaveBeenCalled(); - await act(async () => {}); - - expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId: "my-app", clean: true }), - }); - expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator"); - - confirmSpy.mockRestore(); - }); - - it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => { - render( - , + expect(banner.getByText("My App")).toBeInTheDocument(); + expect(banner.getByText("Orchestrator")).toBeInTheDocument(); + expect(banner.getByText("Active")).toBeInTheDocument(); + expect(banner.getByText("Fleet")).toBeInTheDocument(); + expect(banner.getByText("review")).toBeInTheDocument(); + expect(banner.getByText("working")).toBeInTheDocument(); + expect(banner.getByText("pending")).toBeInTheDocument(); + expect(banner.getByText("done")).toBeInTheDocument(); + expect(banner.queryByText("my-app-orchestrator")).not.toBeInTheDocument(); + expect(banner.getByRole("link", { name: "Open Kanban" })).toHaveAttribute( + "href", + "/projects/my-app", ); - - expect( - within(screen.getByRole("banner")).getByRole("button", { - name: /launch orchestrator \(clean context\)/i, - }), - ).toBeInTheDocument(); + expect(banner.queryByText("Agent Orchestrator")).not.toBeInTheDocument(); + expect(banner.queryByText("# My App Orchestrator")).not.toBeInTheDocument(); + expect(banner.queryByText("orchestrator/my-app-orchestrator")).not.toBeInTheDocument(); + expect(banner.queryByRole("link", { name: "PR #777" })).not.toBeInTheDocument(); }); - it("surfaces a relaunch error banner when POST fails after confirm", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); - vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url === "/api/orchestrators") { - return { - ok: false, - status: 500, - json: async () => ({ error: "kill+respawn failed" }), - text: async () => "kill+respawn failed", - } as Response; - } - return { ok: true, json: async () => ({}), text: async () => "" } as Response; - }); - + it("shows the project name for the Agent Orchestrator project header", () => { render( , ); - fireEvent.click( - within(screen.getByRole("banner")).getByRole("button", { - name: /launch orchestrator \(clean context\)/i, - }), + const banner = within(screen.getByRole("banner")); + + expect(banner.getByText("Agent Orchestrator")).toBeInTheDocument(); + expect(banner.getByText("Orchestrator")).toHaveClass("session-detail-mode-badge--neutral"); + expect(banner.getByRole("link", { name: "Open Kanban" })).toHaveAttribute( + "href", + "/projects/agent-orchestrator", ); - - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent(/kill\+respawn failed/i); - expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i); - - fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" })); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - - confirmSpy.mockRestore(); }); it("does not render Relaunch (clean) on worker sessions", () => { @@ -587,7 +539,7 @@ describe("SessionDetail desktop layout", () => { expect( within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }), ).not.toBeInTheDocument(); - expect(screen.getByText("orchestrator")).toBeInTheDocument(); + expect(within(screen.getByRole("banner")).getByText("Orchestrator")).toBeInTheDocument(); }); it("shows the main orchestrator button when an orchestrator target exists", () => { diff --git a/packages/web/src/components/terminal/TerminalControls.tsx b/packages/web/src/components/terminal/TerminalControls.tsx index 84fb7a1b0..e09ea2961 100644 --- a/packages/web/src/components/terminal/TerminalControls.tsx +++ b/packages/web/src/components/terminal/TerminalControls.tsx @@ -217,7 +217,7 @@ export function TerminalControls({
{/* Pane label — matches the workspace pane-header style used elsewhere */} TERMINAL - {/* Identity group: session name on top, status+XDA below on mobile */} + {/* Identity group: session name on top, status below on mobile */}
{sessionId} @@ -229,15 +229,6 @@ export function TerminalControls({ > {statusText} - - XDA -
diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index 7b496f49b..580519b3e 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -31,6 +31,7 @@ import type { DashboardSession } from "../types"; // Helper to create a minimal Session for testing function createCoreSession(overrides?: Partial): Session { + const { metadata: overrideMetadata, ...sessionOverrides } = overrides ?? {}; const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z")); lifecycle.session.state = "working"; lifecycle.session.reason = "task_in_progress"; @@ -57,8 +58,8 @@ function createCoreSession(overrides?: Partial): Session { agentInfo: null, createdAt: new Date("2025-01-01T00:00:00Z"), lastActivityAt: new Date("2025-01-01T01:00:00Z"), - metadata: {}, - ...overrides, + metadata: { agent: "mock-agent", ...(overrideMetadata ?? {}) }, + ...sessionOverrides, }; } @@ -1196,7 +1197,79 @@ describe("enrichSessionsMetadata", () => { expect(agent.getSessionInfo).toHaveBeenCalledTimes(2); // sessions 1 and 2 only }); - it("should use default agent when project has no agent override", async () => { + it("uses persisted session agent instead of project default for summary enrichment", async () => { + const codexAgent = mockAgent("Wrong Codex summary"); + codexAgent.name = "codex"; + const gooseAgent = mockAgent("Goose summary"); + gooseAgent.name = "goose"; + const registry = { + get: vi.fn((slot: string, name: string) => { + if (slot !== "agent") return null; + if (name === "codex") return codexAgent; + if (name === "goose") return gooseAgent; + return null; + }), + register: vi.fn(), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), + } as unknown as PluginRegistry; + const mixedAgentConfig = { + ...testConfig, + defaults: { ...testConfig.defaults, agent: "codex" }, + projects: { + test: { ...testProject, agent: "codex" } as ProjectConfig, + }, + } as OrchestratorConfig; + + const core = createCoreSession({ metadata: { agent: "goose" } }); + const dashboard = sessionToDashboard(core); + + await enrichSessionsMetadata([core], [dashboard], mixedAgentConfig, registry); + + expect(registry.get).toHaveBeenCalledWith("agent", "goose"); + expect(registry.get).not.toHaveBeenCalledWith("agent", "codex"); + expect(codexAgent.getSessionInfo).not.toHaveBeenCalled(); + expect(gooseAgent.getSessionInfo).toHaveBeenCalledWith(core); + expect(dashboard.summary).toBe("Goose summary"); + }); + + it("uses persisted session agent when no project can be resolved", async () => { + const codexAgent = mockAgent("Wrong Codex summary"); + codexAgent.name = "codex"; + const gooseAgent = mockAgent("Orphan Goose summary"); + gooseAgent.name = "goose"; + const registry = { + get: vi.fn((slot: string, name: string) => { + if (slot !== "agent") return null; + if (name === "codex") return codexAgent; + if (name === "goose") return gooseAgent; + return null; + }), + register: vi.fn(), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), + } as unknown as PluginRegistry; + const noProjectsConfig = { + ...testConfig, + defaults: { ...testConfig.defaults, agent: "codex" }, + projects: {}, + } as OrchestratorConfig; + + const core = createCoreSession({ projectId: "deleted", metadata: { agent: "goose" } }); + const dashboard = sessionToDashboard(core); + + await enrichSessionsMetadata([core], [dashboard], noProjectsConfig, registry); + + expect(registry.get).toHaveBeenCalledWith("agent", "goose"); + expect(registry.get).not.toHaveBeenCalledWith("agent", "codex"); + expect(codexAgent.getSessionInfo).not.toHaveBeenCalled(); + expect(gooseAgent.getSessionInfo).toHaveBeenCalledWith(core); + expect(dashboard.summary).toBe("Orphan Goose summary"); + }); + + it("uses normalized session agent metadata when project has no agent override", async () => { const tracker = mockTracker(); const agent = mockAgent("From default agent"); const registry = mockRegistry(tracker, agent); @@ -1212,7 +1285,7 @@ describe("enrichSessionsMetadata", () => { await enrichSessionsMetadata([core], [dashboard], configNoProjectAgent, registry); - // Falls back to config.defaults.agent + // Uses the normalized Session metadata, not project/default inference. expect(registry.get).toHaveBeenCalledWith("agent", "mock-agent"); expect(dashboard.summary).toBe("From default agent"); }); diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index 2f8b34b6e..a52ee872e 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -547,7 +547,7 @@ function prepareSessionMetadataEnrichment( // Agent summaries (local disk I/O — reads agent JSONL) const summaryPromises = coreSessions.map((core, i) => { if (dashboardSessions[i].summary) return Promise.resolve(); - const agentName = projects[i]?.agent ?? config.defaults.agent; + const agentName = core.metadata["agent"]; if (!agentName) return Promise.resolve(); const agent = registry.get("agent", agentName); if (!agent) return Promise.resolve(); diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts index ef0c5402c..b20b23081 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -39,6 +39,7 @@ import pluginAgentClaudeCode from "@aoagents/ao-plugin-agent-claude-code"; import pluginAgentCodex from "@aoagents/ao-plugin-agent-codex"; import pluginAgentCursor from "@aoagents/ao-plugin-agent-cursor"; import pluginAgentKimicode from "@aoagents/ao-plugin-agent-kimicode"; +import pluginAgentGrok from "@aoagents/ao-plugin-agent-grok"; import pluginAgentOpencode from "@aoagents/ao-plugin-agent-opencode"; import pluginWorkspaceWorktree from "@aoagents/ao-plugin-workspace-worktree"; import pluginScmGithub from "@aoagents/ao-plugin-scm-github"; @@ -111,6 +112,7 @@ async function initServices(): Promise { registry.register(pluginAgentCodex); registry.register(pluginAgentCursor); registry.register(pluginAgentKimicode); + registry.register(pluginAgentGrok); registry.register(pluginAgentOpencode); registry.register(pluginWorkspaceWorktree); registry.register(pluginScmGithub); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7c37b518..ee022f1f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: '@aoagents/ao-plugin-agent-cursor': specifier: workspace:* version: link:../plugins/agent-cursor + '@aoagents/ao-plugin-agent-grok': + specifier: workspace:* + version: link:../plugins/agent-grok '@aoagents/ao-plugin-agent-kimicode': specifier: workspace:* version: link:../plugins/agent-kimicode @@ -339,6 +342,28 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/plugins/agent-grok: + dependencies: + '@aoagents/ao-core': + specifier: workspace:* + version: link:../../core + which: + specifier: ^6.0.1 + version: 6.0.1 + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.6.0 + '@types/which': + specifier: ^3.0.4 + version: 3.0.4 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/plugins/agent-kimicode: dependencies: '@aoagents/ao-core': @@ -688,6 +713,9 @@ importers: '@aoagents/ao-plugin-agent-cursor': specifier: workspace:* version: link:../plugins/agent-cursor + '@aoagents/ao-plugin-agent-grok': + specifier: workspace:* + version: link:../plugins/agent-grok '@aoagents/ao-plugin-agent-kimicode': specifier: workspace:* version: link:../plugins/agent-kimicode @@ -1984,6 +2012,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/which@3.0.4': + resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -5137,6 +5168,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/which@3.0.4': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0