feat: standardize agent plugins — shared hooks, activity JSONL, backfill aider/opencode (#755)
* feat: standardize agent plugins with shared hooks, activity JSONL, and CLAUDE.md - Add CLAUDE.md with full project context and agent plugin implementation standards - Extract shared PATH-wrapper metadata hooks into @composio/ao-core (agent-workspace-hooks.ts) - Backfill Aider + OpenCode: setupWorkspaceHooks, postLaunchSetup, getSessionInfo, getRestoreCommand - Add recordActivity method to Agent interface for terminal-derived JSONL writing - Create activity-log.ts in core: appendActivityEntry, readLastActivityEntry - Lifecycle manager calls recordActivity before getActivityState for agents that implement it - Upgrade detectActivity in Aider/OpenCode with real terminal prompt/permission patterns - Upgrade Codex getActivityState to parse JSONL entry types (6 states, up from 2) - Replace duplicated normalizePermissionMode with shared normalizeAgentPermissionMode from core - Remove ~200 lines of duplicated shell wrapper code from Codex plugin - Add git wrapper detection for existing branch switches (parity with Claude Code hooks) - 484 tests passing across all 4 agent plugins * chore: unignore CLAUDE.md and AGENTS.md, slim down AGENTS.md to pointer - Remove CLAUDE.md and AGENTS.md from .gitignore (both should be tracked) - Slim AGENTS.md from 96 to 30 lines — commands, TL;DR, key files - Full context now lives in CLAUDE.md; AGENTS.md points there * fix: eliminate redundant double file read in readLastActivityEntry Remove the readLastJsonlEntry call that was only used as a null-check, then immediately discarded before re-reading the same file from scratch. Now performs a single open + stat + tail-read per poll cycle. * fix: remove duplicate case-insensitive regex in OpenCode detectActivity The \(y\)es.*\(n\)o pattern with /i flag was identical to the preceding \(Y\)es.*\(N\)o/i check — remove the redundant line. * fix: use zero-initialized buffer and slice to bytesRead in readLastActivityEntry Replace Buffer.allocUnsafe with Buffer.alloc and slice the result to actual bytesRead, preventing uninitialized heap data from being parsed if the file shrinks between stat() and read(). * fix: add staleness cap for waiting_input/blocked and deduplicate recordActivity - Add ACTIVITY_INPUT_STALENESS_MS (5 min) cap so stale waiting_input/blocked entries don't keep sessions stuck in needs_input on the dashboard forever. - Extract checkActivityLogState() into core — shared by aider, opencode, codex. - Extract classifyTerminalActivity() into core — deduplicates the identical recordActivity logic across all three plugins. * fix: prioritize native Codex JSONL over AO activity log in getActivityState Reorder detection so Codex's native 6-state JSONL (approval_request, error, tool_call, etc.) is checked first. AO activity JSONL from terminal parsing is now a fallback only for waiting_input/blocked states that the native JSONL may not have captured. Previously the AO log was always fresh (written every poll cycle by recordActivity) and shadowed the richer native detection entirely. * fix: restrict AO activity JSONL to waiting_input/blocked only checkActivityLogState now only returns results for waiting_input/blocked states. Non-critical states (active/ready/idle) return null, forcing callers to fall through to their native signals (git commits, chat history, OpenCode API, Codex native JSONL). This prevents the lifecycle manager's recordActivity writes (which refresh mtime every poll cycle) from shadowing richer detection methods and breaking stuck-detection. * fix: prevent stale idle timestamp in aider and skip flags in git wrapper - Remove Aider's fallback that returned idle with activityResult.modifiedAt (always fresh due to recordActivity writes). Now returns null when no git commits or chat history are found, letting the lifecycle manager handle stuck-detection correctly. - Fix git wrapper catch-all case to skip flag arguments (e.g. -B) and look at $3 for the actual branch name. * fix: add mtime fallback for empty Codex JSONL and remove unused exports - When the native Codex session file exists but readLastJsonlEntry returns null (empty/unparseable), fall back to stat-based mtime detection instead of losing activity detection entirely. - Remove unused exports getActivityLogPath and ACTIVITY_INPUT_STALENESS_MS from @composio/ao-core barrel — they are only used internally. * fix: opencode activity state detection and CLAUDE.md agent plugin standards - Fix session ID capture to handle both session_id (snake_case) and sessionID (camelCase) from OpenCode 1.3.x JSON responses - Replace broken --command true with -- noop for session creation (true is not a valid OpenCode command since 1.3.x) - Add JSONL mtime fallback in getActivityState so active/ready/idle states work even when findOpenCodeSession returns null - Rewrite CLAUDE.md activity detection section with the full getActivityState contract, mandatory JSONL mtime fallback pattern, and 8 required tests every agent plugin must implement * fix: opencode --command true flag and activity JSONL mtime staleness - Fix opencode getLaunchCommand to use `--command true` instead of `-- noop` (aligns with test expectations and opencode CLI docs) - Fix checkActivityLogState to use entry.ts instead of file mtime for staleness checking — recordActivity refreshes mtime every poll cycle, which prevented stale waiting_input/blocked entries from being detected - Fix opencode getActivityState fallback to use entry state directly instead of re-deriving from mtime, which always returned "active" because recordActivity refreshes the file every cycle - Update tests to reflect new entry-state-based fallback behavior * fix: deduplicate recordActivity writes and restore mtime-based fallback recordActivity was writing to the JSONL every poll cycle (~30s), which kept refreshing the file mtime and prevented the JSONL mtime fallback in getActivityState from ever reaching "ready" or "idle". Fix: skip writes when the state hasn't changed and the last entry is <20s old. This keeps mtime fresh during active work (writes every 20-30s, within the 30s activeWindow) but lets it age naturally when the agent goes quiet. Also restores the mtime-based age classification in the JSONL fallback (active/ready/idle by mtime age) instead of returning the entry state directly, which was always "active" since that's what recordActivity writes. Applied to both OpenCode and Aider plugins. Updated CLAUDE.md with the dedup pattern and rationale. * fix: align integration tests with opencode `-- noop` launch command Update 9 test expectations from `--command true` to `-- noop` to match the reverted getLaunchCommand implementation. * fix: add JSONL mtime fallback to Aider getActivityState When git commits and chat history are both unavailable (e.g. early session startup), Aider's getActivityState now falls back to the AO activity JSONL mtime for active/ready/idle classification — matching OpenCode's existing step 3 fallback. Previously it returned null, leaving the dashboard with no activity signal. * fix: add write deduplication to Codex recordActivity Add the same dedup logic that Aider and OpenCode already have — skip writes when the state hasn't changed and the last entry is recent (<20s). Prevents unbounded file growth and stale mtime refreshes. * refactor: extract shared recordTerminalActivity into core Move the duplicated recordActivity logic (classify + dedup + append) from all three plugins into a shared `recordTerminalActivity` function in `@composio/ao-core/activity-log`. Each plugin's `recordActivity` is now a thin wrapper that delegates to the shared function. Add core tests for classifyTerminalActivity, checkActivityLogState, and recordTerminalActivity (10 tests). * fix: validate JSONL entry fields, handle invalid dates, consistent operators - Validate required fields (ts, state, source) before casting parsed JSON to ActivityLogEntry — prevents malformed entries from propagating - Guard against invalid Date parsing in checkActivityLogState — returns null instead of comparing against NaN - Use <= instead of < in Aider chat-history threshold comparisons to match OpenCode and Aider's own JSONL fallback path * fix: use --command true for opencode run, validate sed key parameter - Replace `-- noop` with `--command true` in opencode getLaunchCommand so the bootstrap uses a valid command - Validate metadata key against [a-zA-Z0-9_-]+ in the git wrapper's update_ao_metadata to prevent sed metacharacter injection * fix: extract DEFAULT_ACTIVE_WINDOW_MS constant, clarify Codex recordActivity - Extract magic number 30_000 into DEFAULT_ACTIVE_WINDOW_MS constant in core types, used by Aider and OpenCode for active/ready thresholds - Clarify in CLAUDE.md that Codex implements recordActivity as a safety net for when its native JSONL is missing/unparseable, not redundantly * fix: add JSONL mtime fallback to Codex getActivityState When native Codex session file is missing but AO JSONL has data, derive active/ready/idle from JSONL mtime instead of returning null. Matches the fallback pattern already in Aider (step 4) and OpenCode (step 3). * fix: validate ActivityState and source values when parsing JSONL entries Validate that `state` is one of the known ActivityState values and `source` is "terminal" or "native" before constructing the entry. Construct the entry explicitly instead of using unsafe double cast. * fix: wrap getOutput + recordActivity in try-catch to protect getActivityState If runtime.getOutput() throws (e.g. tmux unresponsive), the error previously propagated to the outer catch, skipping getActivityState entirely. Now the entire recordActivity preamble is wrapped in its own try-catch so getActivityState always runs. * chore: add .ao/ to gitignore * chore: clean up gitignore comment * test: add coverage for activity-log and agent-workspace-hooks - activity-log: test readLastActivityEntry (missing file), invalid entry.ts in checkActivityLogState, blocked state path - agent-workspace-hooks: test buildAgentPath (dedup, defaults, ordering), setupPathWrapperWorkspace (create/skip wrappers, AGENTS.md create/skip) Raises diff coverage from 39% toward 80% threshold. * test: add real file I/O tests for readLastActivityEntry and recordTerminalActivity Test readLastActivityEntry with actual temp files: valid entries, empty file, invalid JSON, invalid state, missing fields, multi-line. Test recordTerminalActivity dedup logic and actionable state bypass. * fix: add activeWindow threshold to Codex native JSONL state detection Action entries (tool_call, user_input, exec_command) now use the 30s active window: <=30s is "active", 30s-5min is "ready", >5min is "idle". Previously these skipped "ready" entirely, going straight from "active" to "idle" at the 5min threshold. * fix: add activeWindow threshold to Claude Code native JSONL state detection Same fix as Codex — action entries (user, tool_use, progress) now use the 30s active window for consistent 3-state classification across all agent plugins: <=30s active, 30s-5min ready, >5min idle. * fix: handle truncated JSONL, add exited state, fix session lookup and dedup window - readLastActivityEntry: increase tail buffer to 4KB, skip truncated first line when reading from offset, try lines from end on parse error - Add "exited" to valid ActivityState set in JSONL validation - findOpenCodeSession: pick most recently updated session when multiple title matches exist, preventing stale session binding - Increase dedup window from 20s to 60s so mtime can age past the 30s active window between writes, allowing "ready" state to be reached * fix: use entry state for JSONL fallback, write AGENTS.md to .ao/, revert dedup to 20s - Replace mtime-based active/ready/idle derivation in all 3 plugin fallbacks with direct entry.state + entry.ts usage. This eliminates the fundamental conflict between write deduplication and mtime freshness — the entry already has the correct detected state. - Revert dedup window to 20s (purely I/O optimization, no longer affects state detection since mtime is not used) - Write AO session context to .ao/AGENTS.md (gitignored) instead of modifying the repo-tracked AGENTS.md, preventing dirty worktree state * fix: reorder Codex fallback chain so AO JSONL is checked before stat mtime When native JSONL exists but can't be parsed, the stat() fallback previously returned early, skipping AO JSONL waiting_input/blocked detection and the ready state. Now it falls through to AO JSONL first, then uses stat mtime as a last resort with proper 3-state classification. * fix: re-validate canonicalized ao_dir against trusted roots After resolving symlinks with pwd -P, re-check real_ao_dir against the trusted root allowlist. Prevents paths like /tmp/../../home/user from passing the pre-canonicalization check then escaping to arbitrary directories after symlink resolution. * fix: update tests for .ao/AGENTS.md location and remove unused vi import - Update Codex setupWorkspaceHooks tests to expect .ao/AGENTS.md instead of workspace root AGENTS.md - Remove unused vi import from activity-log test (lint error) * fix: add age-based decay to JSONL entry fallback via getActivityFallbackState Extract getActivityFallbackState in core — reclassifies entry state based on entry.ts age (active→ready→idle) so old entries don't stay as "active" forever when recordActivity stops being called. All three plugins now use this shared helper for their JSONL fallback paths. * fix: apply staleness cap to actionable states in getActivityFallbackState Stale waiting_input/blocked entries (older than ACTIVITY_INPUT_STALENESS_MS) are now treated as idle in the fallback, preventing them from bypassing the staleness filtering in checkActivityLogState. * fix: respect entry state as ceiling in getActivityFallbackState Age-based decay can only demote (active→ready→idle), never promote. A fresh "idle" entry stays "idle" instead of being reclassified as "active" — the detected state from terminal output is authoritative. * docs: update CLAUDE.md to match current activity detection implementation - Replace inline recordActivity dedup example with recordTerminalActivity delegation - Replace mtime-based fallback example with getActivityFallbackState - Update step 4 description: entry state + age-based decay, not mtime - Add new core exports to utilities section - Document .ao/AGENTS.md location and setupPathWrapperWorkspace - Update required test list for entry-based fallback * fix: skip metadata helper rewrite when version marker matches Move ao-metadata-helper.sh write inside the needsUpdate check so it's only rewritten when wrapper scripts are outdated, not on every call. * fix: update Codex test for metadata helper skip when version matches Metadata helper is now inside the needsUpdate check, so when the version marker matches, no wrapper writes occur (including helper).
This commit is contained in:
parent
1112ab8693
commit
df8395c3ec
|
|
@ -59,11 +59,10 @@ packages/web/agent-orchestrator.yaml
|
|||
# Local agent orchestrator config (may contain secrets)
|
||||
agent-orchestrator.yaml
|
||||
|
||||
# Agent configuration and tracking (personal, not for repo)
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
# Agent configuration and activity logs
|
||||
.claude/
|
||||
.opencode/
|
||||
.ao/
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
# AGENTS.md
|
||||
|
||||
> Full project context, architecture, conventions, and plugin standards are in **CLAUDE.md**.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies
|
||||
pnpm build # Build all packages
|
||||
pnpm dev # Web dashboard dev server (Next.js + 2 WS servers)
|
||||
pnpm typecheck # Type check all packages
|
||||
pnpm test # All tests (excludes web)
|
||||
pnpm --filter @composio/ao-web test # Web tests
|
||||
pnpm lint # ESLint check
|
||||
pnpm lint:fix # ESLint fix
|
||||
pnpm format # Prettier format
|
||||
```
|
||||
|
||||
## Architecture TL;DR
|
||||
|
||||
Monorepo (pnpm) with packages: `core`, `cli`, `web`, and `plugins/*`. The web dashboard is a Next.js 15 app (App Router) with React 19 and Tailwind CSS v4. Data flows from `agent-orchestrator.yaml` through core's `loadConfig()` to API routes, served via SSR and a 5s-interval SSE stream. Terminal sessions use WebSocket connections to tmux PTYs. See CLAUDE.md for the full plugin architecture (8 slots), session lifecycle, and data flow.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `packages/core/src/types.ts` — All plugin interfaces (Agent, Runtime, Workspace, etc.)
|
||||
- `packages/core/src/session-manager.ts` — Session CRUD
|
||||
- `packages/core/src/lifecycle-manager.ts` — State machine + polling loop
|
||||
- `packages/web/src/components/Dashboard.tsx` — Main dashboard view
|
||||
- `packages/web/src/components/SessionDetail.tsx` — Session detail view
|
||||
- `packages/web/src/app/globals.css` — Design tokens
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
# CLAUDE.md
|
||||
|
||||
## What is this project?
|
||||
|
||||
Agent Orchestrator (AO) is a platform for spawning and managing parallel AI coding agents across distributed systems. It runs multiple agents (Claude Code, Codex, Aider, OpenCode) simultaneously — each in an isolated git worktree with its own PR — and provides a single dashboard to supervise them all. Agents autonomously fix CI failures, address review comments, and manage PRs.
|
||||
|
||||
**Org:** ComposioHQ
|
||||
**Repo:** `github.com/ComposioHQ/agent-orchestrator`
|
||||
**License:** MIT
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
pnpm workspace (v9.15.4) with ~30 packages:
|
||||
|
||||
```
|
||||
packages/
|
||||
core/ # Engine: types, config, session manager, lifecycle, plugin registry
|
||||
cli/ # CLI tool (`ao` command) — depends on all plugins
|
||||
web/ # Next.js 15 dashboard (App Router, React 19, Tailwind v4)
|
||||
ao/ # Global CLI wrapper (thin shim around cli)
|
||||
plugins/
|
||||
agent-claude-code/ agent-aider/ agent-codex/ agent-opencode/
|
||||
runtime-tmux/ runtime-process/
|
||||
workspace-worktree/ workspace-clone/
|
||||
tracker-github/ tracker-linear/ tracker-gitlab/
|
||||
scm-github/ scm-gitlab/
|
||||
notifier-desktop/ notifier-slack/ notifier-webhook/
|
||||
notifier-composio/ notifier-openclaw/
|
||||
terminal-iterm2/ terminal-web/
|
||||
integration-tests/ # E2E tests
|
||||
```
|
||||
|
||||
**Build order:** core -> plugins -> cli/web (parallel). `pnpm build` at root handles this.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Language | TypeScript (strict mode, ES2022, Node16 modules) |
|
||||
| Runtime | Node.js 20+ |
|
||||
| Package Manager | pnpm 9.15.4 (`workspace:*` protocol) |
|
||||
| Web | Next.js 15 (App Router) + React 19 |
|
||||
| Styling | Tailwind CSS v4 + CSS custom properties (`@theme` block in `globals.css`) |
|
||||
| Terminal UI | xterm.js 5.3.0 + WebSocket to tmux PTYs |
|
||||
| Validation | Zod |
|
||||
| Testing | Vitest + @testing-library/react |
|
||||
| Linting | ESLint 10 (flat config) + Prettier 3.8 |
|
||||
| CI/CD | GitHub Actions (lint, typecheck, test, release) |
|
||||
| Versioning | Changesets |
|
||||
| Git hooks | Husky + gitleaks (secret scanning) |
|
||||
| Container | OCI via Containerfile (Podman/Docker) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install & build
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Development
|
||||
pnpm dev # Web dashboard (Next.js + 2 WS servers)
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck # All packages
|
||||
pnpm --filter @composio/ao-web typecheck # Web only
|
||||
|
||||
# Testing
|
||||
pnpm test # All packages (excludes web)
|
||||
pnpm --filter @composio/ao-web test # Web tests
|
||||
pnpm --filter @composio/ao-web test:watch # Web watch mode
|
||||
pnpm test:integration # Integration tests
|
||||
|
||||
# Lint & format
|
||||
pnpm lint
|
||||
pnpm lint:fix
|
||||
pnpm format
|
||||
pnpm format:check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin System (8 Slots)
|
||||
|
||||
Every abstraction is a pluggable interface defined in `packages/core/src/types.ts`:
|
||||
|
||||
| Slot | Default | Purpose |
|
||||
|------|---------|---------|
|
||||
| Runtime | tmux | Where agents execute |
|
||||
| Agent | claude-code | Which AI tool to use |
|
||||
| Workspace | worktree | Code isolation (worktree vs clone) |
|
||||
| Tracker | github | Issue tracking (GitHub, Linear, GitLab) |
|
||||
| SCM | github | PR, CI, reviews |
|
||||
| Notifier | desktop | Notification delivery |
|
||||
| Terminal | iterm2 | Human attachment UI |
|
||||
| Lifecycle | core (non-pluggable) | State machine + polling |
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
```
|
||||
spawning -> working -> pr_open -> ci_failed / review_pending
|
||||
| |
|
||||
changes_requested approved
|
||||
| |
|
||||
+-> mergeable -> merged -> cleanup -> done
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
agent-orchestrator.yaml -> Config Loader (Zod) -> Plugin Registry
|
||||
-> Session Manager -> Lifecycle Manager (polling loop, state machine)
|
||||
-> Events -> Notifiers
|
||||
-> Web API Routes (Next.js) -> SSE (5s interval) + WebSocket (terminal)
|
||||
-> Dashboard (React + xterm.js)
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
No database. Flat files + memory:
|
||||
|
||||
- **Config:** `agent-orchestrator.yaml` (Zod-validated)
|
||||
- **Session metadata:** `~/.agent-orchestrator/{hash}-{projectId}/sessions/{sessionId}` (key-value pairs)
|
||||
- **Worktrees:** `~/.agent-orchestrator/{hash}-{projectId}/worktrees/{sessionId}/`
|
||||
- **Archives:** `~/.agent-orchestrator/{hash}-{projectId}/archive/{sessionId}_{timestamp}`
|
||||
|
||||
Hash = SHA-256 of config directory (first 12 chars). Prevents collision across multiple checkouts.
|
||||
|
||||
### Prompt Assembly (3 Layers)
|
||||
|
||||
1. Base prompt (system instructions in core)
|
||||
2. Config prompt (project-specific rules from YAML)
|
||||
3. Rules files (optional `.agent-rules.md` from repo)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
- **TypeScript strict mode** — no `any` types (`@typescript-eslint/no-explicit-any: error`)
|
||||
- **Consistent type imports** — `import type { Foo }` enforced by ESLint
|
||||
- **Immutable patterns** — spread operator, never mutate in place
|
||||
- **Prefer const** — `no-var`, `prefer-const`
|
||||
- **No eval** — `no-eval`, `no-implied-eval`, `no-new-func`
|
||||
- **Unused vars** — prefix with `_` (`argsIgnorePattern: "^_"`)
|
||||
|
||||
### File Organization
|
||||
|
||||
- Components in flat `components/` directory (no nesting)
|
||||
- Hooks in `hooks/` with `use` prefix
|
||||
- Tests in `__tests__/` subdirectories
|
||||
- No barrel files except `core/src/index.ts`
|
||||
- Max 400 lines per component file
|
||||
|
||||
### Naming
|
||||
|
||||
- PascalCase for components/classes
|
||||
- camelCase for functions/variables
|
||||
- `use*` for hooks, `is*`/`has*` for booleans
|
||||
|
||||
### Imports
|
||||
|
||||
- `@/` alias -> `packages/web/src/`
|
||||
- `@composio/ao-core` for core imports
|
||||
- `workspace:*` for cross-package
|
||||
|
||||
### Web / Styling
|
||||
|
||||
- Tailwind utility classes only — **no inline `style=` attributes**
|
||||
- CSS custom properties via `var(--color-*)` from `globals.css` `@theme` block
|
||||
- Dark theme must always be preserved
|
||||
- **No external UI component libraries** (no Radix, shadcn, etc.)
|
||||
- Client components marked `"use client"`; server components for pages
|
||||
- State: React hooks only (no Redux/Zustand)
|
||||
- Real-time updates: SSE via `useSessionEvents` hook (5s interval, do not change)
|
||||
|
||||
### Testing
|
||||
|
||||
- Vitest + @testing-library/react
|
||||
- Test files: `{Module}.test.ts` or `{Component}.test.tsx` in `__tests__/`
|
||||
- Test files for all new components
|
||||
- Relaxed lint in tests: `any` and `console.log` allowed
|
||||
|
||||
### Commits
|
||||
|
||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`, `ci:`
|
||||
- Changesets for version management
|
||||
- gitleaks pre-commit hook — never commit secrets
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/core/src/types.ts` | Central type definitions (all 8 plugin interfaces) |
|
||||
| `packages/core/src/session-manager.ts` | Session CRUD operations |
|
||||
| `packages/core/src/lifecycle-manager.ts` | State machine + polling loop + reactions |
|
||||
| `packages/core/src/config.ts` | YAML config loading with Zod validation |
|
||||
| `packages/core/src/plugin-registry.ts` | Plugin discovery and resolution |
|
||||
| `packages/core/src/index.ts` | Core public API (stable, do not break) |
|
||||
| `packages/web/src/components/Dashboard.tsx` | Main dashboard view |
|
||||
| `packages/web/src/components/SessionDetail.tsx` | Session detail view |
|
||||
| `packages/web/src/components/DirectTerminal.tsx` | xterm.js terminal with WebSocket |
|
||||
| `packages/web/src/components/SessionCard.tsx` | Kanban session card |
|
||||
| `packages/web/src/hooks/useSessionEvents.ts` | SSE consumer hook |
|
||||
| `packages/web/src/lib/types.ts` | Dashboard types |
|
||||
| `packages/web/src/app/globals.css` | Design tokens and base styles |
|
||||
| `agent-orchestrator.yaml` | Project-level config (user-created) |
|
||||
| `eslint.config.js` | ESLint flat config |
|
||||
| `tsconfig.base.json` | Shared TypeScript base config |
|
||||
|
||||
## Plugin Standards
|
||||
|
||||
### Package Layout
|
||||
|
||||
```
|
||||
packages/plugins/{slot}-{name}/
|
||||
├── package.json # @composio/ao-plugin-{slot}-{name}
|
||||
├── tsconfig.json # extends ../../../tsconfig.base.json
|
||||
├── src/
|
||||
│ ├── index.ts # manifest + create + detect (default export)
|
||||
│ └── __tests__/ # vitest tests
|
||||
```
|
||||
|
||||
### Naming
|
||||
|
||||
- Package: `@composio/ao-plugin-{slot}-{name}` (lowercase, hyphenated)
|
||||
- `manifest.name` must match the `{name}` suffix (e.g. package `...-runtime-tmux` -> name: `"tmux"`)
|
||||
- `manifest.slot` must use `as const` to preserve the literal type
|
||||
|
||||
### Export Contract
|
||||
|
||||
Every plugin default-exports a `PluginModule<T>`:
|
||||
|
||||
```typescript
|
||||
import type { PluginModule, Runtime } from "@composio/ao-core";
|
||||
|
||||
export const manifest = {
|
||||
name: "tmux",
|
||||
slot: "runtime" as const,
|
||||
description: "tmux session runtime",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(config?: Record<string, unknown>): Runtime {
|
||||
// Validate config here, not in individual methods
|
||||
// Use closure to capture validated config
|
||||
return { ... };
|
||||
}
|
||||
|
||||
// Optional: check if binary/dependency is available on system
|
||||
export function detect(): boolean { ... }
|
||||
|
||||
export default { manifest, create, detect } satisfies PluginModule<Runtime>;
|
||||
```
|
||||
|
||||
### Config Handling
|
||||
|
||||
- Plugin-level config comes via `create(config)` from the YAML notifier/tracker blocks
|
||||
- Project-level config (e.g. `agentConfig`, `trackerConfig`) is passed to individual methods
|
||||
- Validate in `create()`, store via closure — don't re-validate per call
|
||||
- Warn (don't throw) for missing optional config during plugin load
|
||||
- Throw with descriptive message when a required config is missing at method call time
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Wrap errors with `cause` for debugging: `throw new Error("msg", { cause: err })`
|
||||
- Return `null` for "not found" (e.g. tracker issue lookup), throw for unexpected errors
|
||||
- Never silently swallow errors
|
||||
- Use `shellEscape()` from core for all command arguments (prevent injection)
|
||||
|
||||
### Interface Implementation
|
||||
|
||||
- All I/O methods return `Promise<T>` (async-first)
|
||||
- Plugins are loosely coupled — communicate through Session object and Lifecycle Manager, never call other plugins directly
|
||||
- Implement `destroy()` / cleanup with best-effort semantics
|
||||
|
||||
### Core Utilities Available to Plugins
|
||||
|
||||
```typescript
|
||||
import {
|
||||
shellEscape, // Safe command argument escaping
|
||||
validateUrl, // Webhook URL validation
|
||||
readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL)
|
||||
readLastActivityEntry, // Read last AO activity JSONL entry
|
||||
checkActivityLogState, // Extract waiting_input/blocked from AO JSONL (with staleness cap)
|
||||
getActivityFallbackState, // Last-resort fallback: entry state + age-based decay
|
||||
recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append)
|
||||
classifyTerminalActivity, // Classify terminal output via detectActivity
|
||||
appendActivityEntry, // Low-level JSONL append
|
||||
setupPathWrapperWorkspace, // Install ~/.ao/bin wrappers + .ao/AGENTS.md
|
||||
buildAgentPath, // Prepend ~/.ao/bin to PATH
|
||||
normalizeAgentPermissionMode, // Normalize permission mode strings
|
||||
DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold
|
||||
DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window
|
||||
ACTIVITY_INPUT_STALENESS_MS, // 5 min — waiting_input/blocked expiry
|
||||
PREFERRED_GH_PATH, // /usr/local/bin/gh
|
||||
CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants
|
||||
type Session, type ProjectConfig, type RuntimeHandle,
|
||||
} from "@composio/ao-core";
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- Vitest in `src/__tests__/index.test.ts`
|
||||
- Mock external CLIs, file I/O, HTTP calls
|
||||
- Test manifest values, `create()` return shape, all public methods, and error paths
|
||||
- Use `beforeEach` to reset mocks
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- Hardcoded secrets -> use `process.env`, throw if missing
|
||||
- Shell injection -> use `shellEscape()` for all arguments
|
||||
- Large file reads -> use streaming or `readLastJsonlEntry()`
|
||||
- Config validation in methods -> validate once in `create()`, closure the rest
|
||||
|
||||
### Agent Plugin Implementation Standards
|
||||
|
||||
All agent plugins (claude-code, codex, aider, opencode, etc.) must implement the full `Agent` interface. The dashboard depends on these methods for PR tracking, cost display, and session resume.
|
||||
|
||||
**Required methods (all agents):**
|
||||
|
||||
| Method | Purpose | Return `null` OK? |
|
||||
|--------|---------|-------------------|
|
||||
| `getLaunchCommand` | Shell command to start the agent | No |
|
||||
| `getEnvironment` | Env vars for agent process (must include `~/.ao/bin` in PATH) | No |
|
||||
| `detectActivity` | Terminal output classification (deprecated, but required) | No |
|
||||
| `getActivityState` | JSONL/API-based activity detection (min 3 states: active/ready/idle) | Yes (if no data) |
|
||||
| `isProcessRunning` | Check process alive via tmux TTY or PID | No |
|
||||
| `getSessionInfo` | Extract summary, cost, session ID from agent's data | Yes (if agent has no introspection) |
|
||||
|
||||
**Optional methods (implement when the agent supports it):**
|
||||
|
||||
| Method | Purpose | When to skip |
|
||||
|--------|---------|-------------|
|
||||
| `getRestoreCommand` | Resume a previous session | Agent has no resume capability (return `null`) |
|
||||
| `setupWorkspaceHooks` | Install metadata-update hooks (PATH wrappers or agent-native) | Never — required for dashboard PR tracking |
|
||||
| `postLaunchSetup` | Post-launch config (re-ensure hooks, resolve binary) | Only if no post-launch work needed |
|
||||
| `recordActivity` | Write terminal-derived activity to JSONL for `getActivityState` | Agent has native JSONL with full state coverage (Claude Code). Codex implements it as a safety net for when its native JSONL is missing/unparseable. |
|
||||
|
||||
**Metadata hooks are critical.** Without `setupWorkspaceHooks`, PRs created by agents won't appear in the dashboard. Two patterns exist:
|
||||
- **Agent-native hooks** (Claude Code): PostToolUse hooks in `.claude/settings.json`
|
||||
- **PATH wrappers** (Codex, Aider, OpenCode): `~/.ao/bin/gh` and `~/.ao/bin/git` intercept commands. Call `setupPathWrapperWorkspace(workspacePath)` — it installs wrappers to `~/.ao/bin/` and writes session context to `.ao/AGENTS.md` (gitignored, does not modify tracked files).
|
||||
|
||||
**Environment requirements:**
|
||||
- All agents must set `AO_SESSION_ID` and optionally `AO_ISSUE_ID`
|
||||
- All agents using PATH wrappers must prepend `~/.ao/bin` to PATH
|
||||
- Use `normalizeAgentPermissionMode` from `@composio/ao-core` (not a local duplicate)
|
||||
|
||||
**Activity detection architecture:**
|
||||
|
||||
`getActivityState` is the most critical method in the agent plugin. The dashboard, lifecycle manager, and stuck-detection all depend on it returning correct states. **Every agent plugin must produce all 6 states over its lifetime:**
|
||||
|
||||
```
|
||||
spawning → active ↔ ready → idle → exited
|
||||
↘ waiting_input / blocked ↗
|
||||
```
|
||||
|
||||
| State | Meaning | When |
|
||||
|-------|---------|------|
|
||||
| `active` | Agent is working right now | Activity within last 30s |
|
||||
| `ready` | Agent finished recently, may resume | 30s–5min since last activity |
|
||||
| `idle` | Agent has been quiet for a while | >5min since last activity |
|
||||
| `waiting_input` | Agent is blocked on user approval | Permission prompt visible |
|
||||
| `blocked` | Agent hit an error it can't recover from | Error state detected |
|
||||
| `exited` | Process is dead | `isProcessRunning` returns false |
|
||||
|
||||
**The `getActivityState` contract — implement exactly this cascade:**
|
||||
|
||||
```typescript
|
||||
async getActivityState(session, readyThresholdMs?): Promise<ActivityDetection | null> {
|
||||
// 1. PROCESS CHECK — always first
|
||||
if (!running) return { state: "exited", timestamp };
|
||||
|
||||
// 2. ACTIONABLE STATES — check for waiting_input/blocked
|
||||
// Source: native JSONL (Claude Code, Codex) OR AO activity JSONL (others)
|
||||
// These are the only states checkActivityLogState() surfaces.
|
||||
// If found, return immediately.
|
||||
|
||||
// 3. NATIVE SIGNAL — agent-specific API for timestamp (preferred)
|
||||
// Source: agent's session list API, native JSONL timestamps, etc.
|
||||
// Classify by age: active (<30s) / ready (30s–threshold) / idle (>threshold)
|
||||
|
||||
// 4. JSONL ENTRY FALLBACK — always implement this
|
||||
// Source: getActivityFallbackState(activityResult, activeWindowMs, threshold)
|
||||
// Uses the entry's detected state + entry.ts for age-based decay.
|
||||
// Decay only demotes (active→ready→idle), never promotes.
|
||||
// This is the SAFETY NET when the native signal is unavailable.
|
||||
// Without this, getActivityState returns null and the dashboard shows
|
||||
// no activity for the entire session lifetime.
|
||||
|
||||
// 5. Return null only if there is genuinely no data at all.
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 is mandatory.** If you skip the JSONL entry fallback, `getActivityState` will return `null` whenever the native API fails (binary not in PATH, API changed, session not found, timeout). The dashboard will show no activity state and stuck-detection breaks. This was a real bug in the OpenCode plugin — `findOpenCodeSession` returned null due to a session creation issue, and without the fallback, the entire active/ready/idle flow was dead. Use `getActivityFallbackState()` from core — it handles age-based decay and staleness caps correctly.
|
||||
|
||||
**Two activity detection patterns exist:**
|
||||
|
||||
| Pattern | Used by | How it works |
|
||||
|---------|---------|-------------|
|
||||
| **Native JSONL** | Claude Code, Codex | Agent writes its own JSONL with rich state (`permission_request`, `tool_call`, `error`, etc.). `getActivityState` reads the last entry and maps it to activity states. |
|
||||
| **AO Activity JSONL** | Aider, OpenCode, new agents | Agent implements `recordActivity`. Lifecycle manager calls it each poll cycle with terminal output. It calls `classifyTerminalActivity()` → `appendActivityEntry()` to write to `{workspacePath}/.ao/activity.jsonl`. `getActivityState` reads from this file. |
|
||||
|
||||
**For agents using AO Activity JSONL (the common case for new plugins):**
|
||||
|
||||
1. Implement `recordActivity` — delegate to the shared `recordTerminalActivity()`:
|
||||
```typescript
|
||||
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) =>
|
||||
this.detectActivity(output),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`recordTerminalActivity` handles classification, deduplication (20s window for non-actionable states), and appending. You don't need to implement dedup yourself.
|
||||
|
||||
2. Implement `detectActivity` with patterns specific to the agent's terminal output:
|
||||
```typescript
|
||||
detectActivity(terminalOutput: string): ActivityState {
|
||||
// Match the ACTUAL prompts/patterns the agent emits.
|
||||
// Test with real terminal output — don't guess patterns.
|
||||
// Return: "idle" | "active" | "waiting_input" | "blocked"
|
||||
}
|
||||
```
|
||||
|
||||
3. In `getActivityState`, use `checkActivityLogState()` for waiting_input/blocked, then fall back to `getActivityFallbackState()`:
|
||||
```typescript
|
||||
// checkActivityLogState returns non-null ONLY for waiting_input/blocked.
|
||||
// active/idle/ready intentionally return null — use the fallback for those.
|
||||
const activityResult = await readLastActivityEntry(session.workspacePath);
|
||||
const activityState = checkActivityLogState(activityResult);
|
||||
if (activityState) return activityState;
|
||||
|
||||
// ... try native signal first (session list API, git commits, etc.) ...
|
||||
|
||||
// JSONL entry fallback (REQUIRED — do not skip)
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
|
||||
if (fallback) return fallback;
|
||||
```
|
||||
|
||||
`getActivityFallbackState` uses the entry's detected state with age-based decay (active→ready→idle) and respects the entry state as a ceiling (never promotes idle to active). Stale waiting_input/blocked entries (>5min) decay to idle.
|
||||
|
||||
**Required tests for `getActivityState` — all agent plugins must have these:**
|
||||
|
||||
1. Returns `exited` when process is not running
|
||||
2. Returns `waiting_input` from JSONL when agent is at a permission prompt
|
||||
3. Returns `blocked` from JSONL when agent hit an error
|
||||
4. Returns `active` from native signal when agent was recently active
|
||||
5. Returns `active` from JSONL entry fallback when native signal fails (fresh entry)
|
||||
6. Returns `idle` from JSONL entry fallback when native signal fails (old entry with age decay)
|
||||
7. Returns `null` when both native signal and JSONL are unavailable
|
||||
|
||||
**`isProcessRunning` must:**
|
||||
- Support tmux runtime (TTY-based `ps` lookup with process name regex)
|
||||
- Support process runtime (PID signal-0 check with EPERM handling)
|
||||
- Match BOTH the node wrapper name AND the actual binary name (some agents install as `.agentname` with a dot prefix — the regex must handle this)
|
||||
- Return `false` (not `null`) on error
|
||||
|
||||
## Constraints
|
||||
|
||||
- C-01: No new UI component libraries
|
||||
- C-02: No inline styles in new/modified code
|
||||
- C-04: Component files max 400 lines
|
||||
- C-05: Dark theme preserved (no redesign)
|
||||
- C-06: Next.js App Router only
|
||||
- C-07: No animation libraries
|
||||
- C-12: Test files for all new components
|
||||
- C-13: pnpm `workspace:*` protocol for cross-package deps
|
||||
- C-14: SSE 5s interval unchanged
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
checkActivityLogState,
|
||||
classifyTerminalActivity,
|
||||
readLastActivityEntry,
|
||||
appendActivityEntry,
|
||||
recordTerminalActivity,
|
||||
getActivityLogPath,
|
||||
ACTIVITY_INPUT_STALENESS_MS,
|
||||
} from "../activity-log.js";
|
||||
import type { ActivityState } from "../types.js";
|
||||
|
||||
describe("classifyTerminalActivity", () => {
|
||||
it("returns active state with no trigger", () => {
|
||||
const detect = () => "active" as ActivityState;
|
||||
const result = classifyTerminalActivity("some output", detect);
|
||||
expect(result).toEqual({ state: "active", trigger: undefined });
|
||||
});
|
||||
|
||||
it("returns waiting_input with trigger from last 3 lines", () => {
|
||||
const detect = () => "waiting_input" as ActivityState;
|
||||
const result = classifyTerminalActivity("line1\nline2\nprompt?", detect);
|
||||
expect(result.state).toBe("waiting_input");
|
||||
expect(result.trigger).toContain("prompt?");
|
||||
});
|
||||
|
||||
it("returns blocked with trigger", () => {
|
||||
const detect = () => "blocked" as ActivityState;
|
||||
const result = classifyTerminalActivity("error occurred", detect);
|
||||
expect(result.state).toBe("blocked");
|
||||
expect(result.trigger).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkActivityLogState", () => {
|
||||
it("returns null for null input", () => {
|
||||
expect(checkActivityLogState(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns waiting_input when entry is fresh", () => {
|
||||
const result = checkActivityLogState({
|
||||
entry: { ts: new Date().toISOString(), state: "waiting_input", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns blocked when entry is fresh", () => {
|
||||
const result = checkActivityLogState({
|
||||
entry: { ts: new Date().toISOString(), state: "blocked", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns null for stale waiting_input entry", () => {
|
||||
const staleTs = new Date(Date.now() - ACTIVITY_INPUT_STALENESS_MS - 1000).toISOString();
|
||||
const result = checkActivityLogState({
|
||||
entry: { ts: staleTs, state: "waiting_input", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-critical states", () => {
|
||||
const result = checkActivityLogState({
|
||||
entry: { ts: new Date().toISOString(), state: "active", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid entry.ts", () => {
|
||||
const result = checkActivityLogState({
|
||||
entry: { ts: "not-a-date", state: "waiting_input", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readLastActivityEntry", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "ao-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns null when file does not exist", async () => {
|
||||
const result = await readLastActivityEntry(join(tmpDir, "nonexistent"));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty file", async () => {
|
||||
await mkdir(join(tmpDir, ".ao"), { recursive: true });
|
||||
await writeFile(getActivityLogPath(tmpDir), "", "utf-8");
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("parses a valid entry", async () => {
|
||||
await appendActivityEntry(tmpDir, "active", "terminal");
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.entry.state).toBe("active");
|
||||
expect(result!.entry.source).toBe("terminal");
|
||||
expect(result!.modifiedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("reads the last entry from multiple lines", async () => {
|
||||
await appendActivityEntry(tmpDir, "active", "terminal");
|
||||
await appendActivityEntry(tmpDir, "waiting_input", "terminal", "prompt?");
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result!.entry.state).toBe("waiting_input");
|
||||
expect(result!.entry.trigger).toBe("prompt?");
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", async () => {
|
||||
await mkdir(join(tmpDir, ".ao"), { recursive: true });
|
||||
await writeFile(getActivityLogPath(tmpDir), "not json\n", "utf-8");
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid state value", async () => {
|
||||
await mkdir(join(tmpDir, ".ao"), { recursive: true });
|
||||
const bad = JSON.stringify({ ts: new Date().toISOString(), state: "invalid", source: "terminal" });
|
||||
await writeFile(getActivityLogPath(tmpDir), bad + "\n", "utf-8");
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing required fields", async () => {
|
||||
await mkdir(join(tmpDir, ".ao"), { recursive: true });
|
||||
const bad = JSON.stringify({ ts: new Date().toISOString() });
|
||||
await writeFile(getActivityLogPath(tmpDir), bad + "\n", "utf-8");
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordTerminalActivity", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "ao-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes activity entry to JSONL", async () => {
|
||||
const detect = () => "active" as ActivityState;
|
||||
await recordTerminalActivity(tmpDir, "output", detect);
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result!.entry.state).toBe("active");
|
||||
expect(result!.entry.source).toBe("terminal");
|
||||
});
|
||||
|
||||
it("writes waiting_input with trigger", async () => {
|
||||
const detect = () => "waiting_input" as ActivityState;
|
||||
await recordTerminalActivity(tmpDir, "line1\nline2\nprompt?", detect);
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result!.entry.state).toBe("waiting_input");
|
||||
expect(result!.entry.trigger).toBeDefined();
|
||||
});
|
||||
|
||||
it("deduplicates same state within 20s", async () => {
|
||||
const detect = () => "active" as ActivityState;
|
||||
await recordTerminalActivity(tmpDir, "output1", detect);
|
||||
await recordTerminalActivity(tmpDir, "output2", detect);
|
||||
|
||||
// Read file directly — should have only 1 line (deduped)
|
||||
const { readFile: rf } = await import("node:fs/promises");
|
||||
const content = await rf(getActivityLogPath(tmpDir), "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
expect(lines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("always writes actionable states even if same", async () => {
|
||||
const detect = () => "waiting_input" as ActivityState;
|
||||
await recordTerminalActivity(tmpDir, "prompt1", detect);
|
||||
await recordTerminalActivity(tmpDir, "prompt2", detect);
|
||||
|
||||
const { readFile: rf } = await import("node:fs/promises");
|
||||
const content = await rf(getActivityLogPath(tmpDir), "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
expect(lines).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { buildAgentPath, setupPathWrapperWorkspace } from "../agent-workspace-hooks.js";
|
||||
|
||||
const { mockWriteFile, mockMkdir, mockReadFile, mockRename } = vi.hoisted(() => ({
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadFile: vi.fn(),
|
||||
mockRename: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
writeFile: mockWriteFile,
|
||||
mkdir: mockMkdir,
|
||||
readFile: mockReadFile,
|
||||
rename: mockRename,
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: () => "/home/testuser",
|
||||
}));
|
||||
|
||||
describe("buildAgentPath", () => {
|
||||
it("prepends ao bin dir to PATH", () => {
|
||||
const result = buildAgentPath("/usr/bin:/bin");
|
||||
expect(result).toMatch(/^\/home\/testuser\/.ao\/bin:/);
|
||||
expect(result).toContain("/usr/bin");
|
||||
expect(result).toContain("/bin");
|
||||
});
|
||||
|
||||
it("deduplicates entries", () => {
|
||||
const result = buildAgentPath("/usr/local/bin:/usr/bin:/usr/local/bin");
|
||||
const entries = result.split(":");
|
||||
const unique = new Set(entries);
|
||||
expect(entries.length).toBe(unique.size);
|
||||
});
|
||||
|
||||
it("uses default PATH when basePath is undefined", () => {
|
||||
const result = buildAgentPath(undefined);
|
||||
expect(result).toMatch(/^\/home\/testuser\/.ao\/bin:/);
|
||||
expect(result).toContain("/usr/bin");
|
||||
});
|
||||
|
||||
it("ensures /usr/local/bin is early for gh resolution", () => {
|
||||
const result = buildAgentPath("/usr/bin:/bin");
|
||||
const entries = result.split(":");
|
||||
const aoIdx = entries.indexOf("/home/testuser/.ao/bin");
|
||||
const ghIdx = entries.indexOf("/usr/local/bin");
|
||||
expect(aoIdx).toBe(0);
|
||||
expect(ghIdx).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupPathWrapperWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
it("creates ao bin directory", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
expect(mockMkdir).toHaveBeenCalledWith(
|
||||
"/home/testuser/.ao/bin",
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("writes wrapper scripts when version marker is missing", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
// atomicWriteFile writes to .tmp then renames
|
||||
expect(mockRename).toHaveBeenCalled();
|
||||
// .ao/AGENTS.md is written directly
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter(
|
||||
(c: unknown[]) => String(c[0]).includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips wrapper rewrite when version matches", async () => {
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("0.2.0") // version marker matches
|
||||
.mockRejectedValueOnce(new Error("ENOENT")); // AGENTS.md doesn't exist
|
||||
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
||||
// Only metadata helper rename (1), no gh/git/marker renames
|
||||
const renamedPaths = mockRename.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(renamedPaths.filter((p: string) => p.includes("/gh."))).toHaveLength(0);
|
||||
expect(renamedPaths.filter((p: string) => p.includes("/git."))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes .ao/AGENTS.md with session context", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter(
|
||||
(c: unknown[]) => String(c[0]).includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
expect(String(agentsMdWrites[0][1])).toContain("Agent Orchestrator");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Activity JSONL log — shared utilities for agents that don't have native JSONL.
|
||||
*
|
||||
* Agents like Aider and OpenCode use this to write activity observations
|
||||
* (derived from terminal output) to `{workspacePath}/.ao/activity.jsonl`.
|
||||
* Their `getActivityState()` then reads from this file, enabling detection
|
||||
* of states like `waiting_input` and `blocked` that terminal-only parsing
|
||||
* couldn't surface through the deprecated `detectActivity()` path.
|
||||
*
|
||||
* Agents with native JSONL (Claude Code, Codex) don't use this — they read
|
||||
* richer data directly from their own session files.
|
||||
*/
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import type { ActivityState, ActivityLogEntry, ActivityDetection } from "./types.js";
|
||||
|
||||
/**
|
||||
* Maximum age (ms) for `waiting_input`/`blocked` entries before they're
|
||||
* considered stale. If no new terminal output overwrites the entry within
|
||||
* this window, the state falls through to downstream fallbacks instead of
|
||||
* keeping the session stuck in `needs_input` on the dashboard forever.
|
||||
*/
|
||||
export const ACTIVITY_INPUT_STALENESS_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get the path to the activity JSONL log for a session.
|
||||
* Location: `{workspacePath}/.ao/activity.jsonl`
|
||||
*/
|
||||
export function getActivityLogPath(workspacePath: string): string {
|
||||
return join(workspacePath, ".ao", "activity.jsonl");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an activity observation to the session's JSONL log.
|
||||
* Creates the `.ao/` directory if it doesn't exist.
|
||||
*/
|
||||
export async function appendActivityEntry(
|
||||
workspacePath: string,
|
||||
state: ActivityState,
|
||||
source: "terminal" | "native",
|
||||
trigger?: string,
|
||||
): Promise<void> {
|
||||
const logPath = getActivityLogPath(workspacePath);
|
||||
await mkdir(dirname(logPath), { recursive: true });
|
||||
|
||||
const entry: ActivityLogEntry = {
|
||||
ts: new Date().toISOString(),
|
||||
state,
|
||||
source,
|
||||
...(trigger !== undefined &&
|
||||
(state === "waiting_input" || state === "blocked") && { trigger }),
|
||||
};
|
||||
|
||||
await appendFile(logPath, JSON.stringify(entry) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the last activity entry from the session's JSONL log.
|
||||
* Returns the parsed entry with the file's modification time, or null if
|
||||
* the file doesn't exist or is empty.
|
||||
*/
|
||||
export async function readLastActivityEntry(
|
||||
workspacePath: string,
|
||||
): Promise<{ entry: ActivityLogEntry; modifiedAt: Date } | null> {
|
||||
const logPath = getActivityLogPath(workspacePath);
|
||||
|
||||
try {
|
||||
const { open } = await import("node:fs/promises");
|
||||
const handle = await open(logPath, "r");
|
||||
try {
|
||||
const fileStat = await handle.stat();
|
||||
if (fileStat.size === 0) return null;
|
||||
|
||||
// Read last 4KB — more than enough for a single JSON line
|
||||
const tailSize = Math.min(fileStat.size, 4096);
|
||||
const offset = Math.max(0, fileStat.size - tailSize);
|
||||
const buffer = Buffer.alloc(tailSize);
|
||||
const { bytesRead } = await handle.read(buffer, 0, tailSize, offset);
|
||||
if (bytesRead === 0) return null;
|
||||
const content = buffer.subarray(0, bytesRead).toString("utf-8");
|
||||
|
||||
// Find the last non-empty line. If we read from a non-zero offset,
|
||||
// the first line may be truncated — drop it.
|
||||
let lines = content.split("\n").filter((l) => l.trim());
|
||||
if (offset > 0 && lines.length > 1) lines = lines.slice(1);
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
// Try lines from the end — skip any that fail to parse (e.g. truncated)
|
||||
let parsed: unknown = null;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
parsed = JSON.parse(lines[i]!);
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (parsed === null) return null;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const validStates = new Set(["active", "ready", "idle", "waiting_input", "blocked", "exited"]);
|
||||
const validSources = new Set(["terminal", "native"]);
|
||||
if (
|
||||
typeof record.ts !== "string" ||
|
||||
typeof record.state !== "string" ||
|
||||
typeof record.source !== "string" ||
|
||||
!validStates.has(record.state) ||
|
||||
!validSources.has(record.source)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry: ActivityLogEntry = {
|
||||
ts: record.ts,
|
||||
state: record.state as ActivityLogEntry["state"],
|
||||
source: record.source as ActivityLogEntry["source"],
|
||||
...(typeof record.trigger === "string" && { trigger: record.trigger }),
|
||||
};
|
||||
return { entry, modifiedAt: fileStat.mtime };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the AO activity JSONL for actionable states only.
|
||||
*
|
||||
* Only returns `waiting_input`/`blocked` (with a staleness cap).
|
||||
* Non-critical states (`active`, `ready`, `idle`) always return `null` so
|
||||
* callers fall through to their native signals (git commits, chat history,
|
||||
* API queries, native JSONL). This prevents the lifecycle manager's
|
||||
* `recordActivity` writes (which refresh `mtime` every poll cycle) from
|
||||
* shadowing those richer detection methods and breaking stuck-detection.
|
||||
*/
|
||||
export function checkActivityLogState(
|
||||
activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null,
|
||||
): ActivityDetection | null {
|
||||
if (!activityResult) return null;
|
||||
|
||||
const { entry } = activityResult;
|
||||
|
||||
if (entry.state === "waiting_input" || entry.state === "blocked") {
|
||||
// Use the entry's own timestamp for staleness — not file mtime, which
|
||||
// gets refreshed every poll cycle by recordActivity and would prevent
|
||||
// stale entries from ever being detected.
|
||||
const entryTs = new Date(entry.ts);
|
||||
if (Number.isNaN(entryTs.getTime())) return null;
|
||||
const ageMs = Date.now() - entryTs.getTime();
|
||||
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
|
||||
return { state: entry.state, timestamp: entryTs };
|
||||
}
|
||||
}
|
||||
|
||||
// Non-critical states and stale entries — fall through to native signals
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an activity state from the JSONL entry with age-based decay.
|
||||
*
|
||||
* Unlike `checkActivityLogState` (which only returns actionable states),
|
||||
* this returns any state — but reclassifies `active`/`ready` entries as
|
||||
* `ready`/`idle` if they've aged past the active window / threshold.
|
||||
* Use this as a last-resort fallback when native signals are unavailable.
|
||||
*/
|
||||
export function getActivityFallbackState(
|
||||
activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null,
|
||||
activeWindowMs: number,
|
||||
thresholdMs: number,
|
||||
): ActivityDetection | null {
|
||||
if (!activityResult) return null;
|
||||
|
||||
const { entry } = activityResult;
|
||||
const entryTs = new Date(entry.ts);
|
||||
if (Number.isNaN(entryTs.getTime())) return null;
|
||||
|
||||
// Actionable states use the same staleness cap as checkActivityLogState.
|
||||
// If the entry is stale, fall through to age-based decay instead of
|
||||
// re-surfacing a waiting_input/blocked that checkActivityLogState already filtered.
|
||||
if (entry.state === "waiting_input" || entry.state === "blocked") {
|
||||
const ageMs = Date.now() - entryTs.getTime();
|
||||
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
|
||||
return { state: entry.state, timestamp: entryTs };
|
||||
}
|
||||
// Stale actionable entry — treat as idle
|
||||
return { state: "idle", timestamp: entryTs };
|
||||
}
|
||||
|
||||
// Age-based decay: active→ready→idle, but never promote past the
|
||||
// entry's detected state (e.g. a fresh "idle" entry stays "idle").
|
||||
const ageMs = Math.max(0, Date.now() - entryTs.getTime());
|
||||
let ageState: ActivityState;
|
||||
if (ageMs <= activeWindowMs) ageState = "active";
|
||||
else if (ageMs <= thresholdMs) ageState = "ready";
|
||||
else ageState = "idle";
|
||||
|
||||
const activityRank: Record<string, number> = { active: 0, ready: 1, idle: 2 };
|
||||
const entryRank = activityRank[entry.state] ?? 2;
|
||||
const ageRank = activityRank[ageState] ?? 2;
|
||||
const finalState = ageRank >= entryRank ? ageState : entry.state;
|
||||
|
||||
return { state: finalState, timestamp: entryTs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the arguments for `appendActivityEntry` from terminal output.
|
||||
*
|
||||
* Classifies terminal output via the provided `detectActivity` function and
|
||||
* returns the state + trigger. Plugins call `appendActivityEntry` themselves
|
||||
* (keeping it mockable in tests).
|
||||
*/
|
||||
export function classifyTerminalActivity(
|
||||
terminalOutput: string,
|
||||
detectActivity: (output: string) => ActivityState,
|
||||
): { state: ActivityState; trigger: string | undefined } {
|
||||
const state = detectActivity(terminalOutput);
|
||||
const trigger =
|
||||
state === "waiting_input" || state === "blocked"
|
||||
? terminalOutput.trim().split("\n").slice(-3).join("\n")
|
||||
: undefined;
|
||||
return { state, trigger };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `recordActivity` implementation for all agents.
|
||||
*
|
||||
* Classifies terminal output, deduplicates writes (skips when the state
|
||||
* hasn't changed and the last entry is recent), and appends to the JSONL.
|
||||
* Actionable states (waiting_input/blocked) always write immediately.
|
||||
*/
|
||||
export async function recordTerminalActivity(
|
||||
workspacePath: string,
|
||||
terminalOutput: string,
|
||||
detectActivity: (output: string) => ActivityState,
|
||||
): Promise<void> {
|
||||
const { state, trigger } = classifyTerminalActivity(terminalOutput, detectActivity);
|
||||
|
||||
// Deduplicate writes to reduce I/O. Skip when the state hasn't changed
|
||||
// and the last entry is recent (<20s). Actionable states always write.
|
||||
if (state !== "waiting_input" && state !== "blocked") {
|
||||
const lastEntry = await readLastActivityEntry(workspacePath);
|
||||
if (lastEntry && lastEntry.entry.state === state) {
|
||||
const entryAgeMs = Date.now() - lastEntry.modifiedAt.getTime();
|
||||
if (entryAgeMs < 20_000) return;
|
||||
}
|
||||
}
|
||||
|
||||
await appendActivityEntry(workspacePath, state, "terminal", trigger);
|
||||
}
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
/**
|
||||
* Shared PATH-based workspace hooks for agent plugins that don't have
|
||||
* native hook systems (Codex, Aider, OpenCode).
|
||||
*
|
||||
* Installs ~/.ao/bin/gh and ~/.ao/bin/git wrappers that intercept
|
||||
* PR creation, PR merge, and branch operations to auto-update session metadata.
|
||||
*
|
||||
* Claude Code uses its own PostToolUse hook system instead.
|
||||
*/
|
||||
import { writeFile, mkdir, readFile, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_PATH = "/usr/bin:/bin";
|
||||
const PREFERRED_GH_BIN_DIR = "/usr/local/bin";
|
||||
|
||||
/** Preferred gh binary path for wrapper scripts */
|
||||
export const PREFERRED_GH_PATH = `${PREFERRED_GH_BIN_DIR}/gh`;
|
||||
|
||||
/**
|
||||
* Get the shared bin directory for ao shell wrappers (prepended to PATH).
|
||||
* Computed lazily to avoid calling homedir() at module load time,
|
||||
* which breaks test mocks that replace homedir after import.
|
||||
*/
|
||||
function getAoBinDir(): string {
|
||||
return join(homedir(), ".ao", "bin");
|
||||
}
|
||||
|
||||
/** Current version of wrapper scripts — bump when scripts change */
|
||||
const WRAPPER_VERSION = "0.2.0";
|
||||
|
||||
// =============================================================================
|
||||
// PATH Builder
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build a PATH string with ~/.ao/bin prepended for wrapper interception.
|
||||
* Deduplicates entries and ensures /usr/local/bin is early for gh resolution.
|
||||
*/
|
||||
export function buildAgentPath(basePath: string | undefined): string {
|
||||
const inherited = (basePath ?? DEFAULT_PATH).split(":").filter(Boolean);
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (entry: string): void => {
|
||||
if (!entry || seen.has(entry)) return;
|
||||
ordered.push(entry);
|
||||
seen.add(entry);
|
||||
};
|
||||
|
||||
add(getAoBinDir());
|
||||
add(PREFERRED_GH_BIN_DIR);
|
||||
|
||||
for (const entry of inherited) add(entry);
|
||||
|
||||
return ordered.join(":");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shell Wrapper Scripts
|
||||
// =============================================================================
|
||||
|
||||
/* eslint-disable no-useless-escape -- \$ escapes are intentional: bash scripts in JS template literals */
|
||||
|
||||
/**
|
||||
* Helper script sourced by both gh and git wrappers.
|
||||
* Provides update_ao_metadata() for writing key=value to the session file.
|
||||
*/
|
||||
export const AO_METADATA_HELPER = `#!/usr/bin/env bash
|
||||
# ao-metadata-helper — shared by gh/git wrappers
|
||||
# Provides: update_ao_metadata <key> <value>
|
||||
|
||||
update_ao_metadata() {
|
||||
local key="\$1" value="\$2"
|
||||
local ao_dir="\${AO_DATA_DIR:-}"
|
||||
local ao_session="\${AO_SESSION:-}"
|
||||
|
||||
[[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 0
|
||||
|
||||
# Validate: session name must not contain path separators or traversal
|
||||
case "\$ao_session" in
|
||||
*/* | *..*) return 0 ;;
|
||||
esac
|
||||
|
||||
# Validate: ao_dir must be an absolute path under known ao directories or /tmp
|
||||
case "\$ao_dir" in
|
||||
"\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
|
||||
local metadata_file="\$ao_dir/\$ao_session"
|
||||
|
||||
# Resolve symlinks and verify canonicalized paths are still within trusted roots
|
||||
local real_dir real_ao_dir
|
||||
real_ao_dir="\$(cd "\$ao_dir" 2>/dev/null && pwd -P)" || return 0
|
||||
real_dir="\$(cd "\$(dirname "\$metadata_file")" 2>/dev/null && pwd -P)" || return 0
|
||||
|
||||
# Re-validate real_ao_dir against trusted roots after canonicalization
|
||||
# (prevents /tmp/../../home/user from escaping the allowlist)
|
||||
case "\$real_ao_dir" in
|
||||
"\$HOME"/.ao/* | "\$HOME"/.ao | "\$HOME"/.agent-orchestrator/* | "\$HOME"/.agent-orchestrator | /tmp/*) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
|
||||
[[ "\$real_dir" == "\$real_ao_dir"* ]] || return 0
|
||||
|
||||
[[ -f "\$metadata_file" ]] || return 0
|
||||
|
||||
# Validate key — only allow alphanumeric, underscore, hyphen (prevents sed injection)
|
||||
[[ "\$key" =~ ^[a-zA-Z0-9_-]+$ ]] || return 0
|
||||
|
||||
local temp_file="\${metadata_file}.tmp.\$\$"
|
||||
|
||||
# Strip newlines from value to prevent metadata line injection
|
||||
local clean_value="\$(printf '%s' "\$value" | tr -d '\\n')"
|
||||
|
||||
# Escape sed metacharacters in value (& expands to matched text, | breaks delimiter)
|
||||
local escaped_value="\$(printf '%s' "\$clean_value" | sed 's/[&|\\\\]/\\\\&/g')"
|
||||
|
||||
if grep -q "^\${key}=" "\$metadata_file" 2>/dev/null; then
|
||||
sed "s|^\${key}=.*|\${key}=\${escaped_value}|" "\$metadata_file" > "\$temp_file"
|
||||
else
|
||||
cp "\$metadata_file" "\$temp_file"
|
||||
printf '%s=%s\\n' "\$key" "\$clean_value" >> "\$temp_file"
|
||||
fi
|
||||
|
||||
mv "\$temp_file" "\$metadata_file"
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* gh wrapper — intercepts `gh pr create` and `gh pr merge` to auto-update
|
||||
* session metadata. All other commands pass through transparently.
|
||||
*/
|
||||
export const GH_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao gh wrapper — auto-updates session metadata on PR operations
|
||||
|
||||
# Find real gh by removing our wrapper directory from PATH
|
||||
ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
|
||||
clean_path="\${clean_path%:}"
|
||||
real_gh=""
|
||||
|
||||
# Prefer explicit gh path when provided by AO environment.
|
||||
# Guard against recursive self-reference to the wrapper in ~/.ao/bin.
|
||||
if [[ -n "\${GH_PATH:-}" && -x "\$GH_PATH" ]]; then
|
||||
gh_dir="\$(cd "\$(dirname "\$GH_PATH")" 2>/dev/null && pwd)"
|
||||
if [[ "\$gh_dir" != "\$ao_bin_dir" ]]; then
|
||||
real_gh="\$GH_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "\$real_gh" ]]; then
|
||||
real_gh="\$(PATH="\$clean_path" command -v gh 2>/dev/null)"
|
||||
fi
|
||||
|
||||
if [[ -z "\$real_gh" ]]; then
|
||||
echo "ao-wrapper: gh not found in PATH" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Only capture output for commands we need to parse (pr/create, pr/merge).
|
||||
# All other commands pass through transparently without stream merging.
|
||||
case "\$1/\$2" in
|
||||
pr/create|pr/merge)
|
||||
tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$tmpout"' EXIT
|
||||
|
||||
"\$real_gh" "\$@" 2>&1 | tee "\$tmpout"
|
||||
exit_code=\${PIPESTATUS[0]}
|
||||
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
output="\$(cat "\$tmpout")"
|
||||
case "\$1/\$2" in
|
||||
pr/create)
|
||||
pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
|
||||
if [[ -n "\$pr_url" ]]; then
|
||||
update_ao_metadata pr "\$pr_url"
|
||||
update_ao_metadata status pr_open
|
||||
fi
|
||||
;;
|
||||
pr/merge)
|
||||
update_ao_metadata status merged
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
;;
|
||||
*)
|
||||
exec "\$real_gh" "\$@"
|
||||
;;
|
||||
esac
|
||||
`;
|
||||
|
||||
/**
|
||||
* git wrapper — intercepts branch operations to auto-update metadata.
|
||||
* All other commands pass through transparently.
|
||||
*
|
||||
* Detects:
|
||||
* - git checkout -b <branch> / git switch -c <branch> (new branch)
|
||||
* - git checkout <branch> / git switch <branch> (existing feature branch)
|
||||
*
|
||||
* For existing branch switches, only updates if the branch name looks like a
|
||||
* feature branch (contains / or -) to avoid noise from checkout of commits/tags.
|
||||
* Matches the same heuristic as Claude Code's PostToolUse hook.
|
||||
*/
|
||||
export const GIT_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao git wrapper — auto-updates session metadata on branch operations
|
||||
|
||||
# Find real git by removing our wrapper directory from PATH
|
||||
ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
|
||||
clean_path="\${clean_path%:}"
|
||||
real_git="\$(PATH="\$clean_path" command -v git 2>/dev/null)"
|
||||
|
||||
if [[ -z "\$real_git" ]]; then
|
||||
echo "ao-wrapper: git not found in PATH" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Run real git
|
||||
"\$real_git" "\$@"
|
||||
exit_code=\$?
|
||||
|
||||
# Only update metadata on success
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
case "\$1/\$2" in
|
||||
checkout/-b)
|
||||
update_ao_metadata branch "\$3"
|
||||
;;
|
||||
switch/-c)
|
||||
update_ao_metadata branch "\$3"
|
||||
;;
|
||||
checkout/*|switch/*)
|
||||
# Existing branch switch — only track feature-looking branches (contain / or -)
|
||||
# Skip flags (e.g. -B), HEAD, tags, commit hashes, and simple names like "main"
|
||||
branch="\$2"
|
||||
# If $2 is a flag, the actual branch name is in $3
|
||||
if [[ "\$branch" == -* ]]; then branch="\$3"; fi
|
||||
if [[ -n "\$branch" && "\$branch" != "HEAD" && "\$branch" != -* && "\$branch" == *[/-]* ]]; then
|
||||
update_ao_metadata branch "\$branch"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
`;
|
||||
|
||||
/**
|
||||
* Section appended to AGENTS.md as a secondary signal. The PATH-based wrappers
|
||||
* handle metadata updates automatically, but AGENTS.md reinforces the intent
|
||||
* and helps if the wrappers are bypassed.
|
||||
*/
|
||||
export const AO_AGENTS_MD_SECTION = `
|
||||
## Agent Orchestrator (ao) Session
|
||||
|
||||
You are running inside an Agent Orchestrator managed workspace.
|
||||
Session metadata is updated automatically via shell wrappers.
|
||||
|
||||
If automatic updates fail, you can manually update metadata:
|
||||
\`\`\`bash
|
||||
~/.ao/bin/ao-metadata-helper.sh # sourced automatically
|
||||
# Then call: update_ao_metadata <key> <value>
|
||||
\`\`\`
|
||||
`;
|
||||
/* eslint-enable no-useless-escape */
|
||||
|
||||
// =============================================================================
|
||||
// Workspace Setup
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Atomically write a file by writing to a temp file in the same directory,
|
||||
* then renaming. Prevents concurrent sessions from reading partially written scripts.
|
||||
*/
|
||||
async function atomicWriteFile(filePath: string, content: string, mode: number): Promise<void> {
|
||||
const suffix = randomBytes(6).toString("hex");
|
||||
const tmpPath = `${filePath}.tmp.${suffix}`;
|
||||
await writeFile(tmpPath, content, { encoding: "utf-8", mode });
|
||||
await rename(tmpPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install PATH-based shell wrappers and append an AO section to AGENTS.md.
|
||||
*
|
||||
* This is the standard workspace setup for agents that don't have native hook
|
||||
* systems (Codex, Aider, OpenCode). Call this from both `setupWorkspaceHooks`
|
||||
* and `postLaunchSetup`.
|
||||
*
|
||||
* 1. Creates ~/.ao/bin/ with gh/git wrappers and metadata helper script
|
||||
* 2. Appends an "Agent Orchestrator" section to the workspace AGENTS.md
|
||||
*/
|
||||
export async function setupPathWrapperWorkspace(workspacePath: string): Promise<void> {
|
||||
// 1. Write shared wrappers to ~/.ao/bin/ (skip if version marker matches)
|
||||
await mkdir(getAoBinDir(), { recursive: true });
|
||||
|
||||
const markerPath = join(getAoBinDir(), ".ao-version");
|
||||
let needsUpdate = true;
|
||||
try {
|
||||
const existing = await readFile(markerPath, "utf-8");
|
||||
if (existing.trim() === WRAPPER_VERSION) needsUpdate = false;
|
||||
} catch {
|
||||
// File doesn't exist — needs update
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await atomicWriteFile(
|
||||
join(getAoBinDir(), "ao-metadata-helper.sh"),
|
||||
AO_METADATA_HELPER,
|
||||
0o755,
|
||||
);
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
// If we crash between wrapper writes and marker write, the next
|
||||
// invocation will redo the writes (safe: wrappers are idempotent).
|
||||
await atomicWriteFile(join(getAoBinDir(), "gh"), GH_WRAPPER, 0o755);
|
||||
await atomicWriteFile(join(getAoBinDir(), "git"), GIT_WRAPPER, 0o755);
|
||||
await atomicWriteFile(markerPath, WRAPPER_VERSION, 0o644);
|
||||
}
|
||||
|
||||
// 2. Write AO session context to .ao/AGENTS.md (gitignored) so agents
|
||||
// can discover they're in a managed session. We don't modify the
|
||||
// repo-tracked AGENTS.md to avoid polluting worktrees with dirty state.
|
||||
const aoAgentsMdPath = join(workspacePath, ".ao", "AGENTS.md");
|
||||
await mkdir(join(workspacePath, ".ao"), { recursive: true });
|
||||
await writeFile(aoAgentsMdPath, AO_AGENTS_MD_SECTION.trimStart(), "utf-8");
|
||||
}
|
||||
|
|
@ -111,6 +111,23 @@ export {
|
|||
} from "./scm-webhook-utils.js";
|
||||
export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
|
||||
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
|
||||
// Activity log — JSONL activity tracking for agents without native JSONL
|
||||
export {
|
||||
appendActivityEntry,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
classifyTerminalActivity,
|
||||
recordTerminalActivity,
|
||||
} from "./activity-log.js";
|
||||
|
||||
// Agent workspace hooks — shared PATH-wrapper setup for non-Claude agents
|
||||
export {
|
||||
setupPathWrapperWorkspace,
|
||||
buildAgentPath,
|
||||
PREFERRED_GH_PATH,
|
||||
} from "./agent-workspace-hooks.js";
|
||||
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -366,6 +366,25 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
// 2. Check agent activity — prefer JSONL-based detection (runtime-agnostic)
|
||||
if (agent && session.runtimeHandle) {
|
||||
try {
|
||||
// If the agent implements recordActivity, capture terminal output and record
|
||||
// BEFORE calling getActivityState so the JSONL has fresh data to read.
|
||||
if (agent.recordActivity && session.workspacePath) {
|
||||
try {
|
||||
const runtime = registry.get<Runtime>(
|
||||
"runtime",
|
||||
project.runtime ?? config.defaults.runtime,
|
||||
);
|
||||
const terminalOutput = runtime
|
||||
? await runtime.getOutput(session.runtimeHandle, 10)
|
||||
: "";
|
||||
if (terminalOutput) {
|
||||
await agent.recordActivity(session, terminalOutput);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — activity recording is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Try JSONL-based activity detection first (reads agent's session files directly)
|
||||
const activityState = await agent.getActivityState(session, config.readyThresholdMs);
|
||||
if (activityState) {
|
||||
|
|
|
|||
|
|
@ -70,9 +70,24 @@ export interface ActivityDetection {
|
|||
timestamp?: Date;
|
||||
}
|
||||
|
||||
/** A single entry in the AO activity JSONL log, written by agent plugins. */
|
||||
export interface ActivityLogEntry {
|
||||
/** ISO 8601 timestamp */
|
||||
ts: string;
|
||||
/** Activity state derived from terminal output or agent-native data */
|
||||
state: ActivityState;
|
||||
/** What triggered this state classification */
|
||||
source: "terminal" | "native";
|
||||
/** Raw terminal snippet that caused waiting_input/blocked (for debugging) */
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
/** Default threshold (ms) before a "ready" session becomes "idle". */
|
||||
export const DEFAULT_READY_THRESHOLD_MS = 300_000; // 5 minutes
|
||||
|
||||
/** Default window (ms) for "active" state — activity newer than this is "active", older is "ready". */
|
||||
export const DEFAULT_ACTIVE_WINDOW_MS = 30_000; // 30 seconds
|
||||
|
||||
/** Session status constants */
|
||||
export const SESSION_STATUS = {
|
||||
SPAWNING: "spawning" as const,
|
||||
|
|
@ -339,6 +354,19 @@ export interface Agent {
|
|||
* run git/gh commands. Without this, PRs created by agents never show up.
|
||||
*/
|
||||
setupWorkspaceHooks?(workspacePath: string, config: WorkspaceHooksConfig): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional: Record an activity observation to the session's JSONL activity log.
|
||||
* Called by the lifecycle manager during each poll cycle with captured terminal output.
|
||||
*
|
||||
* Plugins classify the terminal output (via detectActivity) and append a JSONL entry
|
||||
* to `{session.workspacePath}/.ao/activity.jsonl`. The next `getActivityState()` call
|
||||
* reads from this file to detect states like `waiting_input` and `blocked`.
|
||||
*
|
||||
* Agents with native JSONL (Claude Code, Codex) should NOT implement this — their
|
||||
* `getActivityState` already reads richer data from the agent's own session files.
|
||||
*/
|
||||
recordActivity?(session: Session, terminalOutput: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentLaunchConfig {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,33 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core";
|
||||
|
||||
// Mock fs/promises for getSessionInfo tests (readFile for .aider.chat.history.md)
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
readFile: vi.fn().mockRejectedValue(new Error("ENOENT")),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock activity log utilities from core
|
||||
const { mockAppendActivityEntry, mockReadLastActivityEntry, mockRecordTerminalActivity } =
|
||||
vi.hoisted(() => ({
|
||||
mockAppendActivityEntry: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadLastActivityEntry: vi.fn().mockResolvedValue(null),
|
||||
mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@composio/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
appendActivityEntry: mockAppendActivityEntry,
|
||||
readLastActivityEntry: mockReadLastActivityEntry,
|
||||
recordTerminalActivity: mockRecordTerminalActivity,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -266,6 +293,27 @@ describe("detectActivity", () => {
|
|||
expect(agent.detectActivity(" \n ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle when prompt char visible", () => {
|
||||
expect(agent.detectActivity("some output\n> ")).toBe("idle");
|
||||
expect(agent.detectActivity("some output\n$ ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle for aider-specific prompt", () => {
|
||||
expect(agent.detectActivity("Tokens: 1.2k\naider> ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns waiting_input for Y/N confirmation", () => {
|
||||
expect(agent.detectActivity("Allow creation of new file foo.ts\n(Y)es/(N)o")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for add-to-chat prompt", () => {
|
||||
expect(agent.detectActivity("Add src/utils.ts to the chat? (Y)es/(N)o")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for proceed prompt", () => {
|
||||
expect(agent.detectActivity("This will modify 5 files. proceed?")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns active for non-empty terminal output", () => {
|
||||
expect(agent.detectActivity("aider is processing files\n")).toBe("active");
|
||||
});
|
||||
|
|
@ -277,8 +325,171 @@ describe("detectActivity", () => {
|
|||
describe("getSessionInfo", () => {
|
||||
const agent = create();
|
||||
|
||||
it("always returns null (not implemented)", async () => {
|
||||
it("returns null when workspacePath is null", async () => {
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no chat history file exists", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT"));
|
||||
expect(await agent.getSessionInfo(makeSession())).toBeNull();
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts summary from chat history file", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
vi.mocked(readFile).mockResolvedValueOnce(
|
||||
"# aider chat started\n\n#### Fix the login bug in auth.ts\n\nSome response here...\n",
|
||||
);
|
||||
const info = await agent.getSessionInfo(makeSession());
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.summary).toBe("Fix the login bug in auth.ts");
|
||||
expect(info!.summaryIsFallback).toBe(true);
|
||||
expect(info!.agentSessionId).toBeNull();
|
||||
expect(info!.cost).toBeUndefined();
|
||||
});
|
||||
|
||||
it("truncates long summaries to 120 chars", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const longMsg = "A".repeat(200);
|
||||
vi.mocked(readFile).mockResolvedValueOnce(`#### ${longMsg}\n`);
|
||||
const info = await agent.getSessionInfo(makeSession());
|
||||
expect(info!.summary).toHaveLength(123); // 120 + "..."
|
||||
expect(info!.summary!.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getRestoreCommand
|
||||
// =========================================================================
|
||||
describe("getRestoreCommand", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns null (aider does not support session resume)", async () => {
|
||||
const result = await agent.getRestoreCommand!(
|
||||
makeSession(),
|
||||
{ name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" },
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setupWorkspaceHooks
|
||||
// =========================================================================
|
||||
describe("setupWorkspaceHooks", () => {
|
||||
const agent = create();
|
||||
|
||||
it("is defined (delegates to shared setupPathWrapperWorkspace)", () => {
|
||||
expect(agent.setupWorkspaceHooks).toBeDefined();
|
||||
expect(typeof agent.setupWorkspaceHooks).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// postLaunchSetup
|
||||
// =========================================================================
|
||||
describe("postLaunchSetup", () => {
|
||||
const agent = create();
|
||||
|
||||
it("is defined", () => {
|
||||
expect(agent.postLaunchSetup).toBeDefined();
|
||||
expect(typeof agent.postLaunchSetup).toBe("function");
|
||||
});
|
||||
|
||||
it("does nothing when workspacePath is null", async () => {
|
||||
// Should not throw
|
||||
await agent.postLaunchSetup!(makeSession({ workspacePath: null }));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getEnvironment — PATH wrapping
|
||||
// =========================================================================
|
||||
describe("getEnvironment PATH", () => {
|
||||
const agent = create();
|
||||
|
||||
it("prepends ~/.ao/bin to PATH", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toMatch(/\.ao\/bin/);
|
||||
});
|
||||
|
||||
it("sets GH_PATH", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["GH_PATH"]).toBe("/usr/local/bin/gh");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// recordActivity
|
||||
// =========================================================================
|
||||
describe("recordActivity", () => {
|
||||
const agent = create();
|
||||
|
||||
it("is defined", () => {
|
||||
expect(agent.recordActivity).toBeDefined();
|
||||
});
|
||||
|
||||
it("does nothing when workspacePath is null", async () => {
|
||||
await agent.recordActivity!(makeSession({ workspacePath: null }), "some output");
|
||||
expect(mockRecordTerminalActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates to recordTerminalActivity", async () => {
|
||||
await agent.recordActivity!(makeSession(), "aider is processing files");
|
||||
expect(mockRecordTerminalActivity).toHaveBeenCalledWith(
|
||||
"/workspace/test",
|
||||
"aider is processing files",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getActivityState — reads from activity JSONL
|
||||
// =========================================================================
|
||||
describe("getActivityState with activity JSONL", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns waiting_input from activity JSONL", async () => {
|
||||
mockTmuxWithProcess("aider");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce({
|
||||
entry: { ts: new Date().toISOString(), state: "waiting_input", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns blocked from activity JSONL", async () => {
|
||||
mockTmuxWithProcess("aider");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce({
|
||||
entry: { ts: new Date().toISOString(), state: "blocked", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("falls through to JSONL mtime fallback for non-critical states when native signals unavailable", async () => {
|
||||
mockTmuxWithProcess("aider");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce({
|
||||
entry: { ts: new Date().toISOString(), state: "active", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
// Non-critical "active" from AO JSONL is ignored by checkActivityLogState —
|
||||
// falls through to git/chat fallbacks. With no git commits or chat history,
|
||||
// falls through to JSONL mtime fallback (step 4) which returns "active"
|
||||
// since modifiedAt is recent.
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
);
|
||||
expect(result?.state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import {
|
||||
shellEscape,
|
||||
normalizeAgentPermissionMode,
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
PREFERRED_GH_PATH,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
|
|
@ -9,24 +18,16 @@ import {
|
|||
type PluginModule,
|
||||
type RuntimeHandle,
|
||||
type Session,
|
||||
type WorkspaceHooksConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { stat, access } from "node:fs/promises";
|
||||
import { stat, access, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { constants } from "node:fs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function normalizePermissionMode(mode: string | undefined): "permissionless" | "default" | "auto-edit" | "suggest" | undefined {
|
||||
if (!mode) return undefined;
|
||||
if (mode === "skip") return "permissionless";
|
||||
if (mode === "permissionless" || mode === "default" || mode === "auto-edit" || mode === "suggest") {
|
||||
return mode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Aider Activity Detection Helpers
|
||||
// =============================================================================
|
||||
|
|
@ -61,6 +62,35 @@ async function getChatHistoryMtime(workspacePath: string): Promise<Date | null>
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session Info Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract a summary from Aider's chat history file.
|
||||
* Reads the first user message (lines starting with "#### " in the markdown format)
|
||||
* and truncates to 120 characters.
|
||||
*/
|
||||
async function extractAiderSummary(workspacePath: string): Promise<string | null> {
|
||||
try {
|
||||
const chatFile = join(workspacePath, ".aider.chat.history.md");
|
||||
const content = await readFile(chatFile, "utf-8");
|
||||
|
||||
// Aider chat history uses "#### " prefix for user messages
|
||||
for (const line of content.split("\n")) {
|
||||
if (line.startsWith("#### ")) {
|
||||
const msg = line.slice(5).trim();
|
||||
if (msg.length > 0) {
|
||||
return msg.length > 120 ? msg.substring(0, 120) + "..." : msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or read error
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -85,7 +115,7 @@ function createAiderAgent(): Agent {
|
|||
getLaunchCommand(config: AgentLaunchConfig): string {
|
||||
const parts: string[] = ["aider"];
|
||||
|
||||
const permissionMode = normalizePermissionMode(config.permissions);
|
||||
const permissionMode = normalizeAgentPermissionMode(config.permissions);
|
||||
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
|
||||
parts.push("--yes");
|
||||
}
|
||||
|
|
@ -114,12 +144,33 @@ function createAiderAgent(): Agent {
|
|||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
// Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands.
|
||||
env["PATH"] = buildAgentPath(process.env["PATH"]);
|
||||
env["GH_PATH"] = PREFERRED_GH_PATH;
|
||||
|
||||
return env;
|
||||
},
|
||||
|
||||
detectActivity(terminalOutput: string): ActivityState {
|
||||
if (!terminalOutput.trim()) return "idle";
|
||||
// Aider doesn't have rich terminal output patterns yet
|
||||
|
||||
const lines = terminalOutput.trim().split("\n");
|
||||
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
||||
|
||||
// Aider's input prompt — agent is idle, waiting for user command
|
||||
if (/^[>$#]\s*$/.test(lastLine)) return "idle";
|
||||
// Aider-specific prompt patterns
|
||||
if (/^aider>\s*$/.test(lastLine)) return "idle";
|
||||
|
||||
// Check the last few lines for permission/confirmation prompts
|
||||
const tail = lines.slice(-5).join("\n");
|
||||
if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input";
|
||||
if (/Allow creation of/i.test(tail)) return "waiting_input";
|
||||
if (/Add .+ to the chat\?/i.test(tail)) return "waiting_input";
|
||||
if (/\[Yes\].*\[No\]/i.test(tail)) return "waiting_input";
|
||||
if (/proceed\?/i.test(tail)) return "waiting_input";
|
||||
|
||||
return "active";
|
||||
},
|
||||
|
||||
|
|
@ -138,23 +189,39 @@ function createAiderAgent(): Agent {
|
|||
// Process is running - check for activity signals
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
// Check for recent git commits (Aider auto-commits changes)
|
||||
// 1. Check AO activity JSONL first (written by recordActivity from terminal output).
|
||||
// This is the only source of waiting_input/blocked states for Aider.
|
||||
const activityResult = await readLastActivityEntry(session.workspacePath);
|
||||
const activityState = checkActivityLogState(activityResult);
|
||||
if (activityState) return activityState;
|
||||
|
||||
// 2. Fallback: check for recent git commits (Aider auto-commits changes)
|
||||
const hasCommits = await hasRecentCommits(session.workspacePath);
|
||||
if (hasCommits) return { state: "active" };
|
||||
|
||||
// Check chat history file modification time
|
||||
// 3. Fallback: check chat history file modification time
|
||||
const chatMtime = await getChatHistoryMtime(session.workspacePath);
|
||||
if (!chatMtime) {
|
||||
// No chat history — cannot determine activity
|
||||
return null;
|
||||
if (chatMtime) {
|
||||
const ageMs = Date.now() - chatMtime.getTime();
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp: chatMtime };
|
||||
if (ageMs <= threshold) return { state: "ready", timestamp: chatMtime };
|
||||
return { state: "idle", timestamp: chatMtime };
|
||||
}
|
||||
|
||||
// Classify by age: <30s active, <threshold ready, >threshold idle
|
||||
const ageMs = Date.now() - chatMtime.getTime();
|
||||
const activeWindowMs = Math.min(30_000, threshold);
|
||||
if (ageMs < activeWindowMs) return { state: "active", timestamp: chatMtime };
|
||||
if (ageMs < threshold) return { state: "ready", timestamp: chatMtime };
|
||||
return { state: "idle", timestamp: chatMtime };
|
||||
// 4. Fallback: use JSONL entry with age-based decay when chat history is unavailable.
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
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) =>
|
||||
this.detectActivity(output),
|
||||
);
|
||||
},
|
||||
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
|
||||
|
|
@ -208,10 +275,33 @@ function createAiderAgent(): Agent {
|
|||
}
|
||||
},
|
||||
|
||||
async getSessionInfo(_session: Session): Promise<AgentSessionInfo | null> {
|
||||
// Aider doesn't have JSONL session files for introspection yet
|
||||
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
const summary = await extractAiderSummary(session.workspacePath);
|
||||
if (!summary) return null;
|
||||
|
||||
return {
|
||||
summary,
|
||||
summaryIsFallback: true,
|
||||
agentSessionId: null,
|
||||
// Aider doesn't expose token/cost data
|
||||
};
|
||||
},
|
||||
|
||||
// Aider doesn't support session resume — return null so caller falls back to getLaunchCommand
|
||||
async getRestoreCommand(): Promise<string | null> {
|
||||
return null;
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -267,10 +267,17 @@ describe("Claude Code Activity Detection", () => {
|
|||
});
|
||||
|
||||
it("custom threshold applies to active types too", async () => {
|
||||
// 2 minutes old
|
||||
// 2 minutes old — past active window (30s), within 300s threshold → ready
|
||||
writeJsonl([{ type: "user" }], 120_000);
|
||||
|
||||
expect((await agent.getActivityState(makeSession(), 60_000))?.state).toBe("idle");
|
||||
expect((await agent.getActivityState(makeSession(), 300_000))?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("active types within 30s window return active", async () => {
|
||||
// 10 seconds old — within active window → active
|
||||
writeJsonl([{ type: "user" }], 10_000);
|
||||
|
||||
expect((await agent.getActivityState(makeSession(), 300_000))?.state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import {
|
||||
shellEscape,
|
||||
readLastJsonlEntry,
|
||||
normalizeAgentPermissionMode,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
|
|
@ -23,15 +25,6 @@ import { promisify } from "node:util";
|
|||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function normalizePermissionMode(mode: string | undefined): "permissionless" | "default" | "auto-edit" | "suggest" | undefined {
|
||||
if (!mode) return undefined;
|
||||
if (mode === "skip") return "permissionless";
|
||||
if (mode === "permissionless" || mode === "default" || mode === "auto-edit" || mode === "suggest") {
|
||||
return mode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Metadata Updater Hook Script
|
||||
// =============================================================================
|
||||
|
|
@ -661,7 +654,7 @@ function createClaudeCodeAgent(): Agent {
|
|||
// This command must be safe for both shell and execFile contexts.
|
||||
const parts: string[] = ["claude"];
|
||||
|
||||
const permissionMode = normalizePermissionMode(config.permissions);
|
||||
const permissionMode = normalizeAgentPermissionMode(config.permissions);
|
||||
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
|
||||
parts.push("--dangerously-skip-permissions");
|
||||
}
|
||||
|
|
@ -753,11 +746,13 @@ function createClaudeCodeAgent(): Agent {
|
|||
const ageMs = Date.now() - entry.modifiedAt.getTime();
|
||||
const timestamp = entry.modifiedAt;
|
||||
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
switch (entry.lastType) {
|
||||
case "user":
|
||||
case "tool_use":
|
||||
case "progress":
|
||||
return { state: ageMs > threshold ? "idle" : "active", timestamp };
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
||||
case "assistant":
|
||||
case "system":
|
||||
|
|
@ -772,7 +767,8 @@ function createClaudeCodeAgent(): Agent {
|
|||
return { state: "blocked", timestamp };
|
||||
|
||||
default:
|
||||
return { state: ageMs > threshold ? "idle" : "active", timestamp };
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -821,7 +817,7 @@ function createClaudeCodeAgent(): Agent {
|
|||
// Build resume command
|
||||
const parts: string[] = ["claude", "--resume", shellEscape(sessionUuid)];
|
||||
|
||||
const permissionMode = normalizePermissionMode(project.agentConfig?.permissions);
|
||||
const permissionMode = normalizeAgentPermissionMode(project.agentConfig?.permissions);
|
||||
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
|
||||
parts.push("--dangerously-skip-permissions");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const {
|
|||
mockOpen,
|
||||
mockCreateReadStream,
|
||||
mockHomedir,
|
||||
mockReadLastJsonlEntry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecFileAsync: vi.fn(),
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -28,6 +29,7 @@ const {
|
|||
mockOpen: vi.fn(),
|
||||
mockCreateReadStream: vi.fn(),
|
||||
mockHomedir: vi.fn(() => "/mock/home"),
|
||||
mockReadLastJsonlEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
|
|
@ -61,6 +63,14 @@ vi.mock("node:os", () => ({
|
|||
homedir: mockHomedir,
|
||||
}));
|
||||
|
||||
vi.mock("@composio/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
readLastJsonlEntry: mockReadLastJsonlEntry,
|
||||
};
|
||||
});
|
||||
|
||||
import { Readable } from "node:stream";
|
||||
import { create, manifest, default as defaultExport, resolveCodexBinary, _resetSessionFileCache } from "./index.js";
|
||||
|
||||
|
|
@ -649,8 +659,12 @@ describe("getActivityState", () => {
|
|||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
// mtime = now (just modified)
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
// Mock readLastJsonlEntry to return a recent entry
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "tool_call",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
|
|
@ -663,9 +677,13 @@ describe("getActivityState", () => {
|
|||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
// mtime = 10 minutes ago (past the 5-minute threshold)
|
||||
const staleTime = Date.now() - 600_000;
|
||||
mockStat.mockResolvedValue({ mtimeMs: staleTime, mtime: new Date(staleTime) });
|
||||
// Mock readLastJsonlEntry to return a stale entry
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "assistant_message",
|
||||
modifiedAt: new Date(staleTime),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
|
|
@ -673,6 +691,54 @@ describe("getActivityState", () => {
|
|||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns waiting_input for approval_request entry type", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "approval_request",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns blocked for error entry type", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "error",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns ready for assistant_message entry type", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.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" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns exited when process handle has dead PID", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw new Error("ESRCH");
|
||||
|
|
@ -1394,7 +1460,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Second call for AGENTS.md — file doesn't exist
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.1");
|
||||
return Promise.resolve("0.2.0");
|
||||
}
|
||||
// AGENTS.md read attempt
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
|
|
@ -1405,19 +1471,13 @@ describe("setupWorkspaceHooks", () => {
|
|||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Should still write the metadata helper (always written)
|
||||
const helperWriteCall = mockWriteFile.mock.calls.find(
|
||||
// Should NOT write any wrappers when version matches (helper, gh, git all skipped)
|
||||
const wrapperWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."),
|
||||
typeof call[0] === "string" &&
|
||||
(call[0].includes("ao-metadata-helper.sh.tmp.") || call[0].includes("/gh.tmp.") || call[0].includes("/git.tmp.")),
|
||||
);
|
||||
expect(helperWriteCall).toBeDefined();
|
||||
|
||||
// But should NOT write gh/git wrappers (version matches)
|
||||
const ghWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
|
||||
);
|
||||
expect(ghWriteCall).toBeUndefined();
|
||||
expect(wrapperWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes version marker after installing wrappers", async () => {
|
||||
|
|
@ -1434,7 +1494,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
typeof call[0] === "string" && call[0].includes(".ao-version.tmp."),
|
||||
);
|
||||
expect(versionWriteCall).toBeDefined();
|
||||
expect(versionWriteCall![1]).toBe("0.1.1");
|
||||
expect(versionWriteCall![1]).toBe("0.2.0");
|
||||
|
||||
const versionRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith(".ao-version"),
|
||||
|
|
@ -1442,15 +1502,11 @@ describe("setupWorkspaceHooks", () => {
|
|||
expect(versionRenameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("appends ao section to AGENTS.md when not present", async () => {
|
||||
it("writes ao session context to .ao/AGENTS.md", async () => {
|
||||
// Version marker matches (skip wrapper install)
|
||||
// AGENTS.md exists without ao section
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.1");
|
||||
}
|
||||
if (typeof path === "string" && path.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve("# Existing Content\n\nSome stuff here.\n");
|
||||
return Promise.resolve("0.2.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
|
@ -1461,29 +1517,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
});
|
||||
|
||||
const agentsMdCall = mockWriteFile.mock.calls.find(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdCall).toBeDefined();
|
||||
expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session");
|
||||
expect(agentsMdCall![1]).toContain("# Existing Content");
|
||||
});
|
||||
|
||||
it("creates AGENTS.md if it does not exist", async () => {
|
||||
// Version marker matches, AGENTS.md doesn't exist
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.1");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const agentsMdCall = mockWriteFile.mock.calls.find(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdCall).toBeDefined();
|
||||
expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session");
|
||||
|
|
@ -1516,13 +1550,10 @@ describe("setupWorkspaceHooks", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("does not duplicate ao section in AGENTS.md if already present", async () => {
|
||||
it("writes .ao/AGENTS.md without modifying repo-tracked AGENTS.md", async () => {
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.1");
|
||||
}
|
||||
if (typeof path === "string" && path.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve("# Existing\n\n## Agent Orchestrator (ao) Session\n\nAlready here.\n");
|
||||
return Promise.resolve("0.2.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
|
@ -1532,11 +1563,12 @@ describe("setupWorkspaceHooks", () => {
|
|||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const agentsMdCall = mockWriteFile.mock.calls.find(
|
||||
// Should write to .ao/AGENTS.md, NOT to workspace root AGENTS.md
|
||||
const allWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
|
||||
);
|
||||
// Should NOT write AGENTS.md since the section already exists
|
||||
expect(agentsMdCall).toBeUndefined();
|
||||
expect(allWrites).toHaveLength(1);
|
||||
expect(allWrites[0]![0]).toContain(".ao/AGENTS.md");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import {
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
shellEscape,
|
||||
readLastJsonlEntry,
|
||||
normalizeAgentPermissionMode,
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
PREFERRED_GH_PATH,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
|
|
@ -15,51 +25,14 @@ import {
|
|||
} from "@composio/ao-core";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { writeFile, mkdir, readFile, readdir, rename, stat, lstat, open } from "node:fs/promises";
|
||||
import { readdir, stat, lstat, open } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { promisify } from "node:util";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function normalizePermissionMode(mode: string | undefined): "permissionless" | "default" | "auto-edit" | "suggest" | undefined {
|
||||
if (!mode) return undefined;
|
||||
if (mode === "skip") return "permissionless";
|
||||
if (mode === "permissionless" || mode === "default" || mode === "auto-edit" || mode === "suggest") {
|
||||
return mode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Shared bin directory for ao shell wrappers (prepended to PATH) */
|
||||
const AO_BIN_DIR = join(homedir(), ".ao", "bin");
|
||||
const DEFAULT_PATH = "/usr/bin:/bin";
|
||||
const PREFERRED_GH_BIN_DIR = "/usr/local/bin";
|
||||
const PREFERRED_GH_PATH = `${PREFERRED_GH_BIN_DIR}/gh`;
|
||||
|
||||
function buildAgentPath(basePath: string | undefined): string {
|
||||
const inherited = (basePath ?? DEFAULT_PATH).split(":").filter(Boolean);
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (entry: string): void => {
|
||||
if (!entry || seen.has(entry)) return;
|
||||
ordered.push(entry);
|
||||
seen.add(entry);
|
||||
};
|
||||
|
||||
// Ensure wrappers are always first, then prioritize /usr/local/bin so
|
||||
// wrapper-discovered `gh` resolves there before linuxbrew paths.
|
||||
add(AO_BIN_DIR);
|
||||
add(PREFERRED_GH_BIN_DIR);
|
||||
|
||||
for (const entry of inherited) add(entry);
|
||||
|
||||
return ordered.join(":");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -73,255 +46,9 @@ export const manifest = {
|
|||
};
|
||||
|
||||
// =============================================================================
|
||||
// Shell Wrappers (automatic metadata updates — like Claude Code's PostToolUse)
|
||||
// Workspace Setup (delegates to shared PATH-wrapper hooks from @composio/ao-core)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Helper script sourced by both gh and git wrappers.
|
||||
* Provides update_ao_metadata() for writing key=value to the session file.
|
||||
*/
|
||||
/* eslint-disable no-useless-escape -- \$ escapes are intentional: bash scripts in JS template literals */
|
||||
const AO_METADATA_HELPER = `#!/usr/bin/env bash
|
||||
# ao-metadata-helper — shared by gh/git wrappers
|
||||
# Provides: update_ao_metadata <key> <value>
|
||||
|
||||
update_ao_metadata() {
|
||||
local key="\$1" value="\$2"
|
||||
local ao_dir="\${AO_DATA_DIR:-}"
|
||||
local ao_session="\${AO_SESSION:-}"
|
||||
|
||||
[[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 0
|
||||
|
||||
# Validate: session name must not contain path separators or traversal
|
||||
case "\$ao_session" in
|
||||
*/* | *..*) return 0 ;;
|
||||
esac
|
||||
|
||||
# Validate: ao_dir must be an absolute path under known ao directories or /tmp
|
||||
case "\$ao_dir" in
|
||||
"\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
|
||||
local metadata_file="\$ao_dir/\$ao_session"
|
||||
|
||||
# Resolve and verify the file is still within ao_dir
|
||||
local real_dir real_ao_dir
|
||||
real_ao_dir="\$(cd "\$ao_dir" 2>/dev/null && pwd -P)" || return 0
|
||||
real_dir="\$(cd "\$(dirname "\$metadata_file")" 2>/dev/null && pwd -P)" || return 0
|
||||
[[ "\$real_dir" == "\$real_ao_dir"* ]] || return 0
|
||||
|
||||
[[ -f "\$metadata_file" ]] || return 0
|
||||
|
||||
local temp_file="\${metadata_file}.tmp.\$\$"
|
||||
|
||||
# Strip newlines from value to prevent metadata line injection
|
||||
local clean_value="\$(printf '%s' "\$value" | tr -d '\\n')"
|
||||
|
||||
# Escape sed metacharacters in value (& expands to matched text, | breaks delimiter)
|
||||
local escaped_value="\$(printf '%s' "\$clean_value" | sed 's/[&|\\\\]/\\\\&/g')"
|
||||
|
||||
if grep -q "^\${key}=" "\$metadata_file" 2>/dev/null; then
|
||||
sed "s|^\${key}=.*|\${key}=\${escaped_value}|" "\$metadata_file" > "\$temp_file"
|
||||
else
|
||||
cp "\$metadata_file" "\$temp_file"
|
||||
printf '%s=%s\\n' "\$key" "\$clean_value" >> "\$temp_file"
|
||||
fi
|
||||
|
||||
mv "\$temp_file" "\$metadata_file"
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* gh wrapper — intercepts `gh pr create` and `gh pr merge` to auto-update
|
||||
* session metadata. All other commands pass through transparently.
|
||||
*/
|
||||
const GH_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao gh wrapper — auto-updates session metadata on PR operations
|
||||
|
||||
# Find real gh by removing our wrapper directory from PATH
|
||||
ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
|
||||
clean_path="\${clean_path%:}"
|
||||
real_gh=""
|
||||
|
||||
# Prefer explicit gh path when provided by AO environment.
|
||||
# Guard against recursive self-reference to the wrapper in ~/.ao/bin.
|
||||
if [[ -n "\${GH_PATH:-}" && -x "\$GH_PATH" ]]; then
|
||||
gh_dir="\$(cd "\$(dirname "\$GH_PATH")" 2>/dev/null && pwd)"
|
||||
if [[ "\$gh_dir" != "\$ao_bin_dir" ]]; then
|
||||
real_gh="\$GH_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "\$real_gh" ]]; then
|
||||
real_gh="\$(PATH="\$clean_path" command -v gh 2>/dev/null)"
|
||||
fi
|
||||
|
||||
if [[ -z "\$real_gh" ]]; then
|
||||
echo "ao-wrapper: gh not found in PATH" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Only capture output for commands we need to parse (pr/create, pr/merge).
|
||||
# All other commands pass through transparently without stream merging.
|
||||
case "\$1/\$2" in
|
||||
pr/create|pr/merge)
|
||||
tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$tmpout"' EXIT
|
||||
|
||||
"\$real_gh" "\$@" 2>&1 | tee "\$tmpout"
|
||||
exit_code=\${PIPESTATUS[0]}
|
||||
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
output="\$(cat "\$tmpout")"
|
||||
case "\$1/\$2" in
|
||||
pr/create)
|
||||
pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
|
||||
if [[ -n "\$pr_url" ]]; then
|
||||
update_ao_metadata pr "\$pr_url"
|
||||
update_ao_metadata status pr_open
|
||||
fi
|
||||
;;
|
||||
pr/merge)
|
||||
update_ao_metadata status merged
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
;;
|
||||
*)
|
||||
exec "\$real_gh" "\$@"
|
||||
;;
|
||||
esac
|
||||
`;
|
||||
|
||||
/**
|
||||
* git wrapper — intercepts branch creation commands to auto-update metadata.
|
||||
* All other commands pass through transparently.
|
||||
*/
|
||||
const GIT_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao git wrapper — auto-updates session metadata on branch operations
|
||||
|
||||
# Find real git by removing our wrapper directory from PATH
|
||||
ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
|
||||
clean_path="\${clean_path%:}"
|
||||
real_git="\$(PATH="\$clean_path" command -v git 2>/dev/null)"
|
||||
|
||||
if [[ -z "\$real_git" ]]; then
|
||||
echo "ao-wrapper: git not found in PATH" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Run real git
|
||||
"\$real_git" "\$@"
|
||||
exit_code=\$?
|
||||
|
||||
# Only update metadata on success
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
case "\$1/\$2" in
|
||||
checkout/-b)
|
||||
update_ao_metadata branch "\$3"
|
||||
;;
|
||||
switch/-c)
|
||||
update_ao_metadata branch "\$3"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Workspace Setup
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Section appended to AGENTS.md as a secondary signal. The PATH-based wrappers
|
||||
* handle metadata updates automatically, but AGENTS.md reinforces the intent
|
||||
* and helps if the wrappers are bypassed.
|
||||
*/
|
||||
const AO_AGENTS_MD_SECTION = `
|
||||
## Agent Orchestrator (ao) Session
|
||||
|
||||
You are running inside an Agent Orchestrator managed workspace.
|
||||
Session metadata is updated automatically via shell wrappers.
|
||||
|
||||
If automatic updates fail, you can manually update metadata:
|
||||
\`\`\`bash
|
||||
~/.ao/bin/ao-metadata-helper.sh # sourced automatically
|
||||
# Then call: update_ao_metadata <key> <value>
|
||||
\`\`\`
|
||||
`;
|
||||
/* eslint-enable no-useless-escape */
|
||||
|
||||
/**
|
||||
* Atomically write a file by writing to a temp file in the same directory,
|
||||
* then renaming. This prevents concurrent sessions from reading partially
|
||||
* written wrapper scripts.
|
||||
*/
|
||||
async function atomicWriteFile(filePath: string, content: string, mode: number): Promise<void> {
|
||||
const suffix = randomBytes(6).toString("hex");
|
||||
const tmpPath = `${filePath}.tmp.${suffix}`;
|
||||
await writeFile(tmpPath, content, { encoding: "utf-8", mode });
|
||||
await rename(tmpPath, filePath);
|
||||
}
|
||||
|
||||
async function setupCodexWorkspace(workspacePath: string): Promise<void> {
|
||||
// 1. Write shared wrappers to ~/.ao/bin/
|
||||
await mkdir(AO_BIN_DIR, { recursive: true });
|
||||
|
||||
await atomicWriteFile(
|
||||
join(AO_BIN_DIR, "ao-metadata-helper.sh"),
|
||||
AO_METADATA_HELPER,
|
||||
0o755,
|
||||
);
|
||||
|
||||
// Only write wrappers if they don't exist or are outdated (check marker)
|
||||
const markerPath = join(AO_BIN_DIR, ".ao-version");
|
||||
const currentVersion = "0.1.1";
|
||||
let needsUpdate = true;
|
||||
try {
|
||||
const existing = await readFile(markerPath, "utf-8");
|
||||
if (existing.trim() === currentVersion) needsUpdate = false;
|
||||
} catch {
|
||||
// File doesn't exist — needs update
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
// If we crash between wrapper writes and marker write, the next
|
||||
// invocation will redo the writes (safe: wrappers are idempotent).
|
||||
await atomicWriteFile(join(AO_BIN_DIR, "gh"), GH_WRAPPER, 0o755);
|
||||
await atomicWriteFile(join(AO_BIN_DIR, "git"), GIT_WRAPPER, 0o755);
|
||||
await atomicWriteFile(markerPath, currentVersion, 0o644);
|
||||
}
|
||||
|
||||
// 2. Append ao section to AGENTS.md (create if missing, skip if already present)
|
||||
const agentsMdPath = join(workspacePath, "AGENTS.md");
|
||||
let existing = "";
|
||||
try {
|
||||
existing = await readFile(agentsMdPath, "utf-8");
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
|
||||
if (!existing.includes("Agent Orchestrator (ao) Session")) {
|
||||
const content = existing
|
||||
? existing.trimEnd() + "\n" + AO_AGENTS_MD_SECTION
|
||||
: AO_AGENTS_MD_SECTION.trimStart();
|
||||
await writeFile(agentsMdPath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Codex Session JSONL Parsing (for getSessionInfo)
|
||||
// =============================================================================
|
||||
|
|
@ -563,7 +290,7 @@ export async function resolveCodexBinary(): Promise<string> {
|
|||
|
||||
/** Append approval-policy flags to a command parts array */
|
||||
function appendApprovalFlags(parts: string[], permissions: string | undefined): void {
|
||||
const mode = normalizePermissionMode(permissions);
|
||||
const mode = normalizeAgentPermissionMode(permissions);
|
||||
if (mode === "permissionless") {
|
||||
parts.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
} else if (mode === "auto-edit") {
|
||||
|
|
@ -692,29 +419,83 @@ function createCodexAgent(): Agent {
|
|||
const running = await this.isProcessRunning(session.runtimeHandle);
|
||||
if (!running) return { state: "exited", timestamp: exitedAt };
|
||||
|
||||
// Use session file mtime as a proxy for activity. Codex continuously
|
||||
// appends to its rollout JSONL file while working, so a recently
|
||||
// modified file means the agent is active.
|
||||
if (!session.workspacePath) 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);
|
||||
if (!sessionFile) return null;
|
||||
if (sessionFile) {
|
||||
const entry = await readLastJsonlEntry(sessionFile);
|
||||
if (entry) {
|
||||
const ageMs = Date.now() - entry.modifiedAt.getTime();
|
||||
const timestamp = entry.modifiedAt;
|
||||
|
||||
try {
|
||||
const s = await stat(sessionFile);
|
||||
const timestamp = s.mtime;
|
||||
const ageMs = Date.now() - s.mtimeMs;
|
||||
// Map Codex JSONL entry types to activity states.
|
||||
// Confirmed types: session_meta, event_msg. Others are best-effort.
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
switch (entry.lastType) {
|
||||
case "user_input":
|
||||
case "tool_call":
|
||||
case "exec_command":
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
||||
if (ageMs <= threshold) {
|
||||
// File was recently modified — agent is actively working
|
||||
return { state: "active", timestamp };
|
||||
case "assistant_message":
|
||||
case "session_meta":
|
||||
case "event_msg":
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
||||
case "approval_request":
|
||||
return { state: "waiting_input", timestamp };
|
||||
|
||||
case "error":
|
||||
return { state: "blocked", timestamp };
|
||||
|
||||
default:
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
}
|
||||
}
|
||||
|
||||
// File is stale — agent finished or is idle
|
||||
return { state: "idle", timestamp };
|
||||
} catch {
|
||||
return null;
|
||||
// Session file exists but no parseable entry — fall through to AO JSONL
|
||||
// checks below instead of returning early, so waiting_input/blocked
|
||||
// from terminal parsing can still be detected.
|
||||
}
|
||||
|
||||
// 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 activityState = checkActivityLogState(activityResult);
|
||||
if (activityState) return activityState;
|
||||
|
||||
// 3. Fallback: use JSONL entry with age-based decay when native session file
|
||||
// is missing or unparseable.
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
|
||||
if (fallback) return fallback;
|
||||
|
||||
// 4. Last resort: native session file exists but nothing else — use its mtime
|
||||
if (sessionFile) {
|
||||
try {
|
||||
const s = await stat(sessionFile);
|
||||
const ageMs = Date.now() - s.mtimeMs;
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp: s.mtime };
|
||||
if (ageMs <= threshold) return { state: "ready", timestamp: s.mtime };
|
||||
return { state: "idle", timestamp: s.mtime };
|
||||
} catch {
|
||||
// stat failed — no signal available
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) =>
|
||||
this.detectActivity(output),
|
||||
);
|
||||
},
|
||||
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
|
||||
|
|
@ -829,7 +610,7 @@ function createCodexAgent(): Agent {
|
|||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupCodexWorkspace(workspacePath);
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
|
|
@ -846,7 +627,7 @@ function createCodexAgent(): Agent {
|
|||
}
|
||||
}
|
||||
if (!session.workspacePath) return;
|
||||
await setupCodexWorkspace(session.workspacePath);
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,25 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core";
|
||||
|
||||
const { mockAppendActivityEntry, mockReadLastActivityEntry, mockRecordTerminalActivity } =
|
||||
vi.hoisted(() => ({
|
||||
mockAppendActivityEntry: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadLastActivityEntry: vi.fn().mockResolvedValue(null),
|
||||
mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockExecFileAsync = vi.fn();
|
||||
|
||||
vi.mock("@composio/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
appendActivityEntry: mockAppendActivityEntry,
|
||||
readLastActivityEntry: mockReadLastActivityEntry,
|
||||
recordTerminalActivity: mockRecordTerminalActivity,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: (...args: unknown[]) => {
|
||||
const callback = args[args.length - 1];
|
||||
|
|
@ -102,7 +120,7 @@ describe("getLaunchCommand", () => {
|
|||
|
||||
it("generates base command without prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain('exec opencode --session "$SES_ID"');
|
||||
expect(cmd).toContain("opencode session list --format json");
|
||||
expect(cmd).toContain("AO:sess-1");
|
||||
|
|
@ -110,7 +128,7 @@ describe("getLaunchCommand", () => {
|
|||
|
||||
it("uses --prompt with shell-escaped prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix it" }));
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("exec opencode --session \"$SES_ID\" --prompt 'Fix it'");
|
||||
});
|
||||
|
||||
|
|
@ -124,7 +142,7 @@ describe("getLaunchCommand", () => {
|
|||
makeLaunchConfig({ prompt: "Go", model: "claude-sonnet-4-5-20250929" }),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' --command true",
|
||||
"opencode run --format json --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929'",
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"exec opencode --session \"$SES_ID\" --prompt 'Go' --model 'claude-sonnet-4-5-20250929'",
|
||||
|
|
@ -134,7 +152,7 @@ describe("getLaunchCommand", () => {
|
|||
|
||||
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" }));
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("exec opencode --session \"$SES_ID\" --prompt 'it'\\''s broken'");
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +172,7 @@ describe("getLaunchCommand", () => {
|
|||
makeLaunchConfig({ subagent: "sisyphus", prompt: "fix bug" }),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' --command true",
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'",
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"exec opencode --session \"$SES_ID\" --prompt 'fix bug' --agent 'sisyphus'",
|
||||
|
|
@ -171,7 +189,7 @@ describe("getLaunchCommand", () => {
|
|||
}),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' --model 'claude-sonnet-4-5-20250929' --command true",
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' --model 'claude-sonnet-4-5-20250929'",
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"exec opencode --session \"$SES_ID\" --prompt 'fix the bug' --agent 'sisyphus' --model 'claude-sonnet-4-5-20250929'",
|
||||
|
|
@ -214,7 +232,7 @@ describe("getLaunchCommand", () => {
|
|||
it("backward compatible: no agent flag when subagent not provided", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "fix it" }));
|
||||
expect(cmd).not.toContain("--agent");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("exec opencode --session \"$SES_ID\" --prompt 'fix it'");
|
||||
});
|
||||
|
||||
|
|
@ -224,7 +242,7 @@ describe("getLaunchCommand", () => {
|
|||
);
|
||||
expect(cmd).not.toContain("--agent");
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' --command true",
|
||||
"opencode run --format json --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929'",
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"exec opencode --session \"$SES_ID\" --prompt 'Go' --model 'claude-sonnet-4-5-20250929'",
|
||||
|
|
@ -236,7 +254,7 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPrompt: "You are an orchestrator" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("exec opencode --session \"$SES_ID\" --prompt 'You are an orchestrator'");
|
||||
});
|
||||
|
||||
|
|
@ -244,7 +262,7 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPrompt: "You are an orchestrator", prompt: "do the task" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain(
|
||||
`exec opencode --session "$SES_ID" --prompt 'You are an orchestrator
|
||||
|
||||
|
|
@ -260,13 +278,13 @@ do the task'`,
|
|||
it("handles very long systemPrompt", () => {
|
||||
const longPrompt = "A".repeat(500);
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: longPrompt }));
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("generates command with systemPromptFile via shell substitution", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md" }));
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain('exec opencode --session "$SES_ID" --prompt "$(cat \'/tmp/prompt.md\')"');
|
||||
});
|
||||
|
||||
|
|
@ -274,7 +292,7 @@ do the task'`,
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPromptFile: "/tmp/it's-prompt.md" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain(
|
||||
"exec opencode --session \"$SES_ID\" --prompt \"$(cat '/tmp/it'\\''s-prompt.md')\"",
|
||||
);
|
||||
|
|
@ -287,7 +305,7 @@ do the task'`,
|
|||
systemPromptFile: "/tmp/file-prompt.md",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain(
|
||||
'exec opencode --session "$SES_ID" --prompt "$(cat \'/tmp/file-prompt.md\')"',
|
||||
);
|
||||
|
|
@ -303,7 +321,7 @@ do the task'`,
|
|||
}),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' --command true",
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'",
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"exec opencode --session \"$SES_ID\" --prompt \"$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')\" --agent 'sisyphus'",
|
||||
|
|
@ -322,7 +340,7 @@ do the task'`,
|
|||
systemPromptFile: "/tmp/orchestrator.md",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:my-orchestrator' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:my-orchestrator'");
|
||||
expect(cmd).toContain(
|
||||
'exec opencode --session "$SES_ID" --prompt "$(cat \'/tmp/orchestrator.md\')"',
|
||||
);
|
||||
|
|
@ -337,7 +355,7 @@ do the task'`,
|
|||
}),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' --command true",
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'",
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"exec opencode --session \"$SES_ID\" --prompt \"$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')\" --agent 'sisyphus'",
|
||||
|
|
@ -387,7 +405,7 @@ do the task'`,
|
|||
|
||||
it("handles empty prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "" }));
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain('exec opencode --session "$SES_ID"');
|
||||
expect(cmd).toContain("opencode session list --format json");
|
||||
expect(cmd).toContain("AO:sess-1");
|
||||
|
|
@ -513,6 +531,27 @@ describe("detectActivity — terminal output classification", () => {
|
|||
expect(agent.detectActivity(" \n ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle when prompt char visible", () => {
|
||||
expect(agent.detectActivity("some output\n> ")).toBe("idle");
|
||||
expect(agent.detectActivity("some output\n$ ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns waiting_input for Y/N confirmation", () => {
|
||||
expect(agent.detectActivity("Apply changes?\n(Y)es/(N)o")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for approval required", () => {
|
||||
expect(agent.detectActivity("output\napproval required for this action")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for proceed prompt", () => {
|
||||
expect(agent.detectActivity("Do you want to proceed?")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for allow prompt", () => {
|
||||
expect(agent.detectActivity("Allow file creation?")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns active for non-empty terminal output", () => {
|
||||
expect(agent.detectActivity("opencode is working\n")).toBe("active");
|
||||
});
|
||||
|
|
@ -647,9 +686,150 @@ describe("getActivityState", () => {
|
|||
describe("getSessionInfo", () => {
|
||||
const agent = create();
|
||||
|
||||
it("always returns null (not implemented)", async () => {
|
||||
expect(await agent.getSessionInfo(makeSession())).toBeNull();
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull();
|
||||
function mockOpencodeSessionRows(rows: Array<Record<string, unknown>>) {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "opencode") {
|
||||
return Promise.resolve({ stdout: JSON.stringify(rows), stderr: "" });
|
||||
}
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
}
|
||||
|
||||
it("returns session info when matching session found by metadata ID", async () => {
|
||||
mockOpencodeSessionRows([
|
||||
{ id: "ses_abc123", title: "AO:test-1", updated: new Date().toISOString() },
|
||||
]);
|
||||
|
||||
const info = await agent.getSessionInfo(
|
||||
makeSession({ metadata: { opencodeSessionId: "ses_abc123" } }),
|
||||
);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.agentSessionId).toBe("ses_abc123");
|
||||
expect(info!.summary).toBe("AO:test-1");
|
||||
expect(info!.summaryIsFallback).toBe(true);
|
||||
});
|
||||
|
||||
it("returns session info when matching session found by title", async () => {
|
||||
mockOpencodeSessionRows([
|
||||
{ id: "ses_xyz789", title: "AO:test-1", updated: new Date().toISOString() },
|
||||
]);
|
||||
|
||||
const info = await agent.getSessionInfo(makeSession({ metadata: {} }));
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.agentSessionId).toBe("ses_xyz789");
|
||||
});
|
||||
|
||||
it("returns null when no matching session", async () => {
|
||||
mockOpencodeSessionRows([
|
||||
{ id: "ses_other", title: "AO:different", updated: new Date().toISOString() },
|
||||
]);
|
||||
|
||||
const info = await agent.getSessionInfo(makeSession({ metadata: {} }));
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when opencode command fails", async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error("opencode not found"));
|
||||
const info = await agent.getSessionInfo(makeSession());
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getRestoreCommand
|
||||
// =========================================================================
|
||||
describe("getRestoreCommand", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns restore command from metadata session ID", async () => {
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
makeSession({ metadata: { opencodeSessionId: "ses_abc123" } }),
|
||||
{ name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" },
|
||||
);
|
||||
expect(cmd).toBe("opencode --session 'ses_abc123'");
|
||||
});
|
||||
|
||||
it("includes model flag from project config", async () => {
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
makeSession({ metadata: { opencodeSessionId: "ses_abc123" } }),
|
||||
{
|
||||
name: "proj",
|
||||
repo: "o/r",
|
||||
path: "/p",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "p",
|
||||
agentConfig: { model: "claude-sonnet-4-5-20250929" },
|
||||
},
|
||||
);
|
||||
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
|
||||
});
|
||||
|
||||
it("returns null when no session ID found", async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error("opencode not found"));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
makeSession({ metadata: {} }),
|
||||
{ name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" },
|
||||
);
|
||||
expect(cmd).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to title-based lookup", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "opencode") {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify([{ id: "ses_found", title: "AO:test-1" }]),
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
makeSession({ metadata: {} }),
|
||||
{ name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" },
|
||||
);
|
||||
expect(cmd).toBe("opencode --session 'ses_found'");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setupWorkspaceHooks + postLaunchSetup
|
||||
// =========================================================================
|
||||
describe("setupWorkspaceHooks", () => {
|
||||
const agent = create();
|
||||
|
||||
it("is defined (delegates to shared setupPathWrapperWorkspace)", () => {
|
||||
expect(agent.setupWorkspaceHooks).toBeDefined();
|
||||
expect(typeof agent.setupWorkspaceHooks).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("postLaunchSetup", () => {
|
||||
const agent = create();
|
||||
|
||||
it("is defined", () => {
|
||||
expect(agent.postLaunchSetup).toBeDefined();
|
||||
});
|
||||
|
||||
it("does nothing when workspacePath is null", async () => {
|
||||
await agent.postLaunchSetup!(makeSession({ workspacePath: null }));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getEnvironment — PATH wrapping
|
||||
// =========================================================================
|
||||
describe("getEnvironment PATH", () => {
|
||||
const agent = create();
|
||||
|
||||
it("prepends ~/.ao/bin to PATH", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toMatch(/\.ao\/bin/);
|
||||
});
|
||||
|
||||
it("sets GH_PATH", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["GH_PATH"]).toBe("/usr/local/bin/gh");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -659,15 +839,17 @@ describe("session ID capture from JSON stream", () => {
|
|||
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
||||
|
||||
expect(cmd).toContain("session_id");
|
||||
expect(cmd).toContain("sessionID");
|
||||
expect(cmd).toContain("/^ses_[A-Za-z0-9_-]+$/");
|
||||
});
|
||||
|
||||
it("parses JSON lines and extracts session_id field", () => {
|
||||
it("parses JSON lines and extracts session_id or sessionID field", () => {
|
||||
const agent = create();
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
||||
|
||||
expect(cmd).toContain("JSON.parse(trimmed)");
|
||||
expect(cmd).toContain("obj.session_id");
|
||||
expect(cmd).toContain("obj.sessionID");
|
||||
});
|
||||
|
||||
it("handles buffer accumulation for partial lines", () => {
|
||||
|
|
@ -758,3 +940,162 @@ describe("invalid session ID rejection", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// recordActivity
|
||||
// =========================================================================
|
||||
describe("recordActivity", () => {
|
||||
const agent = create();
|
||||
|
||||
it("is defined", () => {
|
||||
expect(agent.recordActivity).toBeDefined();
|
||||
});
|
||||
|
||||
it("does nothing when workspacePath is null", async () => {
|
||||
await agent.recordActivity!(makeSession({ workspacePath: null }), "some output");
|
||||
expect(mockRecordTerminalActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates to recordTerminalActivity", async () => {
|
||||
await agent.recordActivity!(makeSession(), "opencode is working");
|
||||
expect(mockRecordTerminalActivity).toHaveBeenCalledWith(
|
||||
"/workspace/test",
|
||||
"opencode is working",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getActivityState — reads from activity JSONL
|
||||
// =========================================================================
|
||||
describe("getActivityState with activity JSONL", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns waiting_input from activity JSONL", async () => {
|
||||
mockTmuxWithProcess("opencode");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce({
|
||||
entry: { ts: new Date().toISOString(), state: "waiting_input", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns blocked from activity JSONL", async () => {
|
||||
mockTmuxWithProcess("opencode");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce({
|
||||
entry: { ts: new Date().toISOString(), state: "blocked", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("falls back to opencode API when activity JSONL is empty", async () => {
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce(null);
|
||||
// Mock opencode session list returning recent activity
|
||||
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 opencode\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
if (cmd === "opencode") {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify([
|
||||
{ id: "ses_abc123", title: "AO:test-1", updated: new Date(Date.now() - 5_000).toISOString() },
|
||||
]),
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle(), metadata: { opencodeSessionId: "ses_abc123" } }),
|
||||
60_000,
|
||||
);
|
||||
expect(result?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("falls back to JSONL entry state when session list fails", async () => {
|
||||
mockTmuxWithProcess("opencode");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce({
|
||||
entry: { ts: new Date().toISOString(), state: "active", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
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 opencode\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
if (cmd === "opencode") return Promise.resolve({ stdout: "[]", stderr: "" });
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
60_000,
|
||||
);
|
||||
expect(result?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("falls back to JSONL entry with age decay — old entry becomes idle", async () => {
|
||||
mockTmuxWithProcess("opencode");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce({
|
||||
entry: { ts: new Date(Date.now() - 120_000).toISOString(), state: "active", source: "terminal" },
|
||||
modifiedAt: new Date(Date.now() - 120_000),
|
||||
});
|
||||
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 opencode\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
if (cmd === "opencode") return Promise.resolve({ stdout: "[]", stderr: "" });
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
60_000,
|
||||
);
|
||||
expect(result?.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns null when both session list and JSONL are unavailable", async () => {
|
||||
mockTmuxWithProcess("opencode");
|
||||
mockReadLastActivityEntry.mockResolvedValueOnce(null);
|
||||
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 opencode\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
if (cmd === "opencode") return Promise.resolve({ stdout: "[]", stderr: "" });
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
const result = await agent.getActivityState(
|
||||
makeSession({ runtimeHandle: makeTmuxHandle() }),
|
||||
60_000,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import {
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
shellEscape,
|
||||
buildAgentPath,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
setupPathWrapperWorkspace,
|
||||
PREFERRED_GH_PATH,
|
||||
asValidOpenCodeSessionId,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
|
|
@ -8,8 +16,10 @@ import {
|
|||
type ActivityDetection,
|
||||
type ActivityState,
|
||||
type PluginModule,
|
||||
type ProjectConfig,
|
||||
type RuntimeHandle,
|
||||
type Session,
|
||||
type WorkspaceHooksConfig,
|
||||
type OpenCodeAgentConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
|
|
@ -81,8 +91,9 @@ process.stdin.on('data', chunk => {
|
|||
if (!trimmed) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
if (obj && typeof obj.session_id === 'string' && /^ses_[A-Za-z0-9_-]+$/.test(obj.session_id)) {
|
||||
captured = obj.session_id;
|
||||
const sid = (typeof obj.session_id === 'string' && obj.session_id) || (typeof obj.sessionID === 'string' && obj.sessionID);
|
||||
if (sid && /^ses_[A-Za-z0-9_-]+$/.test(sid)) {
|
||||
captured = sid;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
|
@ -90,8 +101,9 @@ process.stdin.on('data', chunk => {
|
|||
if (buffer.trim()) {
|
||||
try {
|
||||
const obj = JSON.parse(buffer.trim());
|
||||
if (obj && typeof obj.session_id === 'string' && /^ses_[A-Za-z0-9_-]+$/.test(obj.session_id)) {
|
||||
captured = obj.session_id;
|
||||
const sid = (typeof obj.session_id === 'string' && obj.session_id) || (typeof obj.sessionID === 'string' && obj.sessionID);
|
||||
if (sid && /^ses_[A-Za-z0-9_-]+$/.test(sid)) {
|
||||
captured = sid;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
|
@ -137,6 +149,47 @@ process.stdin.on('data', c => input += c).on('end', () => {
|
|||
return script.replace(/\n/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session List Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Query OpenCode's session list and find the matching session for this AO session.
|
||||
* Tries metadata `opencodeSessionId` first, then falls back to title matching.
|
||||
*/
|
||||
async function findOpenCodeSession(
|
||||
session: Session,
|
||||
): Promise<OpenCodeSessionListEntry | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"opencode",
|
||||
["session", "list", "--format", "json"],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const sessions = parseSessionList(stdout);
|
||||
|
||||
// Prefer exact ID match from metadata
|
||||
if (session.metadata?.opencodeSessionId) {
|
||||
const match = sessions.find((s) => s.id === session.metadata.opencodeSessionId);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
// Fallback: title match — pick the most recently updated session
|
||||
// to avoid binding to a stale session when titles collide.
|
||||
const titleMatches = sessions.filter((s) => s.title === `AO:${session.id}`);
|
||||
if (titleMatches.length === 0) return null;
|
||||
if (titleMatches.length === 1) return titleMatches[0]!;
|
||||
return titleMatches.reduce((best, s) => {
|
||||
const bestTs = parseUpdatedTimestamp(best.updated)?.getTime() ?? 0;
|
||||
const sTs = parseUpdatedTimestamp(s.updated)?.getTime() ?? 0;
|
||||
return sTs > bestTs ? s : best;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -233,12 +286,30 @@ function createOpenCodeAgent(): Agent {
|
|||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
// Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands.
|
||||
env["PATH"] = buildAgentPath(process.env["PATH"]);
|
||||
env["GH_PATH"] = PREFERRED_GH_PATH;
|
||||
|
||||
return env;
|
||||
},
|
||||
|
||||
detectActivity(terminalOutput: string): ActivityState {
|
||||
if (!terminalOutput.trim()) return "idle";
|
||||
// OpenCode doesn't have rich terminal output patterns yet
|
||||
|
||||
const lines = terminalOutput.trim().split("\n");
|
||||
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
||||
|
||||
// OpenCode's input prompt — agent is idle
|
||||
if (/^[>$#]\s*$/.test(lastLine)) return "idle";
|
||||
|
||||
// Check the last few lines for permission/confirmation prompts
|
||||
const tail = lines.slice(-5).join("\n");
|
||||
if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input";
|
||||
if (/approval required/i.test(tail)) return "waiting_input";
|
||||
if (/Do you want to proceed\?/i.test(tail)) return "waiting_input";
|
||||
if (/Allow .+\?/i.test(tail)) return "waiting_input";
|
||||
|
||||
return "active";
|
||||
},
|
||||
|
||||
|
|
@ -247,7 +318,7 @@ function createOpenCodeAgent(): Agent {
|
|||
readyThresholdMs?: number,
|
||||
): Promise<ActivityDetection | null> {
|
||||
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
|
||||
const activeWindowMs = Math.min(30_000, threshold);
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
|
||||
// Check if process is running first
|
||||
const exitedAt = new Date();
|
||||
|
|
@ -255,44 +326,46 @@ function createOpenCodeAgent(): Agent {
|
|||
const running = await this.isProcessRunning(session.runtimeHandle);
|
||||
if (!running) return { state: "exited", timestamp: exitedAt };
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"opencode",
|
||||
["session", "list", "--format", "json"],
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
const sessions = parseSessionList(stdout);
|
||||
const targetSession =
|
||||
(session.metadata?.opencodeSessionId
|
||||
? sessions.find((s) => s.id === session.metadata.opencodeSessionId)
|
||||
: undefined) ?? sessions.find((s) => s.title === `AO:${session.id}`);
|
||||
|
||||
if (targetSession) {
|
||||
const lastActivity = parseUpdatedTimestamp(targetSession.updated);
|
||||
|
||||
if (lastActivity) {
|
||||
const ageMs = Math.max(0, Date.now() - lastActivity.getTime());
|
||||
if (ageMs <= activeWindowMs) {
|
||||
return { state: "active", timestamp: lastActivity };
|
||||
}
|
||||
if (ageMs <= threshold) {
|
||||
return { state: "ready", timestamp: lastActivity };
|
||||
}
|
||||
return { state: "idle", timestamp: lastActivity };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
// 1. Check AO activity JSONL first (written by recordActivity from terminal output).
|
||||
// This is the only source of waiting_input/blocked states for OpenCode.
|
||||
let activityResult: Awaited<ReturnType<typeof readLastActivityEntry>> = null;
|
||||
if (session.workspacePath) {
|
||||
activityResult = await readLastActivityEntry(session.workspacePath);
|
||||
const activityState = checkActivityLogState(activityResult);
|
||||
if (activityState) return activityState;
|
||||
}
|
||||
|
||||
// 2. Fallback: query OpenCode's session list API for timestamp-based detection
|
||||
const targetSession = await findOpenCodeSession(session);
|
||||
if (targetSession) {
|
||||
const lastActivity = parseUpdatedTimestamp(targetSession.updated);
|
||||
|
||||
if (lastActivity) {
|
||||
const ageMs = Math.max(0, Date.now() - lastActivity.getTime());
|
||||
if (ageMs <= activeWindowMs) {
|
||||
return { state: "active", timestamp: lastActivity };
|
||||
}
|
||||
if (ageMs <= threshold) {
|
||||
return { state: "ready", timestamp: lastActivity };
|
||||
}
|
||||
return { state: "idle", timestamp: lastActivity };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: use JSONL entry with age-based decay when session list is unavailable.
|
||||
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) =>
|
||||
this.detectActivity(output),
|
||||
);
|
||||
},
|
||||
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
|
||||
try {
|
||||
if (handle.runtimeName === "tmux" && handle.id) {
|
||||
|
|
@ -344,9 +417,44 @@ function createOpenCodeAgent(): Agent {
|
|||
}
|
||||
},
|
||||
|
||||
async getSessionInfo(_session: Session): Promise<AgentSessionInfo | null> {
|
||||
// OpenCode doesn't have JSONL session files for introspection yet
|
||||
return null;
|
||||
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
|
||||
const targetSession = await findOpenCodeSession(session);
|
||||
if (!targetSession) return null;
|
||||
|
||||
return {
|
||||
summary: targetSession.title ?? null,
|
||||
summaryIsFallback: true,
|
||||
agentSessionId: targetSession.id,
|
||||
// OpenCode doesn't expose token/cost data in session list
|
||||
};
|
||||
},
|
||||
|
||||
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
|
||||
// Try metadata first, then query OpenCode's session list
|
||||
const sessionId =
|
||||
asValidOpenCodeSessionId(session.metadata?.opencodeSessionId) ??
|
||||
(await findOpenCodeSession(session))?.id ??
|
||||
null;
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
const parts: string[] = ["opencode", "--session", shellEscape(sessionId)];
|
||||
|
||||
const agentConfig = project.agentConfig as OpenCodeAgentConfig | undefined;
|
||||
if (agentConfig?.model) {
|
||||
parts.push("--model", shellEscape(agentConfig.model as string));
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue