Merge origin/main into remote control
This commit is contained in:
commit
87e3fce535
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-agent-claude-code": patch
|
||||
---
|
||||
|
||||
Harden Claude Code activity detection against five real-world edge cases identified during PR #1927's analysis:
|
||||
|
||||
1. **Bookkeeping types → false-active.** `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title` were falling through to the `default` switch branch and showing as `active` for 30s after Claude finished a turn. They now correctly map to `ready`/`idle` by age. Likely root cause of "Claude looks busy when it's done" reports.
|
||||
|
||||
2. **Multi-session disambiguation.** `findLatestSessionFile` picked newest-mtime, which is the wrong session's JSONL when two Claude sessions are running in the same workspace. Now prefers the UUID-named file (`<projectDir>/<claudeSessionUuid>.jsonl`) when `session.metadata.claudeSessionUuid` is set, falling back to newest-mtime otherwise.
|
||||
|
||||
3. **Symlinked workspaces.** `toClaudeProjectPath` was a pure string transform — symlink paths produced different slugs than what Claude itself wrote. Added `resolveWorkspaceForClaude(path)` that runs `realpathSync` (with fallback) and used it in all three slug-computing sites (`getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`).
|
||||
|
||||
4. **Process regex too narrow.** `(?:^|\/)claude(?:\s|$)` was missing several legitimate install variants — `.claude`, `claude-code`, `claude.exe`, `claude.js`, and npm shims like `node /opt/.../@anthropic-ai/claude-code/cli.js`. Broadened to `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)`; still rejects look-alikes (`claudia`, `claudine`).
|
||||
|
||||
5. **Silent permission-denied.** `findLatestSessionFile` was swallowing every `readdir` error silently — a missing `~/.claude/projects/<slug>/` (ENOENT) is normal, but a permission-denied (EACCES/EPERM) or fd-exhausted (EMFILE) misconfig left the session looking permanently `idle` on the dashboard with zero telemetry. Now logs a single `console.warn` for non-ENOENT errors.
|
||||
|
||||
193/193 plugin tests pass. No public-API change. New helper `resolveWorkspaceForClaude` is re-exported from `index.ts` for downstream consumers.
|
||||
|
|
@ -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.
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
]
|
||||
],
|
||||
"snapshot": {
|
||||
"useCalculatedVersionForSnapshots": true,
|
||||
"useCalculatedVersion": true,
|
||||
"prereleaseTemplate": "{tag}-{commit}"
|
||||
},
|
||||
"access": "public",
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
SETUP.md
7
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:<port>`
|
||||
- Example: `export AO_PUBLIC_URL="https://ao.example.com"`
|
||||
- When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`.
|
||||
- **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side.
|
||||
|
||||
## Installation
|
||||
|
||||
### Install via npm (recommended)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
<div align="center">
|
||||
|
||||
# Agent Orchestrator (`ao`)
|
||||
|
||||
**The orchestration layer for parallel AI coding agents.**
|
||||
|
||||
[](https://www.npmjs.com/package/@aoagents/ao)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/blob/main/LICENSE)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator)
|
||||
[](https://discord.gg/UZv7JjxbwG)
|
||||
|
||||
<img width="800" alt="Agent Orchestrator" src="https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/docs/assets/agent_orchestrator_banner.png">
|
||||
|
||||
</div>
|
||||
|
||||
Spawn parallel AI coding agents, each in its own git worktree, on a single machine. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise the whole fleet from one dashboard.
|
||||
|
||||
**Agent-agnostic** (Claude Code, Codex, Aider, Cursor, OpenCode) · **Runtime-agnostic** (tmux, ConPTY/process, Docker) · **Tracker-agnostic** (GitHub, Linear, GitLab)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @aoagents/ao
|
||||
```
|
||||
|
||||
> **Nightly builds** (latest `main`): `npm install -g @aoagents/ao@nightly` — back to stable with `@latest`.
|
||||
|
||||
**Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), the [`gh` CLI](https://cli.github.com), and at least one coding-agent CLI (e.g. [Claude Code](https://www.anthropic.com/claude-code)).
|
||||
|
||||
- **macOS / Linux:** [tmux](https://github.com/tmux/tmux/wiki/Installing) — `brew install tmux` or `sudo apt install tmux`.
|
||||
- **Windows:** PowerShell 7+ recommended; tmux is **not** required (AO uses native ConPTY via the `process` runtime).
|
||||
|
||||
## Quick start
|
||||
|
||||
Point it at any repo — it clones, configures, and launches the dashboard in one command:
|
||||
|
||||
```bash
|
||||
ao start https://github.com/your-org/your-repo
|
||||
```
|
||||
|
||||
Or from inside an existing local repo:
|
||||
|
||||
```bash
|
||||
cd ~/your-project && ao start
|
||||
```
|
||||
|
||||
The dashboard opens at `http://localhost:3000` and an orchestrator agent starts managing your project. Add more repos any time:
|
||||
|
||||
```bash
|
||||
ao start ~/path/to/another-repo
|
||||
```
|
||||
|
||||
You don't need to learn the CLI — the dashboard and the orchestrator agent drive everything. (Individual `ao` commands are documented in the [CLI Reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md) and used internally by the orchestrator.)
|
||||
|
||||
## How it works
|
||||
|
||||
1. **You start** — `ao start` launches the dashboard and an orchestrator agent.
|
||||
2. **Orchestrator spawns workers** — each issue gets its own agent in an isolated git worktree and branch.
|
||||
3. **Agents work autonomously** — they read code, write tests, and open PRs.
|
||||
4. **Reactions handle feedback** — CI failures and review comments are routed back to the responsible agent automatically.
|
||||
5. **You review and merge** — you're pulled in only when human judgment is needed.
|
||||
|
||||
## Pluggable by design
|
||||
|
||||
Seven plugin slots; the lifecycle state machine stays in core:
|
||||
|
||||
| Slot | Default | Alternatives |
|
||||
| --- | --- | --- |
|
||||
| Runtime | tmux (macOS/Linux) / process (Windows) | process, docker |
|
||||
| Agent | claude-code | codex, aider, cursor, opencode, kimicode |
|
||||
| Workspace | worktree | clone |
|
||||
| Tracker | github | linear, gitlab |
|
||||
| SCM | github | gitlab |
|
||||
| Notifier | desktop | slack, discord, composio, webhook, openclaw |
|
||||
| Terminal | iterm2 | web |
|
||||
|
||||
## Why Agent Orchestrator?
|
||||
|
||||
Running one AI agent in a terminal is easy. Running 30 across different issues, branches, and PRs is a coordination problem: creating branches, detecting stuck agents, reading CI failures, forwarding review comments, tracking which PRs are ready, and cleaning up afterward.
|
||||
|
||||
Agent Orchestrator handles the isolation, feedback routing, and status tracking. You `ao start` and walk away — then review PRs and make decisions. The rest is automated.
|
||||
|
||||
## Documentation
|
||||
|
||||
- 📖 [Project README & overview](https://github.com/ComposioHQ/agent-orchestrator)
|
||||
- 🛠️ [Setup guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/SETUP.md) — install, configuration, troubleshooting
|
||||
- ⌨️ [CLI reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md)
|
||||
- 🧩 [Development & plugin guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/DEVELOPMENT.md)
|
||||
- 💬 [Discord community](https://discord.gg/UZv7JjxbwG)
|
||||
|
||||
## License
|
||||
|
||||
MIT © [ComposioHQ](https://github.com/ComposioHQ/agent-orchestrator)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,91 @@
|
|||
# @aoagents/ao-cli
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
|
||||
- Updated dependencies [2d4c457]
|
||||
- @aoagents/ao-core@0.9.1
|
||||
- @aoagents/ao-web@0.9.1
|
||||
- @aoagents/ao-notifier-macos@0.9.1
|
||||
- @aoagents/ao-plugin-agent-aider@0.9.1
|
||||
- @aoagents/ao-plugin-agent-claude-code@0.9.1
|
||||
- @aoagents/ao-plugin-agent-codex@0.9.1
|
||||
- @aoagents/ao-plugin-agent-cursor@0.9.1
|
||||
- @aoagents/ao-plugin-agent-grok@0.1.2
|
||||
- @aoagents/ao-plugin-agent-kimicode@0.9.1
|
||||
- @aoagents/ao-plugin-agent-opencode@0.9.1
|
||||
- @aoagents/ao-plugin-notifier-composio@0.9.1
|
||||
- @aoagents/ao-plugin-notifier-dashboard@0.9.1
|
||||
- @aoagents/ao-plugin-notifier-desktop@0.9.1
|
||||
- @aoagents/ao-plugin-notifier-discord@0.9.1
|
||||
- @aoagents/ao-plugin-notifier-openclaw@0.9.1
|
||||
- @aoagents/ao-plugin-notifier-slack@0.9.1
|
||||
- @aoagents/ao-plugin-notifier-webhook@0.9.1
|
||||
- @aoagents/ao-plugin-runtime-process@0.9.1
|
||||
- @aoagents/ao-plugin-runtime-tmux@0.9.1
|
||||
- @aoagents/ao-plugin-scm-github@0.9.1
|
||||
- @aoagents/ao-plugin-terminal-iterm2@0.9.1
|
||||
- @aoagents/ao-plugin-terminal-web@0.9.1
|
||||
- @aoagents/ao-plugin-tracker-github@0.9.1
|
||||
- @aoagents/ao-plugin-tracker-linear@0.9.1
|
||||
- @aoagents/ao-plugin-workspace-clone@0.9.1
|
||||
- @aoagents/ao-plugin-workspace-worktree@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6d48022: Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.
|
||||
- ecdf0c7: Add `AO_PUBLIC_URL` environment variable for users running AO behind a reverse proxy (remote dev containers, VPS deployments, internal tooling). When set, all user-facing dashboard URLs — `ao start` / `ao dashboard` console output, `ao open` browser launches, and `projectSessionUrl()` links surfaced to the orchestrator agent — use the public URL instead of `http://localhost:<port>`. Internal IPC (`daemon.ts` reload calls) still uses localhost since that traffic stays on the host.
|
||||
|
||||
Also documents the existing `TERMINAL_WS_PATH` env var and the dashboard's automatic path-based mux WebSocket routing for standard-port (HTTPS / HTTP) deployments — these together let users front AO with a single hostname and one reverse-proxy rule, no extra ports or subdomains.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fcedb25: Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI.
|
||||
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
|
||||
- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
|
||||
- Updated dependencies [73bed33]
|
||||
- Updated dependencies [a610601]
|
||||
- Updated dependencies [8c71bde]
|
||||
- Updated dependencies [7d9b862]
|
||||
- Updated dependencies [6d48022]
|
||||
- Updated dependencies [ee3fb5d]
|
||||
- Updated dependencies [fcedb25]
|
||||
- Updated dependencies [94981dc]
|
||||
- Updated dependencies [6d48022]
|
||||
- Updated dependencies [2980570]
|
||||
- Updated dependencies [d5d0f07]
|
||||
- Updated dependencies [07c9099]
|
||||
- @aoagents/ao-core@0.9.0
|
||||
- @aoagents/ao-web@0.9.0
|
||||
- @aoagents/ao-plugin-agent-claude-code@0.9.0
|
||||
- @aoagents/ao-plugin-tracker-linear@0.9.0
|
||||
- @aoagents/ao-notifier-macos@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-composio@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-dashboard@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-desktop@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-discord@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-openclaw@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-slack@0.9.0
|
||||
- @aoagents/ao-plugin-agent-aider@0.9.0
|
||||
- @aoagents/ao-plugin-agent-codex@0.9.0
|
||||
- @aoagents/ao-plugin-agent-cursor@0.9.0
|
||||
- @aoagents/ao-plugin-agent-grok@0.1.1
|
||||
- @aoagents/ao-plugin-agent-kimicode@0.9.0
|
||||
- @aoagents/ao-plugin-agent-opencode@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-webhook@0.9.0
|
||||
- @aoagents/ao-plugin-runtime-process@0.9.0
|
||||
- @aoagents/ao-plugin-runtime-tmux@0.9.0
|
||||
- @aoagents/ao-plugin-scm-github@0.9.0
|
||||
- @aoagents/ao-plugin-terminal-iterm2@0.9.0
|
||||
- @aoagents/ao-plugin-terminal-web@0.9.0
|
||||
- @aoagents/ao-plugin-tracker-github@0.9.0
|
||||
- @aoagents/ao-plugin-workspace-clone@0.9.0
|
||||
- @aoagents/ao-plugin-workspace-worktree@0.9.0
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ import { Command } from "commander";
|
|||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockRunRepoScript,
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockRunRepoScript } = vi.hoisted(() => ({
|
||||
mockRunRepoScript: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -55,8 +53,8 @@ vi.mock("../../src/lib/update-check.js", () => ({
|
|||
isManualOnlyInstall: (m: string) => m === "homebrew",
|
||||
}));
|
||||
|
||||
// Stub the active-session guard's dependencies so handlers don't try to load
|
||||
// real config / spawn plugins. Default: no sessions, so the guard passes.
|
||||
// Stub the update lifecycle planner's dependencies so handlers don't try to
|
||||
// load real config / spawn plugins. Default: no sessions, so no stop is needed.
|
||||
const { mockSessions } = vi.hoisted(() => ({
|
||||
mockSessions: { value: [] as Array<{ id: string; status: string }> },
|
||||
}));
|
||||
|
|
@ -84,8 +82,7 @@ vi.mock("@aoagents/ao-core", async () => {
|
|||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args),
|
||||
getGlobalConfigPath: () => "/tmp/test-global-config.yaml",
|
||||
isCanonicalGlobalConfigPath: (p: string | undefined) =>
|
||||
p === "/tmp/test-global-config.yaml",
|
||||
isCanonicalGlobalConfigPath: (p: string | undefined) => p === "/tmp/test-global-config.yaml",
|
||||
isWindows: () => mockIsWindows(),
|
||||
};
|
||||
});
|
||||
|
|
@ -98,10 +95,8 @@ vi.mock("node:fs", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
// running.json is the live signal: ensureNoActiveSessions now consults
|
||||
// `getRunning()` before falling back to the global registry. Default to
|
||||
// "no daemon running" so the existing global-config-driven tests keep
|
||||
// exercising the fallback path. Per-test overrides simulate a live daemon.
|
||||
// running.json is the live signal for update stop/start orchestration. Default
|
||||
// to "no daemon running"; per-test overrides simulate a live daemon.
|
||||
const { mockGetRunning } = vi.hoisted(() => ({
|
||||
mockGetRunning: vi.fn<() => Promise<unknown>>(async () => null),
|
||||
}));
|
||||
|
|
@ -147,9 +142,28 @@ function makeNpmUpdateInfo(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function createMockChild(exitCode: number | null, signal?: NodeJS.Signals) {
|
||||
function createMockChild(
|
||||
exitCode: number | null,
|
||||
signal?: NodeJS.Signals,
|
||||
output: { stdout?: string; stderr?: string } = { stdout: "0.3.0\n" },
|
||||
) {
|
||||
const child = new EventEmitter();
|
||||
setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0);
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
Object.assign(child, { stdout, stderr });
|
||||
const emitResult = () => {
|
||||
setTimeout(() => {
|
||||
if (output.stdout) stdout.emit("data", Buffer.from(output.stdout));
|
||||
if (output.stderr) stderr.emit("data", Buffer.from(output.stderr));
|
||||
child.emit("exit", exitCode, signal ?? null);
|
||||
}, 0);
|
||||
};
|
||||
const originalOn = child.on.bind(child);
|
||||
child.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
const result = originalOn(event, listener);
|
||||
if (event === "exit") emitResult();
|
||||
return result;
|
||||
}) as EventEmitter["on"];
|
||||
return child;
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +180,9 @@ describe("update command", () => {
|
|||
mockRunRepoScript.mockResolvedValue(0);
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockCheckForUpdate.mockReset();
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }));
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }),
|
||||
);
|
||||
mockInvalidateCache.mockReset();
|
||||
mockPromptConfirm.mockReset();
|
||||
mockPromptConfirm.mockResolvedValue(false);
|
||||
|
|
@ -234,9 +250,9 @@ describe("update command", () => {
|
|||
it("rejects --smoke-only on npm installs with an actionable message", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update", "--smoke-only"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update", "--smoke-only"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(messages).toMatch(/--smoke-only only applies to git installs/);
|
||||
});
|
||||
|
|
@ -306,9 +322,9 @@ describe("update command", () => {
|
|||
new Error("Script not found: ao-update.sh. Expected at: /tmp/ao-update.sh"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockCheckForUpdate).not.toHaveBeenCalled();
|
||||
|
|
@ -353,7 +369,9 @@ describe("update command", () => {
|
|||
});
|
||||
|
||||
it("prints already up to date when not outdated", async () => {
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }));
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }),
|
||||
);
|
||||
|
||||
const logSpy = vi.mocked(console.log);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
|
@ -365,9 +383,9 @@ describe("update command", () => {
|
|||
makeNpmUpdateInfo({ latestVersion: null, isOutdated: false }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
expect(vi.mocked(console.error)).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Could not reach npm registry"),
|
||||
);
|
||||
|
|
@ -375,9 +393,7 @@ describe("update command", () => {
|
|||
|
||||
it("forces a fresh registry fetch", async () => {
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockCheckForUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ force: true }),
|
||||
);
|
||||
expect(mockCheckForUpdate).toHaveBeenCalledWith(expect.objectContaining({ force: true }));
|
||||
});
|
||||
|
||||
it("prints command and exits cleanly in non-TTY mode without prompting", async () => {
|
||||
|
|
@ -400,7 +416,11 @@ describe("update command", () => {
|
|||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith("npm", expect.arrayContaining(["install"]), expect.anything());
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"npm",
|
||||
expect.arrayContaining(["install"]),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockInvalidateCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -410,9 +430,9 @@ describe("update command", () => {
|
|||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(1));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
expect(mockInvalidateCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -438,9 +458,9 @@ describe("update command", () => {
|
|||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(null, "SIGTERM"));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
expect(vi.mocked(console.error)).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("exited with code null"),
|
||||
|
|
@ -448,18 +468,26 @@ describe("update command", () => {
|
|||
expect(mockInvalidateCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles spawn error (e.g. npm not found)", async () => {
|
||||
it("handles spawn error (e.g. npm not found) with a friendly update failure", async () => {
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
|
||||
const child = new EventEmitter();
|
||||
Object.assign(child, { stdout: new EventEmitter(), stderr: new EventEmitter() });
|
||||
mockSpawn.mockReturnValue(child);
|
||||
setTimeout(() => child.emit("error", new Error("ENOENT: npm not found")), 0);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("ENOENT");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
|
||||
expect(stderr).toContain("npm not found");
|
||||
});
|
||||
|
||||
it("does nothing when user declines prompt", async () => {
|
||||
|
|
@ -489,7 +517,9 @@ describe("update command", () => {
|
|||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Could not detect install method"));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Could not detect install method"),
|
||||
);
|
||||
expect(mockRunRepoScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -521,269 +551,157 @@ describe("update command", () => {
|
|||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Active-session guard (Section C)
|
||||
// Update lifecycle orchestration (#1972)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("active-session guard", () => {
|
||||
describe("update lifecycle orchestration", () => {
|
||||
beforeEach(() => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
|
||||
mockDetectInstallMethod.mockReturnValue("pnpm-global");
|
||||
mockResolveUpdateChannel.mockReturnValue("stable");
|
||||
mockGetUpdateCommand.mockImplementation((method: string) => {
|
||||
if (method === "pnpm-global") return "pnpm add -g @aoagents/ao@latest";
|
||||
return "npm install -g @aoagents/ao@latest";
|
||||
});
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({
|
||||
installMethod: "pnpm-global",
|
||||
recommendedCommand: "pnpm add -g @aoagents/ao@latest",
|
||||
}),
|
||||
);
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
// The guard now ALWAYS loads from global config. Stage a registered
|
||||
// project so the early-return ("no registry → allow") doesn't fire.
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
});
|
||||
mockLoadConfig.mockImplementation((path?: string) =>
|
||||
path
|
||||
? { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path }
|
||||
: { projects: { "my-app": { path: "/tmp/foo" } }, configPath: "/cwd/agent-orchestrator.yaml" },
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to install when a session is in 'working'", async () => {
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(messages).toMatch(/1 session active/);
|
||||
expect(messages).toMatch(/ao stop/);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["working", "idle", "needs_input", "stuck"])(
|
||||
"refuses for status %s",
|
||||
async (status) => {
|
||||
mockSessions.value = [{ id: "feat-1", status }];
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
},
|
||||
);
|
||||
|
||||
it("does NOT refuse for terminal statuses (done, terminated, killed)", async () => {
|
||||
mockSessions.value = [
|
||||
{ id: "old-1", status: "done" },
|
||||
{ id: "old-2", status: "terminated" },
|
||||
];
|
||||
mockPromptConfirm.mockResolvedValue(false); // decline, no install
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
// Reaches the prompt step since the guard passed.
|
||||
expect(mockPromptConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Global-config layout (review #3 / scope-gap follow-up)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
it("the refusal message lists active sessions from EVERY registered project, not just one (Dhruv proof)", async () => {
|
||||
// Reviewer challenge: prove loadConfig(globalPath) actually enumerates
|
||||
// sessions across all registered projects, not just the cwd's project.
|
||||
// We register proj-a + proj-b in the global config, seed one active
|
||||
// session in each, and assert BOTH ids appear in the stderr output.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
// Mimic buildEffectiveConfigFromGlobalConfigPath: the global path
|
||||
// returns BOTH projects; project-local would only return one.
|
||||
if (!path) {
|
||||
return { projects: { "proj-a": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
|
||||
}
|
||||
return {
|
||||
projects: {
|
||||
"proj-a": { path: "/repos/a" },
|
||||
"proj-b": { path: "/repos/b" },
|
||||
},
|
||||
configPath: path,
|
||||
};
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: {
|
||||
"proj-a": { path: "/repos/a" },
|
||||
"proj-b": { path: "/repos/b" },
|
||||
},
|
||||
});
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
// One active session per project. sm.list() is single-call (the SM
|
||||
// implementation enumerates across all projectIds), so we return both
|
||||
// sessions in one shot — matching real behavior. `projectId` is
|
||||
// included so it's visible to anyone reading the refusal output.
|
||||
mockSessions.value = [
|
||||
{ id: "proj-a-feat-1", status: "working", projectId: "proj-a" },
|
||||
{ id: "proj-b-feat-2", status: "needs_input", projectId: "proj-b" },
|
||||
];
|
||||
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
// Refusal message reports the correct total count (2, not 1).
|
||||
expect(stderr).toMatch(/2 sessions active/);
|
||||
// Both project's session ids appear in the listing.
|
||||
expect(stderr).toMatch(/proj-a-feat-1/);
|
||||
expect(stderr).toMatch(/proj-b-feat-2/);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("always loads global config (never project-local), so sessions in OTHER projects fire the guard", async () => {
|
||||
// Simulate running inside a project: project-local loadConfig() would
|
||||
// succeed and return only THIS project's sessions. The guard must
|
||||
// ignore it and still consult the global registry, otherwise active
|
||||
// sessions in other projects get missed and the install would proceed.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) {
|
||||
// Project-local: would return only "this-project"'s sessions.
|
||||
return { projects: { "this-project": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
|
||||
}
|
||||
return {
|
||||
projects: {
|
||||
"this-project": { path: "/cwd" },
|
||||
"other-project": { path: "/other" },
|
||||
},
|
||||
configPath: path,
|
||||
};
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: {
|
||||
"this-project": { path: "/cwd" },
|
||||
"other-project": { path: "/other" },
|
||||
},
|
||||
});
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
// Active session lives in the OTHER project — only visible via global.
|
||||
mockSessions.value = [
|
||||
{ id: "other-1", status: "working" },
|
||||
];
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(mockLoadGlobalConfig).toHaveBeenCalled();
|
||||
// Critical: we did NOT call the project-local (no-arg) loadConfig path.
|
||||
const noArgCalls = mockLoadConfig.mock.calls.filter((c) => c.length === 0);
|
||||
expect(noArgCalls).toHaveLength(0);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the global registry when running outside any project", async () => {
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) throw new Error("no config found");
|
||||
return { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path };
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
});
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(mockLoadGlobalConfig).toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early without building SessionManager when global registry is empty", async () => {
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) throw new Error("no config found");
|
||||
return { projects: {}, configPath: path };
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
// Guard returns true (allow update) without ever calling sm.list().
|
||||
// No mockSessions configured, no spawn → confirms we never reached
|
||||
// SessionManager construction.
|
||||
mockPromptConfirm.mockResolvedValue(false); // decline soft-install
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockLoadGlobalConfig).toHaveBeenCalled();
|
||||
// The decline-prompt path means the guard let us through.
|
||||
expect(mockPromptConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early without building SessionManager when global config file is missing", async () => {
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) throw new Error("no config found");
|
||||
return { projects: {}, configPath: path };
|
||||
});
|
||||
mockExistsSync.mockReturnValue(false); // no ~/.agent-orchestrator/config.yaml
|
||||
|
||||
mockPromptConfirm.mockResolvedValue(false);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
// We didn't even consult loadGlobalConfig — existsSync(globalPath) was false.
|
||||
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
|
||||
expect(mockPromptConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)", async () => {
|
||||
// The bypass: user ran `ao start` from a repo with a local
|
||||
// agent-orchestrator.yaml and no global registration. running.json
|
||||
// says that project is being polled, sessions live on disk, but the
|
||||
// global registry is empty. Before this fix, the guard hit the
|
||||
// "global has no projects → allow" branch and let `ao update`
|
||||
// clobber the running daemon.
|
||||
//
|
||||
// Fix: consult running.json BEFORE falling back to global. When
|
||||
// running.json has projects, build the SessionManager from
|
||||
// running.configPath (which is the local project's yaml in this case)
|
||||
// and enumerate from there.
|
||||
mockGetRunning.mockResolvedValue({
|
||||
mockGetRunning
|
||||
.mockResolvedValueOnce({
|
||||
pid: 12345,
|
||||
configPath: "/repos/local-only/agent-orchestrator.yaml",
|
||||
configPath: "/tmp/test-global-config.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["local-only"],
|
||||
projects: ["my-app"],
|
||||
})
|
||||
.mockResolvedValue(null);
|
||||
mockLoadConfig.mockImplementation((path?: string) => ({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
configPath: path ?? "/cwd/agent-orchestrator.yaml",
|
||||
}));
|
||||
mockSessions.value = [{ id: "feat-1", status: "working", projectId: "my-app" }];
|
||||
});
|
||||
// Global registry has no record of `local-only` — this is the bypass
|
||||
// condition. With the old code, we'd return true here.
|
||||
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
|
||||
// loadConfig with the local configPath returns the local project's
|
||||
// OrchestratorConfig (project-local schema is auto-wrapped).
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/repos/local-only/agent-orchestrator.yaml") {
|
||||
return {
|
||||
projects: { "local-only": { path: "/repos/local-only" } },
|
||||
configPath: path,
|
||||
};
|
||||
|
||||
it("runs stop → package install → version verification → start/restore when sessions are active", async () => {
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return { projects: {}, configPath: path ?? "/cwd/agent-orchestrator.yaml" };
|
||||
});
|
||||
mockSessions.value = [
|
||||
{ id: "local-feat-1", status: "working", projectId: "local-only" },
|
||||
];
|
||||
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(stderr).toMatch(/1 session active/);
|
||||
expect(stderr).toMatch(/local-feat-1/);
|
||||
// We must have routed through running.configPath, NOT the global path.
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/repos/local-only/agent-orchestrator.yaml");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
|
||||
it("returns true (allows update) when running.json is gone and global is empty", async () => {
|
||||
// No daemon running, no global projects. Genuinely safe to update.
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(4);
|
||||
expect(mockSpawn.mock.calls[0]).toEqual([
|
||||
"ao",
|
||||
["stop", "--yes"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[1][1]).toEqual(["add", "-g", "@aoagents/ao@latest"]);
|
||||
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
|
||||
expect(mockSpawn.mock.calls[3]).toEqual([
|
||||
"ao",
|
||||
["start", "my-app", "--restore"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const stopOrder = mockSpawn.mock.invocationCallOrder[0];
|
||||
const installOrder = mockSpawn.mock.invocationCallOrder[1];
|
||||
const startOrder = mockSpawn.mock.invocationCallOrder[3];
|
||||
expect(stopOrder).toBeLessThan(installOrder);
|
||||
expect(installOrder).toBeLessThan(startOrder);
|
||||
});
|
||||
|
||||
it("does not stop/start when no daemon or active sessions exist", async () => {
|
||||
mockGetRunning.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockPromptConfirm.mockResolvedValue(false);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockPromptConfirm).toHaveBeenCalled(); // guard passed → reached prompt
|
||||
mockSessions.value = [];
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
|
||||
it("trusts running.json over an inconsistent global config", async () => {
|
||||
// running.json says project P is being polled. Global config also
|
||||
// lists P. We should use running.configPath (the live signal), and
|
||||
// any active session in P fires the guard.
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[1][1]).toEqual(["--version"]);
|
||||
});
|
||||
|
||||
it("cleans up orphaned active sessions without starting a daemon that was not running", async () => {
|
||||
mockGetRunning.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
});
|
||||
mockSessions.value = [{ id: "orphan-1", status: "working", projectId: "my-app" }];
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "stop") {
|
||||
mockSessions.value = [];
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
}
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
);
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
|
||||
expect(mockSpawn.mock.calls.some((call) => call[0] === "ao" && call[1][0] === "start")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("honors --no-restore when restarting after update", async () => {
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "update", "--no-restore"]);
|
||||
|
||||
expect(mockSpawn.mock.calls.at(-1)).toEqual([
|
||||
"ao",
|
||||
["start", "my-app", "--no-restore"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts before install if ao stop exits 0 but AO still appears active", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 12345,
|
||||
configPath: "/tmp/test-global-config.yaml",
|
||||
|
|
@ -791,23 +709,46 @@ describe("update command", () => {
|
|||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockLoadConfig.mockImplementation((path?: string) => ({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
configPath: path ?? "/cwd/agent-orchestrator.yaml",
|
||||
}));
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "" }));
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls.some((call) => call[0] === "pnpm")).toBe(false);
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(stderr).toContain("AO still appears to be running after `ao stop --yes`");
|
||||
});
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
it("prints a friendly pnpm diagnostic and npm fallback when pnpm fails", async () => {
|
||||
mockGetRunning.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockSpawn.mockReturnValue(
|
||||
createMockChild(1, undefined, {
|
||||
stderr: "ERR_PNPM_UNEXPECTED_VIRTUAL_STORE Unexpected virtual store location\n",
|
||||
}),
|
||||
);
|
||||
|
||||
// Because getRunning() returned a daemon, we went straight to its
|
||||
// configPath — we should NOT have fallen back to loadGlobalConfig.
|
||||
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
|
||||
expect(stderr).toContain("pnpm's global store metadata is inconsistent");
|
||||
expect(stderr).toContain("You can also try: npm install -g @aoagents/ao@latest");
|
||||
expect(stderr).toContain("ERR_PNPM_UNEXPECTED_VIRTUAL_STORE");
|
||||
expect(mockInvalidateCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -874,7 +815,7 @@ describe("update command", () => {
|
|||
}),
|
||||
);
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(0));
|
||||
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
|
|
@ -990,7 +931,7 @@ describe("update command", () => {
|
|||
}),
|
||||
);
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(0));
|
||||
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
|
|
@ -1035,9 +976,7 @@ describe("update command", () => {
|
|||
beforeEach(() => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockResolveUpdateChannel.mockReturnValue("stable");
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({ installMethod: "npm-global" }),
|
||||
);
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
|
|
@ -1069,7 +1008,11 @@ describe("update command", () => {
|
|||
// would run. Asserting spawn was called proves the install actually
|
||||
// happens in the API-invoked path.
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"npm",
|
||||
expect.arrayContaining(["install"]),
|
||||
expect.anything(),
|
||||
);
|
||||
// And without a TTY, we MUST NOT have prompted — that would hang the
|
||||
// detached child forever.
|
||||
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||
|
|
@ -1084,12 +1027,25 @@ describe("update command", () => {
|
|||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refuses on active sessions even when API-invoked (the API's own guard isn't a single point of trust)", async () => {
|
||||
it("orchestrates stop/install/verify/start when active sessions exist and update is API-invoked", async () => {
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockGetRunning
|
||||
.mockResolvedValueOnce({
|
||||
pid: 12345,
|
||||
configPath: "/tmp/test-global-config.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
})
|
||||
.mockResolvedValue(null);
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls.at(-1)?.[0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls.at(-1)?.[1]).toEqual(["start", "my-app", "--restore"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1128,17 +1084,15 @@ describe("update command", () => {
|
|||
it("passes shell:true and windowsHide:true on Windows so PATHEXT resolves npm.cmd", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
|
||||
expect(opts.shell).toBe(true);
|
||||
expect(opts.windowsHide).toBe(true);
|
||||
expect(opts.stdio).toBe("inherit");
|
||||
expect(opts.stdio).toEqual(["inherit", "pipe", "pipe"]);
|
||||
});
|
||||
|
||||
it("passes shell:false on macOS / Linux (no shell wrap needed)", async () => {
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
|
||||
expect(opts.shell).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@
|
|||
*/
|
||||
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, basename, dirname } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import chalk from "chalk";
|
||||
|
|
@ -91,6 +87,7 @@ import {
|
|||
type DetectedAgent,
|
||||
} from "../lib/detect-agent.js";
|
||||
import { detectDefaultBranch } from "../lib/git-utils.js";
|
||||
import { dashboardUrl } from "../lib/dashboard-url.js";
|
||||
import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js";
|
||||
import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js";
|
||||
import {
|
||||
|
|
@ -824,12 +821,7 @@ async function startDashboard(
|
|||
directTerminalPort?: number,
|
||||
devMode?: boolean,
|
||||
): Promise<ChildProcess> {
|
||||
const env = await buildDashboardEnv(
|
||||
port,
|
||||
configPath,
|
||||
terminalPort,
|
||||
directTerminalPort,
|
||||
);
|
||||
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);
|
||||
|
||||
// Detect monorepo vs npm install: the `server/` source directory only exists
|
||||
// in the monorepo. Published npm packages only have `dist-server/`.
|
||||
|
|
@ -895,6 +887,8 @@ async function runStartup(
|
|||
orchestrator?: boolean;
|
||||
rebuild?: boolean;
|
||||
dev?: boolean;
|
||||
/** true = restore without prompting, false = skip restore, undefined = prompt for humans */
|
||||
restore?: boolean;
|
||||
},
|
||||
): Promise<StartupResult> {
|
||||
await runtimePreflight(config);
|
||||
|
|
@ -954,7 +948,7 @@ async function runStartup(
|
|||
config.directTerminalPort,
|
||||
opts?.dev,
|
||||
);
|
||||
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
|
||||
spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`);
|
||||
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
|
||||
}
|
||||
|
||||
|
|
@ -1028,11 +1022,14 @@ async function runStartup(
|
|||
}
|
||||
}
|
||||
|
||||
// Check for sessions from last `ao stop` and offer to restore them
|
||||
if (isHumanCaller()) {
|
||||
// Check for sessions from last `ao stop` and restore/prompt/skip based on caller intent.
|
||||
if (opts?.restore !== false && (opts?.restore === true || isHumanCaller())) {
|
||||
try {
|
||||
const lastStop = await readLastStop();
|
||||
if (lastStop && lastStop.sessionIds.length > 0) {
|
||||
const totalLastStopSessions =
|
||||
(lastStop?.sessionIds.length ?? 0) +
|
||||
(lastStop?.otherProjects ?? []).reduce((sum, p) => sum + p.sessionIds.length, 0);
|
||||
if (lastStop && totalLastStopSessions > 0) {
|
||||
const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`;
|
||||
const otherProjects = lastStop.otherProjects ?? [];
|
||||
const restoreProjectBySessionId = new Map<string, string>();
|
||||
|
|
@ -1073,7 +1070,8 @@ async function runStartup(
|
|||
}
|
||||
|
||||
if (allRestoreSessions.length > 0) {
|
||||
const shouldRestore = await promptConfirm("Restore these sessions?", true);
|
||||
const shouldRestore =
|
||||
opts?.restore === true ? true : await promptConfirm("Restore these sessions?", true);
|
||||
if (shouldRestore) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
|
|
@ -1206,7 +1204,7 @@ async function runStartup(
|
|||
console.log(chalk.bold.green("\n✓ Startup complete\n"));
|
||||
|
||||
if (opts?.dashboard !== false) {
|
||||
console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`);
|
||||
console.log(chalk.cyan("Dashboard:"), dashboardUrl(port));
|
||||
console.log(chalk.dim(" Use the dashboard Remote button to enable mobile access at runtime."));
|
||||
}
|
||||
|
||||
|
|
@ -1241,7 +1239,7 @@ async function runStartup(
|
|||
openAbort = new AbortController();
|
||||
const orchestratorUrl = selectedOrchestratorId
|
||||
? projectSessionUrl(port, projectId, selectedOrchestratorId)
|
||||
: `http://localhost:${port}`;
|
||||
: dashboardUrl(port);
|
||||
void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal);
|
||||
}
|
||||
|
||||
|
|
@ -1438,10 +1436,10 @@ async function attachAndSpawnOrchestrator(opts: {
|
|||
}
|
||||
|
||||
if (isHumanCaller()) {
|
||||
console.log(chalk.dim(` Opening dashboard: http://localhost:${daemon.port}\n`));
|
||||
openUrl(`http://localhost:${daemon.port}`);
|
||||
console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`));
|
||||
openUrl(dashboardUrl(daemon.port));
|
||||
} else {
|
||||
console.log(`Dashboard: http://localhost:${daemon.port}`);
|
||||
console.log(`Dashboard: ${dashboardUrl(daemon.port)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1461,6 +1459,8 @@ export function registerStart(program: Command): void {
|
|||
.option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)")
|
||||
.option("--interactive", "Prompt to configure config settings")
|
||||
.option("--reap-orphans", "Kill orphaned AO child processes before starting")
|
||||
.option("--restore", "Restore sessions from last ao stop without prompting")
|
||||
.option("--no-restore", "Skip restoring sessions from last ao stop")
|
||||
.action(
|
||||
async (
|
||||
projectArg?: string,
|
||||
|
|
@ -1471,6 +1471,7 @@ export function registerStart(program: Command): void {
|
|||
dev?: boolean;
|
||||
interactive?: boolean;
|
||||
reapOrphans?: boolean;
|
||||
restore?: boolean;
|
||||
},
|
||||
) => {
|
||||
recordActivityEvent({
|
||||
|
|
@ -1523,7 +1524,7 @@ export function registerStart(program: Command): void {
|
|||
// exit. Project-id args fall through to attach+spawn so
|
||||
// automation can `ao start <id>` against a live daemon.
|
||||
console.log(`AO is already running.`);
|
||||
console.log(`Dashboard: http://localhost:${running.port}`);
|
||||
console.log(`Dashboard: ${dashboardUrl(running.port)}`);
|
||||
console.log(`PID: ${running.pid}`);
|
||||
console.log(`Projects: ${running.projects.join(", ")}`);
|
||||
console.log(`To restart: ao stop && ao start`);
|
||||
|
|
@ -1533,7 +1534,7 @@ export function registerStart(program: Command): void {
|
|||
|
||||
if (isHumanCaller() && !projectArg) {
|
||||
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
||||
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
|
||||
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
||||
console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`);
|
||||
console.log(` Projects: ${running.projects.join(", ")}\n`);
|
||||
|
||||
|
|
@ -1580,7 +1581,7 @@ export function registerStart(program: Command): void {
|
|||
);
|
||||
|
||||
if (choice === "open") {
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
} else if (choice === "quit") {
|
||||
|
|
@ -1612,7 +1613,7 @@ export function registerStart(program: Command): void {
|
|||
),
|
||||
);
|
||||
}
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
} else if (choice === "new") {
|
||||
|
|
@ -1698,9 +1699,9 @@ export function registerStart(program: Command): void {
|
|||
running.projects.includes(projectId)
|
||||
) {
|
||||
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
||||
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
|
||||
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
||||
console.log(` Project "${projectId}" is already registered and running.\n`);
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -1868,7 +1869,12 @@ export function registerStop(program: Command): void {
|
|||
.description("Stop orchestrator agent and dashboard")
|
||||
.option("--purge-session", "Delete mapped OpenCode session when stopping")
|
||||
.option("--all", "Stop all running AO instances")
|
||||
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => {
|
||||
.option("-y, --yes", "Confirm stopping active sessions without prompting")
|
||||
.action(
|
||||
async (
|
||||
projectArg?: string,
|
||||
opts: { purgeSession?: boolean; all?: boolean; yes?: boolean } = {},
|
||||
) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stop_invoked",
|
||||
|
|
@ -1917,10 +1923,28 @@ export function registerStop(program: Command): void {
|
|||
config = loadConfig(globalPath);
|
||||
}
|
||||
}
|
||||
const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop");
|
||||
let _projectId: string;
|
||||
let project: ProjectConfig;
|
||||
if (projectArg) {
|
||||
({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"));
|
||||
} else {
|
||||
const projectIds = Object.keys(config.projects);
|
||||
if (projectIds.length === 0) {
|
||||
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
|
||||
}
|
||||
const currentDir = resolve(cwd());
|
||||
const cwdProjectId = findProjectForDirectory(config.projects, currentDir);
|
||||
_projectId =
|
||||
running?.projects.find((id) => config.projects[id]) ?? cwdProjectId ?? projectIds[0];
|
||||
project = config.projects[_projectId];
|
||||
}
|
||||
const port = config.port ?? DEFAULT_PORT;
|
||||
|
||||
if (projectArg) {
|
||||
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
|
||||
} else {
|
||||
console.log(chalk.bold(`\nStopping AO across all projects\n`));
|
||||
}
|
||||
|
||||
const sm = await getSessionManager(config);
|
||||
try {
|
||||
|
|
@ -1946,6 +1970,16 @@ export function registerStop(program: Command): void {
|
|||
const otherByProject = new Map<string, string[]>();
|
||||
|
||||
if (activeSessions.length > 0) {
|
||||
if (!projectArg && opts.yes !== true && isHumanCaller()) {
|
||||
const confirmed = await promptConfirm(
|
||||
`Stop AO and ${activeSessions.length} active session(s)?`,
|
||||
false,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log(chalk.yellow("Stop cancelled."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start();
|
||||
const purgeOpenCode = opts?.purgeSession === true;
|
||||
const warnings: string[] = [];
|
||||
|
|
@ -2136,5 +2170,6 @@ export function registerStop(program: Command): void {
|
|||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { Command } from "commander";
|
|||
import {
|
||||
createInitialCanonicalLifecycle,
|
||||
createActivitySignal,
|
||||
type Agent,
|
||||
type SCM,
|
||||
type Session,
|
||||
type PRInfo,
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
type Tracker,
|
||||
type ProjectConfig,
|
||||
type AgentReportAuditEntry,
|
||||
type PluginRegistry,
|
||||
isOrchestratorSession,
|
||||
isTerminalSession,
|
||||
isWindows,
|
||||
|
|
@ -127,7 +127,7 @@ function gatherProjectReviewStatus(projectId: string): ProjectReviewStatus {
|
|||
|
||||
async function gatherSessionInfo(
|
||||
session: Session,
|
||||
agent: Agent,
|
||||
registry: PluginRegistry,
|
||||
scm: SCM,
|
||||
projectConfig: ReturnType<typeof loadConfig>,
|
||||
reportsLimit: number = 0,
|
||||
|
|
@ -160,11 +160,17 @@ async function gatherSessionInfo(
|
|||
lastActivity = activityTs ? formatAge(activityTs) : "-";
|
||||
}
|
||||
|
||||
// Get agent's auto-generated summary via introspection
|
||||
// Get agent's auto-generated summary via introspection. The SessionManager
|
||||
// normalizes session.metadata.agent on read, so never infer the session agent
|
||||
// from current project/default config here.
|
||||
let claudeSummary: string | null = null;
|
||||
try {
|
||||
const agentName = session.metadata["agent"];
|
||||
if (agentName) {
|
||||
const agent = getAgentByNameFromRegistry(registry, agentName);
|
||||
const introspection = await agent.getSessionInfo(session);
|
||||
claudeSummary = introspection?.summary ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Summary extraction failed — not critical
|
||||
}
|
||||
|
|
@ -500,9 +506,9 @@ export function registerStatus(program: Command): void {
|
|||
totalActiveReviewRuns += reviewStatus.activeRunCount;
|
||||
totalOpenReviewFindings += reviewStatus.openFindingCount;
|
||||
|
||||
// Resolve agent and SCM for this project via the shared registry
|
||||
const agentName = projectConfig.agent ?? config.defaults.agent;
|
||||
const agent = getAgentByNameFromRegistry(registry, agentName);
|
||||
// Resolve SCM for this project via the shared registry. Agents are
|
||||
// resolved per session because historical metadata may record a
|
||||
// different agent than the current project default.
|
||||
const scm = getSCMFromRegistry(registry, config, projectId);
|
||||
|
||||
if (!opts.json) {
|
||||
|
|
@ -519,7 +525,9 @@ export function registerStatus(program: Command): void {
|
|||
}
|
||||
|
||||
// Gather all session info in parallel
|
||||
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config, reportsLimit));
|
||||
const infoPromises = projectSessions.map((s) =>
|
||||
gatherSessionInfo(s, registry, scm, config, reportsLimit),
|
||||
);
|
||||
const sessionInfos = await Promise.all(infoPromises);
|
||||
|
||||
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");
|
||||
|
|
|
|||
|
|
@ -68,12 +68,16 @@ export function registerUpdate(program: Command): void {
|
|||
.option("--skip-smoke", "Skip smoke tests after rebuilding (git installs only)")
|
||||
.option("--smoke-only", "Run smoke tests without fetching or rebuilding (git installs only)")
|
||||
.option("--check", "Print version info as JSON without upgrading")
|
||||
.option("--no-restore", "Restart AO after updating but do not restore stopped sessions")
|
||||
.action(
|
||||
async (opts: { skipSmoke?: boolean; smokeOnly?: boolean; check?: boolean }) => {
|
||||
async (opts: {
|
||||
skipSmoke?: boolean;
|
||||
smokeOnly?: boolean;
|
||||
check?: boolean;
|
||||
restore?: boolean;
|
||||
}) => {
|
||||
if (opts.skipSmoke && opts.smokeOnly) {
|
||||
console.error(
|
||||
"`ao update` does not allow `--skip-smoke` together with `--smoke-only`.",
|
||||
);
|
||||
console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +117,7 @@ export function registerUpdate(program: Command): void {
|
|||
case "npm-global":
|
||||
case "pnpm-global":
|
||||
case "bun-global":
|
||||
await handleNpmUpdate(method);
|
||||
await handleNpmUpdate(method, { restore: opts.restore !== false });
|
||||
break;
|
||||
case "unknown":
|
||||
await handleUnknownUpdate();
|
||||
|
|
@ -133,21 +137,26 @@ async function handleCheck(): Promise<void> {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-session guard
|
||||
// Update lifecycle planning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refuse to update when the user has live sessions.
|
||||
*
|
||||
* Auto-stopping would lose the agent's in-flight context (and potentially
|
||||
* uncommitted work). The release doc is explicit: refuse, surface the
|
||||
* `ao stop` command, let the user decide.
|
||||
*
|
||||
* Best-effort — when no config is reachable (fresh install, broken yaml)
|
||||
* we skip the guard rather than blocking the update on a missing dependency.
|
||||
* Best-effort snapshot used by `ao update` to pause and resume AO around the
|
||||
* package-manager install. Missing/broken config should not block an update;
|
||||
* in that case we proceed without attempting a stop/start round trip.
|
||||
*/
|
||||
async function ensureNoActiveSessions(): Promise<boolean> {
|
||||
interface UpdateLifecyclePlan {
|
||||
runningBeforeUpdate: boolean;
|
||||
configPath?: string;
|
||||
primaryProjectId?: string;
|
||||
activeSessions: Session[];
|
||||
}
|
||||
|
||||
async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
|
||||
let sessions: Session[];
|
||||
let configPath: string | undefined;
|
||||
let primaryProjectId: string | undefined;
|
||||
let runningBeforeUpdate = false;
|
||||
try {
|
||||
// Live signal first: running.json lists whichever projects the active
|
||||
// `ao start` daemon is currently polling. That can include local-only
|
||||
|
|
@ -160,6 +169,9 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
// truth for "which sessions does the running ao instance own?"
|
||||
const running = await getRunning();
|
||||
if (running && running.projects.length > 0) {
|
||||
runningBeforeUpdate = true;
|
||||
configPath = running.configPath;
|
||||
primaryProjectId = running.projects[0];
|
||||
// running.configPath could be local-wrapped (a project's
|
||||
// agent-orchestrator.yaml) OR the canonical global path. loadConfig
|
||||
// dispatches based on the path shape — both cases produce a full
|
||||
|
|
@ -174,15 +186,19 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
// SessionManager's enrichment will reconcile any stale-runtime
|
||||
// sessions to `killed`, so terminal statuses don't block the update.
|
||||
const globalPath = getGlobalConfigPath();
|
||||
if (!existsSync(globalPath)) return true;
|
||||
if (!existsSync(globalPath)) {
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
const globalConfig = loadGlobalConfig(globalPath);
|
||||
if (!globalConfig || Object.keys(globalConfig.projects).length === 0) {
|
||||
return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
if (!isCanonicalGlobalConfigPath(globalPath)) {
|
||||
return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
configPath = globalPath;
|
||||
const config = loadConfig(globalPath);
|
||||
primaryProjectId = Object.keys(config.projects)[0];
|
||||
const sm = await getSessionManager(config);
|
||||
sessions = await sm.list();
|
||||
}
|
||||
|
|
@ -190,29 +206,132 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
// If we can't enumerate sessions, don't pretend there are zero — but
|
||||
// also don't block the upgrade indefinitely. Surface a soft warning.
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
"⚠ Could not check for active sessions before updating. Proceeding anyway.",
|
||||
),
|
||||
chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."),
|
||||
);
|
||||
return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
|
||||
const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
|
||||
if (active.length === 0) return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: active };
|
||||
}
|
||||
|
||||
const noun = active.length === 1 ? "session" : "sessions";
|
||||
console.error(
|
||||
chalk.red(
|
||||
`\n✗ ${active.length} ${noun} active. Run \`ao stop\` first, then \`ao update\`.\n`,
|
||||
async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
|
||||
const shouldStop = plan.runningBeforeUpdate || plan.activeSessions.length > 0;
|
||||
if (!shouldStop) return false;
|
||||
|
||||
if (plan.activeSessions.length > 0) {
|
||||
const noun = plan.activeSessions.length === 1 ? "session" : "sessions";
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\n${plan.activeSessions.length} active ${noun} will be paused and restored after the update.`,
|
||||
),
|
||||
);
|
||||
for (const s of active.slice(0, 5)) {
|
||||
for (const s of plan.activeSessions.slice(0, 5)) {
|
||||
console.log(chalk.dim(` • ${s.id} (${s.status})`));
|
||||
}
|
||||
if (plan.activeSessions.length > 5) {
|
||||
console.log(chalk.dim(` … and ${plan.activeSessions.length - 5} more`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.dim("\nAO is running; it will be restarted after the update."));
|
||||
}
|
||||
|
||||
const stopExit = await runAoLifecycleCommand(["stop", "--yes"], {
|
||||
configPath: plan.configPath,
|
||||
});
|
||||
if (stopExit !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update failed: internal ao stop exited non-zero`,
|
||||
data: { exitCode: stopExit },
|
||||
});
|
||||
console.error(chalk.red(`\nAO update could not stop the running daemon (exit ${stopExit}).`));
|
||||
process.exit(stopExit);
|
||||
}
|
||||
|
||||
const afterStop = await getUpdateLifecyclePlan();
|
||||
if (afterStop.runningBeforeUpdate || afterStop.activeSessions.length > 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update failed: AO still appears active after internal ao stop`,
|
||||
data: {
|
||||
runningAfterStop: afterStop.runningBeforeUpdate,
|
||||
activeSessionCount: afterStop.activeSessions.length,
|
||||
activeSessionIds: afterStop.activeSessions.map((s) => s.id).slice(0, 20),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
"\nAO update stopped before installing because AO still appears to be running after `ao stop --yes`.",
|
||||
),
|
||||
);
|
||||
if (afterStop.activeSessions.length > 0) {
|
||||
console.error(chalk.dim("Still-active sessions:"));
|
||||
for (const s of afterStop.activeSessions.slice(0, 5)) {
|
||||
console.error(chalk.dim(` • ${s.id} (${s.status})`));
|
||||
}
|
||||
if (active.length > 5) {
|
||||
console.error(chalk.dim(` … and ${active.length - 5} more`));
|
||||
if (afterStop.activeSessions.length > 5) {
|
||||
console.error(chalk.dim(` … and ${afterStop.activeSessions.length - 5} more`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
console.error(chalk.dim("Run `ao stop` and retry `ao update` after AO is fully stopped."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return plan.runningBeforeUpdate;
|
||||
}
|
||||
|
||||
async function restartAoAfterUpdate(
|
||||
plan: UpdateLifecyclePlan,
|
||||
opts: { restore: boolean },
|
||||
): Promise<void> {
|
||||
const args = ["start"];
|
||||
if (plan.primaryProjectId) args.push(plan.primaryProjectId);
|
||||
args.push(opts.restore ? "--restore" : "--no-restore");
|
||||
|
||||
console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`));
|
||||
const exitCode = await runAoLifecycleCommand(args, { configPath: plan.configPath });
|
||||
if (exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_restart_failed",
|
||||
level: "error",
|
||||
summary: `ao update could not restart AO after install`,
|
||||
data: { exitCode, args },
|
||||
});
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`\nAO was updated, but \`ao ${args.join(" ")}\` failed with exit ${exitCode}. ` +
|
||||
`Run it manually to restore your sessions.`,
|
||||
),
|
||||
);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
function runAoLifecycleCommand(
|
||||
args: string[],
|
||||
opts: { configPath?: string } = {},
|
||||
): Promise<number> {
|
||||
return new Promise<number>((resolveExit) => {
|
||||
const child = spawn("ao", args, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
env: opts.configPath ? { ...process.env, AO_CONFIG_PATH: opts.configPath } : process.env,
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`));
|
||||
resolveExit(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
resolveExit(signal ? 1 : (code ?? 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -222,10 +341,10 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
async function handleGitUpdate(opts: {
|
||||
skipSmoke?: boolean;
|
||||
smokeOnly?: boolean;
|
||||
restore?: boolean;
|
||||
}): Promise<void> {
|
||||
if (!(await ensureNoActiveSessions())) {
|
||||
process.exit(1);
|
||||
}
|
||||
const lifecyclePlan = await getUpdateLifecyclePlan();
|
||||
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
|
||||
|
||||
const args: string[] = [];
|
||||
if (opts.skipSmoke) args.push("--skip-smoke");
|
||||
|
|
@ -241,14 +360,17 @@ async function handleGitUpdate(opts: {
|
|||
summary: `ao update (git) failed: ao-update.sh exited non-zero`,
|
||||
data: { method: "git", exitCode },
|
||||
});
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
invalidateCache();
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("Script not found: ao-update.sh")
|
||||
) {
|
||||
if (error instanceof Error && error.message.includes("Script not found: ao-update.sh")) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
|
|
@ -263,6 +385,9 @@ async function handleGitUpdate(opts: {
|
|||
"If you're on a package install, reinstall the package.",
|
||||
),
|
||||
);
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -277,6 +402,9 @@ async function handleGitUpdate(opts: {
|
|||
},
|
||||
});
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -285,7 +413,7 @@ async function handleGitUpdate(opts: {
|
|||
// npm / pnpm / bun global install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
||||
async function handleNpmUpdate(method: InstallMethod, opts: { restore: boolean }): Promise<void> {
|
||||
const channel = resolveUpdateChannel();
|
||||
|
||||
// Snapshot the previously cached channel BEFORE we force a refresh, so we
|
||||
|
|
@ -332,9 +460,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
// is the right UX for a channel transition, and the install command we'd
|
||||
// run is genuinely different even if the version-compare says "no".
|
||||
const isChannelSwitch =
|
||||
!info.isOutdated &&
|
||||
previousChannel !== undefined &&
|
||||
previousChannel !== channel;
|
||||
!info.isOutdated && previousChannel !== undefined && previousChannel !== channel;
|
||||
|
||||
// First-channel opt-in. previousChannel === undefined means we've never
|
||||
// installed via the auto-updater. A user who just ran `ao config set
|
||||
|
|
@ -363,9 +489,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
console.log(`Channel: ${chalk.cyan(channel)}`);
|
||||
if (isChannelSwitch) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`,
|
||||
),
|
||||
chalk.yellow(`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`),
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
|
|
@ -384,15 +508,12 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
const command = getUpdateCommand(method, channel);
|
||||
const apiInvoked = isApiInvoked();
|
||||
const interactive = isTTY() && !apiInvoked;
|
||||
const lifecyclePlan = await getUpdateLifecyclePlan();
|
||||
|
||||
// Non-interactive path: API-invoked OR piped output. We still gate on the
|
||||
// active-session guard (refusing returns true/false), but we never bail
|
||||
// out just because there's no terminal — the dashboard's "Update" click
|
||||
// must actually install. The only thing we skip is the confirm prompt.
|
||||
if (!(await ensureNoActiveSessions())) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Non-interactive path: API-invoked OR piped output. We still plan the
|
||||
// stop/start lifecycle, but we never bail out just because there's no
|
||||
// terminal — the dashboard's "Update" click must actually install. The
|
||||
// only thing we skip is the confirm prompt.
|
||||
if (interactive) {
|
||||
// Soft auto-install: when the user has opted into stable or nightly we
|
||||
// skip the confirm prompt — they've already said "keep me on this channel."
|
||||
|
|
@ -404,10 +525,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
isChannelSwitch || isFirstChannelOptIn
|
||||
? `Switch to ${channel} via ${chalk.cyan(command)}?`
|
||||
: `Run ${chalk.cyan(command)}?`;
|
||||
const confirmed = await promptConfirm(
|
||||
promptText,
|
||||
!(isChannelSwitch || isFirstChannelOptIn),
|
||||
);
|
||||
const confirmed = await promptConfirm(promptText, !(isChannelSwitch || isFirstChannelOptIn));
|
||||
if (!confirmed) return;
|
||||
} else {
|
||||
console.log(chalk.dim(`Updating: ${command}`));
|
||||
|
|
@ -422,25 +540,95 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const exitCode = await runNpmInstall(command);
|
||||
if (exitCode === 0) {
|
||||
invalidateCache();
|
||||
console.log(chalk.green("\nUpdate complete."));
|
||||
} else {
|
||||
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
|
||||
const installResult = await runNpmInstall(command);
|
||||
if (installResult.exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: install command exited non-zero`,
|
||||
data: { method, command, exitCode },
|
||||
data: {
|
||||
method,
|
||||
command,
|
||||
exitCode: installResult.exitCode,
|
||||
classification: classifyInstallFailure(installResult.output).kind,
|
||||
},
|
||||
});
|
||||
process.exit(exitCode);
|
||||
printInstallFailure({
|
||||
method,
|
||||
command,
|
||||
channel,
|
||||
currentVersion: info.currentVersion,
|
||||
exitCode: installResult.exitCode,
|
||||
output: installResult.output,
|
||||
});
|
||||
if (shouldRestart) {
|
||||
console.log(chalk.dim("\nRestarting AO with the existing installation..."));
|
||||
await restartAoAfterUpdate(lifecyclePlan, opts);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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<number> {
|
||||
interface CommandResult {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
function runNpmInstall(command: string): Promise<CommandResult> {
|
||||
const [cmd, ...args] = command.split(" ");
|
||||
return new Promise<number>((resolveExit, reject) => {
|
||||
return runCommandCapture(cmd!, args, { echo: true }).then((result) => {
|
||||
if (result.exitCode !== 0) {
|
||||
console.error(chalk.yellow(`\n${cmd} exited with code ${result.exitCode}.`));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function runCommandCapture(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { echo?: boolean } = {},
|
||||
): Promise<CommandResult> {
|
||||
return new Promise<CommandResult>((resolveExit) => {
|
||||
// `shell: isWindows()` is required so PATHEXT gets consulted on Windows —
|
||||
// npm/pnpm/bun install as `*.cmd` shims, and Node.js does not look at
|
||||
// PATHEXT for non-shell spawns, so a bare `npm` / `pnpm` / `bun` lookup
|
||||
|
|
@ -448,23 +636,160 @@ function runNpmInstall(command: string): Promise<number> {
|
|||
// keeps the shell window from flashing. Same fix that landed for the
|
||||
// dashboard's /api/update spawn in commit 9f29131d.
|
||||
const child = spawn(cmd!, args, {
|
||||
stdio: "inherit",
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on("error", reject);
|
||||
let output = "";
|
||||
const collect = (chunk: Buffer | string, stream: NodeJS.WriteStream): void => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
if (opts.echo) stream.write(chunk);
|
||||
};
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => collect(chunk, process.stdout));
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => collect(chunk, process.stderr));
|
||||
child.on("error", (error) => {
|
||||
output += `${error.name}: ${error.message}`;
|
||||
resolveExit({ exitCode: 1, output });
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
resolveExit(1);
|
||||
resolveExit({ exitCode: 1, output: `${output}\nTerminated by signal ${signal}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
console.error(chalk.yellow(`\n${cmd} exited with code ${code}.`));
|
||||
resolveExit({ exitCode: code ?? 1, output });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
ok: boolean;
|
||||
actualVersion?: string;
|
||||
output: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function verifyInstalledVersion(
|
||||
expectedVersion: string,
|
||||
previousVersion: string,
|
||||
): Promise<VerificationResult> {
|
||||
const result = await runCommandCapture("ao", ["--version"]);
|
||||
const output = result.output.trim();
|
||||
const actualVersion = parseAoVersion(output);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
output,
|
||||
message: `\`ao --version\` failed with exit ${result.exitCode}.`,
|
||||
};
|
||||
}
|
||||
resolveExit(code ?? 1);
|
||||
});
|
||||
});
|
||||
if (!actualVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
output,
|
||||
message: `Could not parse \`ao --version\` output: ${output || "<empty>"}`,
|
||||
};
|
||||
}
|
||||
if (actualVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
actualVersion,
|
||||
output,
|
||||
message:
|
||||
actualVersion === previousVersion
|
||||
? `The install command exited successfully, but AO is still on ${previousVersion}.`
|
||||
: `The install command exited successfully, but AO reports ${actualVersion}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, actualVersion, output, message: "verified" };
|
||||
}
|
||||
|
||||
function parseAoVersion(output: string): string | undefined {
|
||||
const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function classifyInstallFailure(output: string): { kind: string; guidance: string } {
|
||||
if (/ERR_PNPM_UNEXPECTED_VIRTUAL_STORE/i.test(output)) {
|
||||
return {
|
||||
kind: "pnpm_virtual_store",
|
||||
guidance:
|
||||
"pnpm's global store metadata is inconsistent. Try `pnpm store prune`, then retry `ao update`. " +
|
||||
"If pnpm remains stuck, use the npm fallback below.",
|
||||
};
|
||||
}
|
||||
if (/(?:EACCES|EPERM|permission denied|access denied)/i.test(output)) {
|
||||
return {
|
||||
kind: "permission",
|
||||
guidance:
|
||||
"The package manager could not write to the global install location. Fix your npm/pnpm global prefix permissions, or retry from a shell with access to that directory.",
|
||||
};
|
||||
}
|
||||
if (/(?:ENETUNREACH|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|socket hang up)/i.test(output)) {
|
||||
return {
|
||||
kind: "network",
|
||||
guidance:
|
||||
"The registry request failed due to a network error. Check connectivity/VPN/proxy settings and retry `ao update`.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
/(?:lockfile|ERR_PNPM_LOCKFILE|ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_BROKEN_LOCKFILE)/i.test(
|
||||
output,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "lockfile",
|
||||
guidance:
|
||||
"pnpm reported lockfile state problems. Clear the affected global install metadata or retry with the npm fallback below.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
/(?:registry|ERR_PNPM_FETCH|ERR_PNPM_META_FETCH_FAIL|E401|E403|E404|404 Not Found|401 Unauthorized|403 Forbidden)/i.test(
|
||||
output,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "registry",
|
||||
guidance:
|
||||
"The npm registry rejected or failed the package request. Check registry configuration, auth tokens, and the selected AO update channel.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "unknown",
|
||||
guidance:
|
||||
"The package manager failed before AO could verify the new version. Retry `ao update` after addressing the package-manager error below.",
|
||||
};
|
||||
}
|
||||
|
||||
function printInstallFailure(opts: {
|
||||
method: InstallMethod;
|
||||
command: string;
|
||||
channel: ReturnType<typeof resolveUpdateChannel>;
|
||||
currentVersion: string;
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}): void {
|
||||
const classification = classifyInstallFailure(opts.output);
|
||||
const fallbackCommand = getUpdateCommand("npm-global", opts.channel);
|
||||
|
||||
console.error(
|
||||
chalk.red(`\nAO was not updated. You are still on version ${opts.currentVersion}.`),
|
||||
);
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`The package manager (${opts.method.replace("-global", "")}) failed with exit ${opts.exitCode}.`,
|
||||
),
|
||||
);
|
||||
console.error(chalk.yellow(classification.guidance));
|
||||
console.error(chalk.dim(`\nTo retry: ao update`));
|
||||
if (opts.command !== fallbackCommand) {
|
||||
console.error(chalk.dim(`You can also try: ${fallbackCommand}`));
|
||||
}
|
||||
console.error(chalk.dim("\nPackage manager output:"));
|
||||
console.error(opts.output.trim() || "<no output>");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -480,9 +805,7 @@ async function handleHomebrewUpdate(): Promise<void> {
|
|||
console.log(`Latest version: ${chalk.green(info.latestVersion)}`);
|
||||
}
|
||||
console.log();
|
||||
console.log(
|
||||
`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`,
|
||||
);
|
||||
console.log(`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
" (AO does not auto-install for brew installs because it would clobber brew's symlinks.)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import codexPlugin from "@aoagents/ao-plugin-agent-codex";
|
|||
import aiderPlugin from "@aoagents/ao-plugin-agent-aider";
|
||||
import cursorPlugin from "@aoagents/ao-plugin-agent-cursor";
|
||||
import kimicodePlugin from "@aoagents/ao-plugin-agent-kimicode";
|
||||
import grokPlugin from "@aoagents/ao-plugin-agent-grok";
|
||||
import opencodePlugin from "@aoagents/ao-plugin-agent-opencode";
|
||||
import githubSCMPlugin from "@aoagents/ao-plugin-scm-github";
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ const agentPlugins: Record<string, { create(): Agent }> = {
|
|||
aider: aiderPlugin,
|
||||
cursor: cursorPlugin,
|
||||
kimicode: kimicodePlugin,
|
||||
grok: grokPlugin,
|
||||
opencode: opencodePlugin,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)" : "";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ function makeAssessment(overrides: Partial<RecoveryAssessment> = {}): RecoveryAs
|
|||
metadataStatus: "working",
|
||||
rawMetadata: {
|
||||
project: "app",
|
||||
agent: "claude-code",
|
||||
branch: "feat/test",
|
||||
issue: "123",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
|
|
@ -169,6 +170,7 @@ describe("recoverSession", () => {
|
|||
expect(result.session?.restoredAt).toBeInstanceOf(Date);
|
||||
expect(metadata?.["restoredAt"]).toBeDefined();
|
||||
expect(metadata?.["recoveredAt"]).toBeUndefined();
|
||||
expect(metadata?.["agent"]).toBe("claude-code");
|
||||
});
|
||||
|
||||
it("preserves project ownership when legacy metadata omits the project field", async () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function getActivityLogPath(workspacePath: string): string {
|
|||
export async function appendActivityEntry(
|
||||
workspacePath: string,
|
||||
state: ActivityState,
|
||||
source: "terminal" | "native",
|
||||
source: "terminal" | "native" | "hook",
|
||||
trigger?: string,
|
||||
): Promise<void> {
|
||||
const logPath = getActivityLogPath(workspacePath);
|
||||
|
|
@ -98,7 +98,7 @@ export async function readLastActivityEntry(
|
|||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const validStates = new Set(["active", "ready", "idle", "waiting_input", "blocked", "exited"]);
|
||||
const validSources = new Set(["terminal", "native"]);
|
||||
const validSources = new Set(["terminal", "native", "hook"]);
|
||||
if (
|
||||
typeof record.ts !== "string" ||
|
||||
typeof record.state !== "string" ||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,31 @@ export function resolveSessionRole(
|
|||
: "worker";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the agent identity for a session metadata record. Normalized Session
|
||||
* objects are expected to carry metadata.agent; fallback resolution exists only
|
||||
* for legacy raw metadata read/repair boundaries.
|
||||
*/
|
||||
export function resolveAgentSelectionForSession(params: {
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string>;
|
||||
project: ProjectConfig;
|
||||
defaults: DefaultPlugins;
|
||||
allSessionPrefixes?: string[];
|
||||
}): ResolvedAgentSelection {
|
||||
return resolveAgentSelection({
|
||||
role: resolveSessionRole(
|
||||
params.sessionId,
|
||||
params.metadata,
|
||||
params.project.sessionPrefix,
|
||||
params.allSessionPrefixes,
|
||||
),
|
||||
project: params.project,
|
||||
defaults: params.defaults,
|
||||
persistedAgent: params.metadata?.["agent"],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAgentSelection(params: {
|
||||
role: SessionRole;
|
||||
project: ProjectConfig;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import {
|
|||
import { createCorrelationId, createProjectObserver } from "./observability.js";
|
||||
import { resolveNotifierTarget } from "./notifier-resolution.js";
|
||||
import { recordNotificationDelivery } from "./notification-observability.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
|
||||
import { resolveSessionRole } from "./agent-selection.js";
|
||||
import {
|
||||
DETECTING_MAX_ATTEMPTS,
|
||||
createDetectingDecision,
|
||||
|
|
@ -910,20 +910,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
|
||||
const lifecycle = cloneLifecycle(session.lifecycle);
|
||||
const nowIso = new Date().toISOString();
|
||||
const allSessionPrefixes = Object.values(config.projects).map((p) => p.sessionPrefix);
|
||||
const sessionRole = resolveSessionRole(
|
||||
session.id,
|
||||
session.metadata,
|
||||
project.sessionPrefix,
|
||||
allSessionPrefixes,
|
||||
);
|
||||
const agentName = resolveAgentSelection({
|
||||
role: sessionRole,
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
persistedAgent: session.metadata["agent"],
|
||||
}).agentName;
|
||||
const agent = registry.get<Agent>("agent", agentName);
|
||||
const agentName = session.metadata["agent"];
|
||||
const agent = agentName ? registry.get<Agent>("agent", agentName) : null;
|
||||
const scm = project.scm?.plugin ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
let detectedIdleTimestamp: Date | null = null;
|
||||
let idleWasBlocked = false;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ function buildLifecycleRecoveryPatch(
|
|||
return buildLifecycleMetadataPatch(updated);
|
||||
}
|
||||
|
||||
function preserveSessionAgentPatch(
|
||||
rawMetadata: Record<string, string>,
|
||||
): Partial<Record<string, string>> {
|
||||
return rawMetadata["agent"] ? { agent: rawMetadata["agent"] } : {};
|
||||
}
|
||||
|
||||
export async function recoverSession(
|
||||
assessment: RecoveryAssessment,
|
||||
config: OrchestratorConfig,
|
||||
|
|
@ -101,6 +107,7 @@ export async function recoverSession(
|
|||
escalatedAt: now,
|
||||
escalationReason: `Exceeded max recovery attempts (${context.recoveryConfig.maxRecoveryAttempts})`,
|
||||
recoveryCount: String(recoveryCount),
|
||||
...preserveSessionAgentPatch(rawMetadata),
|
||||
...buildLifecycleRecoveryPatch(rawMetadata, {
|
||||
state: "stuck",
|
||||
reason: "probe_failure",
|
||||
|
|
@ -121,6 +128,7 @@ export async function recoverSession(
|
|||
status: preservedStatus,
|
||||
restoredAt: now,
|
||||
recoveryCount: String(recoveryCount),
|
||||
...preserveSessionAgentPatch(rawMetadata),
|
||||
});
|
||||
context.invalidateCache?.();
|
||||
|
||||
|
|
@ -225,6 +233,7 @@ export async function cleanupSession(
|
|||
status: "terminated",
|
||||
terminatedAt: cleanupAt,
|
||||
terminationReason: "cleanup",
|
||||
...preserveSessionAgentPatch(rawMetadata),
|
||||
...buildLifecycleRecoveryPatch(rawMetadata, {
|
||||
state: "terminated",
|
||||
reason: "auto_cleanup",
|
||||
|
|
@ -284,6 +293,7 @@ export async function escalateSession(
|
|||
status: "stuck",
|
||||
escalatedAt: new Date().toISOString(),
|
||||
escalationReason: reason,
|
||||
...preserveSessionAgentPatch(rawMetadata),
|
||||
...buildLifecycleRecoveryPatch(rawMetadata, {
|
||||
state: "stuck",
|
||||
reason: "probe_failure",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
type RecoveryAction,
|
||||
type RecoveryConfig,
|
||||
} from "./types.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js";
|
||||
import { resolveAgentSelectionForSession } from "../agent-selection.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
|
||||
|
|
@ -44,27 +44,26 @@ export async function validateSession(
|
|||
const { sessionId, projectId, project, rawMetadata } = scanned;
|
||||
|
||||
const runtimeName = project.runtime ?? config.defaults.runtime;
|
||||
const agentName = resolveAgentSelection({
|
||||
role: resolveSessionRole(
|
||||
const agentName = resolveAgentSelectionForSession({
|
||||
sessionId,
|
||||
rawMetadata,
|
||||
project.sessionPrefix,
|
||||
Object.values(config.projects).map((p) => p.sessionPrefix),
|
||||
),
|
||||
metadata: rawMetadata,
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
persistedAgent: rawMetadata["agent"],
|
||||
allSessionPrefixes: Object.values(config.projects).map((p) => p.sessionPrefix),
|
||||
}).agentName;
|
||||
const normalizedMetadata = rawMetadata["agent"]
|
||||
? rawMetadata
|
||||
: { ...rawMetadata, agent: agentName };
|
||||
const workspaceName = project.workspace ?? config.defaults.workspace;
|
||||
|
||||
const runtime = registry.get<Runtime>("runtime", runtimeName);
|
||||
const agent = registry.get<Agent>("agent", agentName);
|
||||
const workspace = registry.get<Workspace>("workspace", workspaceName);
|
||||
|
||||
const workspacePath = rawMetadata["worktree"] || null;
|
||||
const runtimeHandleStr = rawMetadata["runtimeHandle"];
|
||||
const workspacePath = normalizedMetadata["worktree"] || null;
|
||||
const runtimeHandleStr = normalizedMetadata["runtimeHandle"];
|
||||
const runtimeHandle = runtimeHandleStr ? safeJsonParse<RuntimeHandle>(runtimeHandleStr) : null;
|
||||
const metadataStatus = validateStatus(rawMetadata["status"]);
|
||||
const metadataStatus = validateStatus(normalizedMetadata["status"]);
|
||||
const recoveryConfig: RecoveryConfig = {
|
||||
...DEFAULT_RECOVERY_CONFIG,
|
||||
...(recoveryConfigInput ?? {}),
|
||||
|
|
@ -120,15 +119,15 @@ export async function validateSession(
|
|||
activity: null,
|
||||
activitySignal: createActivitySignal("unavailable"),
|
||||
lifecycle,
|
||||
branch: rawMetadata["branch"] ?? null,
|
||||
issueId: rawMetadata["issue"] ?? null,
|
||||
branch: normalizedMetadata["branch"] ?? null,
|
||||
issueId: normalizedMetadata["issue"] ?? null,
|
||||
pr: null,
|
||||
workspacePath,
|
||||
runtimeHandle,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(rawMetadata["createdAt"] ?? Date.now()),
|
||||
lastActivityAt: new Date(rawMetadata["lastActivityAt"] ?? Date.now()),
|
||||
metadata: rawMetadata,
|
||||
createdAt: new Date(normalizedMetadata["createdAt"] ?? Date.now()),
|
||||
lastActivityAt: new Date(normalizedMetadata["lastActivityAt"] ?? Date.now()),
|
||||
metadata: normalizedMetadata,
|
||||
};
|
||||
const detection = await agent.getActivityState(probeSession, config.readyThresholdMs);
|
||||
agentActivity = detection?.state ?? null;
|
||||
|
|
@ -147,7 +146,7 @@ export async function validateSession(
|
|||
}
|
||||
}
|
||||
|
||||
const metadataValid = Object.keys(rawMetadata).length > 0;
|
||||
const metadataValid = Object.keys(normalizedMetadata).length > 0;
|
||||
const classification = classifySession(
|
||||
runtimeAlive,
|
||||
workspaceExists,
|
||||
|
|
@ -195,7 +194,7 @@ export async function validateSession(
|
|||
agentActivity,
|
||||
metadataValid,
|
||||
metadataStatus,
|
||||
rawMetadata,
|
||||
rawMetadata: normalizedMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ import {
|
|||
import { sessionFromMetadata } from "./utils/session-from-metadata.js";
|
||||
import { safeJsonParse, validateStatus } from "./utils/validation.js";
|
||||
import { isGitBranchNameSafe } from "./utils.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
|
||||
import { resolveAgentSelection, resolveAgentSelectionForSession } from "./agent-selection.js";
|
||||
import {
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
|
|
@ -272,7 +272,7 @@ function deriveDisplayName(input: { issueTitle?: string; prompt?: string }): str
|
|||
}
|
||||
|
||||
if (input.prompt && input.prompt.trim()) {
|
||||
const line = pickLine(input.prompt);
|
||||
const line = pickLine(input.prompt).replace(/^#{1,6}\s+/, "");
|
||||
if (line) return truncate(line);
|
||||
}
|
||||
|
||||
|
|
@ -538,6 +538,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
sessionCache = null;
|
||||
}
|
||||
|
||||
function repairSessionAgentMetadataOnRead(
|
||||
sessionsDir: string,
|
||||
record: ActiveSessionRecord,
|
||||
project: ProjectConfig,
|
||||
): ActiveSessionRecord {
|
||||
if (record.raw["agent"]) return record;
|
||||
|
||||
const agent = resolveSelectionForSession(project, record.sessionName, record.raw).agentName;
|
||||
updateMetadataPreservingMtime(sessionsDir, record.sessionName, { agent }, record.modifiedAt);
|
||||
return {
|
||||
...record,
|
||||
raw: applyMetadataUpdatesToRaw(record.raw, { agent }),
|
||||
};
|
||||
}
|
||||
|
||||
function repairSingleSessionMetadataOnRead(
|
||||
sessionsDir: string,
|
||||
record: ActiveSessionRecord,
|
||||
|
|
@ -599,7 +614,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
function repairSessionMetadataOnRead(
|
||||
sessionsDir: string,
|
||||
records: ActiveSessionRecord[],
|
||||
sessionPrefix?: string,
|
||||
project: ProjectConfig,
|
||||
): ActiveSessionRecord[] {
|
||||
const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } }));
|
||||
const duplicatePRAttachments = new Map<string, ActiveSessionRecord[]>();
|
||||
|
|
@ -611,7 +626,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
sessionId: record.sessionName,
|
||||
status: validateStatus(record.raw["status"]),
|
||||
createdAt: record.raw["createdAt"] ? new Date(record.raw["createdAt"]) : undefined,
|
||||
sessionKind: isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)
|
||||
sessionKind: isOrchestratorSessionRecord(
|
||||
record.sessionName,
|
||||
record.raw,
|
||||
project.sessionPrefix,
|
||||
)
|
||||
? "orchestrator"
|
||||
: "worker",
|
||||
}),
|
||||
|
|
@ -626,11 +645,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
record.raw = applyMetadataUpdatesToRaw(record.raw, canonicalUpdates);
|
||||
}
|
||||
|
||||
if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) {
|
||||
record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw;
|
||||
if (isOrchestratorSessionRecord(record.sessionName, record.raw, project.sessionPrefix)) {
|
||||
record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, project.sessionPrefix).raw;
|
||||
record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw;
|
||||
continue;
|
||||
}
|
||||
|
||||
record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw;
|
||||
|
||||
const prUrl = record.raw["pr"];
|
||||
if (!prUrl) continue;
|
||||
|
||||
|
|
@ -705,7 +727,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
return [{ sessionName, raw, modifiedAt } satisfies ActiveSessionRecord];
|
||||
});
|
||||
|
||||
return repairSessionMetadataOnRead(sessionsDir, records, project.sessionPrefix);
|
||||
return repairSessionMetadataOnRead(sessionsDir, records, project);
|
||||
}
|
||||
|
||||
function sortSessionIdsForReuse(ids: string[]): string[] {
|
||||
|
|
@ -900,16 +922,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
sessionId: string,
|
||||
metadata: Record<string, string>,
|
||||
) {
|
||||
return resolveAgentSelection({
|
||||
role: resolveSessionRole(
|
||||
return resolveAgentSelectionForSession({
|
||||
sessionId,
|
||||
metadata,
|
||||
project.sessionPrefix,
|
||||
Object.values(config.projects).map((p) => p.sessionPrefix),
|
||||
),
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
persistedAgent: metadata["agent"],
|
||||
allSessionPrefixes: Object.values(config.projects).map((p) => p.sessionPrefix),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -947,10 +965,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
modifiedAt = undefined;
|
||||
}
|
||||
|
||||
const repaired = repairSingleSessionMetadataOnRead(
|
||||
const repaired = repairSessionAgentMetadataOnRead(
|
||||
sessionsDir,
|
||||
repairSingleSessionMetadataOnRead(
|
||||
sessionsDir,
|
||||
{ sessionName: sessionId, raw, modifiedAt },
|
||||
project.sessionPrefix,
|
||||
),
|
||||
project,
|
||||
);
|
||||
|
||||
return { raw: repaired.raw, sessionsDir, project, projectId };
|
||||
|
|
@ -1504,6 +1526,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
await plugins.agent.postLaunchSetup(session);
|
||||
}
|
||||
|
||||
if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) {
|
||||
await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt);
|
||||
}
|
||||
|
||||
if (
|
||||
plugins.agent.name === "opencode" &&
|
||||
opencodeIssueSessionStrategy === "reuse" &&
|
||||
|
|
@ -1979,6 +2005,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
await plugins.agent.postLaunchSetup(session);
|
||||
}
|
||||
|
||||
if (plugins.agent.promptDelivery === "post-launch" && orchestratorConfig.systemPrompt) {
|
||||
// The orchestrator prompt is already passed via systemPromptFile in the launch command.
|
||||
// Send only a minimal trigger so interactive post-launch agents start without
|
||||
// receiving their system instructions again as a user message.
|
||||
await plugins.runtime.sendMessage(handle, "Begin.");
|
||||
}
|
||||
|
||||
if (
|
||||
plugins.agent.name === "opencode" &&
|
||||
orchestratorSessionStrategy === "reuse" &&
|
||||
|
|
@ -2369,10 +2402,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// If stat fails, timestamps will fall back to current time
|
||||
}
|
||||
|
||||
const repaired = repairSingleSessionMetadataOnRead(
|
||||
const repaired = repairSessionAgentMetadataOnRead(
|
||||
sessionsDir,
|
||||
repairSingleSessionMetadataOnRead(
|
||||
sessionsDir,
|
||||
{ sessionName: sessionId, raw, modifiedAt },
|
||||
project.sessionPrefix,
|
||||
),
|
||||
project,
|
||||
);
|
||||
|
||||
const session = metadataToSession(
|
||||
|
|
@ -3539,6 +3576,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
updateMetadata(sessionsDir, sessionId, {
|
||||
...buildLifecycleMetadataPatch(restoredLifecycle),
|
||||
agent: selection.agentName,
|
||||
restoredAt: now,
|
||||
mergedPendingCleanupSince: "",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,89 @@
|
|||
# @aoagents/ao-plugin-agent-claude-code
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
|
||||
- Updated dependencies [2d4c457]
|
||||
- @aoagents/ao-core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7d9b862: Replace Claude Code terminal-regex activity detection with platform-event hooks (#1941).
|
||||
|
||||
Claude Code emits a lifecycle hook on every state transition that matters
|
||||
(`PermissionRequest`, `StopFailure`, `Notification`, `Stop`, `PreToolUse`,
|
||||
…). Until now, AO ignored all but one of them and tried to infer the
|
||||
same information by regex-matching Claude's rendered terminal output —
|
||||
fragile by construction. Every Claude UI tweak (footer wording, status
|
||||
verb, spinner glyph) broke a heuristic; PR #1932 spent 15 commits
|
||||
patching the sharpest edges.
|
||||
|
||||
This release pivots:
|
||||
|
||||
**`@aoagents/ao-plugin-agent-claude-code`** now installs two scripts per
|
||||
workspace:
|
||||
- `metadata-updater` — unchanged; PostToolUse(Bash) extracts gh/git
|
||||
side-effects (PR URL, branch, merge status).
|
||||
- `activity-updater` — new; registered on every hook that carries
|
||||
activity information (SessionStart, UserPromptSubmit, PreToolUse,
|
||||
PostToolUse, PostToolUseFailure, PostToolBatch, Notification,
|
||||
PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop,
|
||||
PreCompact, PostCompact). The script reads the JSON payload from
|
||||
stdin, maps `hook_event_name` to an activity state, and appends a
|
||||
JSONL entry to `{workspace}/.ao/activity.jsonl` with `source: "hook"`.
|
||||
|
||||
Notification is filtered by `notification_type` so `auth_success` /
|
||||
`elicitation_*` no longer false-fire `waiting_input` (the RFC's blanket
|
||||
"Notification → waiting_input" would have regressed here).
|
||||
|
||||
The terminal-regex layer (`classifyTerminalOutput`, ~80 LOC of
|
||||
patterns + `agent.recordActivity`) is retired. `detectActivity` stays on
|
||||
the Agent interface for other agents but is now a stable `return "idle"`
|
||||
stub for Claude — the JSONL-backed cascade is the only source of truth
|
||||
for active / ready / waiting_input / blocked.
|
||||
|
||||
**`@aoagents/ao-core`** extends `ActivityLogEntry.source` and
|
||||
`ActivitySignalSource` with a `"hook"` value so the new entries are
|
||||
parseable and their provenance is visible in telemetry. No downstream
|
||||
consumer needs changes — the cascade has always read whatever source
|
||||
appeared in the JSONL, and the new tests assert hook-sourced entries
|
||||
flow through `checkActivityLogState` / `getActivityFallbackState`
|
||||
identically to terminal-sourced ones.
|
||||
|
||||
Idempotent install: calling `setupWorkspaceHooks` twice keeps exactly
|
||||
one entry per event and preserves user-installed hooks alongside ours.
|
||||
Cross-platform: bash + Node (.cjs) variants behave identically against a
|
||||
shared 52-case scenario table.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a610601: Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"` → `blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903.
|
||||
|
||||
Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}` → `blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider).
|
||||
|
||||
- 8c71bde: Harden Claude Code activity detection against five real-world edge cases identified during PR #1927's analysis:
|
||||
1. **Bookkeeping types → false-active.** `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title` were falling through to the `default` switch branch and showing as `active` for 30s after Claude finished a turn. They now correctly map to `ready`/`idle` by age. Likely root cause of "Claude looks busy when it's done" reports.
|
||||
2. **Multi-session disambiguation.** `findLatestSessionFile` picked newest-mtime, which is the wrong session's JSONL when two Claude sessions are running in the same workspace. Now prefers the UUID-named file (`<projectDir>/<claudeSessionUuid>.jsonl`) when `session.metadata.claudeSessionUuid` is set, falling back to newest-mtime otherwise.
|
||||
3. **Symlinked workspaces.** `toClaudeProjectPath` was a pure string transform — symlink paths produced different slugs than what Claude itself wrote. Added `resolveWorkspaceForClaude(path)` that runs `realpathSync` (with fallback) and used it in all three slug-computing sites (`getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`).
|
||||
4. **Process regex too narrow.** `(?:^|\/)claude(?:\s|$)` was missing several legitimate install variants — `.claude`, `claude-code`, `claude.exe`, `claude.js`, and npm shims like `node /opt/.../@anthropic-ai/claude-code/cli.js`. Broadened to `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)`; still rejects look-alikes (`claudia`, `claudine`).
|
||||
5. **Silent permission-denied.** `findLatestSessionFile` was swallowing every `readdir` error silently — a missing `~/.claude/projects/<slug>/` (ENOENT) is normal, but a permission-denied (EACCES/EPERM) or fd-exhausted (EMFILE) misconfig left the session looking permanently `idle` on the dashboard with zero telemetry. Now logs a single `console.warn` for non-ENOENT errors.
|
||||
|
||||
193/193 plugin tests pass. No public-API change. New helper `resolveWorkspaceForClaude` is re-exported from `index.ts` for downstream consumers.
|
||||
|
||||
- Updated dependencies [73bed33]
|
||||
- Updated dependencies [a610601]
|
||||
- Updated dependencies [7d9b862]
|
||||
- Updated dependencies [6d48022]
|
||||
- Updated dependencies [fcedb25]
|
||||
- Updated dependencies [94981dc]
|
||||
- Updated dependencies [2980570]
|
||||
- Updated dependencies [d5d0f07]
|
||||
- @aoagents/ao-core@0.9.0
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { toClaudeProjectPath, create } from "../index.js";
|
|||
import { resetWarnedReaddirPaths } from "../activity-detection.js";
|
||||
import {
|
||||
createActivitySignal,
|
||||
readLastActivityEntry,
|
||||
type ActivityState,
|
||||
type Session,
|
||||
type RuntimeHandle,
|
||||
|
|
@ -72,13 +71,20 @@ function writeJsonl(
|
|||
}
|
||||
}
|
||||
|
||||
function writeActivityLog(state: ActivityState, ageMs = 0): void {
|
||||
function writeActivityLog(
|
||||
state: ActivityState,
|
||||
ageMs = 0,
|
||||
source: "terminal" | "native" | "hook" = "terminal",
|
||||
trigger?: string,
|
||||
): void {
|
||||
const ts = new Date(Date.now() - ageMs).toISOString();
|
||||
const aoDir = join(workspacePath, ".ao");
|
||||
mkdirSync(aoDir, { recursive: true });
|
||||
const entry: Record<string, unknown> = { ts, state, source };
|
||||
if (trigger !== undefined) entry.trigger = trigger;
|
||||
writeFileSync(
|
||||
join(aoDir, "activity.jsonl"),
|
||||
JSON.stringify({ ts, state, source: "terminal" }) + "\n",
|
||||
JSON.stringify(entry) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -288,21 +294,16 @@ describe("Claude Code Activity Detection", () => {
|
|||
expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull();
|
||||
});
|
||||
|
||||
it("recordActivity writes to .ao/activity.jsonl when workspacePath is set", async () => {
|
||||
await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o");
|
||||
|
||||
const result = await readLastActivityEntry(workspacePath);
|
||||
expect(result?.entry.state).toBe("waiting_input");
|
||||
expect(result?.entry.source).toBe("terminal");
|
||||
expect(result?.entry.trigger).toContain("Do you want to proceed?");
|
||||
it("recordActivity is intentionally not implemented (#1941 — hooks write activity-JSONL directly)", () => {
|
||||
// Lifecycle manager calls agent.recordActivity? only if defined.
|
||||
// For Claude, hooks are the source of truth so this method is
|
||||
// omitted — guarding here surfaces accidental re-introduction.
|
||||
expect(agent.recordActivity).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recordActivity is a no-op when workspacePath is null", async () => {
|
||||
await agent.recordActivity?.(
|
||||
makeSession({ workspacePath: null }),
|
||||
"Do you want to proceed?\n(Y)es / (N)o",
|
||||
);
|
||||
|
||||
it("does NOT write to .ao/activity.jsonl on its own (hook-only producer)", () => {
|
||||
// Without recordActivity, the plugin no longer derives anything from
|
||||
// terminal output. .ao/activity.jsonl stays empty until a hook fires.
|
||||
expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -313,8 +314,11 @@ describe("Claude Code Activity Detection", () => {
|
|||
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("falls back to AO JSONL waiting_input when native session lookup is unavailable", async () => {
|
||||
await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o");
|
||||
it("falls back to AO JSONL waiting_input when native session lookup is unavailable (#1941 hook entry)", async () => {
|
||||
// PermissionRequest hook fires → activity-updater appends a JSONL entry
|
||||
// with source: "hook". The cascade picks it up exactly like the old
|
||||
// terminal-derived entry.
|
||||
writeActivityLog("waiting_input", 0, "hook", "PermissionRequest (Bash)");
|
||||
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input");
|
||||
});
|
||||
|
|
@ -323,11 +327,21 @@ describe("Claude Code Activity Detection", () => {
|
|||
writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000);
|
||||
const session = makeSession({ createdAt: new Date() });
|
||||
|
||||
await agent.recordActivity?.(session, "Do you want to proceed?\n(Y)es / (N)o");
|
||||
writeActivityLog("waiting_input", 0, "hook", "PermissionRequest");
|
||||
|
||||
expect((await agent.getActivityState(session))?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("surfaces blocked from a StopFailure hook entry in AO JSONL", async () => {
|
||||
// StopFailure → activity-updater appends `{state: blocked, source: hook,
|
||||
// trigger: "StopFailure (rate_limit)"}`. With no Claude native JSONL
|
||||
// present, the cascade must surface it through checkActivityLogState.
|
||||
writeActivityLog("blocked", 0, "hook", "StopFailure (rate_limit)");
|
||||
|
||||
const result = await agent.getActivityState(makeSession());
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns idle for stale native session entry when AO JSONL is unavailable", async () => {
|
||||
writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000);
|
||||
const session = makeSession({ createdAt: new Date() });
|
||||
|
|
|
|||
|
|
@ -276,86 +276,27 @@ export async function isClaudeProcessAlive(handle: RuntimeHandle): Promise<Proce
|
|||
}
|
||||
|
||||
// =============================================================================
|
||||
// Terminal output classification
|
||||
// Terminal output classification — retired (#1941)
|
||||
// =============================================================================
|
||||
|
||||
/** Classify Claude Code's activity state from terminal output (pure, sync). */
|
||||
export function classifyTerminalOutput(terminalOutput: string): ActivityState {
|
||||
if (!terminalOutput.trim()) return "idle";
|
||||
|
||||
const lines = terminalOutput.trim().split("\n");
|
||||
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
||||
|
||||
// Empty prompt on the last line is unambiguously idle.
|
||||
if (/^[❯>$#]\s*$/.test(lastLine)) return "idle";
|
||||
|
||||
// Use a wider window (last 12 lines) than the bottom-of-buffer prompt
|
||||
// check above because Claude's spinner+status line, ⎿ tool-result lines,
|
||||
// and api-error text often sit 6-8 lines above the input area + footer.
|
||||
// All multi-line state checks (blocked, active) use this window — full
|
||||
// `terminalOutput` would let scrolled-off error text falsely return
|
||||
// "blocked" forever after a successful retry pushes the error out of
|
||||
// view but not out of scrollback.
|
||||
const wideTail = lines.slice(-12).join("\n");
|
||||
|
||||
// Check for blocked. Claude's persistent UI footer contains
|
||||
// "bypass permissions on (shift+tab to cycle)" on every session — the
|
||||
// tightened waiting_input regex no longer matches it, and the blocked
|
||||
// patterns below are specific to api-error retry text.
|
||||
//
|
||||
// Patterns observed empirically by capturing tmux output during a real
|
||||
// api-blocked retry loop (see PR #1932 description):
|
||||
// ⎿ Unable to connect to API (ConnectionRefused)
|
||||
// Retrying in 19s · attempt 7/10
|
||||
if (/Unable to connect to API/i.test(wideTail)) return "blocked";
|
||||
if (/Retrying in \d+s.*attempt \d+\/\d+/i.test(wideTail)) return "blocked";
|
||||
|
||||
// Check the bottom of the buffer for permission prompts. Historical
|
||||
// "Thinking"/"Reading" text earlier in the buffer must not override a
|
||||
// current permission prompt at the bottom.
|
||||
const tail = lines.slice(-5).join("\n");
|
||||
if (/Do you want to proceed\?/i.test(tail)) return "waiting_input";
|
||||
if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input";
|
||||
// Match the ACTUAL permission-bypass prompt, NOT the static footer toggle.
|
||||
if (/bypass\s+all\s+future\s+permissions/i.test(tail)) return "waiting_input";
|
||||
|
||||
// Active: only when an explicit active-work indicator is present. Default
|
||||
// is IDLE — Claude's tmux pane has a persistent input area + footer that
|
||||
// looks identical between "just finished" and "working". Treating
|
||||
// unrecognized output as active caused dormant sessions (ao-160 etc.) to
|
||||
// get an "active" written to AO activity-JSONL every poll cycle, which the
|
||||
// age-decayed fallback then surfaced as ready forever.
|
||||
|
||||
// Strongest active signal: gerund (present-participle) status verb
|
||||
// followed by the trailing ellipsis "…". Claude cycles through many
|
||||
// status words (Germinating, Fluttering, Pondering, Mulling, Crafting,
|
||||
// Thinking, Reasoning, ...) and many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.)
|
||||
// depending on animation frame. The gerund+ellipsis combo is the
|
||||
// consistent signal that survives glyph rotation. Past-tense lines like
|
||||
// "✻ Worked for 11s" or "✻ Crunched for 11s" lack the ellipsis and are
|
||||
// turn-complete summaries — they must NOT match (ao-143 repro).
|
||||
if (/\b\w+ing…/.test(wideTail)) return "active";
|
||||
// NOTE — these patterns look "active-ish" but are NOT, and should never
|
||||
// go back as active indicators:
|
||||
// - /\bCrunched\s+for\s+\d+s/ — past-tense turn summary (ao-154 repro:
|
||||
// "✻ Crunched for 22s" was making sessions perpetually "active")
|
||||
// - /\bWorked\s+for\s+\d+s/ — past-tense turn summary (ao-143 repro)
|
||||
// - /^\s*⎿\s+/ — prefixes past tool-results too
|
||||
// - /esc to interrupt/ — in the persistent UI footer
|
||||
// - bare /\bWorking\b/ — matches "working on issue #N" in recap
|
||||
// The gerund+ellipsis above catches present-tense forms across all
|
||||
// status words regardless of spinner glyph rotation.
|
||||
|
||||
// Word-based fallbacks for synthetic test inputs and rare cases where
|
||||
// the ellipsis isn't captured. Tight patterns to avoid false-firing on
|
||||
// benign text.
|
||||
if (/\bGerminating/i.test(wideTail)) return "active";
|
||||
if (/\b(?:Thinking|Working)\s*(?:…|\.\.\.)/i.test(wideTail)) return "active";
|
||||
if (/\bReading\s+file/i.test(wideTail)) return "active";
|
||||
if (/\bWriting\s+to\b/i.test(wideTail)) return "active";
|
||||
if (/\bSearching\s+codebase/i.test(wideTail)) return "active";
|
||||
if (/Press\s+up\s+to\s+edit\s+queued/i.test(wideTail)) return "active";
|
||||
|
||||
/**
|
||||
* Retained as a stable no-signal stub for the deprecated
|
||||
* `Agent.detectActivity` method on the Claude plugin.
|
||||
*
|
||||
* Claude activity is now derived from platform-event hooks
|
||||
* (PermissionRequest / StopFailure / Notification / Stop / PreToolUse / ...)
|
||||
* which write directly to `{workspace}/.ao/activity.jsonl`. The previous
|
||||
* implementation regex-matched Claude's rendered terminal output, which
|
||||
* regressed every time Claude's UI footer or status-line wording changed
|
||||
* (15-commit churn in #1932 motivated the rewrite).
|
||||
*
|
||||
* The function is preserved so the Claude agent's `detectActivity` can
|
||||
* delegate to a stable export rather than inlining `() => "idle"`, and
|
||||
* because the hard-deprecated `detectActivity` method on the `Agent`
|
||||
* interface still has callers outside this plugin (lifecycle-manager's
|
||||
* terminal-output fallback, used by agents that haven't moved to hooks).
|
||||
*/
|
||||
export function classifyTerminalOutput(_terminalOutput: string): ActivityState {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isWindows } from "@aoagents/ao-core";
|
||||
import { ACTIVITY_UPDATER_SCRIPT, ACTIVITY_UPDATER_SCRIPT_NODE } from "./index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests for the activity-updater hook script (#1941).
|
||||
// Pipes synthetic Claude Code hook JSON payloads into the real script and
|
||||
// asserts the JSONL line written to {workspace}/.ao/activity.jsonl matches.
|
||||
//
|
||||
// Both the bash variant (Unix) and the Node variant (Windows) are exercised
|
||||
// against the same scenario table to keep them in lockstep.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let scratchDir: string;
|
||||
let bashScript: string;
|
||||
let nodeScript: string;
|
||||
|
||||
beforeAll(() => {
|
||||
scratchDir = mkdtempSync(join(tmpdir(), "ao-activity-hook-"));
|
||||
bashScript = join(scratchDir, "activity-updater.sh");
|
||||
nodeScript = join(scratchDir, "activity-updater.cjs");
|
||||
writeFileSync(bashScript, ACTIVITY_UPDATER_SCRIPT, { mode: 0o755 });
|
||||
writeFileSync(nodeScript, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratchDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
interface HookInput {
|
||||
hook_event_name: string;
|
||||
// Common-payload fields are optional in this synthetic shape; runtime payloads always have them.
|
||||
session_id?: string;
|
||||
cwd?: string;
|
||||
notification_type?: string;
|
||||
tool_name?: string;
|
||||
error_type?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
interface HookResult {
|
||||
stdout: string;
|
||||
lastEntry: Record<string, unknown> | null;
|
||||
rawJsonl: string;
|
||||
}
|
||||
|
||||
type Variant = "bash" | "node";
|
||||
|
||||
function runHook(variant: Variant, payload: HookInput): HookResult {
|
||||
const workspace = mkdtempSync(join(scratchDir, "ws-"));
|
||||
const input = JSON.stringify(payload);
|
||||
let stdout: string;
|
||||
try {
|
||||
const cmd = variant === "bash" ? `bash "${bashScript}"` : `node "${nodeScript}"`;
|
||||
stdout = execSync(cmd, {
|
||||
input,
|
||||
env: { ...process.env, CLAUDE_PROJECT_DIR: workspace },
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stdout?: string; stderr?: string };
|
||||
stdout = e.stdout ?? "";
|
||||
}
|
||||
|
||||
const logFile = join(workspace, ".ao", "activity.jsonl");
|
||||
let rawJsonl = "";
|
||||
let lastEntry: Record<string, unknown> | null = null;
|
||||
if (existsSync(logFile)) {
|
||||
rawJsonl = readFileSync(logFile, "utf-8");
|
||||
const lines = rawJsonl.split("\n").filter((l) => l.trim());
|
||||
if (lines.length > 0) {
|
||||
try {
|
||||
lastEntry = JSON.parse(lines[lines.length - 1]!) as Record<string, unknown>;
|
||||
} catch {
|
||||
lastEntry = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { stdout, lastEntry, rawJsonl };
|
||||
}
|
||||
|
||||
// Each scenario is executed against both the bash and the Node variant so
|
||||
// drift between the two implementations is caught immediately. The bash
|
||||
// suite is skipped on Windows — bash isn't a native shell there, so
|
||||
// `execSync('bash "..."')` would throw ENOENT for every case. The Node
|
||||
// variant is the Windows-supported path (and is exercised on Unix too,
|
||||
// guarding against drift). Matches the `describe.skipIf` pattern used by
|
||||
// `packages/core/src/__tests__/migration-storage-v2.test.ts`.
|
||||
const variants: Variant[] = ["bash", "node"];
|
||||
|
||||
for (const variant of variants) {
|
||||
describe.skipIf(variant === "bash" && isWindows())(`activity-updater (${variant})`, () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// active states — turn-in-progress markers
|
||||
// -----------------------------------------------------------------------
|
||||
it.each([
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SubagentStart",
|
||||
"PostToolBatch",
|
||||
])("writes active for %s", (event) => {
|
||||
const { lastEntry } = runHook(variant, { hook_event_name: event });
|
||||
expect(lastEntry).not.toBeNull();
|
||||
expect(lastEntry!.state).toBe("active");
|
||||
expect(lastEntry!.source).toBe("hook");
|
||||
expect(lastEntry).not.toHaveProperty("trigger");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ready states — turn boundaries
|
||||
// -----------------------------------------------------------------------
|
||||
it.each(["SessionStart", "Stop", "SubagentStop"])("writes ready for %s", (event) => {
|
||||
const { lastEntry } = runHook(variant, { hook_event_name: event });
|
||||
expect(lastEntry!.state).toBe("ready");
|
||||
expect(lastEntry!.source).toBe("hook");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// waiting_input — PermissionRequest is the authoritative signal
|
||||
// -----------------------------------------------------------------------
|
||||
it("writes waiting_input for PermissionRequest with tool_name in trigger", () => {
|
||||
const { lastEntry } = runHook(variant, {
|
||||
hook_event_name: "PermissionRequest",
|
||||
tool_name: "Bash",
|
||||
});
|
||||
expect(lastEntry!.state).toBe("waiting_input");
|
||||
expect(lastEntry!.source).toBe("hook");
|
||||
expect(lastEntry!.trigger).toBe("PermissionRequest (Bash)");
|
||||
});
|
||||
|
||||
it("writes waiting_input for PermissionRequest without tool_name", () => {
|
||||
const { lastEntry } = runHook(variant, { hook_event_name: "PermissionRequest" });
|
||||
expect(lastEntry!.state).toBe("waiting_input");
|
||||
expect(lastEntry!.trigger).toBe("PermissionRequest");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Notification — MUST filter by notification_type so auth_success /
|
||||
// elicitation_* don't false-fire waiting_input.
|
||||
// -----------------------------------------------------------------------
|
||||
it("writes waiting_input for Notification(permission_prompt)", () => {
|
||||
const { lastEntry } = runHook(variant, {
|
||||
hook_event_name: "Notification",
|
||||
notification_type: "permission_prompt",
|
||||
});
|
||||
expect(lastEntry!.state).toBe("waiting_input");
|
||||
expect(lastEntry!.trigger).toBe("Notification (permission_prompt)");
|
||||
});
|
||||
|
||||
it("writes waiting_input for Notification(idle_prompt)", () => {
|
||||
const { lastEntry } = runHook(variant, {
|
||||
hook_event_name: "Notification",
|
||||
notification_type: "idle_prompt",
|
||||
});
|
||||
expect(lastEntry!.state).toBe("waiting_input");
|
||||
expect(lastEntry!.trigger).toBe("Notification (idle_prompt)");
|
||||
});
|
||||
|
||||
it("skips Notification(auth_success) — not a stuck-on-the-user state", () => {
|
||||
const { lastEntry, stdout } = runHook(variant, {
|
||||
hook_event_name: "Notification",
|
||||
notification_type: "auth_success",
|
||||
});
|
||||
expect(lastEntry).toBeNull();
|
||||
expect(stdout.trim()).toBe("{}");
|
||||
});
|
||||
|
||||
it("skips Notification(elicitation_response)", () => {
|
||||
const { lastEntry } = runHook(variant, {
|
||||
hook_event_name: "Notification",
|
||||
notification_type: "elicitation_response",
|
||||
});
|
||||
expect(lastEntry).toBeNull();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// blocked — StopFailure is the authoritative API-error signal
|
||||
// -----------------------------------------------------------------------
|
||||
it("writes blocked for StopFailure with error_type in trigger", () => {
|
||||
const { lastEntry } = runHook(variant, {
|
||||
hook_event_name: "StopFailure",
|
||||
error_type: "rate_limit",
|
||||
error_message: "Rate limited",
|
||||
});
|
||||
expect(lastEntry!.state).toBe("blocked");
|
||||
expect(lastEntry!.source).toBe("hook");
|
||||
expect(lastEntry!.trigger).toBe("StopFailure (rate_limit)");
|
||||
});
|
||||
|
||||
it("writes blocked for StopFailure without error_type", () => {
|
||||
const { lastEntry } = runHook(variant, { hook_event_name: "StopFailure" });
|
||||
expect(lastEntry!.state).toBe("blocked");
|
||||
expect(lastEntry!.trigger).toBe("StopFailure");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No-ops — unknown event names + ignored events
|
||||
// -----------------------------------------------------------------------
|
||||
it.each(["SessionEnd", "TaskCreated", "FileChanged", "UnknownFutureEvent"])(
|
||||
"ignores unhandled event %s",
|
||||
(event) => {
|
||||
const { lastEntry, stdout } = runHook(variant, { hook_event_name: event });
|
||||
expect(lastEntry).toBeNull();
|
||||
expect(stdout.trim()).toBe("{}");
|
||||
},
|
||||
);
|
||||
|
||||
it("returns {} stdout so Claude doesn't surface a hook decision", () => {
|
||||
const { stdout } = runHook(variant, { hook_event_name: "Stop" });
|
||||
expect(stdout.trim()).toBe("{}");
|
||||
});
|
||||
|
||||
it("creates .ao/ directory on first write", () => {
|
||||
const { rawJsonl } = runHook(variant, { hook_event_name: "Stop" });
|
||||
expect(rawJsonl.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("emits a parseable JSON line with a valid ISO timestamp", () => {
|
||||
const { lastEntry } = runHook(variant, { hook_event_name: "Stop" });
|
||||
expect(lastEntry).not.toBeNull();
|
||||
const ts = lastEntry!.ts as string;
|
||||
expect(typeof ts).toBe("string");
|
||||
// ISO 8601 with millisecond precision and Z suffix
|
||||
expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/);
|
||||
});
|
||||
|
||||
it("escapes control chars in trigger so the JSONL line stays parseable", () => {
|
||||
// Bounded by Claude's enums today (`error_type`, `tool_name` never contain
|
||||
// \n/\r/\t), but if a future hook payload field flows into `trigger`
|
||||
// unescaped, a literal newline would split one entry into two and break
|
||||
// the JSONL parser. This locks in JSON-style escaping across both
|
||||
// bash and Node variants.
|
||||
const { lastEntry, rawJsonl } = runHook(variant, {
|
||||
hook_event_name: "StopFailure",
|
||||
// Smuggle a multi-line value through error_type — covers \n/\t/\r/\\/".
|
||||
error_type: 'multi\nline\twith\rmixed\\\\and"quotes',
|
||||
});
|
||||
// Exactly one JSONL line (control chars escaped, not literal)
|
||||
expect(rawJsonl.split("\n").filter((l) => l.trim())).toHaveLength(1);
|
||||
// Parsed entry round-trips the original string
|
||||
expect(lastEntry!.state).toBe("blocked");
|
||||
expect(lastEntry!.trigger).toBe(
|
||||
'StopFailure (multi\nline\twith\rmixed\\\\and"quotes)',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -78,6 +78,8 @@ import {
|
|||
toClaudeProjectPath,
|
||||
METADATA_UPDATER_SCRIPT,
|
||||
METADATA_UPDATER_SCRIPT_NODE,
|
||||
ACTIVITY_UPDATER_SCRIPT,
|
||||
ACTIVITY_UPDATER_SCRIPT_NODE,
|
||||
} from "./index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -493,7 +495,18 @@ describe("isProcessRunning", () => {
|
|||
// =========================================================================
|
||||
// detectActivity — terminal output classification
|
||||
// =========================================================================
|
||||
describe("detectActivity", () => {
|
||||
describe("detectActivity (retired — see #1941)", () => {
|
||||
// Claude activity is derived from platform-event hooks (PermissionRequest,
|
||||
// StopFailure, Notification, Stop, ...) which write directly to
|
||||
// .ao/activity.jsonl with source: "hook". The terminal-regex layer was
|
||||
// structurally fragile (every Claude UI tweak regressed it; #1932 spent
|
||||
// 15 commits patching its sharpest edges) and has been retired.
|
||||
//
|
||||
// The `detectActivity` method is kept on the Agent interface for other
|
||||
// plugins (Aider, OpenCode, Codex fallback) but is a stable no-signal
|
||||
// stub for Claude — returns "idle" for every input so the lifecycle
|
||||
// manager's terminal-output path stays neutral and the JSONL-backed
|
||||
// cascade is the only source of truth for active/ready/waiting_input/blocked.
|
||||
const agent = create();
|
||||
|
||||
it("returns idle for empty terminal output", () => {
|
||||
|
|
@ -504,210 +517,20 @@ describe("detectActivity", () => {
|
|||
expect(agent.detectActivity(" \n \n ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns active when 'esc to interrupt' is visible", () => {
|
||||
expect(agent.detectActivity("Working... esc to interrupt\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active when Thinking indicator is visible", () => {
|
||||
expect(agent.detectActivity("Thinking...\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active when Reading indicator is visible", () => {
|
||||
expect(agent.detectActivity("Reading file src/index.ts\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active when Writing indicator is visible", () => {
|
||||
expect(agent.detectActivity("Writing to src/main.ts\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active when Searching indicator is visible", () => {
|
||||
expect(agent.detectActivity("Searching codebase...\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns waiting_input for permission prompt (Y/N)", () => {
|
||||
expect(agent.detectActivity("Do you want to proceed? (Y)es / (N)o\n")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for 'Do you want to proceed?' prompt", () => {
|
||||
expect(agent.detectActivity("Do you want to proceed?\n")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for bypass permissions prompt", () => {
|
||||
expect(agent.detectActivity("bypass all future permissions for this session\n")).toBe(
|
||||
"waiting_input",
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT match Claude's persistent UI footer 'bypass permissions on (shift+tab to cycle)'", () => {
|
||||
// Regression test: the old `/bypass.*permissions/i` regex matched this
|
||||
// footer toggle (visible on EVERY Claude session) and falsely fired
|
||||
// waiting_input for every session that fell through to the AO JSONL
|
||||
// pipeline. ao-143/144/151 all flipped to waiting_input on dormant
|
||||
// sessions until this was tightened to require "all future".
|
||||
const footerOnly = [
|
||||
"✻ Crunched for 11s",
|
||||
"",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
"❯ ",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(footerOnly)).not.toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns active when queued message indicator is visible", () => {
|
||||
expect(agent.detectActivity("Press up to edit queued messages\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns idle when shell prompt is visible", () => {
|
||||
expect(agent.detectActivity("some output\n> ")).toBe("idle");
|
||||
expect(agent.detectActivity("some output\n$ ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle when prompt follows historical activity indicators", () => {
|
||||
// Key regression test: historical "Reading file..." output in the buffer
|
||||
// should NOT override an idle prompt on the last line.
|
||||
expect(agent.detectActivity("Reading file src/index.ts\nWriting to out.ts\n❯ ")).toBe("idle");
|
||||
expect(agent.detectActivity("Thinking...\nSearching codebase...\n$ ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns waiting_input when permission prompt follows historical activity", () => {
|
||||
// Permission prompt at the bottom should NOT be overridden by historical
|
||||
// "Reading"/"Thinking" output higher in the buffer.
|
||||
expect(
|
||||
agent.detectActivity("Reading file src/index.ts\nThinking...\nDo you want to proceed?\n"),
|
||||
).toBe("waiting_input");
|
||||
expect(agent.detectActivity("Searching codebase...\n(Y)es / (N)o\n")).toBe("waiting_input");
|
||||
expect(
|
||||
agent.detectActivity("Writing to out.ts\nbypass all future permissions for this session\n"),
|
||||
).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns idle for non-empty output with no active-work indicators", () => {
|
||||
// Default-to-idle (changed from default-to-active in this PR). Claude's
|
||||
// tmux pane has a persistent input area + footer that looks identical
|
||||
// between "just finished" and "currently working". Treating
|
||||
// unrecognized output as active caused dormant sessions to get an
|
||||
// "active" written to AO activity-JSONL every poll cycle, which the
|
||||
// age-decayed fallback then surfaced as ready forever (ao-160 repro).
|
||||
expect(agent.detectActivity("some random terminal output\n")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle for dormant session showing only Claude's input area + footer", () => {
|
||||
// Real captured output from a dormant session (ao-143 style): assistant
|
||||
// output above, separator, empty prompt line, separator, footer toggle.
|
||||
// The empty prompt ❯ is NOT the LAST line (footer is) so the existing
|
||||
// lastLine check misses it, and previously the default-to-active sent
|
||||
// every dormant session into the AO-JSONL active-loop.
|
||||
const dormant = [
|
||||
"※ recap: working on issue #143; next: wait for review",
|
||||
"",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
"❯ ",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(dormant)).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns active when spinner+ellipsis is in the tail (✻ Fluttering…)", () => {
|
||||
// Real captured output from ao-161 mid-active-turn. The ✻ spinner
|
||||
// followed by a verb and trailing ellipsis is the canonical Claude
|
||||
// active indicator across all turn-status words (Germinating,
|
||||
// Fluttering, Thinking, Pondering, etc).
|
||||
const active = [
|
||||
"✻ Fluttering… (6m 49s · ↓ 26.9k tokens)",
|
||||
" ⎿ Tip: Use /feedback to help us improve!",
|
||||
"",
|
||||
"──────",
|
||||
"❯ ",
|
||||
"──────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(active)).toBe("active");
|
||||
});
|
||||
|
||||
it("returns idle for past-tense spinner status like '✻ Worked for 11s' (no ellipsis)", () => {
|
||||
// Real captured output from ao-143 dormant. The ✻ glyph appears in
|
||||
// past-tense turn summaries too — without the trailing ellipsis,
|
||||
// Claude is done, not active.
|
||||
const dormant = [
|
||||
"⏺ Posted: https://github.com/owner/repo/pull/1#comment-1",
|
||||
"",
|
||||
"✻ Worked for 11s",
|
||||
"",
|
||||
"※ recap: working on issue #143; next: wait for review",
|
||||
"──────",
|
||||
"❯ ",
|
||||
"──────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(dormant)).toBe("idle");
|
||||
});
|
||||
|
||||
// Blocked detection from terminal regex — empirically captured from real
|
||||
// Claude output during api.anthropic.com block (see PR #1932).
|
||||
it("returns blocked for 'Unable to connect to API' error line", () => {
|
||||
const real = [
|
||||
"❯ what is 2+2? answer in one word.",
|
||||
" ⎿ Unable to connect to API (ConnectionRefused)",
|
||||
" Retrying in 19s · attempt 7/10",
|
||||
"",
|
||||
"✽ Germinating… (56s)",
|
||||
"",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(real)).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns blocked for FailedToOpenSocket error variant", () => {
|
||||
expect(
|
||||
agent.detectActivity(" ⎿ Unable to connect to API (FailedToOpenSocket)\n"),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns blocked for retry counter alone (Retrying in Ns · attempt N/M)", () => {
|
||||
// If only the retry line is in the visible window (error scrolled off),
|
||||
// the retry counter is still a sufficient signal.
|
||||
expect(agent.detectActivity(" Retrying in 30s · attempt 9/10\n")).toBe("blocked");
|
||||
});
|
||||
|
||||
it("does NOT return blocked when API error has scrolled out of the visible window after a successful retry", () => {
|
||||
// Regression test: blocked detection must be bounded to the last 12
|
||||
// lines (wideTail), NOT the full terminalOutput buffer. Otherwise an
|
||||
// api_error that scrolled off the visible area after a successful
|
||||
// retry but stayed in scrollback would falsely return "blocked"
|
||||
// forever (Greptile review on PR #1932).
|
||||
const recoveredAndContinued = [
|
||||
" ⎿ Unable to connect to API (ConnectionRefused)",
|
||||
" Retrying in 1s · attempt 1/10",
|
||||
" ⎿ ✓ Connected, retry succeeded",
|
||||
"",
|
||||
"(many lines of work output below pushing the error off the visible area)",
|
||||
...Array.from({ length: 15 }, (_, i) => ` line ${i + 1} of subsequent work`),
|
||||
"",
|
||||
"✻ Fluttering… (2m 14s)",
|
||||
" ⎿ Tip: Use /feedback to help us improve!",
|
||||
"",
|
||||
"──────",
|
||||
"❯ ",
|
||||
"──────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(recoveredAndContinued)).toBe("active");
|
||||
});
|
||||
|
||||
it("blocked takes precedence over waiting_input when both 'bypass permissions' footer and api-error are present", () => {
|
||||
// Claude's static UI footer always contains "bypass permissions on …",
|
||||
// which the existing waiting_input regex matches. A real blocked state
|
||||
// must win over that incidental match.
|
||||
const real = [
|
||||
" ⎿ Unable to connect to API (ConnectionRefused)",
|
||||
" Retrying in 1s · attempt 5/10",
|
||||
"",
|
||||
"────────────────────────────────────────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(real)).toBe("blocked");
|
||||
it.each([
|
||||
"Working... esc to interrupt\n",
|
||||
"Thinking...\n",
|
||||
"Reading file src/index.ts\n",
|
||||
"Writing to src/main.ts\n",
|
||||
"Searching codebase...\n",
|
||||
"Do you want to proceed? (Y)es / (N)o\n",
|
||||
"bypass all future permissions for this session\n",
|
||||
" ⎿ Unable to connect to API (ConnectionRefused)\n",
|
||||
" Retrying in 19s · attempt 7/10\n",
|
||||
"✻ Fluttering… (6m 49s · ↓ 26.9k tokens)\n",
|
||||
"some random terminal output\n",
|
||||
])("returns idle for ALL non-empty input (no terminal-regex active/waiting_input/blocked): %s", (input) => {
|
||||
expect(agent.detectActivity(input)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1182,6 +1005,269 @@ describe("hook setup — relative path (symlink-safe)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setupWorkspaceHooks — activity-updater registration (#1941)
|
||||
// =========================================================================
|
||||
describe("setupWorkspaceHooks — activity-updater (#1941)", () => {
|
||||
const agent = create();
|
||||
|
||||
function getParsedSettings(): Record<string, unknown> {
|
||||
const settingsWrite = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
|
||||
);
|
||||
expect(settingsWrite).toBeDefined();
|
||||
return JSON.parse(settingsWrite![1] as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Activity-updater command paths (unix vs win32) */
|
||||
const ACTIVITY_CMD_UNIX = ".claude/activity-updater.sh";
|
||||
const ACTIVITY_CMD_WIN = "node .claude/activity-updater.cjs";
|
||||
|
||||
/**
|
||||
* Every Claude Code hook event the script knows how to translate into an
|
||||
* activity state. The dashboard / lifecycle reducer relies on these firing
|
||||
* so platform events replace terminal-output regex.
|
||||
*/
|
||||
const ACTIVITY_EVENTS = [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"PostToolBatch",
|
||||
"Notification",
|
||||
"PermissionRequest",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
] as const;
|
||||
|
||||
it("writes the activity-updater script to .claude/", async () => {
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
|
||||
const scriptWrite = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"),
|
||||
);
|
||||
expect(scriptWrite).toBeDefined();
|
||||
expect(scriptWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT);
|
||||
});
|
||||
|
||||
it("makes the activity-updater script executable on unix (chmod 0o755)", async () => {
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
|
||||
const chmodCall = mockChmod.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"),
|
||||
);
|
||||
expect(chmodCall).toBeDefined();
|
||||
expect(chmodCall![1]).toBe(0o755);
|
||||
});
|
||||
|
||||
it.each(ACTIVITY_EVENTS)(
|
||||
"registers the activity-updater hook on %s",
|
||||
async (event) => {
|
||||
mockWriteFile.mockClear();
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
|
||||
const settings = getParsedSettings();
|
||||
const hookGroup = (settings.hooks as Record<string, unknown>)[event] as Array<{
|
||||
matcher: string;
|
||||
hooks: Array<{ command: string; timeout?: number }>;
|
||||
}>;
|
||||
expect(hookGroup).toBeDefined();
|
||||
const activity = hookGroup.flatMap((g) => g.hooks).find((h) => h.command === ACTIVITY_CMD_UNIX);
|
||||
expect(activity).toBeDefined();
|
||||
// The script does a single JSON parse + append — short timeout keeps a
|
||||
// stuck hook from slowing the turn down.
|
||||
expect(activity!.timeout).toBe(2000);
|
||||
},
|
||||
);
|
||||
|
||||
it("registers activity-updater PostToolUse alongside metadata-updater", async () => {
|
||||
mockWriteFile.mockClear();
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
|
||||
const settings = getParsedSettings();
|
||||
const postToolUse = (settings.hooks as Record<string, unknown>)["PostToolUse"] as Array<{
|
||||
matcher: string;
|
||||
hooks: Array<{ command: string }>;
|
||||
}>;
|
||||
expect(postToolUse.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const metadataEntry = postToolUse.find((g) =>
|
||||
g.hooks.some((h) => h.command.includes("metadata-updater")),
|
||||
);
|
||||
const activityEntry = postToolUse.find((g) =>
|
||||
g.hooks.some((h) => h.command.includes("activity-updater")),
|
||||
);
|
||||
|
||||
expect(metadataEntry).toBeDefined();
|
||||
expect(metadataEntry!.matcher).toBe("Bash"); // unchanged from before #1941
|
||||
expect(activityEntry).toBeDefined();
|
||||
expect(activityEntry!.matcher).toBe(""); // fires on every PostToolUse, not just Bash
|
||||
});
|
||||
|
||||
it("is idempotent — calling twice keeps exactly one activity-updater entry per event", async () => {
|
||||
mockWriteFile.mockClear();
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
const firstSettings = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
|
||||
);
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
mockReadFile.mockResolvedValueOnce(firstSettings![1] as string);
|
||||
mockWriteFile.mockClear();
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
|
||||
const settings = getParsedSettings();
|
||||
for (const event of ACTIVITY_EVENTS) {
|
||||
const hookGroup = (settings.hooks as Record<string, unknown>)[event] as Array<{
|
||||
hooks: Array<{ command: string }>;
|
||||
}>;
|
||||
const activityHooks = hookGroup.flatMap((g) => g.hooks).filter(
|
||||
(h) => h.command === ACTIVITY_CMD_UNIX,
|
||||
);
|
||||
expect(activityHooks).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a user-installed Stop hook when adding our activity-updater", async () => {
|
||||
const existingSettings = {
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
matcher: "",
|
||||
hooks: [{ type: "command", command: "echo user-hook", timeout: 1000 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings));
|
||||
mockWriteFile.mockClear();
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
|
||||
const settings = getParsedSettings();
|
||||
const stopGroup = (settings.hooks as Record<string, unknown>)["Stop"] as Array<{
|
||||
hooks: Array<{ command: string }>;
|
||||
}>;
|
||||
const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command);
|
||||
expect(commands).toContain("echo user-hook"); // user hook preserved
|
||||
expect(commands).toContain(ACTIVITY_CMD_UNIX); // our hook added
|
||||
});
|
||||
|
||||
it("tolerates malformed hooks.<event> (object instead of array)", async () => {
|
||||
// A user could hand-edit settings.json or an older plugin could have
|
||||
// written a non-array shape there. We must not crash — start fresh.
|
||||
const malformed = {
|
||||
hooks: {
|
||||
// Object where an array is expected
|
||||
Stop: { matcher: "", command: "broken" },
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
mockReadFile.mockResolvedValueOnce(JSON.stringify(malformed));
|
||||
mockWriteFile.mockClear();
|
||||
|
||||
await expect(
|
||||
agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const settings = getParsedSettings();
|
||||
const stopGroup = (settings.hooks as Record<string, unknown>)["Stop"] as Array<{
|
||||
hooks: Array<{ command: string }>;
|
||||
}>;
|
||||
expect(Array.isArray(stopGroup)).toBe(true);
|
||||
const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command);
|
||||
expect(commands).toContain(ACTIVITY_CMD_UNIX);
|
||||
});
|
||||
|
||||
it("preserves matcher of an entry where user co-located their own def alongside ours", async () => {
|
||||
// User has added their own hook def into the SAME { matcher, hooks: [...] }
|
||||
// object that contains our activity-updater. If we naively reset
|
||||
// entry.matcher to ours (""), the user's def starts firing on every
|
||||
// PreToolUse event instead of only "Edit|Write".
|
||||
const existingSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Edit|Write",
|
||||
hooks: [
|
||||
{ type: "command", command: ".claude/activity-updater.sh", timeout: 2000 },
|
||||
{ type: "command", command: "echo user-edits-only", timeout: 1000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings));
|
||||
mockWriteFile.mockClear();
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
|
||||
|
||||
const settings = getParsedSettings();
|
||||
const pre = (settings.hooks as Record<string, unknown>)["PreToolUse"] as Array<{
|
||||
matcher: string;
|
||||
hooks: Array<{ command: string }>;
|
||||
}>;
|
||||
const sharedEntry = pre.find((g) =>
|
||||
g.hooks.some((h) => h.command === "echo user-edits-only"),
|
||||
);
|
||||
expect(sharedEntry).toBeDefined();
|
||||
// Matcher must NOT be overwritten — user's hook keeps firing on "Edit|Write"
|
||||
expect(sharedEntry!.matcher).toBe("Edit|Write");
|
||||
// Both defs still present
|
||||
expect(sharedEntry!.hooks.map((h) => h.command)).toEqual([
|
||||
ACTIVITY_CMD_UNIX,
|
||||
"echo user-edits-only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("on Windows writes activity-updater.cjs (not .sh) and uses node invocation", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockWriteFile.mockClear();
|
||||
|
||||
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
|
||||
|
||||
const cjsWrite = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"),
|
||||
);
|
||||
expect(cjsWrite).toBeDefined();
|
||||
expect(cjsWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT_NODE);
|
||||
|
||||
const shWrite = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"),
|
||||
);
|
||||
expect(shWrite).toBeUndefined();
|
||||
|
||||
const settings = getParsedSettings();
|
||||
const stopGroup = (settings.hooks as Record<string, unknown>)["Stop"] as Array<{
|
||||
hooks: Array<{ command: string }>;
|
||||
}>;
|
||||
expect(stopGroup.flatMap((g) => g.hooks).some((h) => h.command === ACTIVITY_CMD_WIN)).toBe(true);
|
||||
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("does not chmod on Windows (Windows uses extension for executability)", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockChmod.mockClear();
|
||||
|
||||
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
|
||||
|
||||
const chmodCalls = mockChmod.mock.calls.filter(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"),
|
||||
);
|
||||
expect(chmodCalls).toHaveLength(0);
|
||||
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setupWorkspaceHooks on win32 — Node.js hook script
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import {
|
|||
shellEscape,
|
||||
normalizeAgentPermissionMode,
|
||||
isWindows,
|
||||
recordTerminalActivity,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
|
|
@ -385,6 +384,237 @@ process.stdout.write("{}\\n");
|
|||
process.exit(0);
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Activity Updater Hook Script
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Bash hook script that translates Claude Code lifecycle hooks into AO activity
|
||||
* JSONL entries. Registered on every event whose firing carries activity
|
||||
* information (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
|
||||
* PermissionRequest, Notification, Stop, SubagentStop, StopFailure, PreCompact,
|
||||
* PostCompact, SubagentStart, PostToolBatch).
|
||||
*
|
||||
* Reads the JSON payload from stdin, parses `hook_event_name`, maps it to an
|
||||
* activity state, and appends a single JSONL entry to
|
||||
* `$CLAUDE_PROJECT_DIR/.ao/activity.jsonl` with `source: "hook"`.
|
||||
*
|
||||
* Notification is filtered by `notification_type` — only `permission_prompt`
|
||||
* and `idle_prompt` map to `waiting_input`; `auth_success`/`elicitation_*` etc.
|
||||
* are skipped because they don't represent a stuck-on-the-user transition.
|
||||
*
|
||||
* The script always exits 0 (never blocks Claude). Unknown events exit
|
||||
* silently. Exported for integration testing.
|
||||
*/
|
||||
export const ACTIVITY_UPDATER_SCRIPT = `#!/usr/bin/env bash
|
||||
# Activity Updater Hook for Agent Orchestrator
|
||||
#
|
||||
# Records Claude Code lifecycle events to {workspace}/.ao/activity.jsonl so
|
||||
# the dashboard / lifecycle reducer derives activity state from authoritative
|
||||
# platform events instead of regex over rendered terminal output. (#1941)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
input=$(cat)
|
||||
|
||||
if command -v jq &>/dev/null; then
|
||||
event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty')
|
||||
notif_type=$(printf '%s' "$input" | jq -r '.notification_type // empty')
|
||||
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty')
|
||||
error_type=$(printf '%s' "$input" | jq -r '.error_type // empty')
|
||||
else
|
||||
event=$(printf '%s' "$input" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
||||
notif_type=$(printf '%s' "$input" | grep -o '"notification_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
||||
tool_name=$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
||||
error_type=$(printf '%s' "$input" | grep -o '"error_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
||||
fi
|
||||
|
||||
state=""
|
||||
trigger=""
|
||||
case "$event" in
|
||||
SessionStart|Stop|SubagentStop)
|
||||
state="ready"
|
||||
trigger="$event"
|
||||
;;
|
||||
UserPromptSubmit|PreToolUse|PostToolUse|PostToolUseFailure|PreCompact|PostCompact|SubagentStart|PostToolBatch)
|
||||
state="active"
|
||||
trigger="$event"
|
||||
;;
|
||||
PermissionRequest)
|
||||
state="waiting_input"
|
||||
if [[ -n "$tool_name" ]]; then
|
||||
trigger="PermissionRequest ($tool_name)"
|
||||
else
|
||||
trigger="PermissionRequest"
|
||||
fi
|
||||
;;
|
||||
Notification)
|
||||
if [[ "$notif_type" == "permission_prompt" || "$notif_type" == "idle_prompt" ]]; then
|
||||
state="waiting_input"
|
||||
trigger="Notification ($notif_type)"
|
||||
else
|
||||
# auth_success / elicitation_* / unrecognized — not an activity transition
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
StopFailure)
|
||||
state="blocked"
|
||||
if [[ -n "$error_type" ]]; then
|
||||
trigger="StopFailure ($error_type)"
|
||||
else
|
||||
trigger="StopFailure"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo '{}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
workspace="\${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
log_dir="$workspace/.ao"
|
||||
log_file="$log_dir/activity.jsonl"
|
||||
|
||||
mkdir -p "$log_dir" 2>/dev/null || { echo '{}'; exit 0; }
|
||||
|
||||
# Node is a hard runtime dep of Claude Code, so node -p is always available
|
||||
# and gives millisecond-precision ISO timestamps matching the rest of the
|
||||
# activity-JSONL log. Fall back to seconds-precision date for the unlikely
|
||||
# case where node is unavailable (still valid ISO 8601).
|
||||
ts=$(node -p 'new Date().toISOString()' 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Escape JSON-special characters in the trigger value. Triggers are bounded
|
||||
# today to event/tool/error names (no control chars in practice) but escape
|
||||
# defensively — \\ and " for content, plus the five common control chars
|
||||
# (\\n \\r \\t \\b \\f) so the JSONL line stays parseable for any future
|
||||
# trigger source. Matches what Node's JSON.stringify produces in the .cjs
|
||||
# variant so both implementations stay in lockstep.
|
||||
escape_json() {
|
||||
local s="$1"
|
||||
s="\${s//\\\\/\\\\\\\\}"
|
||||
s="\${s//\\"/\\\\\\"}"
|
||||
s="\${s//$'\\n'/\\\\n}"
|
||||
s="\${s//$'\\r'/\\\\r}"
|
||||
s="\${s//$'\\t'/\\\\t}"
|
||||
s="\${s//$'\\b'/\\\\b}"
|
||||
s="\${s//$'\\f'/\\\\f}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
if [[ "$state" == "waiting_input" || "$state" == "blocked" ]]; then
|
||||
esc_trigger=$(escape_json "$trigger")
|
||||
printf '{"ts":"%s","state":"%s","source":"hook","trigger":"%s"}\\n' "$ts" "$state" "$esc_trigger" >> "$log_file"
|
||||
else
|
||||
printf '{"ts":"%s","state":"%s","source":"hook"}\\n' "$ts" "$state" >> "$log_file"
|
||||
fi
|
||||
|
||||
echo '{}'
|
||||
exit 0
|
||||
`;
|
||||
|
||||
/**
|
||||
* Node.js equivalent of ACTIVITY_UPDATER_SCRIPT for Windows. No bash, no jq,
|
||||
* no shebang interpretation; relies only on Node built-ins. Exported for
|
||||
* testing.
|
||||
*/
|
||||
export const ACTIVITY_UPDATER_SCRIPT_NODE = `#!/usr/bin/env node
|
||||
// Activity Updater Hook for Agent Orchestrator (Node.js — Windows). See
|
||||
// ACTIVITY_UPDATER_SCRIPT for the canonical bash version. (#1941)
|
||||
|
||||
const { appendFileSync, mkdirSync, readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
let inputRaw = "";
|
||||
try {
|
||||
inputRaw = readFileSync(0, "utf-8");
|
||||
} catch {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(inputRaw || "{}");
|
||||
} catch {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
||||
const notifType = typeof payload.notification_type === "string" ? payload.notification_type : "";
|
||||
const toolName = typeof payload.tool_name === "string" ? payload.tool_name : "";
|
||||
const errorType = typeof payload.error_type === "string" ? payload.error_type : "";
|
||||
|
||||
let state = "";
|
||||
let trigger = "";
|
||||
switch (event) {
|
||||
case "SessionStart":
|
||||
case "Stop":
|
||||
case "SubagentStop":
|
||||
state = "ready";
|
||||
trigger = event;
|
||||
break;
|
||||
case "UserPromptSubmit":
|
||||
case "PreToolUse":
|
||||
case "PostToolUse":
|
||||
case "PostToolUseFailure":
|
||||
case "PreCompact":
|
||||
case "PostCompact":
|
||||
case "SubagentStart":
|
||||
case "PostToolBatch":
|
||||
state = "active";
|
||||
trigger = event;
|
||||
break;
|
||||
case "PermissionRequest":
|
||||
state = "waiting_input";
|
||||
trigger = toolName ? \`PermissionRequest (\${toolName})\` : "PermissionRequest";
|
||||
break;
|
||||
case "Notification":
|
||||
if (notifType === "permission_prompt" || notifType === "idle_prompt") {
|
||||
state = "waiting_input";
|
||||
trigger = \`Notification (\${notifType})\`;
|
||||
} else {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
case "StopFailure":
|
||||
state = "blocked";
|
||||
trigger = errorType ? \`StopFailure (\${errorType})\` : "StopFailure";
|
||||
break;
|
||||
default:
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const workspace = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
const logDir = join(workspace, ".ao");
|
||||
const logFile = join(logDir, "activity.jsonl");
|
||||
|
||||
try {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
} catch {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const entry =
|
||||
state === "waiting_input" || state === "blocked"
|
||||
? { ts, state, source: "hook", trigger }
|
||||
: { ts, state, source: "hook" };
|
||||
|
||||
try {
|
||||
appendFileSync(logFile, JSON.stringify(entry) + "\\n", "utf-8");
|
||||
} catch {
|
||||
// Best-effort — never block Claude on log append failure
|
||||
}
|
||||
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -567,41 +797,188 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined {
|
|||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Shared helper to setup PostToolUse hooks in a workspace.
|
||||
* Writes metadata-updater.sh script and updates settings.json.
|
||||
* Single hook registration: which event, which variant (matcher), which
|
||||
* command to invoke, and a substring used to find-and-update an existing
|
||||
* entry so repeated setup calls are idempotent.
|
||||
*/
|
||||
interface HookRegistration {
|
||||
event: string;
|
||||
matcher: string;
|
||||
command: string;
|
||||
timeout: number;
|
||||
/** Substring(s) of `command` that identify a pre-existing entry to update. */
|
||||
identifiers: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the registration's hook in the `event`'s hook array, updating any
|
||||
* existing entry whose command contains one of `identifiers` (idempotent).
|
||||
*
|
||||
* @param workspacePath - Path to the workspace directory
|
||||
* @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR)
|
||||
* Tolerates malformed pre-existing settings: if `hooks[event]` is not an
|
||||
* array (object, string, missing) we start a fresh array rather than
|
||||
* throwing on `.push`.
|
||||
*
|
||||
* Only refreshes the entry-level `matcher` when the entry contains a single
|
||||
* hook def (ours). When a user has co-located their own hook def in the
|
||||
* same `{ matcher, hooks: [...] }` object, we leave their matcher alone and
|
||||
* only update our def's `command`/`timeout` so their hook keeps firing on
|
||||
* the matchers they chose.
|
||||
*/
|
||||
function upsertHookEntry(
|
||||
hooks: Record<string, unknown>,
|
||||
reg: HookRegistration,
|
||||
): void {
|
||||
const existing = hooks[reg.event];
|
||||
const entries: Array<unknown> = Array.isArray(existing) ? existing : [];
|
||||
|
||||
let foundEntryIdx = -1;
|
||||
let foundDefIdx = -1;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const hooksList = (entry as Record<string, unknown>)["hooks"];
|
||||
if (!Array.isArray(hooksList)) continue;
|
||||
for (let j = 0; j < hooksList.length; j++) {
|
||||
const def = hooksList[j];
|
||||
if (typeof def !== "object" || def === null || Array.isArray(def)) continue;
|
||||
const cmd = (def as Record<string, unknown>)["command"];
|
||||
if (typeof cmd === "string" && reg.identifiers.some((id) => cmd.includes(id))) {
|
||||
foundEntryIdx = i;
|
||||
foundDefIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundEntryIdx >= 0) break;
|
||||
}
|
||||
|
||||
if (foundEntryIdx === -1) {
|
||||
entries.push({
|
||||
matcher: reg.matcher,
|
||||
hooks: [{ type: "command", command: reg.command, timeout: reg.timeout }],
|
||||
});
|
||||
} else {
|
||||
const entry = entries[foundEntryIdx] as Record<string, unknown>;
|
||||
const hooksList = entry["hooks"] as Array<Record<string, unknown>>;
|
||||
hooksList[foundDefIdx]!["command"] = reg.command;
|
||||
hooksList[foundDefIdx]!["timeout"] = reg.timeout;
|
||||
// Only refresh the matcher when the entry is clearly owned by AO
|
||||
// (single hook def == ours). With multiple defs the entry is shared
|
||||
// with a user hook; changing the matcher would change when their hook
|
||||
// fires.
|
||||
if (hooksList.length === 1) {
|
||||
entry["matcher"] = reg.matcher;
|
||||
}
|
||||
}
|
||||
|
||||
hooks[reg.event] = entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of hooks to register for this workspace. Two scripts are
|
||||
* installed:
|
||||
* - metadata-updater: PostToolUse(Bash) only — extracts gh/git side-effects.
|
||||
* - activity-updater: every event that carries activity information, so
|
||||
* dashboard / lifecycle reducer state derives from platform events
|
||||
* instead of regex over rendered terminal output (#1941).
|
||||
*
|
||||
* Activity events use matcher "" — match every variant. PermissionRequest's
|
||||
* tool-name and Notification's notification_type are filtered inside the
|
||||
* script itself so the registered set stays small.
|
||||
*/
|
||||
function buildHookRegistrations(
|
||||
metadataCommand: string,
|
||||
activityCommand: string,
|
||||
): HookRegistration[] {
|
||||
const METADATA_IDS = [
|
||||
"metadata-updater.sh",
|
||||
"metadata-updater.cjs",
|
||||
"metadata-updater.js",
|
||||
] as const;
|
||||
const ACTIVITY_IDS = ["activity-updater.sh", "activity-updater.cjs"] as const;
|
||||
|
||||
const regs: HookRegistration[] = [
|
||||
{
|
||||
event: "PostToolUse",
|
||||
matcher: "Bash",
|
||||
command: metadataCommand,
|
||||
timeout: 5000,
|
||||
identifiers: METADATA_IDS,
|
||||
},
|
||||
];
|
||||
|
||||
// Activity-updater events. Every event that the activity-updater script
|
||||
// knows how to map (see ACTIVITY_UPDATER_SCRIPT) must be registered here;
|
||||
// unregistered events fire no hook, so unrecognized hooks waste no time.
|
||||
const activityEvents = [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"PostToolBatch",
|
||||
"Notification",
|
||||
"PermissionRequest",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
];
|
||||
for (const event of activityEvents) {
|
||||
regs.push({
|
||||
event,
|
||||
matcher: "",
|
||||
command: activityCommand,
|
||||
// Hook execution is best-effort and the activity-updater is intentionally
|
||||
// O(few ms): JSON parse, one append, exit. A short timeout keeps a stuck
|
||||
// hook from slowing a turn down.
|
||||
timeout: 2000,
|
||||
identifiers: ACTIVITY_IDS,
|
||||
});
|
||||
}
|
||||
|
||||
return regs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Claude Code workspace hooks. Writes both helper scripts
|
||||
* (metadata-updater + activity-updater) and merges hook registrations into
|
||||
* `.claude/settings.json` — preserving any user-installed hooks, updating our
|
||||
* own in place on repeated calls.
|
||||
*/
|
||||
async function setupHookInWorkspace(workspacePath: string): Promise<void> {
|
||||
const claudeDir = join(workspacePath, ".claude");
|
||||
const settingsPath = join(claudeDir, "settings.json");
|
||||
|
||||
// Create .claude directory if it doesn't exist
|
||||
try {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
// Directory may already exist; ignore
|
||||
}
|
||||
|
||||
// On Windows: write a Node.js hook script, skip chmod (not needed).
|
||||
// On Unix: write the bash hook script and make it executable.
|
||||
let hookCommand: string;
|
||||
let metadataCommand: string;
|
||||
let activityCommand: string;
|
||||
if (isWindows()) {
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.cjs");
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8");
|
||||
// No chmod — Windows uses file extension for executability
|
||||
// Use `node` to invoke the script (Windows won't run .js via shebang)
|
||||
// Use .cjs extension to force CJS mode regardless of workspace package.json "type" field
|
||||
hookCommand = "node .claude/metadata-updater.cjs";
|
||||
const metadataPath = join(claudeDir, "metadata-updater.cjs");
|
||||
const activityPath = join(claudeDir, "activity-updater.cjs");
|
||||
await writeFile(metadataPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8");
|
||||
await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8");
|
||||
// .cjs forces CJS regardless of workspace package.json "type"; node
|
||||
// invocation is required on Windows because shebangs aren't honoured.
|
||||
metadataCommand = "node .claude/metadata-updater.cjs";
|
||||
activityCommand = "node .claude/activity-updater.cjs";
|
||||
} else {
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
||||
await chmod(hookScriptPath, 0o755); // Make executable
|
||||
hookCommand = ".claude/metadata-updater.sh";
|
||||
const metadataPath = join(claudeDir, "metadata-updater.sh");
|
||||
const activityPath = join(claudeDir, "activity-updater.sh");
|
||||
await writeFile(metadataPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
||||
await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT, "utf-8");
|
||||
await chmod(metadataPath, 0o755);
|
||||
await chmod(activityPath, 0o755);
|
||||
metadataCommand = ".claude/metadata-updater.sh";
|
||||
activityCommand = ".claude/activity-updater.sh";
|
||||
}
|
||||
|
||||
// Read existing settings if present
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
|
|
@ -612,61 +989,12 @@ async function setupHookInWorkspace(workspacePath: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// Merge hooks configuration
|
||||
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
|
||||
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
|
||||
|
||||
// Check if our hook is already configured
|
||||
let hookIndex = -1;
|
||||
let hookDefIndex = -1;
|
||||
for (let i = 0; i < postToolUse.length; i++) {
|
||||
const hook = postToolUse[i];
|
||||
if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue;
|
||||
const h = hook as Record<string, unknown>;
|
||||
const hooksList = h["hooks"];
|
||||
if (!Array.isArray(hooksList)) continue;
|
||||
for (let j = 0; j < hooksList.length; j++) {
|
||||
const hDef = hooksList[j];
|
||||
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
|
||||
const def = hDef as Record<string, unknown>;
|
||||
if (
|
||||
typeof def["command"] === "string" &&
|
||||
(def["command"].includes("metadata-updater.sh") ||
|
||||
def["command"].includes("metadata-updater.js") ||
|
||||
def["command"].includes("metadata-updater.cjs"))
|
||||
) {
|
||||
hookIndex = i;
|
||||
hookDefIndex = j;
|
||||
break;
|
||||
for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) {
|
||||
upsertHookEntry(hooks, reg);
|
||||
}
|
||||
}
|
||||
if (hookIndex >= 0) break;
|
||||
}
|
||||
|
||||
// Add or update our hook
|
||||
if (hookIndex === -1) {
|
||||
// No metadata hook exists, add it
|
||||
postToolUse.push({
|
||||
matcher: "Bash",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: hookCommand,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Hook exists, update the command
|
||||
const hook = postToolUse[hookIndex] as Record<string, unknown>;
|
||||
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
|
||||
hooksList[hookDefIndex]["command"] = hookCommand;
|
||||
}
|
||||
|
||||
hooks["PostToolUse"] = postToolUse;
|
||||
existingSettings["hooks"] = hooks;
|
||||
|
||||
// Write updated settings
|
||||
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
|
|
@ -741,15 +1069,28 @@ function createClaudeCodeAgent(): Agent {
|
|||
},
|
||||
|
||||
detectActivity(terminalOutput: string): ActivityState {
|
||||
// #1941: Claude activity is derived from platform-event hooks
|
||||
// (PermissionRequest / StopFailure / Notification / Stop / ...) which
|
||||
// write directly to {workspace}/.ao/activity.jsonl. The terminal-regex
|
||||
// layer was structurally fragile (every UI tweak in Claude regressed
|
||||
// it; see the 15-commit churn in #1932) so it has been retired in
|
||||
// favour of those authoritative events.
|
||||
//
|
||||
// detectActivity is kept on the Agent interface for other plugins
|
||||
// (Aider, OpenCode, Codex fallback) that still rely on terminal output.
|
||||
// For Claude, classifyTerminalOutput is a stable "idle" stub — the
|
||||
// lifecycle manager only consults this method when getActivityState
|
||||
// returned null (no Claude process / no JSONL / no hook entry yet),
|
||||
// and in that no-signal case "idle" is the correct conservative
|
||||
// answer (we don't write it back to JSONL — recordActivity is also
|
||||
// intentionally omitted for Claude).
|
||||
return classifyTerminalOutput(terminalOutput);
|
||||
},
|
||||
|
||||
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) =>
|
||||
this.detectActivity(output),
|
||||
);
|
||||
},
|
||||
// recordActivity is intentionally NOT implemented for the Claude agent
|
||||
// (#1941). Hooks write activity entries directly via the activity-updater
|
||||
// script, so polling-driven terminal-output classification would only add
|
||||
// stale duplicates to .ao/activity.jsonl.
|
||||
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
|
||||
return isClaudeProcessAlive(handle);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type * as Readline from "node:readline";
|
||||
import {
|
||||
createActivitySignal,
|
||||
type Session,
|
||||
|
|
@ -21,6 +22,7 @@ const {
|
|||
mockLstat,
|
||||
mockOpen,
|
||||
mockCreateReadStream,
|
||||
mockCreateInterface,
|
||||
mockHomedir,
|
||||
mockReadLastJsonlEntry,
|
||||
mockIsWindows,
|
||||
|
|
@ -35,6 +37,7 @@ const {
|
|||
mockLstat: vi.fn(),
|
||||
mockOpen: vi.fn(),
|
||||
mockCreateReadStream: vi.fn(),
|
||||
mockCreateInterface: vi.fn(),
|
||||
mockHomedir: vi.fn(() => "/mock/home"),
|
||||
mockReadLastJsonlEntry: vi.fn(),
|
||||
mockIsWindows: vi.fn(() => false),
|
||||
|
|
@ -67,6 +70,17 @@ vi.mock("node:fs", () => ({
|
|||
createReadStream: mockCreateReadStream,
|
||||
}));
|
||||
|
||||
vi.mock("node:readline", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Readline>();
|
||||
mockCreateInterface.mockImplementation((...args: Parameters<typeof actual.createInterface>) =>
|
||||
actual.createInterface(...args),
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
createInterface: mockCreateInterface,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: mockHomedir,
|
||||
}));
|
||||
|
|
@ -677,6 +691,57 @@ describe("getActivityState", () => {
|
|||
expect(await agent.getActivityState(session)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl"]);
|
||||
const modifiedAt = new Date();
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "assistant_message",
|
||||
modifiedAt,
|
||||
});
|
||||
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
metadata: { codexThreadId: "thread-fast-activity" },
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
|
||||
expect(result?.state).toBe("ready");
|
||||
expect(mockReadLastJsonlEntry).toHaveBeenCalledWith(
|
||||
pathJoin(
|
||||
"/mock/home",
|
||||
".codex",
|
||||
"sessions",
|
||||
"rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl",
|
||||
),
|
||||
);
|
||||
expect(mockOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to cwd-prefix scanning when codexThreadId lookup misses", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["rollout-other-thread.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "assistant_message",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
metadata: { codexThreadId: "missing-thread" },
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
|
||||
expect(result?.state).toBe("ready");
|
||||
expect(mockReaddir).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns active when session file was recently modified", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
|
|
@ -980,6 +1045,121 @@ describe("getSessionInfo", () => {
|
|||
expect(await agent.getSessionInfo(makeSession())).toBeNull();
|
||||
});
|
||||
|
||||
it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => {
|
||||
const sessionContent = jsonl(
|
||||
{
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: "thread-fast-info",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "turn_context",
|
||||
payload: {
|
||||
model: "gpt-5.5",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-info.jsonl"]);
|
||||
setupMockStream(sessionContent);
|
||||
|
||||
const result = await agent.getSessionInfo(
|
||||
makeSession({
|
||||
workspacePath: "/workspace/test",
|
||||
metadata: { codexThreadId: "thread-fast-info" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.agentSessionId).toBe("rollout-2026-05-22T00-00-00-thread-fast-info");
|
||||
expect(result!.summary).toBe("Codex session (gpt-5.5)");
|
||||
expect(mockOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches codexThreadId filename lookups by thread id", async () => {
|
||||
const sessionContent = jsonl({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: "thread-cached-info",
|
||||
},
|
||||
});
|
||||
|
||||
mockReaddir.mockResolvedValue(["rollout-thread-cached-info.jsonl"]);
|
||||
mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent));
|
||||
|
||||
const first = await agent.getSessionInfo(
|
||||
makeSession({
|
||||
workspacePath: "/workspace/first",
|
||||
metadata: { codexThreadId: "thread-cached-info" },
|
||||
}),
|
||||
);
|
||||
const second = await agent.getSessionInfo(
|
||||
makeSession({
|
||||
workspacePath: "/workspace/second",
|
||||
metadata: { codexThreadId: "thread-cached-info" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBeNull();
|
||||
expect(mockReaddir).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chooses the newest duplicate codexThreadId filename match by mtime", async () => {
|
||||
const oldContent = jsonl({
|
||||
type: "session_meta",
|
||||
payload: { id: "thread-dupe", model: "old-model" },
|
||||
});
|
||||
const newContent = jsonl({
|
||||
type: "session_meta",
|
||||
payload: { id: "thread-dupe", model: "new-model" },
|
||||
});
|
||||
|
||||
mockReaddir.mockResolvedValue([
|
||||
"rollout-old-thread-dupe.jsonl",
|
||||
"rollout-new-thread-dupe.jsonl",
|
||||
]);
|
||||
mockStat.mockImplementation((path: string) => {
|
||||
if (path.includes("rollout-old-thread-dupe")) return Promise.resolve({ mtimeMs: 1000 });
|
||||
if (path.includes("rollout-new-thread-dupe")) return Promise.resolve({ mtimeMs: 2000 });
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
mockCreateReadStream.mockImplementation((path: string) => {
|
||||
if (path.includes("rollout-old-thread-dupe")) return makeContentStream(oldContent);
|
||||
if (path.includes("rollout-new-thread-dupe")) return makeContentStream(newContent);
|
||||
return makeContentStream("");
|
||||
});
|
||||
|
||||
const result = await agent.getSessionInfo(
|
||||
makeSession({
|
||||
workspacePath: "/workspace/test",
|
||||
metadata: { codexThreadId: "thread-dupe" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.agentSessionId).toBe("rollout-new-thread-dupe");
|
||||
expect(result!.summary).toBe("Codex session (new-model)");
|
||||
expect(mockOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat infix filename matches as thread-id hits", async () => {
|
||||
mockReaddir.mockResolvedValue(["rollout-thread-fast-extra.jsonl"]);
|
||||
|
||||
const result = await agent.getSessionInfo(
|
||||
makeSession({
|
||||
workspacePath: null,
|
||||
metadata: { codexThreadId: "fast" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockOpen).not.toHaveBeenCalled();
|
||||
expect(mockCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when no session files match the workspace cwd", async () => {
|
||||
mockReaddir.mockResolvedValue(["session-abc.jsonl"]);
|
||||
const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" });
|
||||
|
|
@ -990,6 +1170,25 @@ describe("getSessionInfo", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to cwd-prefix scanning when codexThreadId is absent", async () => {
|
||||
const sessionContent = jsonl({
|
||||
type: "session_meta",
|
||||
cwd: "/workspace/test",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
mockReaddir.mockResolvedValue(["rollout-cwd-fallback.jsonl"]);
|
||||
setupMockOpen(sessionContent);
|
||||
setupMockStream(sessionContent);
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.summary).toBe("Codex session (gpt-5.4)");
|
||||
expect(mockOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns session info with cost and model when matching session found", async () => {
|
||||
const sessionContent = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
|
||||
|
|
@ -1212,6 +1411,31 @@ describe("getSessionInfo", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it("closes readline and destroys the stream when JSONL streaming is interrupted", async () => {
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test" });
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const stream = makeContentStream(content);
|
||||
const destroySpy = vi.spyOn(stream, "destroy");
|
||||
const closeSpy = vi.fn();
|
||||
mockCreateReadStream.mockReturnValue(stream);
|
||||
mockCreateInterface.mockImplementationOnce(() => ({
|
||||
close: closeSpy,
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield JSON.stringify({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
throw new Error("aborted");
|
||||
},
|
||||
}));
|
||||
|
||||
expect(
|
||||
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
||||
).toBeNull();
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(destroySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips session files when stat throws", async () => {
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
|
|
@ -1371,6 +1595,7 @@ describe("getRestoreCommand", () => {
|
|||
expect(cmd).toContain("--model 'gpt-5.3-codex'");
|
||||
expect(cmd).toContain("persisted-thread");
|
||||
expect(mockReaddir).not.toHaveBeenCalled();
|
||||
expect(mockOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds native resume command from payload-wrapped Codex session id", async () => {
|
||||
|
|
|
|||
|
|
@ -229,8 +229,11 @@ async function sessionFileMatchesCwd(filePath: string, workspacePath: string): P
|
|||
* Recursively scans ~/.codex/sessions/ (date-sharded: YYYY/MM/DD/rollout-*.jsonl).
|
||||
* Returns the path to the most recently modified matching file, or null.
|
||||
*/
|
||||
async function findCodexSessionFile(workspacePath: string): Promise<string | null> {
|
||||
const jsonlFiles = await collectJsonlFiles(CODEX_SESSIONS_DIR);
|
||||
async function findCodexSessionFile(
|
||||
workspacePath: string,
|
||||
jsonlFiles?: string[],
|
||||
): Promise<string | null> {
|
||||
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
|
||||
if (jsonlFiles.length === 0) return null;
|
||||
|
||||
let bestMatch: { path: string; mtime: number } | null = null;
|
||||
|
|
@ -252,6 +255,39 @@ async function findCodexSessionFile(workspacePath: string): Promise<string | nul
|
|||
return bestMatch?.path ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Codex session file by persisted native thread id. Codex rollout
|
||||
* filenames include the thread id, so this path only inspects filenames and
|
||||
* avoids opening historical JSONL files to match session_meta.cwd.
|
||||
*/
|
||||
async function findCodexSessionFileByThreadId(
|
||||
threadId: string,
|
||||
jsonlFiles?: string[],
|
||||
): Promise<string | null> {
|
||||
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
|
||||
const matches = jsonlFiles.filter((filePath) =>
|
||||
basename(filePath).endsWith(`-${threadId}.jsonl`),
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 1) return matches[0] ?? null;
|
||||
|
||||
let bestMatch: { path: string; mtime: number } | null = null;
|
||||
let fallback: string | null = null;
|
||||
for (const filePath of matches) {
|
||||
fallback ??= filePath;
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
if (!bestMatch || s.mtimeMs > bestMatch.mtime) {
|
||||
bestMatch = { path: filePath, mtime: s.mtimeMs };
|
||||
}
|
||||
} catch {
|
||||
// Keep a filename match as fallback; thread id in the filename is enough.
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.path ?? fallback;
|
||||
}
|
||||
|
||||
/** Aggregated data extracted from a Codex session file via streaming */
|
||||
interface CodexSessionData {
|
||||
model: string | null;
|
||||
|
|
@ -268,6 +304,9 @@ interface CodexSessionData {
|
|||
* into memory. This is critical because Codex rollout files can be 100 MB+.
|
||||
*/
|
||||
async function streamCodexSessionData(filePath: string): Promise<CodexSessionData | null> {
|
||||
let stream: ReturnType<typeof createReadStream> | null = null;
|
||||
let rl: ReturnType<typeof createInterface> | null = null;
|
||||
|
||||
try {
|
||||
const data: CodexSessionData = {
|
||||
model: null,
|
||||
|
|
@ -277,8 +316,9 @@ async function streamCodexSessionData(filePath: string): Promise<CodexSessionDat
|
|||
cachedTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
};
|
||||
const rl = createInterface({
|
||||
input: createReadStream(filePath, { encoding: "utf-8" }),
|
||||
stream = createReadStream(filePath, { encoding: "utf-8" });
|
||||
rl = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
|
|
@ -352,6 +392,9 @@ async function streamCodexSessionData(filePath: string): Promise<CodexSessionDat
|
|||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
rl?.close();
|
||||
stream?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -492,23 +535,52 @@ function appendNoUpdateCheckFlag(parts: string[]): void {
|
|||
const SESSION_FILE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
/** Module-level session file cache shared across the agent instance lifetime.
|
||||
* Keyed by workspace path, stores the resolved file path and an expiry timestamp. */
|
||||
* Keyed by Codex thread id when available, otherwise workspace path. */
|
||||
const sessionFileCache = new Map<string, { path: string | null; expiry: number }>();
|
||||
|
||||
/** Find session file with caching to avoid double scans per refresh cycle */
|
||||
async function findCodexSessionFileCached(workspacePath: string): Promise<string | null> {
|
||||
const cached = sessionFileCache.get(workspacePath);
|
||||
function getSessionMetadataString(session: Session, key: string): string | null {
|
||||
const value = session.metadata?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function getCachedSessionFile(
|
||||
cacheKey: string,
|
||||
resolve: () => Promise<string | null>,
|
||||
): Promise<string | null> {
|
||||
const cached = sessionFileCache.get(cacheKey);
|
||||
if (cached && Date.now() < cached.expiry) {
|
||||
return cached.path;
|
||||
}
|
||||
const result = await findCodexSessionFile(workspacePath);
|
||||
sessionFileCache.set(workspacePath, {
|
||||
const result = await resolve();
|
||||
sessionFileCache.set(cacheKey, {
|
||||
path: result,
|
||||
expiry: Date.now() + SESSION_FILE_CACHE_TTL_MS,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Find session file with caching to avoid double scans per refresh cycle */
|
||||
async function findCodexSessionFileCached(session: Session): Promise<string | null> {
|
||||
let jsonlFiles: string[] | null = null;
|
||||
const getJsonlFiles = async (): Promise<string[]> => {
|
||||
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
|
||||
return jsonlFiles;
|
||||
};
|
||||
|
||||
const threadId = getSessionMetadataString(session, "codexThreadId");
|
||||
if (threadId) {
|
||||
const byThreadId = await getCachedSessionFile(`thread:${threadId}`, async () =>
|
||||
findCodexSessionFileByThreadId(threadId, await getJsonlFiles()),
|
||||
);
|
||||
if (byThreadId) return byThreadId;
|
||||
}
|
||||
|
||||
if (!session.workspacePath) return null;
|
||||
return getCachedSessionFile(`cwd:${toComparablePath(session.workspacePath)}`, async () =>
|
||||
findCodexSessionFile(session.workspacePath!, await getJsonlFiles()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a launch command for the host shell. On Windows the resolved binary
|
||||
* path is single-quoted by shellEscape (e.g. `'C:\Users\...\codex.cmd'`), and
|
||||
|
|
@ -604,11 +676,13 @@ function createCodexAgent(): Agent {
|
|||
if (running === PROCESS_PROBE_INDETERMINATE) return null;
|
||||
if (!running) return { state: "exited", timestamp: exitedAt };
|
||||
|
||||
if (!session.workspacePath) return null;
|
||||
if (!session.workspacePath && !getSessionMetadataString(session, "codexThreadId")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Try Codex's native JSONL first — it has richer 6-state detection
|
||||
// (approval_request, error, tool_call, etc.) that terminal parsing can't match.
|
||||
const sessionFile = await findCodexSessionFileCached(session.workspacePath);
|
||||
const sessionFile = await findCodexSessionFileCached(session);
|
||||
if (sessionFile) {
|
||||
const entry = await readLastJsonlEntry(sessionFile);
|
||||
if (entry) {
|
||||
|
|
@ -669,7 +743,9 @@ function createCodexAgent(): Agent {
|
|||
|
||||
// 2. Fallback: check AO activity JSONL (terminal-derived) for waiting_input/blocked
|
||||
// that the native JSONL may not have captured.
|
||||
const activityResult = await readLastActivityEntry(session.workspacePath);
|
||||
const activityResult = session.workspacePath
|
||||
? await readLastActivityEntry(session.workspacePath)
|
||||
: null;
|
||||
const activityState = checkActivityLogState(activityResult);
|
||||
if (activityState) return activityState;
|
||||
|
||||
|
|
@ -758,9 +834,7 @@ function createCodexAgent(): Agent {
|
|||
},
|
||||
|
||||
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
const sessionFile = await findCodexSessionFileCached(session.workspacePath);
|
||||
const sessionFile = await findCodexSessionFileCached(session);
|
||||
if (!sessionFile) return null;
|
||||
|
||||
// Stream the file line-by-line to avoid loading potentially huge
|
||||
|
|
@ -799,13 +873,13 @@ function createCodexAgent(): Agent {
|
|||
},
|
||||
|
||||
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
|
||||
let threadId = session.metadata?.["codexThreadId"]?.trim();
|
||||
let model: string | null = session.metadata?.["codexModel"]?.trim() || null;
|
||||
let threadId = getSessionMetadataString(session, "codexThreadId");
|
||||
let model: string | null = getSessionMetadataString(session, "codexModel");
|
||||
if (!threadId) {
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
// Find the Codex session file for this workspace
|
||||
const sessionFile = await findCodexSessionFileCached(session.workspacePath);
|
||||
const sessionFile = await findCodexSessionFileCached(session);
|
||||
if (!sessionFile) return null;
|
||||
|
||||
// Stream the file line-by-line to avoid loading potentially huge
|
||||
|
|
@ -833,7 +907,10 @@ function createCodexAgent(): Agent {
|
|||
return formatLaunchCommand(parts);
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
async setupWorkspaceHooks(
|
||||
_workspacePath: string,
|
||||
_config: WorkspaceHooksConfig,
|
||||
): Promise<void> {
|
||||
// PATH wrappers are installed by session-manager for all agents.
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,519 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { AgentLaunchConfig, RuntimeHandle, Session } from "@aoagents/ao-core";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJson = require("../../package.json") as {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
};
|
||||
const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-";
|
||||
const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX)
|
||||
? packageJson.name.slice(PACKAGE_NAME_PREFIX.length)
|
||||
: packageJson.name;
|
||||
|
||||
const {
|
||||
mockReadLastActivityEntry,
|
||||
mockRecordTerminalActivity,
|
||||
mockSetupPathWrapperWorkspace,
|
||||
mockExecFileAsync,
|
||||
mockWhichSync,
|
||||
mockIsWindows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockReadLastActivityEntry: vi.fn().mockResolvedValue(null),
|
||||
mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined),
|
||||
mockSetupPathWrapperWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecFileAsync: vi.fn(),
|
||||
mockWhichSync: vi.fn(),
|
||||
mockIsWindows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
readLastActivityEntry: mockReadLastActivityEntry,
|
||||
recordTerminalActivity: mockRecordTerminalActivity,
|
||||
setupPathWrapperWorkspace: mockSetupPathWrapperWorkspace,
|
||||
isWindows: mockIsWindows,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("which", () => ({
|
||||
default: {
|
||||
sync: mockWhichSync,
|
||||
},
|
||||
sync: mockWhichSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: (...args: unknown[]) => {
|
||||
const callback = args[args.length - 1];
|
||||
const result = mockExecFileAsync(...args.slice(0, -1));
|
||||
if (typeof callback === "function" && result && typeof result.then === "function") {
|
||||
result.then(
|
||||
(value: { stdout: string; stderr: string }) => callback(null, value),
|
||||
(err: Error) => callback(err),
|
||||
);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { create, detect, manifest, default as defaultExport } from "../index.js";
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "test-1",
|
||||
projectId: "test-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
activitySignal: {
|
||||
state: "valid",
|
||||
activity: "active",
|
||||
timestamp: new Date(),
|
||||
source: "runtime",
|
||||
},
|
||||
lifecycle: {} as Session["lifecycle"],
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/workspace/test",
|
||||
runtimeHandle: null,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTmuxHandle(id = "test-session"): RuntimeHandle {
|
||||
return { id, runtimeName: "tmux", data: {} };
|
||||
}
|
||||
|
||||
function makeProcessHandle(pid?: number | string): RuntimeHandle {
|
||||
return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} };
|
||||
}
|
||||
|
||||
function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): AgentLaunchConfig {
|
||||
return {
|
||||
sessionId: "sess-1",
|
||||
projectConfig: {
|
||||
name: "my-project",
|
||||
repo: "owner/repo",
|
||||
path: "/workspace/repo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "my",
|
||||
agentConfig: {
|
||||
model: "grok-4.1-fast",
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeActivityResult(
|
||||
state: "active" | "ready" | "idle" | "waiting_input" | "blocked",
|
||||
ts: Date,
|
||||
): {
|
||||
entry: {
|
||||
state: "active" | "ready" | "idle" | "waiting_input" | "blocked";
|
||||
ts: string;
|
||||
source: string;
|
||||
};
|
||||
modifiedAt: Date;
|
||||
} {
|
||||
return {
|
||||
entry: {
|
||||
state,
|
||||
ts: ts.toISOString(),
|
||||
source: "terminal",
|
||||
},
|
||||
modifiedAt: ts,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockWhichSync.mockReset();
|
||||
mockExecFileAsync.mockReset();
|
||||
mockIsWindows.mockReset();
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct Grok manifest", () => {
|
||||
expect(manifest).toEqual({
|
||||
name: pluginName,
|
||||
slot: "agent",
|
||||
description: packageJson.description,
|
||||
version: packageJson.version,
|
||||
displayName: "Grok",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("uses grok as process name and post-launch prompt mode", () => {
|
||||
const agent = create();
|
||||
expect(agent.name).toBe(pluginName);
|
||||
expect(agent.processName).toBe(pluginName);
|
||||
expect(agent.promptDelivery).toBe("post-launch");
|
||||
});
|
||||
|
||||
it("exports plugin module shape", () => {
|
||||
expect(defaultExport.manifest).toBe(manifest);
|
||||
expect(typeof defaultExport.create).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detect", () => {
|
||||
it("returns true when which resolves", () => {
|
||||
mockWhichSync.mockReturnValue("/usr/local/bin/grok");
|
||||
expect(detect()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when which fails", () => {
|
||||
mockWhichSync.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
});
|
||||
expect(detect()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLaunchCommand", () => {
|
||||
const agent = create();
|
||||
|
||||
it("uses interactive launch without a session id", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ projectConfig: { ...makeLaunchConfig().projectConfig, agentConfig: {} } }),
|
||||
);
|
||||
expect(cmd).toBe("grok --no-alt-screen --worktree");
|
||||
});
|
||||
|
||||
it("uses configured model and rules file when provided", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
model: "grok-4.1",
|
||||
systemPromptFile: "/tmp/ao prompt.md",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toBe(
|
||||
"grok --no-alt-screen --worktree --model 'grok-4.1' --rules '@/tmp/ao prompt.md'",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses --resume when a configured Grok session id is present", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
projectConfig: {
|
||||
...makeLaunchConfig().projectConfig,
|
||||
agentConfig: { grokSessionId: "01HXGROKSESSION" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(cmd).toBe("grok --no-alt-screen --resume '01HXGROKSESSION'");
|
||||
});
|
||||
|
||||
it("does not include prompt flags when prompt delivery is post-launch", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
prompt: "Do work",
|
||||
systemPrompt: "You are helpful",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toContain("--rules 'You are helpful'");
|
||||
expect(cmd).not.toContain("-p");
|
||||
expect(cmd).not.toContain("--single");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironment", () => {
|
||||
const agent = create();
|
||||
|
||||
it("writes AO session keys and leaves shared wrapper paths to session-manager", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["AO_SESSION_ID"]).toBe("sess-1");
|
||||
expect(env["AO_ISSUE_ID"]).toBeUndefined();
|
||||
expect(env["PATH"]).toBeUndefined();
|
||||
expect(env["GH_PATH"]).toBeUndefined();
|
||||
expect(env["GROK_SANDBOX"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes AO_ISSUE_ID and GROK_SANDBOX when provided", () => {
|
||||
const env = agent.getEnvironment(
|
||||
makeLaunchConfig({
|
||||
issueId: "INT-42",
|
||||
projectConfig: {
|
||||
...makeLaunchConfig().projectConfig,
|
||||
agentConfig: { grokSandbox: "workspace-write" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(env["AO_ISSUE_ID"]).toBe("INT-42");
|
||||
expect(env["GROK_SANDBOX"]).toBe("workspace-write");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProcessRunning", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns true when grok is on tmux pane", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
|
||||
if (cmd === "ps") {
|
||||
return Promise.resolve({
|
||||
stdout: " PID TT ARGS\n 789 ttys003 /Users/me/.grok/bin/grok\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when tmux process is missing", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
|
||||
if (cmd === "ps")
|
||||
return Promise.resolve({ stdout: " PID TT ARGS\n 789 ttys003 bash\n", stderr: "" });
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns indeterminate when tmux probing fails", async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error("tmux unavailable"));
|
||||
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
|
||||
});
|
||||
|
||||
it("returns indeterminate for tmux handles on Windows", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true when process handle pid is alive", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
|
||||
expect(killSpy).toHaveBeenCalledWith(123, 0);
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("treats EPERM as running for process handles", async () => {
|
||||
const err = Object.assign(new Error("EPERM"), { code: "EPERM" });
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when process handle pid is dead", async () => {
|
||||
const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(false);
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordActivity", () => {
|
||||
const agent = create();
|
||||
|
||||
it("classifies idle terminal output when recording activity", async () => {
|
||||
await agent.recordActivity?.(makeSession(), "foo\n› ");
|
||||
expect(mockRecordTerminalActivity).toHaveBeenCalledWith(
|
||||
"/workspace/test",
|
||||
"foo\n› ",
|
||||
expect.any(Function),
|
||||
);
|
||||
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
|
||||
| ((output: string) => string)
|
||||
| undefined;
|
||||
expect(classify?.("foo\n› ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("classifies active terminal output when recording activity", async () => {
|
||||
await agent.recordActivity?.(makeSession(), "Thinking through the plan");
|
||||
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
|
||||
| ((output: string) => string)
|
||||
| undefined;
|
||||
expect(classify?.("Thinking through the plan")).toBe("active");
|
||||
});
|
||||
|
||||
it("classifies waiting_input terminal output when recording activity", async () => {
|
||||
await agent.recordActivity?.(
|
||||
makeSession(),
|
||||
"Signing in with Grok...\n\nOpen this URL to sign in:\n https://auth.x.ai/oauth2/authorize?...",
|
||||
);
|
||||
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
|
||||
| ((output: string) => string)
|
||||
| undefined;
|
||||
expect(classify?.("Allow this command? y/N:")).toBe("waiting_input");
|
||||
expect(classify?.("Open this URL to sign in: https://auth.x.ai/oauth2/authorize")).toBe(
|
||||
"waiting_input",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies blocked terminal output when recording activity", async () => {
|
||||
await agent.recordActivity?.(makeSession(), "Error: Device not configured");
|
||||
const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as
|
||||
| ((output: string) => string)
|
||||
| undefined;
|
||||
expect(classify?.("Error: Device not configured")).toBe("blocked");
|
||||
});
|
||||
|
||||
it("skips recording when workspacePath is missing", async () => {
|
||||
await agent.recordActivity?.(makeSession({ workspacePath: null }), "still running");
|
||||
expect(mockRecordTerminalActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityState", () => {
|
||||
const agent = create();
|
||||
|
||||
it("falls back to exited when runtime handle is missing", async () => {
|
||||
const state = await agent.getActivityState(makeSession({ runtimeHandle: null }));
|
||||
expect(state).toMatchObject({ state: "exited" });
|
||||
});
|
||||
|
||||
it("returns waiting_input from recent activity JSONL", async () => {
|
||||
const activityAt = new Date();
|
||||
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("waiting_input", activityAt));
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const state = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeProcessHandle(101) }),
|
||||
);
|
||||
expect(state?.state).toBe("waiting_input");
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns blocked from recent activity JSONL", async () => {
|
||||
const activityAt = new Date();
|
||||
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("blocked", activityAt));
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const state = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeProcessHandle(101) }),
|
||||
);
|
||||
expect(state?.state).toBe("blocked");
|
||||
expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString());
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns active from JSONL fallback", async () => {
|
||||
const activityAt = new Date();
|
||||
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("active", activityAt));
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const state = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeProcessHandle(101) }),
|
||||
);
|
||||
expect(state?.state).toBe("active");
|
||||
expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString());
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("decays JSONL fallback state to idle when the entry is stale", async () => {
|
||||
const activityAt = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("active", activityAt));
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const state = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeProcessHandle(101) }),
|
||||
);
|
||||
expect(state?.state).toBe("idle");
|
||||
expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString());
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null when no activity data is available", async () => {
|
||||
mockReadLastActivityEntry.mockResolvedValue(null);
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const state = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeProcessHandle(101) }),
|
||||
);
|
||||
expect(state).toBeNull();
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("treats indeterminate process probes as no process verdict", async () => {
|
||||
mockReadLastActivityEntry.mockResolvedValue(null);
|
||||
mockExecFileAsync.mockRejectedValue(new Error("tmux unavailable"));
|
||||
|
||||
const state = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() }));
|
||||
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionInfo", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns null without Grok session metadata", async () => {
|
||||
expect(await agent.getSessionInfo(makeSession())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a known Grok session id from metadata", async () => {
|
||||
const info = await agent.getSessionInfo(
|
||||
makeSession({ metadata: { grokSessionId: "01HXGROKSESSION" } }),
|
||||
);
|
||||
expect(info).toEqual({
|
||||
agentSessionId: "01HXGROKSESSION",
|
||||
summary: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRestoreCommand", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns null without Grok session metadata", async () => {
|
||||
const cmd = await agent.getRestoreCommand?.(makeSession(), makeLaunchConfig().projectConfig);
|
||||
expect(cmd).toBeNull();
|
||||
});
|
||||
|
||||
it("builds restore command using the Grok session id", async () => {
|
||||
const cmd = await agent.getRestoreCommand?.(
|
||||
makeSession({ metadata: { grokSessionId: "01HXGROKSESSION" } }),
|
||||
makeLaunchConfig({ model: "grok-4.1" }).projectConfig,
|
||||
);
|
||||
expect(cmd).toBe("grok --no-alt-screen --model 'grok-4.1-fast' --resume '01HXGROKSESSION'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace hooks", () => {
|
||||
const agent = create();
|
||||
|
||||
it("sets up PATH wrapper workspace hooks", async () => {
|
||||
await agent.setupWorkspaceHooks?.("/workspace/test", { dataDir: "/sessions" });
|
||||
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
|
||||
});
|
||||
|
||||
it("runs post-launch workspace hook setup", async () => {
|
||||
await agent.postLaunchSetup?.(makeSession({ workspacePath: "/workspace/test" }));
|
||||
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
|
||||
});
|
||||
|
||||
it("waits for Grok worktree readiness before post-launch prompt delivery", async () => {
|
||||
mockExecFileAsync.mockResolvedValue({
|
||||
stdout: "Worktree ready: /Users/me/.grok/worktrees/project/smoke\n",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await agent.postLaunchSetup?.(
|
||||
makeSession({ workspacePath: "/workspace/test", runtimeHandle: makeTmuxHandle("ao-smoke") }),
|
||||
);
|
||||
|
||||
expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test");
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["capture-pane", "-t", "ao-smoke", "-p", "-S", "-120"],
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
import {
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
PROCESS_PROBE_INDETERMINATE,
|
||||
shellEscape,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
setupPathWrapperWorkspace,
|
||||
isWindows,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
type ActivityDetection,
|
||||
type ActivityState,
|
||||
type PluginModule,
|
||||
type ProcessProbeResult,
|
||||
type ProjectConfig,
|
||||
type RuntimeHandle,
|
||||
type Session,
|
||||
type WorkspaceHooksConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
import which from "which";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJson = require("../package.json") as {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
};
|
||||
const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-";
|
||||
const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX)
|
||||
? packageJson.name.slice(PACKAGE_NAME_PREFIX.length)
|
||||
: packageJson.name;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const GROK_EXECUTABLE = "grok";
|
||||
const GROK_STARTUP_READY_TIMEOUT_MS = 30_000;
|
||||
const GROK_STARTUP_POLL_MS = 500;
|
||||
const ANSI_ESCAPE_RE = new RegExp(
|
||||
`${String.fromCharCode(27)}(?:[@-Z\\-_]|\\[[0-?]*[ -/]*[@-~])`,
|
||||
"g",
|
||||
);
|
||||
const GROK_CONFIRM_PROMPT_RE =
|
||||
/(?:allow|approve|do you want to continue|continue anyway|proceed).*(?:y\/n|yes\/no|\[[YyNn]\/ ?[YyNn]\]|\([YyNn]\/ ?[YyNn]\)|\?)\s*:?$/i;
|
||||
const GROK_AUTH_PROMPT_RE = /(?:sign in with grok|open this url to sign in|oauth2\/authorize)/i;
|
||||
|
||||
interface GrokAgentConfig {
|
||||
grokSessionId?: unknown;
|
||||
model?: unknown;
|
||||
grokSandbox?: unknown;
|
||||
}
|
||||
|
||||
function asGrokSessionId(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/\p{C}/u.test(trimmed)) return null;
|
||||
return trimmed.length <= 512 ? trimmed : null;
|
||||
}
|
||||
|
||||
function getConfiguredGrokSessionId(config: AgentLaunchConfig): string | null {
|
||||
return asGrokSessionId(
|
||||
(config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.grokSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
function getConfiguredModel(config: AgentLaunchConfig): string | null {
|
||||
const model =
|
||||
config.model ?? (config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.model;
|
||||
return typeof model === "string" && model.trim() ? model.trim() : null;
|
||||
}
|
||||
|
||||
function getConfiguredSandbox(config: AgentLaunchConfig): string | null {
|
||||
const sandbox = (config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.grokSandbox;
|
||||
return typeof sandbox === "string" && sandbox.trim() ? sandbox.trim() : null;
|
||||
}
|
||||
|
||||
function buildGrokCommand(config: AgentLaunchConfig, sessionId?: string | null): string {
|
||||
const restoreSessionId = sessionId ?? getConfiguredGrokSessionId(config);
|
||||
const parts = [GROK_EXECUTABLE, "--no-alt-screen"];
|
||||
if (!restoreSessionId) {
|
||||
parts.push("--worktree");
|
||||
}
|
||||
const model = getConfiguredModel(config);
|
||||
if (model) {
|
||||
parts.push("--model", shellEscape(model));
|
||||
}
|
||||
if (config.systemPromptFile) {
|
||||
parts.push("--rules", shellEscape(`@${config.systemPromptFile}`));
|
||||
} else if (config.systemPrompt) {
|
||||
parts.push("--rules", shellEscape(config.systemPrompt));
|
||||
}
|
||||
if (restoreSessionId) {
|
||||
parts.push("--resume", shellEscape(restoreSessionId));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
async function captureTmuxOutput(handle: RuntimeHandle): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"tmux",
|
||||
["capture-pane", "-t", handle.id, "-p", "-S", "-120"],
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function waitForGrokWorktreeReady(session: Session): Promise<void> {
|
||||
const handle = session.runtimeHandle;
|
||||
if (!handle || handle.runtimeName !== "tmux" || !handle.id) return;
|
||||
if (isWindows()) return;
|
||||
if (asGrokSessionId(session.metadata?.grokSessionId)) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < GROK_STARTUP_READY_TIMEOUT_MS) {
|
||||
const output = await captureTmuxOutput(handle);
|
||||
if (/Worktree ready:/i.test(output)) return;
|
||||
await sleep(GROK_STARTUP_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyGrokTerminalOutput(terminalOutput: string): ActivityState {
|
||||
const normalizedOutput = terminalOutput.replaceAll(ANSI_ESCAPE_RE, "").trim();
|
||||
if (!normalizedOutput) return "idle";
|
||||
|
||||
const lines = normalizedOutput.split("\n").map((line) => line.trim());
|
||||
const lastLine = lines[lines.length - 1] ?? "";
|
||||
const lastNonEmptyLine = [...lines].reverse().find(Boolean) ?? "";
|
||||
|
||||
if (/^[>$#›]\s*$/.test(lastLine)) return "idle";
|
||||
if (GROK_AUTH_PROMPT_RE.test(normalizedOutput)) return "waiting_input";
|
||||
if (GROK_CONFIRM_PROMPT_RE.test(lastNonEmptyLine)) return "waiting_input";
|
||||
if (/\b(error|failed|exception|not authenticated|device not configured)\b/i.test(lastLine))
|
||||
return "blocked";
|
||||
|
||||
return "active";
|
||||
}
|
||||
|
||||
export const manifest = {
|
||||
name: pluginName,
|
||||
slot: "agent" as const,
|
||||
description: packageJson.description,
|
||||
version: packageJson.version,
|
||||
displayName: "Grok",
|
||||
};
|
||||
|
||||
function createGrokAgent(): Agent {
|
||||
return {
|
||||
name: pluginName,
|
||||
processName: pluginName,
|
||||
promptDelivery: "post-launch",
|
||||
|
||||
getLaunchCommand(config: AgentLaunchConfig): string {
|
||||
return buildGrokCommand(config);
|
||||
},
|
||||
|
||||
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
env["AO_SESSION_ID"] = config.sessionId;
|
||||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
const sandbox = getConfiguredSandbox(config);
|
||||
if (sandbox) {
|
||||
env["GROK_SANDBOX"] = sandbox;
|
||||
}
|
||||
|
||||
return env;
|
||||
},
|
||||
|
||||
detectActivity(terminalOutput: string): ActivityState {
|
||||
return classifyGrokTerminalOutput(terminalOutput);
|
||||
},
|
||||
|
||||
async getActivityState(
|
||||
session: Session,
|
||||
readyThresholdMs?: number,
|
||||
): Promise<ActivityDetection | null> {
|
||||
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
|
||||
const exitedAt = new Date();
|
||||
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
|
||||
const running = await this.isProcessRunning(session.runtimeHandle);
|
||||
if (running === false) return { state: "exited", timestamp: exitedAt };
|
||||
|
||||
let activityResult: Awaited<ReturnType<typeof readLastActivityEntry>> = null;
|
||||
if (session.workspacePath) {
|
||||
activityResult = await readLastActivityEntry(session.workspacePath);
|
||||
const activityState = checkActivityLogState(activityResult);
|
||||
if (activityState) return activityState;
|
||||
}
|
||||
|
||||
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
|
||||
if (fallback) return fallback;
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await recordTerminalActivity(session.workspacePath, terminalOutput, (output: string) =>
|
||||
classifyGrokTerminalOutput(output),
|
||||
);
|
||||
},
|
||||
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
|
||||
try {
|
||||
if (handle.runtimeName === "tmux" && handle.id) {
|
||||
if (isWindows()) return PROCESS_PROBE_INDETERMINATE;
|
||||
const { stdout: ttyOut } = await execFileAsync(
|
||||
"tmux",
|
||||
["list-panes", "-t", handle.id, "-F", "#{pane_tty}"],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const ttys = ttyOut
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (ttys.length === 0) return false;
|
||||
|
||||
const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
|
||||
const processRe = /(?:^|[\s/])\.?grok(?:\s|$)/;
|
||||
for (const line of psOut.split("\n")) {
|
||||
const cols = line.trimStart().split(/\s+/);
|
||||
if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue;
|
||||
const args = cols.slice(2).join(" ");
|
||||
if (processRe.test(args)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawPid = handle.data["pid"];
|
||||
const pid = typeof rawPid === "number" ? rawPid : Number(rawPid);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
return PROCESS_PROBE_INDETERMINATE;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return PROCESS_PROBE_INDETERMINATE;
|
||||
}
|
||||
},
|
||||
|
||||
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
|
||||
const grokSessionId = asGrokSessionId(session.metadata?.grokSessionId);
|
||||
if (!grokSessionId) return null;
|
||||
return {
|
||||
agentSessionId: grokSessionId,
|
||||
summary: null,
|
||||
};
|
||||
},
|
||||
|
||||
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
|
||||
const grokSessionId = asGrokSessionId(session.metadata?.grokSessionId);
|
||||
if (!grokSessionId) return null;
|
||||
return buildGrokCommand(
|
||||
{
|
||||
sessionId: session.id,
|
||||
projectConfig: project,
|
||||
workspacePath: session.workspacePath ?? undefined,
|
||||
issueId: session.issueId ?? undefined,
|
||||
},
|
||||
grokSessionId,
|
||||
);
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (session.workspacePath) {
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
}
|
||||
await waitForGrokWorktreeReady(session);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function create(): Agent {
|
||||
return createGrokAgent();
|
||||
}
|
||||
|
||||
export function detect(): boolean {
|
||||
try {
|
||||
return Boolean(which.sync(GROK_EXECUTABLE));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default { manifest, create, detect } satisfies PluginModule<Agent>;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -52,9 +52,12 @@ async function extractKimiSummary(sessionDir: string): Promise<string | null> {
|
|||
// still be planted as symlinks pointing at /etc/passwd or /dev/zero.
|
||||
if (!(await isKimiSessionFile(wirePath))) return null;
|
||||
let summary: string | null = null;
|
||||
let stream: ReturnType<typeof createReadStream> | null = null;
|
||||
let rl: ReturnType<typeof createInterface> | null = null;
|
||||
try {
|
||||
const rl = createInterface({
|
||||
input: createReadStream(wirePath, { encoding: "utf-8" }),
|
||||
stream = createReadStream(wirePath, { encoding: "utf-8" });
|
||||
rl = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
let bytes = 0;
|
||||
|
|
@ -85,9 +88,11 @@ async function extractKimiSummary(sessionDir: string): Promise<string | null> {
|
|||
// Skip malformed line
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
rl?.close();
|
||||
stream?.destroy();
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,28 @@
|
|||
# @aoagents/ao-plugin-notifier-slack
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2d4c457: Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
|
||||
- Updated dependencies [2d4c457]
|
||||
- @aoagents/ao-core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
|
||||
- Updated dependencies [73bed33]
|
||||
- Updated dependencies [a610601]
|
||||
- Updated dependencies [7d9b862]
|
||||
- Updated dependencies [6d48022]
|
||||
- Updated dependencies [fcedb25]
|
||||
- Updated dependencies [94981dc]
|
||||
- Updated dependencies [2980570]
|
||||
- Updated dependencies [d5d0f07]
|
||||
- @aoagents/ao-core@0.9.0
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue