diff --git a/.gitignore b/.gitignore index 1c10a6e3a..e5dfed62e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..709cc518a --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..e88a94874 --- /dev/null +++ b/CLAUDE.md @@ -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`: + +```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): 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; +``` + +### 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` (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 { + // 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 { + 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 diff --git a/packages/core/src/__tests__/activity-log.test.ts b/packages/core/src/__tests__/activity-log.test.ts new file mode 100644 index 000000000..858cfea74 --- /dev/null +++ b/packages/core/src/__tests__/activity-log.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/__tests__/agent-workspace-hooks.test.ts b/packages/core/src/__tests__/agent-workspace-hooks.test.ts new file mode 100644 index 000000000..a953b0d3a --- /dev/null +++ b/packages/core/src/__tests__/agent-workspace-hooks.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/activity-log.ts b/packages/core/src/activity-log.ts new file mode 100644 index 000000000..1334bfb2e --- /dev/null +++ b/packages/core/src/activity-log.ts @@ -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 { + 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; + 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 = { 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 { + 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); +} diff --git a/packages/core/src/agent-workspace-hooks.ts b/packages/core/src/agent-workspace-hooks.ts new file mode 100644 index 000000000..c1f0bb47c --- /dev/null +++ b/packages/core/src/agent-workspace-hooks.ts @@ -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(); + + 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 + +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 / git switch -c (new branch) + * - git checkout / git switch (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 +\`\`\` +`; +/* 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 { + 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 { + // 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"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b8f4f58b6..ef028390a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 6db22c905..b58d383a1 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -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", + 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) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2431511a0..a5c5f804a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; + + /** + * 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; } export interface AgentLaunchConfig { diff --git a/packages/plugins/agent-aider/src/index.test.ts b/packages/plugins/agent-aider/src/index.test.ts index a4f866342..a5e6526c9 100644 --- a/packages/plugins/agent-aider/src/index.test.ts +++ b/packages/plugins/agent-aider/src/index.test.ts @@ -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; + 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; + 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"); }); }); diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 91570ddec..5725e3591 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -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 } } +// ============================================================================= +// 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 { + 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 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 { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => + this.detectActivity(output), + ); }, async isProcessRunning(handle: RuntimeHandle): Promise { @@ -208,10 +275,33 @@ function createAiderAgent(): Agent { } }, - async getSessionInfo(_session: Session): Promise { - // Aider doesn't have JSONL session files for introspection yet + async getSessionInfo(session: Session): Promise { + 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 { return null; }, + + async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { + await setupPathWrapperWorkspace(workspacePath); + }, + + async postLaunchSetup(session: Session): Promise { + if (!session.workspacePath) return; + await setupPathWrapperWorkspace(session.workspacePath); + }, }; } diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index 3c6bbfa5b..b18563b6b 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -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"); }); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 7d58c9f9d..4f141415d 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -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"); } diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index eeed7de3e..fc4b83381 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -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; + 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"); }); }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index a5f50f619..cf72f46ce 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -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(); - - 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 - -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 -\`\`\` -`; -/* 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 { - 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 { - // 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 { /** 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 { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => + this.detectActivity(output), + ); }, async isProcessRunning(handle: RuntimeHandle): Promise { @@ -829,7 +610,7 @@ function createCodexAgent(): Agent { }, async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { - await setupCodexWorkspace(workspacePath); + await setupPathWrapperWorkspace(workspacePath); }, async postLaunchSetup(session: Session): Promise { @@ -846,7 +627,7 @@ function createCodexAgent(): Agent { } } if (!session.workspacePath) return; - await setupCodexWorkspace(session.workspacePath); + await setupPathWrapperWorkspace(session.workspacePath); }, }; } diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index 7bff24171..0dda9569e 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -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; + 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>) { + 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(); + }); +}); diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index ae048f181..e48ea7b3c 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -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 { + 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 { 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> = 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 { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => + this.detectActivity(output), + ); + }, + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { @@ -344,9 +417,44 @@ function createOpenCodeAgent(): Agent { } }, - async getSessionInfo(_session: Session): Promise { - // OpenCode doesn't have JSONL session files for introspection yet - return null; + async getSessionInfo(session: Session): Promise { + 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 { + // 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 { + await setupPathWrapperWorkspace(workspacePath); + }, + + async postLaunchSetup(session: Session): Promise { + if (!session.workspacePath) return; + await setupPathWrapperWorkspace(session.workspacePath); }, }; }