feat: OpenCode session lifecycle and CLI controls (#315)
* feat: refine OpenCode session reuse strategy and cleanup * fix: harden OpenCode session selection and lint errors * refactor: centralize OpenCode reuse resolution flow * fix: return 404 for missing session in message route * feat: replace force remap with terminal reload control * fix: protect project path from session kill cleanup * fix: preserve fullscreen alignment without reload action * fix: harden OpenCode session id handling and title reuse selection * fix: show OpenCode reload control and remap before restart * fix: preserve title-only OpenCode reuse with fallback mapping persistence * fix: resolve remaining PR315 Bugbot findings * fix: keep OpenCode discovery title-based without timestamp sorting * fix: avoid enrichment race fallout in session listing * fix: guard OpenCode discovery parse with array check * fix: stabilize Linear comment integration check * fix: harden OpenCode discovery and prompt option flow * ux: clarify OpenCode terminal restart action * fix: remap OpenCode session fresh on each restart * fix: validate remap session ids before reuse * fix: clean archived metadata only after purge * docs: align OpenCode remap selection with title-based behavior * fix: harden opencode cleanup and ignore local sisyphus state * test: add timeout cleanup coverage for session enrichment * fix: harden Linear integration helper against transient non-JSON errors * fix: make linear integration assertions resilient to eventual consistency * fix: remove unused fs import after rebase * fix: address remaining Bugbot blockers for opencode session handling * fix: avoid stale metadata overwrite during restore post-launch * fix: forward subagent in orchestrator flows and defer reuse lookup * fix: apply configured subagent fallback for session spawn * fix: scope archived cleanup by project and delay archive restore write * fix: normalize orchestrator strategy aliases in start display logic * fix: centralize orchestrator strategy normalization in core * fix: derive orchestrator reuse display from spawn result * fix: keep cleanup results consistent across project-id collisions * fix: namespace cleanup results when session IDs collide * fix: harden GitHub issue stateReason fallback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: avoid false failing CI state mapping Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: add tmux command timeouts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: bound session API enrichment latency Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: repair scm-github merge resolution Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: repair lifecycle-manager test merge Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: delay archive metadata recreation until restore passes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * test: restore claim-pr session mocks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: address review findings for OpenCode lifecycle PR - Fix stripControlChars to preserve newlines for reload commands - Add SessionNotFoundError and use instanceof checks in API routes - Document orchestratorSessionStrategy in YAML example - Validate existingSessionId with asValidOpenCodeSessionId() - Extract inline Node script to buildSessionLookupScript helper - Create OpenCodeSessionManager interface for remap capability - Create OpenCodeAgentConfig type for agent-specific config - Change default orchestratorSessionStrategy from delete to reuse Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: guard reused session display without metadata * chore: add agent config files to .gitignore Agent configuration files (CLAUDE.md, AGENTS.md, IMPROVEMENTS.md, etc.) are personal and project-specific. They should not be committed to the repository. Changes: - Remove CLAUDE.md from git tracking - Add agent config files to .gitignore - Create .gitignore-template for reference Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: update gitignore for agent config folder structure Reorganized agent configuration files: - CLAUDE.md and AGENTS.md stay in root (agents read them there) - Tracking files move to .opencode/ (IMPROVEMENTS.md, etc.) - Optional Claude files in .claude/ Updated .gitignore to ignore folders instead of individual files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: tighten opencode remap and session discovery safeguards * fix: address Bugbot findings in session manager * fix: scope opencode discovery to opencode sessions * fix: restore concurrent listing and strict permission literals * fix: throw SessionNotFoundError, parallelize list enrichment, fix permissions type - Session manager now throws SessionNotFoundError instead of plain Error for missing sessions, so web API routes correctly return 404 (not 500) - Parallelize session enrichment in list() — was sequential, causing O(N) latency for N sessions with subprocess enrichment - Fix AgentLaunchConfig.permissions type to accept legacy "skip" value (AgentPermissionInput instead of AgentPermissionMode) - Add happy-path and validation tests for /api/sessions/:id/message route - Update all test mocks to use SessionNotFoundError Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: route ao send through session manager * feat: add purge option to orchestrator stop * fix: register opencode agent in web services * test: align web API missing-session coverage * fix: match notifier config by plugin name * refactor: dedupe session lookup and tmux buffer send flow * test: update send lifecycle wait expectation * fix: harden send routing and cleanup purge controls --------- Co-authored-by: Harsh <harsh@Ubuntu-24-Forrest.lan> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Prateek <karnalprateek@gmail.com>
This commit is contained in:
parent
025603e11e
commit
4e2144d99e
|
|
@ -53,7 +53,14 @@ id_ed25519
|
|||
|
||||
# Development symlinks (created per-worktree, not committed)
|
||||
.claude
|
||||
.sisyphus
|
||||
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
|
||||
.claude/
|
||||
.opencode/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
# Agent configuration and tracking (personal, not for repo)
|
||||
# Add these to .gitignore for ALL projects with agent configs
|
||||
|
||||
.claude/
|
||||
.opencode/
|
||||
222
CLAUDE.md
222
CLAUDE.md
|
|
@ -1,222 +0,0 @@
|
|||
# CLAUDE.md — Agent Orchestrator
|
||||
|
||||
## What This Is
|
||||
|
||||
Open-source system for orchestrating parallel AI coding agents. Agent-agnostic (Claude Code, Codex, Aider), runtime-agnostic (tmux, docker, k8s), tracker-agnostic (GitHub, Linear, Jira). Manages session lifecycle, tracks PR/CI/review state, auto-handles routine issues (CI failures, review comments), pushes notifications to humans only when needed.
|
||||
|
||||
**Core principle: Push, not pull.** Spawn agents, walk away, get notified when your judgment is needed.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
TypeScript (ESM), Node 20+, pnpm workspaces. Next.js 15 (App Router) + Tailwind. Commander.js CLI. YAML + Zod config. Server-Sent Events for real-time. Flat metadata files + JSONL event log. ESLint + Prettier. vitest.
|
||||
|
||||
## Architecture
|
||||
|
||||
8 plugin slots — every abstraction is swappable:
|
||||
|
||||
| Slot | Interface | Default Plugin |
|
||||
| --------- | ----------- | -------------- |
|
||||
| Runtime | `Runtime` | tmux |
|
||||
| Agent | `Agent` | claude-code |
|
||||
| Workspace | `Workspace` | worktree |
|
||||
| Tracker | `Tracker` | github |
|
||||
| SCM | `SCM` | github |
|
||||
| Notifier | `Notifier` | desktop |
|
||||
| Terminal | `Terminal` | iterm2 |
|
||||
| Lifecycle | (core) | — |
|
||||
|
||||
**All interfaces defined in `packages/core/src/types.ts` — read this file first.**
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
packages/
|
||||
core/ — @composio/ao-core (types, config, services)
|
||||
cli/ — @composio/ao-cli (the `ao` command)
|
||||
web/ — @composio/ao-web (Next.js dashboard)
|
||||
plugins/
|
||||
runtime-{tmux,process}/
|
||||
agent-{claude-code,codex,aider,opencode}/
|
||||
workspace-{worktree,clone}/
|
||||
tracker-{github,linear}/
|
||||
scm-github/
|
||||
notifier-{desktop,slack,composio,webhook}/
|
||||
terminal-{iterm2,web}/
|
||||
```
|
||||
|
||||
## Key Files (Read These First)
|
||||
|
||||
1. `packages/core/src/types.ts` — all interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal)
|
||||
2. `agent-orchestrator.yaml.example` — config format
|
||||
3. Plugin examples:
|
||||
- `packages/plugins/runtime-tmux/src/index.ts` — Runtime implementation
|
||||
- `packages/plugins/agent-claude-code/src/index.ts` — Agent implementation
|
||||
4. This file (CLAUDE.md) — code conventions
|
||||
|
||||
## TypeScript Conventions (MUST follow)
|
||||
|
||||
- **ESM modules** — `"type": "module"` in all packages
|
||||
- **`.js` extensions in imports** — `import { foo } from "./bar.js"` (required for ESM)
|
||||
- **`node:` prefix for builtins** — `import { readFileSync } from "node:fs"`
|
||||
- **Strict mode** — `"strict": true` in tsconfig
|
||||
- **`type` imports** — `import type { Foo }` for type-only (enforced by ESLint)
|
||||
- **No `any`** — use `unknown` + type guards (ESLint error)
|
||||
- **No unsafe casts** — `as unknown as T` bypasses type safety, validate instead
|
||||
- **Prefer `const`** — `let` only when reassignment needed, never `var`
|
||||
- **Semicolons, double quotes, 2-space indent** — enforced by Prettier
|
||||
|
||||
## Plugin Pattern (MUST follow)
|
||||
|
||||
Every plugin exports a `PluginModule` with inline `satisfies` for compile-time type checking:
|
||||
|
||||
```typescript
|
||||
import type { PluginModule, Runtime } from "@composio/ao-core";
|
||||
|
||||
export const manifest = {
|
||||
name: "tmux",
|
||||
slot: "runtime" as const,
|
||||
description: "Runtime plugin: tmux sessions",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(): Runtime {
|
||||
return {
|
||||
name: "tmux",
|
||||
async create(config) {
|
||||
/* ... */
|
||||
},
|
||||
async destroy(handle) {
|
||||
/* ... */
|
||||
},
|
||||
// ... implement interface methods
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Runtime>;
|
||||
```
|
||||
|
||||
**Do NOT** use `const plugin = { ... }; export default plugin;` — always inline `satisfies`.
|
||||
|
||||
## Shell Command Execution (MUST follow — security critical)
|
||||
|
||||
- **Always use `execFile`** (or `spawn`) — NEVER `exec` (shell injection risk)
|
||||
- **Always add timeouts** — `{ timeout: 30_000 }` for external commands
|
||||
- **Never interpolate user input** — pass as array args, not string template
|
||||
- **Do NOT use `JSON.stringify` for shell escaping** — not a shell escaping function
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { timeout: 30_000 });
|
||||
|
||||
// BAD — shell injection risk
|
||||
exec(`git checkout ${branchName}`); // branchName could contain ; rm -rf /
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Throw typed errors, don't return error codes
|
||||
- Plugins throw if they can't do their job
|
||||
- Core services catch and handle plugin errors
|
||||
- **Always wrap `JSON.parse`** in try/catch (corrupted metadata should not crash)
|
||||
- **Guard external data** — validate types from API/CLI/file inputs
|
||||
|
||||
## Naming
|
||||
|
||||
- Files: `kebab-case.ts`
|
||||
- Types/Interfaces: `PascalCase`
|
||||
- Functions/variables: `camelCase`
|
||||
- Constants: `UPPER_SNAKE_CASE` (only true constants: env vars, regex patterns)
|
||||
- Test files: `*.test.ts` (co-located or in `__tests__/`)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install # install deps
|
||||
pnpm build # build all packages
|
||||
pnpm typecheck # typecheck
|
||||
pnpm lint # ESLint check
|
||||
pnpm lint:fix # ESLint auto-fix
|
||||
pnpm format # Prettier format
|
||||
pnpm format:check # Prettier check (CI)
|
||||
pnpm test # run tests
|
||||
|
||||
# Before committing
|
||||
pnpm lint && pnpm typecheck
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running the Dev Server
|
||||
|
||||
**IMPORTANT**: The web dashboard depends on built packages. Always build before running dev server.
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies (first time only)
|
||||
pnpm install
|
||||
|
||||
# 2. Build all packages (required before dev server)
|
||||
pnpm build
|
||||
|
||||
# 3. Ensure config exists
|
||||
# Copy agent-orchestrator.yaml.example to agent-orchestrator.yaml and configure
|
||||
cp agent-orchestrator.yaml.example agent-orchestrator.yaml
|
||||
|
||||
# 4. Run the dev server
|
||||
cd packages/web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
**Why build first?** The web package imports from `@composio/ao-core` and plugin packages. These must be built (TypeScript compiled to JavaScript) before Next.js can resolve them.
|
||||
|
||||
**Config requirement**: The app expects `agent-orchestrator.yaml` in the working directory. Without it, all API routes will fail with "No agent-orchestrator.yaml found".
|
||||
|
||||
### Working with Worktrees
|
||||
|
||||
If using git worktrees (common for parallel agent work):
|
||||
|
||||
```bash
|
||||
# After creating a worktree
|
||||
cd /path/to/worktree
|
||||
pnpm install # Install deps
|
||||
pnpm build # Build packages
|
||||
cp /path/to/main/agent-orchestrator.yaml . # Copy config
|
||||
cd packages/web && pnpm dev # Start server
|
||||
```
|
||||
|
||||
## Using Playwright (MCP browser tool)
|
||||
|
||||
Before navigating with Playwright, kill any stale Chrome for Testing instance first — otherwise it fails silently with "Opening in existing browser session":
|
||||
|
||||
```bash
|
||||
pkill -f "Google Chrome for Testing"
|
||||
```
|
||||
|
||||
Then use `browser_navigate` as normal. If Playwright was previously used in the session it may have left an orphaned Chrome process.
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
- Using `exec` instead of `execFile` — security vulnerability
|
||||
- Using `JSON.stringify` for shell escaping — does not escape `$`, backticks, `$()`
|
||||
- Missing `.js` extension in local imports — runtime error with ESM
|
||||
- Using bare `"fs"` instead of `"node:fs"` — inconsistent
|
||||
- Casting with `as unknown as T` — bypasses type safety, crashes on bad data
|
||||
- `export default plugin` without `satisfies PluginModule<T>` — loses type checking
|
||||
- Interpolating user input into shell commands, AppleScript, or GraphQL queries
|
||||
- Forgetting to clean up setInterval/setTimeout on disconnect/destroy
|
||||
- Using `on("exit")` instead of `once("exit")` for one-time handlers
|
||||
|
||||
## Config
|
||||
|
||||
Config loaded from `agent-orchestrator.yaml` (see `agent-orchestrator.yaml.example`). Paths support `~` expansion. Validated with Zod at load time. Per-project overrides for plugins and reactions.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Stateless orchestrator** — no database, flat metadata files + event log
|
||||
2. **Plugins implement interfaces** — pure implementation of interface from `types.ts`
|
||||
3. **Push notifications** — Notifier is primary human interface, not dashboard
|
||||
4. **Two-tier event handling** — auto-handle routine issues, notify human when judgment needed
|
||||
5. **Backwards-compatible metadata** — flat key=value files
|
||||
6. **Security first** — `execFile` not `exec`, validate all external input
|
||||
|
|
@ -64,6 +64,13 @@ projects:
|
|||
# orchestratorRules: |
|
||||
# Prefer to batch-spawn related issues together.
|
||||
|
||||
# OpenCode issue session strategy (only for agent: opencode)
|
||||
# opencodeIssueSessionStrategy: reuse # reuse | delete | ignore
|
||||
|
||||
# OpenCode orchestrator session strategy (only for agent: opencode)
|
||||
# Controls how the orchestrator's OpenCode session is managed
|
||||
# orchestratorSessionStrategy: reuse # reuse | delete | ignore
|
||||
|
||||
# Per-project reaction overrides
|
||||
# reactions:
|
||||
# approved-and-green:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
# OpenCode Workflow Spec (Agent Orchestrator)
|
||||
|
||||
This document defines intended behavior for Agent Orchestrator when `agent: opencode` is selected, including edge cases and expected outcomes.
|
||||
|
||||
## Scope
|
||||
|
||||
- CLI workflows: `ao start`, `ao spawn`, `ao status`, `ao send`, `ao session cleanup`, `ao session restore`, `ao session remap`.
|
||||
- Core lifecycle paths in `SessionManager` and plugin resolution.
|
||||
- OpenCode session mapping and deletion semantics.
|
||||
|
||||
## Configuration Contract
|
||||
|
||||
- `defaults.agent: opencode` or `projects.<id>.agent: opencode` selects the OpenCode agent plugin.
|
||||
- `projects.<id>.orchestratorSessionStrategy` controls orchestrator session behavior:
|
||||
- `reuse`: reuse existing alive orchestrator runtime; otherwise restart and reuse mapped OpenCode session id when available.
|
||||
- `delete`: destroy alive runtime, delete previously mapped/discovered OpenCode orchestrator sessions, then start fresh.
|
||||
- `ignore`: destroy alive runtime and start fresh without deleting prior OpenCode sessions.
|
||||
- `delete-new` and `kill-previous` normalize to `delete`.
|
||||
- `ignore-new` normalizes to `ignore`.
|
||||
- `projects.<id>.opencodeIssueSessionStrategy` controls issue-session reuse for `ao spawn` with OpenCode:
|
||||
- `reuse` (default): reuse mapped OpenCode session for same issue when available.
|
||||
- `delete`: delete mapped OpenCode sessions for same issue, then spawn fresh.
|
||||
- `ignore`: spawn fresh without deleting prior issue sessions.
|
||||
|
||||
## Workflow Behavior
|
||||
|
||||
## 1) Plugin Resolution
|
||||
|
||||
- CLI must resolve `opencode` via `getAgentByName` and `getAgent` without error.
|
||||
- Core plugin registry built-ins must include `@composio/ao-plugin-agent-opencode` under slot `agent`.
|
||||
- Expected failure mode: unknown agent names fail fast with `Unknown agent plugin: <name>`.
|
||||
|
||||
## 2) `ao start` (orchestrator session)
|
||||
|
||||
- Always delegates orchestrator session lifecycle to `SessionManager.spawnOrchestrator`.
|
||||
- For `orchestratorSessionStrategy: reuse`:
|
||||
- if existing runtime is alive, return existing session without creating a new runtime.
|
||||
- if existing runtime is dead and metadata contains `opencodeSessionId`, pass it to launch config for continuation.
|
||||
- For `delete` strategy:
|
||||
- delete mapped/discovered OpenCode orchestrator sessions (`AO:<prefix>-orchestrator`) before launching new orchestrator.
|
||||
- For `ignore` strategy:
|
||||
- do not delete old OpenCode sessions; launch fresh runtime.
|
||||
|
||||
## 3) `ao spawn`
|
||||
|
||||
- Uses selected agent (default/project/override) and launches OpenCode command from plugin launch config.
|
||||
- OpenCode launch behavior:
|
||||
- no mapped id: run with `--title AO:<session>` and then continue via discovered session id.
|
||||
- mapped id (`agentConfig.opencodeSessionId`): launch directly with `--session <id>`.
|
||||
- Model/subagent/system prompt inputs are forwarded into OpenCode launch command.
|
||||
|
||||
## 4) `ao send`
|
||||
|
||||
- Resolves agent by project config; when session metadata indicates OpenCode and mapping missing, core `send()` attempts title-based discovery and persists mapping.
|
||||
- Sends message through runtime plugin handle; fails if session/runtime cannot be resolved.
|
||||
- Busy detection is plugin-driven (`detectActivity`).
|
||||
|
||||
## 5) `ao status`
|
||||
|
||||
- Uses project/default configured agent for session enrichment and activity checks.
|
||||
- Must not fail solely because project default agent is `opencode`.
|
||||
- Fallback mode (no config) uses `claude-code` for best-effort tmux introspection only.
|
||||
|
||||
## 6) `ao session cleanup`
|
||||
|
||||
- Never cleans up orchestrator sessions (by explicit `role=orchestrator` or `-orchestrator` suffix).
|
||||
- For OpenCode sessions with mapped `opencodeSessionId`:
|
||||
- on cleanup kill path, delete corresponding OpenCode session first, then archive AO session metadata.
|
||||
- archived sessions with mapping are cleaned once; `opencodeCleanedAt` prevents repeated deletion attempts.
|
||||
- If OpenCode delete returns "session not found", treat as already cleaned.
|
||||
|
||||
## 7) `ao session restore`
|
||||
|
||||
- For OpenCode session restore, mapping is required.
|
||||
- If mapping missing:
|
||||
- attempt title discovery using longer interactive timeout.
|
||||
- if still missing, fail with non-restorable error (`OpenCode session mapping is missing`).
|
||||
- Restore must recreate runtime with preserved metadata/session fields and keep mapping persisted.
|
||||
|
||||
## 8) `ao session remap`
|
||||
|
||||
- Only valid for OpenCode sessions.
|
||||
- `remap(session, force=false)`:
|
||||
- reuse existing mapping if present; otherwise discover and persist.
|
||||
- `remap(session, force=true)`:
|
||||
- always re-discover by title and overwrite persisted mapping.
|
||||
- If discovery fails, return explicit mapping-missing error.
|
||||
|
||||
## Edge Cases and Expected Outcomes
|
||||
|
||||
- OpenCode binary missing: OpenCode-specific operations relying on `opencode session ...` discovery/deletion degrade gracefully where coded (discovery returns none), and explicit operations report mapping/deletion errors when required.
|
||||
- Corrupted `runtimeHandle` metadata: `send` fails with `Corrupted runtime handle`.
|
||||
- Existing orchestrator metadata present but runtime dead under `reuse`: restart runtime and pass mapped `opencodeSessionId` when available.
|
||||
- Duplicate OpenCode sessions with same AO title: title match drives selection for remap/discovery (no timestamp ranking).
|
||||
- Archived OpenCode sessions already cleaned: `cleanup` skips duplicate deletion via `opencodeCleanedAt`.
|
||||
- Unknown project/agent: fail fast with clear error.
|
||||
|
||||
## Revalidation Baseline (Current)
|
||||
|
||||
- Unit/integration validation that should remain green for OpenCode workflows:
|
||||
- `@composio/ao-plugin-agent-opencode` tests.
|
||||
- `@composio/ao-core` tests: `session-manager.test.ts`, `plugin-registry.test.ts`.
|
||||
- `@composio/ao-cli` tests: `plugins.test.ts`, `start.test.ts`, `session.test.ts`, `send.test.ts`, `status.test.ts`.
|
||||
- `@composio/ao-integration-tests` with `test:integration` (includes `agent-opencode.integration.test.ts`, conditionally skipped tests where prerequisites are unavailable).
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const { mockTmux, mockExec, mockDetectActivity } = vi.hoisted(() => ({
|
||||
mockTmux: vi.fn(),
|
||||
|
|
@ -6,6 +9,14 @@ const { mockTmux, mockExec, mockDetectActivity } = vi.hoisted(() => ({
|
|||
mockDetectActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockConfigRef, mockSessionManager } = vi.hoisted(() => ({
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
tmux: mockTmux,
|
||||
exec: mockExec,
|
||||
|
|
@ -33,10 +44,17 @@ vi.mock("../../src/lib/session-utils.js", () => ({
|
|||
|
||||
vi.mock("@composio/ao-core", () => ({
|
||||
loadConfig: () => {
|
||||
throw new Error("no config");
|
||||
if (!mockConfigRef.current) {
|
||||
throw new Error("no config");
|
||||
}
|
||||
return mockConfigRef.current;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => mockSessionManager,
|
||||
}));
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerSend } from "../../src/commands/send.js";
|
||||
|
||||
|
|
@ -58,6 +76,9 @@ beforeEach(() => {
|
|||
mockTmux.mockReset();
|
||||
mockExec.mockReset();
|
||||
mockDetectActivity.mockReset();
|
||||
mockSessionManager.get.mockReset();
|
||||
mockSessionManager.send.mockReset();
|
||||
mockConfigRef.current = null;
|
||||
mockExec.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
});
|
||||
|
||||
|
|
@ -245,4 +266,169 @@ describe("send command", () => {
|
|||
expect(mockExec).toHaveBeenCalledWith("tmux", ["send-keys", "-t", "my-session", "C-u"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session manager integration", () => {
|
||||
function makeConfig(): Record<string, unknown> {
|
||||
return {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
},
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
sessionPrefix: "app",
|
||||
path: "/tmp/my-app",
|
||||
defaultBranch: "main",
|
||||
repo: "org/my-app",
|
||||
agent: "claude-code",
|
||||
runtime: "tmux",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {},
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
|
||||
it("routes AO sessions through SessionManager.send", async () => {
|
||||
mockConfigRef.current = makeConfig();
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "idle",
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: { agent: "opencode" },
|
||||
});
|
||||
mockSessionManager.send.mockResolvedValue(undefined);
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "capture-pane") return "❯ ";
|
||||
return "";
|
||||
});
|
||||
mockDetectActivity.mockReturnValue("idle");
|
||||
|
||||
await program.parseAsync(["node", "test", "send", "app-1", "hello", "opencode"]);
|
||||
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "hello opencode");
|
||||
expect(mockExec).not.toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
expect.arrayContaining(["send-keys", "-l", "hello opencode"]),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message sent and processing"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips tmux busy detection when lifecycle send handles delivery", async () => {
|
||||
mockConfigRef.current = makeConfig();
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: { agent: "opencode" },
|
||||
});
|
||||
mockSessionManager.send.mockResolvedValue(undefined);
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "capture-pane") return "some output";
|
||||
return "";
|
||||
});
|
||||
mockDetectActivity.mockReturnValueOnce("active").mockReturnValueOnce("idle");
|
||||
|
||||
await program.parseAsync(["node", "test", "send", "app-1", "fix", "mapping"]);
|
||||
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "fix mapping");
|
||||
expect(consoleSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Waiting for app-1 to become idle"),
|
||||
);
|
||||
expect(mockTmux).not.toHaveBeenCalledWith(
|
||||
"capture-pane",
|
||||
"-t",
|
||||
"tmux-target-1",
|
||||
"-p",
|
||||
"-S",
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips tmux checks for non-tmux AO sessions and still uses lifecycle send", async () => {
|
||||
mockConfigRef.current = makeConfig();
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "proc-1", runtimeName: "process", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: { agent: "opencode" },
|
||||
});
|
||||
mockSessionManager.send.mockResolvedValue(undefined);
|
||||
|
||||
await program.parseAsync(["node", "test", "send", "app-1", "hello"]);
|
||||
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "hello");
|
||||
expect(mockTmux).not.toHaveBeenCalledWith("has-session", "-t", expect.any(String));
|
||||
});
|
||||
|
||||
it("passes file contents through SessionManager.send for AO sessions", async () => {
|
||||
mockConfigRef.current = makeConfig();
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "idle",
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: { agent: "opencode" },
|
||||
});
|
||||
mockSessionManager.send.mockResolvedValue(undefined);
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "capture-pane") return "❯ ";
|
||||
return "";
|
||||
});
|
||||
mockDetectActivity.mockReturnValue("idle");
|
||||
|
||||
const filePath = join(tmpdir(), `ao-send-message-${Date.now()}.txt`);
|
||||
writeFileSync(filePath, "from file");
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "test", "send", "app-1", "--file", filePath]);
|
||||
} finally {
|
||||
rmSync(filePath, { force: true });
|
||||
}
|
||||
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "from file");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,35 +8,65 @@ import {
|
|||
readdirSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type * as ChildProcessModule from "node:child_process";
|
||||
import {
|
||||
type Session,
|
||||
type CleanupResult,
|
||||
type SessionManager,
|
||||
SessionNotFoundError,
|
||||
getSessionsDir,
|
||||
getProjectBaseDir,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
const { mockTmux, mockGit, mockGh, mockExec, mockConfigRef, mockSessionManager, sessionsDirRef } =
|
||||
vi.hoisted(() => ({
|
||||
mockTmux: vi.fn(),
|
||||
mockGit: vi.fn(),
|
||||
mockGh: vi.fn(),
|
||||
mockExec: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
get: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
sessionsDirRef: { current: "" },
|
||||
}));
|
||||
const {
|
||||
mockTmux,
|
||||
mockGit,
|
||||
mockGh,
|
||||
mockExec,
|
||||
mockSpawn,
|
||||
mockConfigRef,
|
||||
mockSessionManager,
|
||||
sessionsDirRef,
|
||||
} = vi.hoisted(() => ({
|
||||
mockTmux: vi.fn(),
|
||||
mockGit: vi.fn(),
|
||||
mockGh: vi.fn(),
|
||||
mockExec: vi.fn(),
|
||||
mockSpawn: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
remap: vi.fn(),
|
||||
get: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
sessionsDirRef: { current: "" },
|
||||
}));
|
||||
|
||||
function makeMockChild(exitCode: number): EventEmitter {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
child.emit("exit", exitCode);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ChildProcessModule>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
tmux: mockTmux,
|
||||
|
|
@ -166,14 +196,19 @@ beforeEach(() => {
|
|||
mockGit.mockReset();
|
||||
mockGh.mockReset();
|
||||
mockExec.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
mockSessionManager.list.mockReset();
|
||||
mockSessionManager.kill.mockReset();
|
||||
mockSessionManager.cleanup.mockReset();
|
||||
mockSessionManager.restore.mockReset();
|
||||
mockSessionManager.remap.mockReset();
|
||||
mockSessionManager.get.mockReset();
|
||||
mockSessionManager.spawn.mockReset();
|
||||
mockSessionManager.send.mockReset();
|
||||
mockSessionManager.claimPR.mockReset();
|
||||
|
||||
mockSpawn.mockImplementation(() => makeMockChild(0));
|
||||
|
||||
// Default: list reads from sessionsDir
|
||||
mockSessionManager.list.mockImplementation(async () => {
|
||||
return buildSessionsFromDir(sessionsDirRef.current, "my-app");
|
||||
|
|
@ -188,6 +223,8 @@ beforeEach(() => {
|
|||
skipped: [],
|
||||
errors: [],
|
||||
} satisfies CleanupResult);
|
||||
mockSessionManager.restore.mockResolvedValue(undefined);
|
||||
mockSessionManager.remap.mockResolvedValue("ses_mock");
|
||||
mockSessionManager.claimPR.mockResolvedValue({
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
|
|
@ -298,7 +335,7 @@ describe("session ls", () => {
|
|||
|
||||
describe("session kill", () => {
|
||||
it("rejects unknown session (no matching project)", async () => {
|
||||
mockSessionManager.kill.mockRejectedValue(new Error("Session not found: unknown-1"));
|
||||
mockSessionManager.kill.mockRejectedValue(new SessionNotFoundError("unknown-1"));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "session", "kill", "unknown-1"]),
|
||||
|
|
@ -317,7 +354,7 @@ describe("session kill", () => {
|
|||
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("Session app-1 killed.");
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1");
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false });
|
||||
});
|
||||
|
||||
it("calls session manager kill with the session name", async () => {
|
||||
|
|
@ -327,7 +364,53 @@ describe("session kill", () => {
|
|||
|
||||
await program.parseAsync(["node", "test", "session", "kill", "app-1"]);
|
||||
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1");
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false });
|
||||
});
|
||||
|
||||
it("passes purge flag for OpenCode cleanup", async () => {
|
||||
mockSessionManager.kill.mockResolvedValue(undefined);
|
||||
|
||||
await program.parseAsync(["node", "test", "session", "kill", "app-1", "--purge-session"]);
|
||||
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("session attach", () => {
|
||||
it("attaches to resolved runtime target when session exists", async () => {
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
} satisfies Session);
|
||||
|
||||
mockTmux.mockResolvedValue("");
|
||||
|
||||
await program.parseAsync(["node", "test", "session", "attach", "app-1"]);
|
||||
|
||||
expect(mockTmux).toHaveBeenCalledWith("has-session", "-t", "tmux-target-1");
|
||||
expect(mockSpawn).toHaveBeenCalledWith("tmux", ["attach", "-t", "tmux-target-1"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when tmux session does not exist", async () => {
|
||||
mockSessionManager.get.mockResolvedValue(null);
|
||||
mockTmux.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "session", "attach", "unknown-1"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -483,3 +566,32 @@ describe("session cleanup", () => {
|
|||
expect(output).toContain("No sessions to clean up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session remap", () => {
|
||||
it("remaps OpenCode session and reports mapped id", async () => {
|
||||
mockSessionManager.remap.mockResolvedValue("ses_123");
|
||||
|
||||
await program.parseAsync(["node", "test", "session", "remap", "app-1"]);
|
||||
|
||||
expect(mockSessionManager.remap).toHaveBeenCalledWith("app-1", false);
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("Session app-1 remapped.");
|
||||
expect(output).toContain("OpenCode session: ses_123");
|
||||
});
|
||||
|
||||
it("passes force flag to remap", async () => {
|
||||
mockSessionManager.remap.mockResolvedValue("ses_123");
|
||||
|
||||
await program.parseAsync(["node", "test", "session", "remap", "app-1", "--force"]);
|
||||
|
||||
expect(mockSessionManager.remap).toHaveBeenCalledWith("app-1", true);
|
||||
});
|
||||
|
||||
it("fails with exit code when remap errors", async () => {
|
||||
mockSessionManager.remap.mockRejectedValue(new Error("mapping failed"));
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "session", "remap", "app-1"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,8 +67,17 @@ vi.mock("ora", () => ({
|
|||
vi.mock("@composio/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@composio/ao-core")>();
|
||||
const normalizeOrchestratorSessionStrategy =
|
||||
actual.normalizeOrchestratorSessionStrategy ??
|
||||
((strategy: string | undefined) => {
|
||||
if (strategy === "kill-previous" || strategy === "delete-new") return "delete";
|
||||
if (strategy === "ignore-new") return "ignore";
|
||||
return strategy ?? "reuse";
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
normalizeOrchestratorSessionStrategy,
|
||||
loadConfig: (path?: string) => {
|
||||
if (path) return actual.loadConfig(path);
|
||||
return mockConfigRef.current;
|
||||
|
|
@ -610,6 +619,83 @@ describe("start command — browser open waits for port", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("start command — orchestrator session strategy display", () => {
|
||||
function getLoggedOutput(): string {
|
||||
return vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
it("shows reused messaging when strategy is reuse and metadata marks the session reused", async () => {
|
||||
mockConfigRef.current = makeConfig({
|
||||
"my-app": makeProject({ orchestratorSessionStrategy: "reuse" }),
|
||||
});
|
||||
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-orchestrator",
|
||||
runtimeHandle: { id: "tmux-session-1" },
|
||||
});
|
||||
mockSessionManager.spawnOrchestrator.mockResolvedValue({
|
||||
id: "app-orchestrator",
|
||||
runtimeHandle: { id: "tmux-session-1" },
|
||||
metadata: { orchestratorSessionReused: "true" },
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "start", "--no-dashboard"]);
|
||||
|
||||
const output = getLoggedOutput();
|
||||
expect(output).toContain("reused existing session (app-orchestrator)");
|
||||
expect(output).not.toContain("tmux attach -t tmux-session-1");
|
||||
});
|
||||
|
||||
it("falls back to attach messaging when strategy is reuse but metadata is missing", async () => {
|
||||
mockConfigRef.current = makeConfig({
|
||||
"my-app": makeProject({ orchestratorSessionStrategy: "reuse" }),
|
||||
});
|
||||
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-orchestrator",
|
||||
runtimeHandle: { id: "tmux-session-1" },
|
||||
});
|
||||
mockSessionManager.spawnOrchestrator.mockResolvedValue({
|
||||
id: "app-orchestrator",
|
||||
runtimeHandle: { id: "tmux-session-1" },
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "start", "--no-dashboard"]);
|
||||
|
||||
const output = getLoggedOutput();
|
||||
expect(output).toContain("tmux attach -t tmux-session-1");
|
||||
expect(output).not.toContain("reused existing session");
|
||||
});
|
||||
|
||||
it.each(["delete", "ignore", "delete-new", "ignore-new", "kill-previous"] as const)(
|
||||
"uses attach messaging when strategy is %s",
|
||||
async (orchestratorSessionStrategy) => {
|
||||
mockConfigRef.current = makeConfig({
|
||||
"my-app": makeProject({ orchestratorSessionStrategy }),
|
||||
});
|
||||
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-orchestrator",
|
||||
runtimeHandle: { id: "tmux-session-1" },
|
||||
});
|
||||
mockSessionManager.spawnOrchestrator.mockResolvedValue({
|
||||
id: "app-orchestrator",
|
||||
runtimeHandle: { id: "tmux-session-1" },
|
||||
metadata: { orchestratorSessionReused: "true" },
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "start", "--no-dashboard"]);
|
||||
|
||||
const output = getLoggedOutput();
|
||||
expect(output).toContain("tmux attach -t tmux-session-1");
|
||||
expect(output).not.toContain("reused existing session");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ao stop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -623,7 +709,9 @@ describe("stop command", () => {
|
|||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator");
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", {
|
||||
purgeOpenCode: false,
|
||||
});
|
||||
expect(mockStopLifecycleWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configPath: expect.any(String) }),
|
||||
"my-app",
|
||||
|
|
@ -653,4 +741,16 @@ describe("stop command", () => {
|
|||
.join("\n");
|
||||
expect(output).toContain("is not running");
|
||||
});
|
||||
|
||||
it("passes purge flag when stopping orchestrator with --purge-session", async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" });
|
||||
mockSessionManager.kill.mockResolvedValue(undefined);
|
||||
|
||||
await program.parseAsync(["node", "test", "stop", "--purge-session"]);
|
||||
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", {
|
||||
purgeOpenCode: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ describe("getAgentByName", () => {
|
|||
expect(getAgentByName("aider").name).toBe("aider");
|
||||
});
|
||||
|
||||
it("returns agent for opencode", () => {
|
||||
expect(getAgentByName("opencode").name).toBe("opencode");
|
||||
});
|
||||
|
||||
it("throws on unknown name", () => {
|
||||
expect(() => getAgentByName("unknown")).toThrow("Unknown agent plugin: unknown");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"@composio/ao-plugin-agent-aider": "workspace:*",
|
||||
"@composio/ao-plugin-agent-claude-code": "workspace:*",
|
||||
"@composio/ao-plugin-agent-codex": "workspace:*",
|
||||
"@composio/ao-plugin-agent-opencode": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-composio": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-desktop": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-slack": "workspace:*",
|
||||
|
|
@ -56,6 +57,7 @@
|
|||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
|
|
|||
|
|
@ -147,10 +147,7 @@ export function registerInit(program: Command): void {
|
|||
.description("Interactive setup wizard — creates agent-orchestrator.yaml")
|
||||
.option("-o, --output <path>", "Output file path", "agent-orchestrator.yaml")
|
||||
.option("--auto", "Auto-generate config with sensible defaults (no prompts)")
|
||||
.option(
|
||||
"--smart",
|
||||
"Analyze project and generate custom rules (coming soon — requires --auto)",
|
||||
)
|
||||
.option("--smart", "Analyze project and generate custom rules (coming soon — requires --auto)")
|
||||
.action(async (opts: { output: string; auto?: boolean; smart?: boolean }) => {
|
||||
const outputPath = resolve(opts.output);
|
||||
|
||||
|
|
@ -260,7 +257,11 @@ export function registerInit(program: Command): void {
|
|||
// Default plugins
|
||||
console.log(chalk.bold("\n Default Plugins\n"));
|
||||
const runtime = await prompt(rl, "Runtime (tmux, process)", "tmux");
|
||||
const agent = await prompt(rl, "Agent (claude-code, codex, aider)", "claude-code");
|
||||
const agent = await prompt(
|
||||
rl,
|
||||
"Agent (claude-code, codex, aider, opencode)",
|
||||
"claude-code",
|
||||
);
|
||||
const workspace = await prompt(rl, "Workspace (worktree, clone)", "worktree");
|
||||
const notifiersStr = await prompt(
|
||||
rl,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { type Agent, type Session, loadConfig } from "@composio/ao-core";
|
||||
import {
|
||||
type Agent,
|
||||
type OpenCodeSessionManager,
|
||||
type Session,
|
||||
loadConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { exec, tmux } from "../lib/shell.js";
|
||||
import { getAgentByName } from "../lib/plugins.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
|
@ -12,9 +17,13 @@ import { getSessionManager } from "../lib/create-session-manager.js";
|
|||
* Resolve session context: tmux target name and Agent plugin.
|
||||
* Loads config and looks up the session once, avoiding duplicate work.
|
||||
*/
|
||||
async function resolveSessionContext(
|
||||
sessionName: string,
|
||||
): Promise<{ tmuxTarget: string; agent: Agent; session: Session | null }> {
|
||||
async function resolveSessionContext(sessionName: string): Promise<{
|
||||
tmuxTarget: string;
|
||||
runtimeName?: string;
|
||||
agent: Agent;
|
||||
session: Session | null;
|
||||
sessionManager: OpenCodeSessionManager | null;
|
||||
}> {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const sm = await getSessionManager(config);
|
||||
|
|
@ -22,13 +31,27 @@ async function resolveSessionContext(
|
|||
if (session) {
|
||||
const tmuxTarget = session.runtimeHandle?.id ?? sessionName;
|
||||
const project = config.projects[session.projectId];
|
||||
const agentName = project?.agent ?? config.defaults.agent;
|
||||
return { tmuxTarget, agent: getAgentByName(agentName), session };
|
||||
const agentName = session.metadata["agent"] ?? project?.agent ?? config.defaults.agent;
|
||||
const runtimeName =
|
||||
session.runtimeHandle?.runtimeName ?? project?.runtime ?? config.defaults.runtime;
|
||||
return {
|
||||
tmuxTarget,
|
||||
runtimeName,
|
||||
agent: getAgentByName(agentName),
|
||||
session,
|
||||
sessionManager: sm,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// No config or session not found — fall back to defaults
|
||||
}
|
||||
return { tmuxTarget: sessionName, agent: getAgentByName("claude-code"), session: null };
|
||||
return {
|
||||
tmuxTarget: sessionName,
|
||||
runtimeName: "tmux",
|
||||
agent: getAgentByName("claude-code"),
|
||||
session: null,
|
||||
sessionManager: null,
|
||||
};
|
||||
}
|
||||
|
||||
function isActive(agent: Agent, terminalOutput: string): boolean {
|
||||
|
|
@ -43,6 +66,50 @@ function sleep(ms: number): Promise<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function readMessageInput(opts: { file?: string }, messageParts: string[]): Promise<string> {
|
||||
const inlineMessage = messageParts.join(" ");
|
||||
if (!opts.file && !inlineMessage) {
|
||||
console.error(chalk.red("No message provided"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!opts.file) {
|
||||
return inlineMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
return readFileSync(opts.file, "utf-8");
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Cannot read file: ${opts.file} (${err})`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaTmux(tmuxTarget: string, message: string): Promise<void> {
|
||||
await exec("tmux", ["send-keys", "-t", tmuxTarget, "C-u"]);
|
||||
await sleep(200);
|
||||
|
||||
if (message.includes("\n") || message.length > 200) {
|
||||
const tmpFile = join(tmpdir(), `ao-send-${Date.now()}.txt`);
|
||||
writeFileSync(tmpFile, message);
|
||||
try {
|
||||
await exec("tmux", ["load-buffer", tmpFile]);
|
||||
await exec("tmux", ["paste-buffer", "-t", tmuxTarget]);
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// ignore cleanup failure
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await exec("tmux", ["send-keys", "-t", tmuxTarget, "-l", message]);
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
await exec("tmux", ["send-keys", "-t", tmuxTarget, "Enter"]);
|
||||
}
|
||||
|
||||
export function registerSend(program: Command): void {
|
||||
program
|
||||
.command("send")
|
||||
|
|
@ -59,32 +126,38 @@ export function registerSend(program: Command): void {
|
|||
opts: { file?: string; wait?: boolean; timeout?: string },
|
||||
) => {
|
||||
// Resolve session context once: tmux target, agent plugin, session data
|
||||
const { tmuxTarget, agent } = await resolveSessionContext(session);
|
||||
const {
|
||||
tmuxTarget,
|
||||
runtimeName,
|
||||
agent,
|
||||
session: existingSession,
|
||||
sessionManager,
|
||||
} = await resolveSessionContext(session);
|
||||
|
||||
const exists = await tmux("has-session", "-t", tmuxTarget);
|
||||
if (exists === null) {
|
||||
console.error(chalk.red(`Session '${session}' does not exist`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate message input before any side effects
|
||||
const msg = opts.file ? null : messageParts.join(" ");
|
||||
if (!opts.file && !msg) {
|
||||
console.error(chalk.red("No message provided"));
|
||||
process.exit(1);
|
||||
}
|
||||
const message = await readMessageInput(opts, messageParts);
|
||||
|
||||
const parsedTimeout = parseInt(opts.timeout || "600", 10);
|
||||
const timeoutMs = (isNaN(parsedTimeout) || parsedTimeout <= 0 ? 600 : parsedTimeout) * 1000;
|
||||
|
||||
const canUseTmux = runtimeName === "tmux";
|
||||
|
||||
if (!existingSession) {
|
||||
const exists = await tmux("has-session", "-t", tmuxTarget);
|
||||
if (exists === null) {
|
||||
console.error(chalk.red(`Session '${session}' does not exist`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to capture output from the resolved tmux target
|
||||
async function captureOutput(lines: number): Promise<string> {
|
||||
if (!canUseTmux) return "";
|
||||
const output = await tmux("capture-pane", "-t", tmuxTarget, "-p", "-S", String(-lines));
|
||||
return output || "";
|
||||
}
|
||||
|
||||
// Wait for idle
|
||||
if (opts.wait !== false) {
|
||||
const delegatesToSessionManager = Boolean(existingSession && sessionManager);
|
||||
if (opts.wait !== false && canUseTmux && !delegatesToSessionManager) {
|
||||
const start = Date.now();
|
||||
let warned = false;
|
||||
while (isActive(agent, await captureOutput(5))) {
|
||||
|
|
@ -100,52 +173,27 @@ export function registerSend(program: Command): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Clear partial input
|
||||
await exec("tmux", ["send-keys", "-t", tmuxTarget, "C-u"]);
|
||||
await sleep(200);
|
||||
|
||||
// Send the message
|
||||
if (opts.file) {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(opts.file, "utf-8");
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Cannot read file: ${opts.file} (${err})`));
|
||||
process.exit(1);
|
||||
}
|
||||
const tmpFile = join(tmpdir(), `ao-send-${Date.now()}.txt`);
|
||||
writeFileSync(tmpFile, content);
|
||||
try {
|
||||
await exec("tmux", ["load-buffer", tmpFile]);
|
||||
await exec("tmux", ["paste-buffer", "-t", tmuxTarget]);
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// ignore cleanup failure
|
||||
}
|
||||
}
|
||||
} else if (msg) {
|
||||
if (msg.includes("\n") || msg.length > 200) {
|
||||
const tmpFile = join(tmpdir(), `ao-send-${Date.now()}.txt`);
|
||||
writeFileSync(tmpFile, msg);
|
||||
try {
|
||||
await exec("tmux", ["load-buffer", tmpFile]);
|
||||
await exec("tmux", ["paste-buffer", "-t", tmuxTarget]);
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// ignore cleanup failure
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await exec("tmux", ["send-keys", "-t", tmuxTarget, "-l", msg]);
|
||||
}
|
||||
if (existingSession && !sessionManager) {
|
||||
console.error(chalk.red("AO-managed session found, but session manager is unavailable"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
await exec("tmux", ["send-keys", "-t", tmuxTarget, "Enter"]);
|
||||
if (!canUseTmux && !delegatesToSessionManager) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Session '${session}' is not tmux-backed and cannot be sent without lifecycle routing`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existingSession && sessionManager) {
|
||||
await sessionManager.send(session, message);
|
||||
console.log(chalk.green("Message sent and processing"));
|
||||
return;
|
||||
}
|
||||
|
||||
await sendViaTmux(tmuxTarget, message);
|
||||
|
||||
// Verify delivery with retries
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig, SessionNotRestorableError, WorkspaceMissingError } from "@composio/ao-core";
|
||||
import { git, getTmuxActivity } from "../lib/shell.js";
|
||||
import { git, getTmuxActivity, tmux } from "../lib/shell.js";
|
||||
import { formatAge } from "../lib/format.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
||||
|
|
@ -74,16 +75,49 @@ export function registerSession(program: Command): void {
|
|||
console.log();
|
||||
});
|
||||
|
||||
session
|
||||
.command("attach")
|
||||
.description("Attach to a session's tmux window")
|
||||
.argument("<session>", "Session name to attach")
|
||||
.action(async (sessionName: string) => {
|
||||
const config = loadConfig();
|
||||
const sm = await getSessionManager(config);
|
||||
const sessionInfo = await sm.get(sessionName);
|
||||
const tmuxTarget = sessionInfo?.runtimeHandle?.id ?? sessionName;
|
||||
|
||||
const exists = await tmux("has-session", "-t", tmuxTarget);
|
||||
if (exists === null) {
|
||||
console.error(chalk.red(`Session '${sessionName}' does not exist`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["attach", "-t", tmuxTarget], { stdio: "inherit" });
|
||||
child.once("error", (err) => reject(err));
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0 || code === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`tmux attach exited with code ${code}`));
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error(chalk.red(`Failed to attach to session ${sessionName}: ${err}`));
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
session
|
||||
.command("kill")
|
||||
.description("Kill a session and remove its worktree")
|
||||
.argument("<session>", "Session name to kill")
|
||||
.action(async (sessionName: string) => {
|
||||
.option("--purge-session", "Delete mapped OpenCode session during kill")
|
||||
.action(async (sessionName: string, opts: { purgeSession?: boolean }) => {
|
||||
const config = loadConfig();
|
||||
const sm = await getSessionManager(config);
|
||||
|
||||
try {
|
||||
await sm.kill(sessionName);
|
||||
await sm.kill(sessionName, { purgeOpenCode: opts.purgeSession === true });
|
||||
console.log(chalk.green(`\nSession ${sessionName} killed.`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to kill session ${sessionName}: ${err}`));
|
||||
|
|
@ -242,4 +276,23 @@ export function registerSession(program: Command): void {
|
|||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
session
|
||||
.command("remap")
|
||||
.description("Re-discover and persist OpenCode session mapping for an AO session")
|
||||
.argument("<session>", "Session name to remap")
|
||||
.option("-f, --force", "Force fresh remap by re-discovering the OpenCode session")
|
||||
.action(async (sessionName: string, opts: { force?: boolean }) => {
|
||||
const config = loadConfig();
|
||||
const sm = await getSessionManager(config);
|
||||
|
||||
try {
|
||||
const mapped = await sm.remap(sessionName, opts.force === true);
|
||||
console.log(chalk.green(`\nSession ${sessionName} remapped.`));
|
||||
console.log(chalk.dim(` OpenCode session: ${mapped}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to remap session ${sessionName}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
isRepoAlreadyCloned,
|
||||
generateConfigFromUrl,
|
||||
configToYaml,
|
||||
normalizeOrchestratorSessionStrategy,
|
||||
type OrchestratorConfig,
|
||||
type ProjectConfig,
|
||||
type ParsedRepoUrl,
|
||||
|
|
@ -31,7 +32,14 @@ import {
|
|||
import { exec, execSilent } from "../lib/shell.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { ensureLifecycleWorker, stopLifecycleWorker } from "../lib/lifecycle-service.js";
|
||||
import { findWebDir, buildDashboardEnv, waitForPortAndOpen, isPortAvailable, findFreePort, MAX_PORT_SCAN } from "../lib/web-dir.js";
|
||||
import {
|
||||
findWebDir,
|
||||
buildDashboardEnv,
|
||||
waitForPortAndOpen,
|
||||
isPortAvailable,
|
||||
findFreePort,
|
||||
MAX_PORT_SCAN,
|
||||
} from "../lib/web-dir.js";
|
||||
import { cleanNextCache } from "../lib/dashboard-rebuild.js";
|
||||
import { preflight } from "../lib/preflight.js";
|
||||
|
||||
|
|
@ -150,7 +158,9 @@ async function cloneRepo(parsed: ParsedRepoUrl, targetDir: string, cwd: string):
|
|||
* Also returns the parsed URL so the caller can match by repo when the config
|
||||
* contains multiple projects.
|
||||
*/
|
||||
async function handleUrlStart(url: string): Promise<{ config: OrchestratorConfig; parsed: ParsedRepoUrl; autoGenerated: boolean }> {
|
||||
async function handleUrlStart(
|
||||
url: string,
|
||||
): Promise<{ config: OrchestratorConfig; parsed: ParsedRepoUrl; autoGenerated: boolean }> {
|
||||
const spinner = ora();
|
||||
|
||||
// 1. Parse URL
|
||||
|
|
@ -253,12 +263,15 @@ async function runStartup(
|
|||
const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false;
|
||||
let lifecycleStatus: Awaited<ReturnType<typeof ensureLifecycleWorker>> | null = null;
|
||||
let port = config.port ?? DEFAULT_PORT;
|
||||
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
|
||||
project.orchestratorSessionStrategy,
|
||||
);
|
||||
|
||||
console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`));
|
||||
|
||||
const spinner = ora();
|
||||
let dashboardProcess: ChildProcess | null = null;
|
||||
let exists = false;
|
||||
let reused = false;
|
||||
|
||||
// Start dashboard (unless --no-dashboard)
|
||||
if (opts?.dashboard !== false) {
|
||||
|
|
@ -324,37 +337,27 @@ async function runStartup(
|
|||
let tmuxTarget = sessionId;
|
||||
if (opts?.orchestrator !== false) {
|
||||
const sm = await getSessionManager(config);
|
||||
const existing = await sm.get(sessionId);
|
||||
exists = existing !== null && existing.status !== "killed";
|
||||
|
||||
if (exists) {
|
||||
if (existing?.runtimeHandle?.id) {
|
||||
tmuxTarget = existing.runtimeHandle.id;
|
||||
try {
|
||||
spinner.start("Creating orchestrator session");
|
||||
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
|
||||
const session = await sm.spawnOrchestrator({ projectId, systemPrompt });
|
||||
if (session.runtimeHandle?.id) {
|
||||
tmuxTarget = session.runtimeHandle.id;
|
||||
}
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Orchestrator session "${sessionId}" is already running (skipping creation)`,
|
||||
),
|
||||
reused =
|
||||
orchestratorSessionStrategy === "reuse" &&
|
||||
session.metadata?.["orchestratorSessionReused"] === "true";
|
||||
spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created");
|
||||
} catch (err) {
|
||||
spinner.fail("Orchestrator setup failed");
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
spinner.start("Creating orchestrator session");
|
||||
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
|
||||
const session = await sm.spawnOrchestrator({ projectId, systemPrompt });
|
||||
if (session.runtimeHandle?.id) {
|
||||
tmuxTarget = session.runtimeHandle.id;
|
||||
}
|
||||
spinner.succeed("Orchestrator session created");
|
||||
} catch (err) {
|
||||
spinner.fail("Orchestrator setup failed");
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,10 +376,10 @@ async function runStartup(
|
|||
console.log(chalk.cyan("Lifecycle:"), lifecycleTarget);
|
||||
}
|
||||
|
||||
if (opts?.orchestrator !== false && !exists) {
|
||||
if (opts?.orchestrator !== false && !reused) {
|
||||
console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`);
|
||||
} else if (exists) {
|
||||
console.log(chalk.cyan("Orchestrator:"), `already running (${sessionId})`);
|
||||
} else if (reused) {
|
||||
console.log(chalk.cyan("Orchestrator:"), `reused existing session (${sessionId})`);
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Config: ${config.configPath}\n`));
|
||||
|
|
@ -492,7 +495,8 @@ export function registerStop(program: Command): void {
|
|||
program
|
||||
.command("stop [project]")
|
||||
.description("Stop orchestrator agent and dashboard for a project")
|
||||
.action(async (projectArg?: string) => {
|
||||
.option("--purge-session", "Also purge the mapped OpenCode session when stopping")
|
||||
.action(async (projectArg?: string, opts: { purgeSession?: boolean } = {}) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const { projectId: _projectId, project } = resolveProject(config, projectArg);
|
||||
|
|
@ -507,7 +511,7 @@ export function registerStop(program: Command): void {
|
|||
|
||||
if (existing) {
|
||||
const spinner = ora("Stopping orchestrator session").start();
|
||||
await sm.kill(sessionId);
|
||||
await sm.kill(sessionId, { purgeOpenCode: opts?.purgeSession === true });
|
||||
spinner.succeed("Orchestrator session stopped");
|
||||
} else {
|
||||
console.log(chalk.yellow(`Orchestrator session "${sessionId}" is not running`));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
createSessionManager,
|
||||
createLifecycleManager,
|
||||
type OrchestratorConfig,
|
||||
type SessionManager,
|
||||
type OpenCodeSessionManager,
|
||||
type PluginRegistry,
|
||||
type LifecycleManager,
|
||||
} from "@composio/ao-core";
|
||||
|
|
@ -42,7 +42,9 @@ async function getRegistry(config: OrchestratorConfig): Promise<PluginRegistry>
|
|||
* Create a SessionManager backed by core's implementation.
|
||||
* Initializes the plugin registry from config and wires everything up.
|
||||
*/
|
||||
export async function getSessionManager(config: OrchestratorConfig): Promise<SessionManager> {
|
||||
export async function getSessionManager(
|
||||
config: OrchestratorConfig,
|
||||
): Promise<OpenCodeSessionManager> {
|
||||
const registry = await getRegistry(config);
|
||||
return createSessionManager({ config, registry });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import type { Agent, OrchestratorConfig, SCM } from "@composio/ao-core";
|
|||
import claudeCodePlugin from "@composio/ao-plugin-agent-claude-code";
|
||||
import codexPlugin from "@composio/ao-plugin-agent-codex";
|
||||
import aiderPlugin from "@composio/ao-plugin-agent-aider";
|
||||
import opencodePlugin from "@composio/ao-plugin-agent-opencode";
|
||||
import githubSCMPlugin from "@composio/ao-plugin-scm-github";
|
||||
|
||||
const agentPlugins: Record<string, { create(): Agent }> = {
|
||||
"claude-code": claudeCodePlugin,
|
||||
codex: codexPlugin,
|
||||
aider: aiderPlugin,
|
||||
opencode: opencodePlugin,
|
||||
};
|
||||
|
||||
const scmPlugins: Record<string, { create(): SCM }> = {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.0.18"
|
||||
|
|
|
|||
|
|
@ -336,6 +336,26 @@ describe("Config Schema Validation", () => {
|
|||
expect(validated.projects.proj1.sessionPrefix).toBeDefined();
|
||||
expect(validated.projects.proj1.sessionPrefix).toBe("test"); // "test" is 4 chars, used as-is
|
||||
});
|
||||
|
||||
it("accepts orchestratorModel in agentConfig", () => {
|
||||
const config = {
|
||||
projects: {
|
||||
proj1: {
|
||||
path: "/repos/test",
|
||||
repo: "org/test",
|
||||
defaultBranch: "main",
|
||||
agentConfig: {
|
||||
model: "worker-model",
|
||||
orchestratorModel: "orchestrator-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validated = validateConfig(config);
|
||||
expect(validated.projects.proj1.agentConfig?.model).toBe("worker-model");
|
||||
expect(validated.projects.proj1.agentConfig?.orchestratorModel).toBe("orchestrator-model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Defaults", () => {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ beforeEach(() => {
|
|||
cleanup: vi.fn(),
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
claimPR: vi.fn(),
|
||||
};
|
||||
} as SessionManager;
|
||||
|
||||
config = {
|
||||
configPath,
|
||||
|
|
@ -970,10 +970,7 @@ describe("reactions", () => {
|
|||
await lm.check("app-1");
|
||||
|
||||
expect(mockSessionManager.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith(
|
||||
"app-1",
|
||||
"Handle requested changes.",
|
||||
);
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "Handle requested changes.");
|
||||
});
|
||||
|
||||
it("dispatches automated review comments only once for an unchanged backlog", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { asValidOpenCodeSessionId } from "../opencode-session-id.js";
|
||||
|
||||
describe("asValidOpenCodeSessionId", () => {
|
||||
it("accepts valid OpenCode session ids", () => {
|
||||
expect(asValidOpenCodeSessionId("ses_abc123")).toBe("ses_abc123");
|
||||
expect(asValidOpenCodeSessionId(" ses_ABC-123_xyz ")).toBe("ses_ABC-123_xyz");
|
||||
});
|
||||
|
||||
it("rejects invalid OpenCode session ids", () => {
|
||||
expect(asValidOpenCodeSessionId("")).toBeUndefined();
|
||||
expect(asValidOpenCodeSessionId("ses bad")).toBeUndefined();
|
||||
expect(asValidOpenCodeSessionId("abc123")).toBeUndefined();
|
||||
expect(asValidOpenCodeSessionId(123)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeOrchestratorSessionStrategy } from "../orchestrator-session-strategy.js";
|
||||
|
||||
describe("normalizeOrchestratorSessionStrategy", () => {
|
||||
it("defaults to reuse when strategy is unset", () => {
|
||||
expect(normalizeOrchestratorSessionStrategy(undefined)).toBe("reuse");
|
||||
});
|
||||
|
||||
it("returns canonical strategies unchanged", () => {
|
||||
expect(normalizeOrchestratorSessionStrategy("reuse")).toBe("reuse");
|
||||
expect(normalizeOrchestratorSessionStrategy("delete")).toBe("delete");
|
||||
expect(normalizeOrchestratorSessionStrategy("ignore")).toBe("ignore");
|
||||
});
|
||||
|
||||
it("maps legacy aliases to canonical values", () => {
|
||||
expect(normalizeOrchestratorSessionStrategy("kill-previous")).toBe("delete");
|
||||
expect(normalizeOrchestratorSessionStrategy("delete-new")).toBe("delete");
|
||||
expect(normalizeOrchestratorSessionStrategy("ignore-new")).toBe("ignore");
|
||||
});
|
||||
});
|
||||
|
|
@ -389,6 +389,30 @@ describe("plugin integration", () => {
|
|||
expect(result.skipped).toContain("app-1");
|
||||
expect(result.killed).not.toContain("app-1");
|
||||
});
|
||||
|
||||
it("list() clears enrichment timeout after fast enrichment", async () => {
|
||||
vi.useFakeTimers();
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config, registry });
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp/mock-ws/app-1",
|
||||
branch: "feat/issue-99",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sessions = await sm.list("my-app");
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
|
||||
clearTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -146,19 +146,75 @@ describe("loadBuiltins", () => {
|
|||
|
||||
const fakeClaudeCode = makePlugin("agent", "claude-code");
|
||||
const fakeCodex = makePlugin("agent", "codex");
|
||||
const fakeOpenCode = makePlugin("agent", "opencode");
|
||||
|
||||
await registry.loadBuiltins(undefined, async (pkg: string) => {
|
||||
if (pkg === "@composio/ao-plugin-agent-claude-code") return fakeClaudeCode;
|
||||
if (pkg === "@composio/ao-plugin-agent-codex") return fakeCodex;
|
||||
if (pkg === "@composio/ao-plugin-agent-opencode") return fakeOpenCode;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
const agents = registry.list("agent");
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" }));
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" }));
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "opencode", slot: "agent" }));
|
||||
|
||||
expect(registry.get("agent", "codex")).not.toBeNull();
|
||||
expect(registry.get("agent", "claude-code")).not.toBeNull();
|
||||
expect(registry.get("agent", "opencode")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("passes configured notifier plugin config to create()", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeWebhookNotifier = makePlugin("notifier", "webhook");
|
||||
const config = makeOrchestratorConfig({
|
||||
notifiers: {
|
||||
webhook: {
|
||||
plugin: "webhook",
|
||||
url: "http://127.0.0.1:8787/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(config, async (pkg: string) => {
|
||||
if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhookNotifier;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({
|
||||
plugin: "webhook",
|
||||
url: "http://127.0.0.1:8787/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("matches notifier config by plugin name instead of instance key", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeWebhookNotifier = makePlugin("notifier", "webhook");
|
||||
const config = makeOrchestratorConfig({
|
||||
notifiers: {
|
||||
"my-webhook": {
|
||||
plugin: "webhook",
|
||||
url: "http://127.0.0.1:8787/custom-hook",
|
||||
retries: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(config, async (pkg: string) => {
|
||||
if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhookNotifier;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({
|
||||
plugin: "webhook",
|
||||
url: "http://127.0.0.1:8787/custom-hook",
|
||||
retries: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -60,6 +60,8 @@ const AgentSpecificConfigSchema = z
|
|||
.object({
|
||||
permissions: AgentPermissionSchema,
|
||||
model: z.string().optional(),
|
||||
orchestratorModel: z.string().optional(),
|
||||
opencodeSessionId: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
|
@ -84,6 +86,10 @@ const ProjectConfigSchema = z.object({
|
|||
agentRules: z.string().optional(),
|
||||
agentRulesFile: z.string().optional(),
|
||||
orchestratorRules: z.string().optional(),
|
||||
orchestratorSessionStrategy: z
|
||||
.enum(["reuse", "delete", "ignore", "delete-new", "ignore-new", "kill-previous"])
|
||||
.optional(),
|
||||
opencodeIssueSessionStrategy: z.enum(["reuse", "delete", "ignore"]).optional(),
|
||||
});
|
||||
|
||||
const DefaultPluginsSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
|
|||
|
||||
// Shared utilities
|
||||
export { shellEscape, escapeAppleScript, validateUrl, readLastJsonlEntry } from "./utils.js";
|
||||
export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
|
||||
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
|
||||
// Path utilities — hash-based directory structure
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
|
|||
directTerminalWsPort: raw["directTerminalWsPort"]
|
||||
? Number(raw["directTerminalWsPort"])
|
||||
: undefined,
|
||||
opencodeSessionId: raw["opencodeSessionId"],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +169,7 @@ export function writeMetadata(
|
|||
data["terminalWsPort"] = String(metadata.terminalWsPort);
|
||||
if (metadata.directTerminalWsPort !== undefined)
|
||||
data["directTerminalWsPort"] = String(metadata.directTerminalWsPort);
|
||||
if (metadata.opencodeSessionId) data["opencodeSessionId"] = metadata.opencodeSessionId;
|
||||
|
||||
atomicWriteFileSync(path, serializeMetadata(data));
|
||||
}
|
||||
|
|
@ -258,6 +260,49 @@ export function readArchivedMetadataRaw(
|
|||
}
|
||||
}
|
||||
|
||||
export function updateArchivedMetadata(
|
||||
dataDir: string,
|
||||
sessionId: SessionId,
|
||||
updates: Partial<Record<string, string>>,
|
||||
): boolean {
|
||||
validateSessionId(sessionId);
|
||||
const archiveDir = join(dataDir, "archive");
|
||||
if (!existsSync(archiveDir)) return false;
|
||||
|
||||
const prefix = `${sessionId}_`;
|
||||
let latest: string | null = null;
|
||||
|
||||
for (const file of readdirSync(archiveDir)) {
|
||||
if (!file.startsWith(prefix)) continue;
|
||||
const charAfterPrefix = file[prefix.length];
|
||||
if (!charAfterPrefix || charAfterPrefix < "0" || charAfterPrefix > "9") continue;
|
||||
if (!latest || file > latest) latest = file;
|
||||
}
|
||||
|
||||
if (!latest) return false;
|
||||
|
||||
const archivePath = join(archiveDir, latest);
|
||||
let existing: Record<string, string>;
|
||||
try {
|
||||
existing = parseMetadataFile(readFileSync(archivePath, "utf-8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === "") {
|
||||
const { [key]: _, ...rest } = existing;
|
||||
existing = rest;
|
||||
} else {
|
||||
existing[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
atomicWriteFileSync(archivePath, serializeMetadata(existing));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all session IDs that have metadata files.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function asValidOpenCodeSessionId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import type { ProjectConfig } from "./types.js";
|
||||
|
||||
export type NormalizedOrchestratorSessionStrategy = "reuse" | "delete" | "ignore";
|
||||
|
||||
export function normalizeOrchestratorSessionStrategy(
|
||||
strategy: ProjectConfig["orchestratorSessionStrategy"] | undefined,
|
||||
): NormalizedOrchestratorSessionStrategy {
|
||||
if (strategy === "kill-previous" || strategy === "delete-new") return "delete";
|
||||
if (strategy === "ignore-new") return "ignore";
|
||||
return strategy ?? "reuse";
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> =
|
|||
{ slot: "agent", name: "claude-code", pkg: "@composio/ao-plugin-agent-claude-code" },
|
||||
{ slot: "agent", name: "codex", pkg: "@composio/ao-plugin-agent-codex" },
|
||||
{ slot: "agent", name: "aider", pkg: "@composio/ao-plugin-agent-aider" },
|
||||
{ slot: "agent", name: "opencode", pkg: "@composio/ao-plugin-agent-opencode" },
|
||||
// Workspaces
|
||||
{ slot: "workspace", name: "worktree", pkg: "@composio/ao-plugin-workspace-worktree" },
|
||||
{ slot: "workspace", name: "clone", pkg: "@composio/ao-plugin-workspace-clone" },
|
||||
|
|
@ -51,11 +52,22 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> =
|
|||
|
||||
/** Extract plugin-specific config from orchestrator config */
|
||||
function extractPluginConfig(
|
||||
_slot: PluginSlot,
|
||||
_name: string,
|
||||
_config: OrchestratorConfig,
|
||||
slot: PluginSlot,
|
||||
name: string,
|
||||
config: OrchestratorConfig,
|
||||
): Record<string, unknown> | undefined {
|
||||
// Reserved for future plugin-specific config mapping
|
||||
if (slot === "notifier") {
|
||||
for (const notifierConfig of Object.values(config.notifiers)) {
|
||||
if (
|
||||
notifierConfig &&
|
||||
typeof notifierConfig === "object" &&
|
||||
notifierConfig["plugin"] === name
|
||||
) {
|
||||
return notifierConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -178,6 +178,8 @@ export interface SessionSpawnConfig {
|
|||
prompt?: string;
|
||||
/** Override the agent plugin for this session (e.g. "codex", "claude-code") */
|
||||
agent?: string;
|
||||
/** Override the OpenCode subagent for this session (e.g. "sisyphus", "oracle") */
|
||||
subagent?: string;
|
||||
}
|
||||
|
||||
/** Config for creating an orchestrator session */
|
||||
|
|
@ -329,7 +331,7 @@ export interface AgentLaunchConfig {
|
|||
projectConfig: ProjectConfig;
|
||||
issueId?: string;
|
||||
prompt?: string;
|
||||
permissions?: AgentPermissionMode;
|
||||
permissions?: AgentPermissionInput;
|
||||
model?: string;
|
||||
/**
|
||||
* System prompt to pass to the agent for orchestrator context.
|
||||
|
|
@ -352,6 +354,12 @@ export interface AgentLaunchConfig {
|
|||
* - Codex/Aider: similar shell substitution
|
||||
*/
|
||||
systemPromptFile?: string;
|
||||
/**
|
||||
* Specialized OpenCode subagent to use (e.g., sisyphus, oracle, librarian).
|
||||
* Requires oh-my-opencode to be installed.
|
||||
* Use --subagent flag to select the subagent.
|
||||
*/
|
||||
subagent?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceHooksConfig {
|
||||
|
|
@ -903,6 +911,16 @@ export interface ProjectConfig {
|
|||
|
||||
/** Rules for the orchestrator agent (stored, reserved for future use) */
|
||||
orchestratorRules?: string;
|
||||
|
||||
orchestratorSessionStrategy?:
|
||||
| "reuse"
|
||||
| "delete"
|
||||
| "ignore"
|
||||
| "delete-new"
|
||||
| "ignore-new"
|
||||
| "kill-previous";
|
||||
|
||||
opencodeIssueSessionStrategy?: "reuse" | "delete" | "ignore";
|
||||
}
|
||||
|
||||
export interface TrackerConfig {
|
||||
|
|
@ -924,9 +942,14 @@ export interface NotifierConfig {
|
|||
export interface AgentSpecificConfig {
|
||||
permissions?: AgentPermissionMode;
|
||||
model?: string;
|
||||
orchestratorModel?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OpenCodeAgentConfig extends AgentSpecificConfig {
|
||||
opencodeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical cross-agent permission policy mode.
|
||||
*
|
||||
|
|
@ -952,7 +975,12 @@ export function normalizeAgentPermissionMode(
|
|||
mode: string | undefined,
|
||||
): AgentPermissionMode | undefined {
|
||||
if (!mode) return undefined;
|
||||
if (mode !== "permissionless" && mode !== "default" && mode !== "auto-edit" && mode !== "suggest") {
|
||||
if (
|
||||
mode !== "permissionless" &&
|
||||
mode !== "default" &&
|
||||
mode !== "auto-edit" &&
|
||||
mode !== "suggest"
|
||||
) {
|
||||
if (mode === "skip") return "permissionless";
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1024,6 +1052,7 @@ export interface SessionMetadata {
|
|||
dashboardPort?: number;
|
||||
terminalWsPort?: number;
|
||||
directTerminalWsPort?: number;
|
||||
opencodeSessionId?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -1037,12 +1066,21 @@ export interface SessionManager {
|
|||
restore(sessionId: SessionId): Promise<Session>;
|
||||
list(projectId?: string): Promise<Session[]>;
|
||||
get(sessionId: SessionId): Promise<Session | null>;
|
||||
kill(sessionId: SessionId): Promise<void>;
|
||||
cleanup(projectId?: string, options?: { dryRun?: boolean }): Promise<CleanupResult>;
|
||||
kill(sessionId: SessionId, options?: { purgeOpenCode?: boolean }): Promise<void>;
|
||||
cleanup(
|
||||
projectId?: string,
|
||||
options?: { dryRun?: boolean; purgeOpenCode?: boolean },
|
||||
): Promise<CleanupResult>;
|
||||
send(sessionId: SessionId, message: string): Promise<void>;
|
||||
claimPR(sessionId: SessionId, prRef: string, options?: ClaimPROptions): Promise<ClaimPRResult>;
|
||||
}
|
||||
|
||||
/** OpenCode-specific session manager with remap capability */
|
||||
export interface OpenCodeSessionManager extends SessionManager {
|
||||
/** Remap session to OpenCode session ID, returns the mapped OpenCode session ID */
|
||||
remap(sessionId: SessionId, force?: boolean): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ClaimPROptions {
|
||||
assignOnGithub?: boolean;
|
||||
takeover?: boolean;
|
||||
|
|
@ -1058,6 +1096,11 @@ export interface ClaimPRResult {
|
|||
takenOverFrom: SessionId[];
|
||||
}
|
||||
|
||||
/** Type guard to check if a SessionManager supports OpenCode-specific remap operation */
|
||||
export function isOpenCodeSessionManager(sm: SessionManager): sm is OpenCodeSessionManager {
|
||||
return typeof (sm as OpenCodeSessionManager).remap === "function";
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
killed: string[];
|
||||
skipped: string[];
|
||||
|
|
@ -1154,3 +1197,11 @@ export class WorkspaceMissingError extends Error {
|
|||
this.name = "WorkspaceMissingError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a session lookup fails (session does not exist). */
|
||||
export class SessionNotFoundError extends Error {
|
||||
constructor(public readonly sessionId: string) {
|
||||
super(`Session not found: ${sessionId}`);
|
||||
this.name = "SessionNotFoundError";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"@composio/ao-plugin-notifier-composio": "workspace:*",
|
||||
"@composio/ao-plugin-terminal-iterm2": "workspace:*",
|
||||
"@composio/ao-plugin-terminal-web": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import { mkdtemp, readdir, realpath, rm } from "node:fs/promises";
|
|||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { readLastJsonlEntry, type ActivityDetection, type AgentSessionInfo } from "@composio/ao-core";
|
||||
import {
|
||||
readLastJsonlEntry,
|
||||
type ActivityDetection,
|
||||
type AgentSessionInfo,
|
||||
} from "@composio/ao-core";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import claudeCodePlugin, { toClaudeProjectPath } from "@composio/ao-plugin-agent-claude-code";
|
||||
import {
|
||||
|
|
@ -89,9 +93,7 @@ async function findRealClaudeProject(): Promise<{
|
|||
continue;
|
||||
}
|
||||
|
||||
const jsonlFiles = files.filter(
|
||||
(f) => f.endsWith(".jsonl") && !f.startsWith("agent-"),
|
||||
);
|
||||
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
|
||||
if (jsonlFiles.length === 0) continue;
|
||||
|
||||
// Try to reconstruct the workspace path from the encoded dir name
|
||||
|
|
@ -163,7 +165,8 @@ describe.skipIf(!realProject)("path encoding & JSONL reading (real Claude data)"
|
|||
|
||||
// Process is "not running" so should get "exited" — but the important thing
|
||||
// is it didn't return null (which would mean the path didn't resolve)
|
||||
expect(state).toBe("exited");
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.state ?? state).toBe("exited");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@
|
|||
* Requires:
|
||||
* - `opencode` binary on PATH
|
||||
* - tmux installed and running
|
||||
* - ANTHROPIC_API_KEY or OPENAI_API_KEY set
|
||||
* - Any supported API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY,
|
||||
* GEMINI_API_KEY, ZAI_API_KEY, ZAI_CODING_PLAN_API_KEY, KIMI_API_KEY,
|
||||
* OPENROUTER_API_KEY, GROK_API_KEY, GITHUB_TOKEN, COPILOT_API_KEY)
|
||||
*
|
||||
* Skipped automatically when prerequisites are missing.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { ActivityDetection, AgentSessionInfo } from "@composio/ao-core";
|
||||
|
|
@ -48,19 +50,15 @@ async function canOpencodeRun(bin: string): Promise<boolean> {
|
|||
const probe = "ao-inttest-opencode-probe";
|
||||
try {
|
||||
await killSessionsByPrefix(probe);
|
||||
// Run a trivial prompt — opencode run exits after completing
|
||||
await createSession(probe, `${bin} run 'Say hello'`, tmpdir());
|
||||
// Wait for the probe to finish (should exit within ~15s)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
try {
|
||||
await execFileAsync("tmux", ["has-session", "-t", probe], { timeout: 5_000 });
|
||||
} catch {
|
||||
// session is gone -> opencode exited cleanly
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Still running after 20s -> possibly stuck
|
||||
await killSession(probe);
|
||||
return false;
|
||||
} catch {
|
||||
|
|
@ -70,13 +68,39 @@ async function canOpencodeRun(bin: string): Promise<boolean> {
|
|||
|
||||
const tmuxOk = await isTmuxAvailable();
|
||||
const opencodeBin = await findOpencodeBinary();
|
||||
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
|
||||
// Skip the expensive canOpencodeRun probe if no API key is available
|
||||
const opencodeReady = hasApiKey && opencodeBin !== null && (await canOpencodeRun(opencodeBin));
|
||||
|
||||
async function hasOpencodeCredentials(): Promise<boolean> {
|
||||
const hasEnvKey = Boolean(
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.GOOGLE_API_KEY ||
|
||||
process.env.GEMINI_API_KEY ||
|
||||
process.env.ZAI_API_KEY ||
|
||||
process.env.ZAI_CODING_PLAN_API_KEY ||
|
||||
process.env.KIMI_API_KEY ||
|
||||
process.env.OPENROUTER_API_KEY ||
|
||||
process.env.GROK_API_KEY ||
|
||||
process.env.GITHUB_TOKEN ||
|
||||
process.env.COPILOT_API_KEY,
|
||||
);
|
||||
|
||||
if (hasEnvKey) return true;
|
||||
|
||||
try {
|
||||
const authPath = join(homedir(), ".local", "share", "opencode", "auth.json");
|
||||
await readFile(authPath, "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCredentials = await hasOpencodeCredentials();
|
||||
const opencodeReady = opencodeBin !== null && hasCredentials && (await canOpencodeRun(opencodeBin));
|
||||
const canRun = tmuxOk && opencodeReady;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// Lifecycle Tests (requires opencode + tmux + API key)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!canRun)("agent-opencode (integration)", () => {
|
||||
|
|
@ -84,11 +108,8 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => {
|
|||
const sessionName = `${SESSION_PREFIX}${Date.now()}`;
|
||||
let tmpDir: string;
|
||||
|
||||
// Observations captured while the agent is alive
|
||||
let aliveRunning = false;
|
||||
let aliveActivityState: ActivityDetection | null | undefined;
|
||||
|
||||
// Observations captured after the agent exits
|
||||
let exitedRunning: boolean;
|
||||
let exitedActivityState: ActivityDetection | null;
|
||||
let sessionInfo: AgentSessionInfo | null;
|
||||
|
|
@ -103,7 +124,6 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => {
|
|||
const handle = makeTmuxHandle(sessionName);
|
||||
const session = makeSession("inttest-opencode", handle, tmpDir);
|
||||
|
||||
// Poll until we observe the agent is running and capture activity state
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
const running = await agent.isProcessRunning(handle);
|
||||
|
|
@ -118,7 +138,6 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => {
|
|||
await sleep(500);
|
||||
}
|
||||
|
||||
// Wait for agent to exit
|
||||
exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, {
|
||||
timeoutMs: 90_000,
|
||||
intervalMs: 2_000,
|
||||
|
|
@ -135,27 +154,269 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => {
|
|||
}
|
||||
}, 30_000);
|
||||
|
||||
it("isProcessRunning → true while agent is alive", () => {
|
||||
it("isProcessRunning -> true while agent is alive", () => {
|
||||
expect(aliveRunning).toBe(true);
|
||||
});
|
||||
|
||||
it("getActivityState → returns null while agent is running (no per-session tracking)", () => {
|
||||
// OpenCode uses a global SQLite database shared by all sessions,
|
||||
// so getActivityState honestly returns null instead of guessing.
|
||||
it("getActivityState -> returns null while agent is running (no per-session tracking)", () => {
|
||||
if (aliveActivityState !== undefined) {
|
||||
expect(aliveActivityState).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("isProcessRunning → false after agent exits", () => {
|
||||
it("isProcessRunning -> false after agent exits", () => {
|
||||
expect(exitedRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("getActivityState → returns exited after agent process terminates", () => {
|
||||
it("getActivityState -> returns exited after agent process terminates", () => {
|
||||
expect(exitedActivityState?.state).toBe("exited");
|
||||
});
|
||||
|
||||
it("getSessionInfo → null (not implemented for opencode)", () => {
|
||||
it("getSessionInfo -> null (not implemented for opencode)", () => {
|
||||
expect(sessionInfo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch command tests (no external dependencies)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getLaunchCommand (integration)", () => {
|
||||
const agent = opencodePlugin.create();
|
||||
|
||||
const baseConfig = {
|
||||
sessionId: "test-1",
|
||||
projectConfig: {
|
||||
name: "test",
|
||||
repo: "owner/repo",
|
||||
path: "/workspace",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "test",
|
||||
},
|
||||
};
|
||||
|
||||
it("generates correct command with subagent", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
subagent: "sisyphus",
|
||||
prompt: "fix the bug",
|
||||
});
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("'fix the bug'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
});
|
||||
|
||||
it("generates correct command with systemPrompt", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
systemPrompt: "You are an orchestrator",
|
||||
prompt: "do the task",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain("You are an orchestrator");
|
||||
expect(cmd).toContain("do the task");
|
||||
});
|
||||
|
||||
it("generates correct command with systemPromptFile", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
systemPromptFile: "/tmp/orchestrator-prompt.md",
|
||||
prompt: "do the task",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain(
|
||||
"$(cat '/tmp/orchestrator-prompt.md'; printf '\\n\\n'; printf %s 'do the task')",
|
||||
);
|
||||
});
|
||||
|
||||
it("generates correct command with model override", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
model: "claude-sonnet-4-5-20250929",
|
||||
prompt: "do the task",
|
||||
});
|
||||
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
|
||||
});
|
||||
|
||||
it("combines subagent + systemPrompt + model + prompt", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
subagent: "oracle",
|
||||
systemPrompt: "You are an expert",
|
||||
model: "gpt-5.2",
|
||||
prompt: "review this code",
|
||||
});
|
||||
expect(cmd).toContain("--agent 'oracle'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain("You are an expert");
|
||||
expect(cmd).toContain("review this code");
|
||||
expect(cmd).toContain("--model 'gpt-5.2'");
|
||||
});
|
||||
|
||||
it("systemPromptFile takes precedence over systemPrompt", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
systemPrompt: "direct prompt",
|
||||
systemPromptFile: "/tmp/file-prompt.md",
|
||||
});
|
||||
expect(cmd).toContain("\"$(cat '/tmp/file-prompt.md')\"");
|
||||
expect(cmd).not.toContain("direct prompt");
|
||||
});
|
||||
|
||||
it("uses prompt with systemPromptFile for orchestrator-style launch", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
sessionId: "test-orchestrator",
|
||||
permissions: "permissionless",
|
||||
systemPromptFile: "/tmp/orchestrator-prompt.md",
|
||||
});
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:test-orchestrator' \"$(cat '/tmp/orchestrator-prompt.md')\"",
|
||||
);
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
});
|
||||
|
||||
it("escapes single quotes in systemPrompt", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
systemPrompt: "it's important",
|
||||
});
|
||||
expect(cmd).toContain("'it'\\''s important'");
|
||||
});
|
||||
|
||||
it("escapes path with single quotes in systemPromptFile", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
systemPromptFile: "/tmp/it's-prompt.md",
|
||||
});
|
||||
expect(cmd).toContain("\"$(cat '/tmp/it'\\''s-prompt.md')\"");
|
||||
});
|
||||
|
||||
it("handles prompt with special shell characters", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
prompt: "fix and `backtick` and 'quote'",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("fix and `backtick`");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
});
|
||||
|
||||
it("handles empty prompt", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
prompt: "",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1' --command true");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode session list --format json");
|
||||
expect(cmd).toContain("AO:test-1");
|
||||
});
|
||||
|
||||
it("handles prompt with newlines", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
prompt: "line1\nline2",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("'line1");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
});
|
||||
|
||||
it("uses run bootstrap launch for fresh sessions", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
prompt: "start work",
|
||||
});
|
||||
expect(cmd).toContain("--title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt 'start work'");
|
||||
expect(cmd).toContain("'start work' && exec opencode --session");
|
||||
});
|
||||
|
||||
it("uses --session when existing OpenCode session id is provided", () => {
|
||||
const cmd = agent.getLaunchCommand({
|
||||
...baseConfig,
|
||||
projectConfig: {
|
||||
...baseConfig.projectConfig,
|
||||
agentConfig: {
|
||||
opencodeSessionId: "ses_abc123",
|
||||
},
|
||||
},
|
||||
prompt: "continue",
|
||||
});
|
||||
expect(cmd).toBe("opencode --session 'ses_abc123' --prompt 'continue'");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getEnvironment tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getEnvironment (integration)", () => {
|
||||
const agent = opencodePlugin.create();
|
||||
|
||||
const baseConfig = {
|
||||
sessionId: "sess-123",
|
||||
projectConfig: {
|
||||
name: "test",
|
||||
repo: "owner/repo",
|
||||
path: "/workspace",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "test",
|
||||
},
|
||||
};
|
||||
|
||||
it("sets AO_SESSION_ID", () => {
|
||||
const env = agent.getEnvironment(baseConfig);
|
||||
expect(env["AO_SESSION_ID"]).toBe("sess-123");
|
||||
});
|
||||
|
||||
it("sets AO_ISSUE_ID when provided", () => {
|
||||
const env = agent.getEnvironment({ ...baseConfig, issueId: "GH-42" });
|
||||
expect(env["AO_ISSUE_ID"]).toBe("GH-42");
|
||||
});
|
||||
|
||||
it("omits AO_ISSUE_ID when not provided", () => {
|
||||
const env = agent.getEnvironment(baseConfig);
|
||||
expect(env["AO_ISSUE_ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not set AO_PROJECT_ID (caller's responsibility)", () => {
|
||||
const env = agent.getEnvironment(baseConfig);
|
||||
expect(env["AO_PROJECT_ID"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectActivity tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("detectActivity (integration)", () => {
|
||||
const agent = opencodePlugin.create();
|
||||
|
||||
it("returns idle for empty terminal output", () => {
|
||||
expect(agent.detectActivity("")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle for whitespace-only terminal output", () => {
|
||||
expect(agent.detectActivity(" \n ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns active for non-empty terminal output", () => {
|
||||
expect(agent.detectActivity("opencode is working\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active for output with ANSI codes", () => {
|
||||
expect(agent.detectActivity("\u001b[32mSuccess\u001b[0m\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active for multiline output", () => {
|
||||
expect(agent.detectActivity("line1\nline2\nline3\n")).toBe("active");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,50 +48,72 @@ function linearGraphQL<T>(query: string, variables: Record<string, unknown>): Pr
|
|||
}
|
||||
const body = JSON.stringify({ query, variables });
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const url = new URL("https://api.linear.app/graphql");
|
||||
const req = request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: LINEAR_API_KEY,
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const text = Buffer.concat(chunks).toString("utf-8");
|
||||
const json = JSON.parse(text) as {
|
||||
data?: T;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
if (json.errors?.length) {
|
||||
reject(new Error(`Linear API error: ${json.errors[0].message}`));
|
||||
return;
|
||||
}
|
||||
resolve(json.data as T);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
async function executeWithRetry(attempt = 1): Promise<T> {
|
||||
try {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const url = new URL("https://api.linear.app/graphql");
|
||||
const req = request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: LINEAR_API_KEY,
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
const text = Buffer.concat(chunks).toString("utf-8");
|
||||
const statusCode = res.statusCode ?? 0;
|
||||
|
||||
if (statusCode >= 500) {
|
||||
reject(new Error(`Linear API ${statusCode}: ${text.slice(0, 200)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let json: { data?: T; errors?: Array<{ message: string }> };
|
||||
try {
|
||||
json = JSON.parse(text) as { data?: T; errors?: Array<{ message: string }> };
|
||||
} catch {
|
||||
reject(
|
||||
new Error(`Linear API returned non-JSON (${statusCode}): ${text.slice(0, 200)}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.errors?.length) {
|
||||
reject(new Error(`Linear API error: ${json.errors[0].message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(json.data as T);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.setTimeout(30_000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Linear API request timed out"));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.setTimeout(30_000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Linear API request timed out"));
|
||||
});
|
||||
req.on("error", (err) => reject(err));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
} catch (err) {
|
||||
if (attempt < 3) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
return executeWithRetry(attempt + 1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
req.on("error", (err) => reject(err));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
return executeWithRetry();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -135,11 +157,15 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
|
|||
|
||||
// Resolve the UUID for cleanup — only possible with direct API key
|
||||
if (LINEAR_API_KEY) {
|
||||
const data = await linearGraphQL<{ issue: { id: string } }>(
|
||||
`query($id: String!) { issue(id: $id) { id } }`,
|
||||
{ id: issueIdentifier },
|
||||
);
|
||||
issueUuid = data.issue.id;
|
||||
try {
|
||||
const data = await linearGraphQL<{ issue: { id: string } }>(
|
||||
`query($id: String!) { issue(id: $id) { id } }`,
|
||||
{ id: issueIdentifier },
|
||||
);
|
||||
issueUuid = data.issue.id;
|
||||
} catch {
|
||||
issueUuid = undefined;
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
|
|
@ -238,18 +264,25 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
|
|||
// Verify the comment was added — use direct API if available,
|
||||
// otherwise trust the plugin didn't throw
|
||||
if (LINEAR_API_KEY) {
|
||||
const data = await linearGraphQL<{
|
||||
issue: { comments: { nodes: Array<{ body: string }> } };
|
||||
}>(
|
||||
`query($id: String!) {
|
||||
issue(id: $id) {
|
||||
comments { nodes { body } }
|
||||
}
|
||||
}`,
|
||||
{ id: issueIdentifier },
|
||||
const commentBodies = await pollUntil(
|
||||
async () => {
|
||||
const data = await linearGraphQL<{
|
||||
issue: { comments: { nodes: Array<{ body: string }> } };
|
||||
}>(
|
||||
`query($id: String!) {
|
||||
issue(id: $id) {
|
||||
comments(first: 50) { nodes { body } }
|
||||
}
|
||||
}`,
|
||||
{ id: issueIdentifier },
|
||||
);
|
||||
|
||||
const bodies = data.issue.comments.nodes.map((c) => c.body);
|
||||
return bodies.includes("Integration test comment") ? bodies : undefined;
|
||||
},
|
||||
{ timeoutMs: 5_000, intervalMs: 500 },
|
||||
);
|
||||
|
||||
const commentBodies = data.issue.comments.nodes.map((c) => c.body);
|
||||
expect(commentBodies).toContain("Integration test comment");
|
||||
}
|
||||
});
|
||||
|
|
@ -265,8 +298,12 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
|
|||
);
|
||||
expect(completed).toBe(true);
|
||||
|
||||
const issue = await tracker.getIssue(issueIdentifier, project);
|
||||
expect(issue.state).toBe("closed");
|
||||
const closedState = await pollUntilEqual(
|
||||
async () => (await tracker.getIssue(issueIdentifier, project)).state,
|
||||
"closed",
|
||||
{ timeoutMs: 5_000, intervalMs: 500 },
|
||||
);
|
||||
expect(closedState).toBe("closed");
|
||||
});
|
||||
|
||||
it("updateIssue reopens the issue", async () => {
|
||||
|
|
@ -280,7 +317,11 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
|
|||
);
|
||||
expect(completed).toBe(false);
|
||||
|
||||
const issue = await tracker.getIssue(issueIdentifier, project);
|
||||
expect(issue.state).toBe("open");
|
||||
const reopenedState = await pollUntilEqual(
|
||||
async () => (await tracker.getIssue(issueIdentifier, project)).state,
|
||||
"open",
|
||||
{ timeoutMs: 5_000, intervalMs: 500 },
|
||||
);
|
||||
expect(reopenedState).toBe("open");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,12 +111,18 @@ describe("getLaunchCommand", () => {
|
|||
const agent = create();
|
||||
|
||||
it("generates base command without prompt", () => {
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("opencode");
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode session list --format json");
|
||||
expect(cmd).toContain("AO:sess-1");
|
||||
expect(cmd).toContain("try { rows = JSON.parse(input); } catch { process.exit(1); }");
|
||||
});
|
||||
|
||||
it("uses run subcommand with shell-escaped prompt", () => {
|
||||
it("uses --prompt with shell-escaped prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix it" }));
|
||||
expect(cmd).toContain("run 'Fix it'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' 'Fix it'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
});
|
||||
|
||||
it("includes --model with shell-escaped value", () => {
|
||||
|
|
@ -125,19 +131,249 @@ describe("getLaunchCommand", () => {
|
|||
});
|
||||
|
||||
it("combines prompt and model", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Go", model: "gpt-4o" }));
|
||||
expect(cmd).toBe("opencode run 'Go' --model 'gpt-4o'");
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ prompt: "Go", model: "claude-sonnet-4-5-20250929" }),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'",
|
||||
);
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
|
||||
});
|
||||
|
||||
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" }));
|
||||
expect(cmd).toContain("run 'it'\\''s broken'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' 'it'\\''s broken'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
});
|
||||
|
||||
it("omits optional flags when not provided", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
||||
expect(cmd).not.toContain("--model");
|
||||
expect(cmd).not.toContain("run");
|
||||
expect(cmd).not.toContain("--agent");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// subagent flag tests
|
||||
// ---------------------------------------------------------------------------
|
||||
it("includes --agent flag when subagent is provided", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ subagent: "sisyphus" }));
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
});
|
||||
|
||||
it("generates command with agent and prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ subagent: "sisyphus", prompt: "fix bug" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' --agent 'sisyphus' 'fix bug'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
});
|
||||
|
||||
it("generates command with agent, model, and prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
subagent: "sisyphus",
|
||||
model: "claude-sonnet-4-5-20250929",
|
||||
prompt: "fix the bug",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:sess-1' --agent 'sisyphus' --model 'claude-sonnet-4-5-20250929' 'fix the bug'",
|
||||
);
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
|
||||
});
|
||||
|
||||
it("works with different agent names: oracle", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ subagent: "oracle", prompt: "review code" }),
|
||||
);
|
||||
expect(cmd).toContain("--agent 'oracle'");
|
||||
expect(cmd).toContain("'review code'");
|
||||
});
|
||||
|
||||
it("works with different agent names: librarian", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ subagent: "librarian", prompt: "find usages" }),
|
||||
);
|
||||
expect(cmd).toContain("--agent 'librarian'");
|
||||
expect(cmd).toContain("'find usages'");
|
||||
});
|
||||
|
||||
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 --title 'AO:sess-1' 'fix it'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
});
|
||||
|
||||
it("combines model and prompt without agent (backward compatible)", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ prompt: "Go", model: "claude-sonnet-4-5-20250929" }),
|
||||
);
|
||||
expect(cmd).not.toContain("--agent");
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'",
|
||||
);
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// systemPrompt tests
|
||||
// ---------------------------------------------------------------------------
|
||||
it("uses run bootstrap when systemPrompt is provided", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPrompt: "You are an orchestrator" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("'You are an orchestrator'");
|
||||
});
|
||||
|
||||
it("generates command with systemPrompt and task prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPrompt: "You are an orchestrator", prompt: "do the task" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).not.toContain("--prompt 'You are an orchestrator");
|
||||
expect(cmd).toContain("do the task'");
|
||||
});
|
||||
|
||||
it("escapes single quotes in systemPrompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: "it's important" }));
|
||||
expect(cmd).toContain("'it'\\''s important'");
|
||||
});
|
||||
|
||||
it("handles very long systemPrompt", () => {
|
||||
const longPrompt = "A".repeat(500);
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: longPrompt }));
|
||||
expect(cmd).toContain("opencode run --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 --title 'AO:sess-1' \"$(cat '/tmp/prompt.md')\"");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
});
|
||||
|
||||
it("escapes path in systemPromptFile", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPromptFile: "/tmp/it's-prompt.md" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' \"$(cat '/tmp/it'\\''s-prompt.md')\"");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
});
|
||||
|
||||
it("systemPromptFile takes precedence over systemPrompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
systemPrompt: "direct prompt",
|
||||
systemPromptFile: "/tmp/file-prompt.md",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toContain("\"$(cat '/tmp/file-prompt.md')\"");
|
||||
expect(cmd).not.toContain("direct prompt");
|
||||
});
|
||||
|
||||
it("generates orchestrator-style systemPromptFile launch", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
sessionId: "my-orchestrator",
|
||||
permissions: "permissionless",
|
||||
systemPromptFile: "/tmp/orchestrator.md",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:my-orchestrator' \"$(cat '/tmp/orchestrator.md')\"",
|
||||
);
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
});
|
||||
|
||||
it("combines systemPromptFile with subagent and prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
systemPromptFile: "/tmp/orchestrator.md",
|
||||
subagent: "sisyphus",
|
||||
prompt: "fix the bug",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain(
|
||||
"$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
it("handles prompt with special characters", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ prompt: "fix $PATH/to/file and `rm -rf /unquoted/path`" }),
|
||||
);
|
||||
expect(cmd).toContain("'fix $PATH/to/file and `rm -rf /unquoted/path`");
|
||||
});
|
||||
|
||||
it("handles prompt with newlines", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "line1\nline2\nline3" }));
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("'line1");
|
||||
});
|
||||
|
||||
it("handles prompt with backticks", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "use `backticks` and $vars`" }));
|
||||
expect(cmd).toContain("'use `backticks` and $vars`");
|
||||
});
|
||||
|
||||
it("handles prompt with dollar signs", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "cost is $100" }));
|
||||
expect(cmd).toContain("'cost is $100'");
|
||||
});
|
||||
|
||||
it("handles prompt with double quotes", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: 'say "hello" and "goodbye"' }));
|
||||
expect(cmd).toContain('\'say "hello" and "goodbye"\'');
|
||||
});
|
||||
|
||||
it("handles prompt with unicode characters", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "fix bug in café.js file" }));
|
||||
expect(cmd).toContain("'fix bug in café.js file'");
|
||||
});
|
||||
|
||||
it("handles prompt with semicolons", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "line1; line2; line3" }));
|
||||
expect(cmd).toContain("'line1; line2; line3'");
|
||||
});
|
||||
|
||||
it("handles empty prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "" }));
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode session list --format json");
|
||||
expect(cmd).toContain("AO:sess-1");
|
||||
});
|
||||
|
||||
it("uses existing session id", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({
|
||||
projectConfig: {
|
||||
name: "my-project",
|
||||
repo: "owner/repo",
|
||||
path: "/workspace/repo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "my",
|
||||
agentConfig: { opencodeSessionId: "ses_abc123" },
|
||||
},
|
||||
prompt: "continue",
|
||||
}),
|
||||
);
|
||||
expect(cmd).toBe("opencode --session 'ses_abc123' --prompt 'continue'");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -250,6 +486,33 @@ describe("detectActivity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getActivityState", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns null when opencode session list output is malformed JSON", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
|
||||
if (cmd === "ps") {
|
||||
return Promise.resolve({
|
||||
stdout: " PID TT ARGS\n 789 ttys003 opencode\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
if (cmd === "opencode") return Promise.resolve({ stdout: "not json", stderr: "" });
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
const state = await agent.getActivityState(
|
||||
makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
metadata: { opencodeSessionId: "ses_abc123" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getSessionInfo
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
shellEscape,
|
||||
asValidOpenCodeSessionId,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
|
|
@ -8,12 +9,55 @@ import {
|
|||
type PluginModule,
|
||||
type RuntimeHandle,
|
||||
type Session,
|
||||
type OpenCodeAgentConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface OpenCodeSessionListEntry {
|
||||
id: string;
|
||||
title?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
function parseSessionList(raw: string): OpenCodeSessionListEntry[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((item): item is OpenCodeSessionListEntry => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as Record<string, unknown>;
|
||||
return asValidOpenCodeSessionId(record["id"]) !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function buildSessionLookupScript(): string {
|
||||
const script = `
|
||||
let input = '';
|
||||
process.stdin.on('data', c => input += c).on('end', () => {
|
||||
const title = process.argv[1];
|
||||
let rows;
|
||||
try { rows = JSON.parse(input); } catch { process.exit(1); }
|
||||
if (!Array.isArray(rows)) process.exit(1);
|
||||
const matches = rows.filter(r => r && r.title === title && typeof r.id === 'string');
|
||||
if (matches.length === 0) process.exit(1);
|
||||
process.stdout.write(matches[0].id);
|
||||
});
|
||||
`.trim();
|
||||
return script.replace(/\n/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function buildContinueSessionCommand(sessionTitle: string): string {
|
||||
const script = buildSessionLookupScript();
|
||||
return `"$(opencode session list --format json | node -e ${shellEscape(script)} ${shellEscape(sessionTitle)})"`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -35,17 +79,60 @@ function createOpenCodeAgent(): Agent {
|
|||
processName: "opencode",
|
||||
|
||||
getLaunchCommand(config: AgentLaunchConfig): string {
|
||||
const parts: string[] = ["opencode"];
|
||||
const options: string[] = [];
|
||||
const sharedOptions: string[] = [];
|
||||
|
||||
const existingSessionId = asValidOpenCodeSessionId(
|
||||
(config.projectConfig.agentConfig as OpenCodeAgentConfig | undefined)?.opencodeSessionId,
|
||||
);
|
||||
|
||||
if (existingSessionId) {
|
||||
options.push("--session", shellEscape(existingSessionId));
|
||||
}
|
||||
|
||||
// Select specific OpenCode subagent if configured
|
||||
if (config.subagent) {
|
||||
sharedOptions.push("--agent", shellEscape(config.subagent));
|
||||
}
|
||||
|
||||
let promptValue: string | undefined;
|
||||
if (config.prompt) {
|
||||
parts.push("run", shellEscape(config.prompt));
|
||||
if (config.systemPromptFile) {
|
||||
promptValue = `"$(cat ${shellEscape(config.systemPromptFile)}; printf '\\n\\n'; printf %s ${shellEscape(config.prompt)})"`;
|
||||
} else if (config.systemPrompt) {
|
||||
promptValue = shellEscape(`${config.systemPrompt}\n\n${config.prompt}`);
|
||||
} else {
|
||||
promptValue = shellEscape(config.prompt);
|
||||
}
|
||||
} else if (config.systemPromptFile) {
|
||||
promptValue = `"$(cat ${shellEscape(config.systemPromptFile)})"`;
|
||||
} else if (config.systemPrompt) {
|
||||
promptValue = shellEscape(config.systemPrompt);
|
||||
}
|
||||
|
||||
if (config.model) {
|
||||
parts.push("--model", shellEscape(config.model));
|
||||
sharedOptions.push("--model", shellEscape(config.model));
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
if (!existingSessionId) {
|
||||
const runOptions = ["--title", shellEscape(`AO:${config.sessionId}`), ...sharedOptions];
|
||||
const runCommand = promptValue
|
||||
? ["opencode", "run", ...runOptions, promptValue].join(" ")
|
||||
: ["opencode", "run", ...runOptions, "--command", "true"].join(" ");
|
||||
const continueSession = buildContinueSessionCommand(`AO:${config.sessionId}`);
|
||||
const continueCommand = ["opencode", "--session", continueSession, ...sharedOptions].join(
|
||||
" ",
|
||||
);
|
||||
return `${runCommand} && exec ${continueCommand}`;
|
||||
}
|
||||
|
||||
if (promptValue) {
|
||||
options.push("--prompt", promptValue);
|
||||
}
|
||||
|
||||
options.push(...sharedOptions);
|
||||
|
||||
return ["opencode", ...options].join(" ");
|
||||
},
|
||||
|
||||
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
|
||||
|
|
@ -64,20 +151,42 @@ function createOpenCodeAgent(): Agent {
|
|||
return "active";
|
||||
},
|
||||
|
||||
async getActivityState(session: Session, _readyThresholdMs?: number): Promise<ActivityDetection | null> {
|
||||
async getActivityState(
|
||||
session: Session,
|
||||
_readyThresholdMs?: number,
|
||||
): Promise<ActivityDetection | null> {
|
||||
// Check if process is running first
|
||||
const exitedAt = new Date();
|
||||
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
|
||||
const running = await this.isProcessRunning(session.runtimeHandle);
|
||||
if (!running) return { state: "exited", timestamp: exitedAt };
|
||||
|
||||
// NOTE: OpenCode stores all session data in a single global SQLite database
|
||||
// at ~/.local/share/opencode/opencode.db without per-workspace scoping. When
|
||||
// multiple OpenCode sessions run in parallel, database modifications from any
|
||||
// session will cause all sessions to appear active. Until OpenCode provides
|
||||
// per-workspace session tracking, we return null (unknown) rather than guessing.
|
||||
//
|
||||
// TODO: Implement proper per-session activity detection when OpenCode supports it.
|
||||
if (session.metadata?.opencodeSessionId) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"opencode",
|
||||
["session", "list", "--format", "json"],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const sessions = parseSessionList(stdout);
|
||||
const targetSession = sessions.find((s) => s.id === session.metadata.opencodeSessionId);
|
||||
|
||||
if (targetSession) {
|
||||
const lastActivity = targetSession.updated
|
||||
? new Date(targetSession.updated)
|
||||
: undefined;
|
||||
return {
|
||||
state: "active",
|
||||
...(lastActivity &&
|
||||
!Number.isNaN(lastActivity.getTime()) && { timestamp: lastActivity }),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ vi.mock("node:fs", () => ({
|
|||
const mockExecFileCustom = (childProcess.execFile as any)[
|
||||
Symbol.for("nodejs.util.promisify.custom")
|
||||
] as ReturnType<typeof vi.fn>;
|
||||
const expectedTmuxOptions = { timeout: 5_000 };
|
||||
|
||||
/** Queue a successful tmux command with the given stdout. */
|
||||
function mockTmuxSuccess(stdout = "") {
|
||||
|
|
@ -98,14 +99,11 @@ describe("runtime.create()", () => {
|
|||
expect(handle.data.workspacePath).toBe("/tmp/workspace");
|
||||
|
||||
// First call: new-session
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
"test-session",
|
||||
"-c",
|
||||
"/tmp/workspace",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["new-session", "-d", "-s", "test-session", "-c", "/tmp/workspace"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes -e KEY=VALUE flags for environment variables", async () => {
|
||||
|
|
@ -143,13 +141,11 @@ describe("runtime.create()", () => {
|
|||
});
|
||||
|
||||
// Second call: send-keys with the launch command
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"launch-test",
|
||||
"claude --session abc",
|
||||
"Enter",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["send-keys", "-t", "launch-test", "claude --session abc", "Enter"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans up session if send-keys fails", async () => {
|
||||
|
|
@ -172,7 +168,11 @@ describe("runtime.create()", () => {
|
|||
).rejects.toThrow('Failed to send launch command to session "fail-session"');
|
||||
|
||||
// Verify kill-session was called for cleanup
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["kill-session", "-t", "fail-session"]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["kill-session", "-t", "fail-session"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid session IDs with special characters", async () => {
|
||||
|
|
@ -244,7 +244,11 @@ describe("runtime.destroy()", () => {
|
|||
|
||||
await runtime.destroy(handle);
|
||||
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["kill-session", "-t", "destroy-test"]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["kill-session", "-t", "destroy-test"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw if session is already gone", async () => {
|
||||
|
|
@ -273,29 +277,28 @@ describe("runtime.sendMessage()", () => {
|
|||
expect(mockExecFileCustom).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Call 0: Clear partial input
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"msg-short",
|
||||
"C-u",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tmux",
|
||||
["send-keys", "-t", "msg-short", "C-u"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
|
||||
// Call 1: Literal text
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"msg-short",
|
||||
"-l",
|
||||
"hello world",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tmux",
|
||||
["send-keys", "-t", "msg-short", "-l", "hello world"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
|
||||
// Call 2: Enter
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"msg-short",
|
||||
"Enter",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"tmux",
|
||||
["send-keys", "-t", "msg-short", "Enter"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses load-buffer + paste-buffer for long text (> 200 chars)", async () => {
|
||||
|
|
@ -315,30 +318,33 @@ describe("runtime.sendMessage()", () => {
|
|||
expect(mockExecFileCustom).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Call 0: clear
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"msg-long",
|
||||
"C-u",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tmux",
|
||||
["send-keys", "-t", "msg-long", "C-u"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
|
||||
// Call 1: load-buffer with named buffer
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [
|
||||
"load-buffer",
|
||||
"-b",
|
||||
"ao-test-uuid-1234",
|
||||
expect.stringContaining("ao-send-test-uuid-1234.txt"),
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tmux",
|
||||
[
|
||||
"load-buffer",
|
||||
"-b",
|
||||
"ao-test-uuid-1234",
|
||||
expect.stringContaining("ao-send-test-uuid-1234.txt"),
|
||||
],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
|
||||
// Call 2: paste-buffer
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [
|
||||
"paste-buffer",
|
||||
"-b",
|
||||
"ao-test-uuid-1234",
|
||||
"-t",
|
||||
"msg-long",
|
||||
"-d",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"tmux",
|
||||
["paste-buffer", "-b", "ao-test-uuid-1234", "-t", "msg-long", "-d"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
|
||||
// Verify writeFileSync was called with the message
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
|
|
@ -366,12 +372,17 @@ describe("runtime.sendMessage()", () => {
|
|||
await runtime.sendMessage(handle, "line1\nline2\nline3");
|
||||
|
||||
// Should use buffer path, not send-keys -l
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [
|
||||
"load-buffer",
|
||||
"-b",
|
||||
"ao-test-uuid-1234",
|
||||
expect.stringContaining("ao-send-test-uuid-1234.txt"),
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tmux",
|
||||
[
|
||||
"load-buffer",
|
||||
"-b",
|
||||
"ao-test-uuid-1234",
|
||||
expect.stringContaining("ao-send-test-uuid-1234.txt"),
|
||||
],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ao-send-test-uuid-1234.txt"),
|
||||
|
|
@ -401,11 +412,11 @@ describe("runtime.sendMessage()", () => {
|
|||
);
|
||||
|
||||
// delete-buffer should be called in finally block
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [
|
||||
"delete-buffer",
|
||||
"-b",
|
||||
"ao-test-uuid-1234",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["delete-buffer", "-b", "ao-test-uuid-1234"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -419,14 +430,11 @@ describe("runtime.getOutput()", () => {
|
|||
const output = await runtime.getOutput(handle);
|
||||
|
||||
expect(output).toBe("some output\nfrom tmux");
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [
|
||||
"capture-pane",
|
||||
"-t",
|
||||
"output-test",
|
||||
"-p",
|
||||
"-S",
|
||||
"-50",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["capture-pane", "-t", "output-test", "-p", "-S", "-50"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes custom line count", async () => {
|
||||
|
|
@ -437,14 +445,11 @@ describe("runtime.getOutput()", () => {
|
|||
|
||||
await runtime.getOutput(handle, 100);
|
||||
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [
|
||||
"capture-pane",
|
||||
"-t",
|
||||
"output-custom",
|
||||
"-p",
|
||||
"-S",
|
||||
"-100",
|
||||
]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["capture-pane", "-t", "output-custom", "-p", "-S", "-100"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string on error", async () => {
|
||||
|
|
@ -469,7 +474,11 @@ describe("runtime.isAlive()", () => {
|
|||
const alive = await runtime.isAlive(handle);
|
||||
|
||||
expect(alive).toBe(true);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["has-session", "-t", "alive-test"]);
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["has-session", "-t", "alive-test"],
|
||||
expectedTmuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when has-session fails", async () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from "@composio/ao-core";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TMUX_COMMAND_TIMEOUT_MS = 5_000;
|
||||
|
||||
export const manifest = {
|
||||
name: "tmux",
|
||||
|
|
@ -34,7 +35,9 @@ function assertValidSessionId(id: string): void {
|
|||
|
||||
/** Run a tmux command and return stdout */
|
||||
async function tmux(...args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("tmux", args);
|
||||
const { stdout } = await execFileAsync("tmux", args, {
|
||||
timeout: TMUX_COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
return stdout.trimEnd();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,111 @@ function parseDate(val: string | undefined | null): Date {
|
|||
return isNaN(d.getTime()) ? new Date(0) : d;
|
||||
}
|
||||
|
||||
function isUnsupportedPrChecksJsonError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
return /pr checks/i.test(err.message) && /unknown json field/i.test(err.message);
|
||||
}
|
||||
|
||||
function mapRawCheckStateToStatus(rawState: string | undefined): CICheck["status"] {
|
||||
const state = (rawState ?? "").toUpperCase();
|
||||
if (state === "IN_PROGRESS") return "running";
|
||||
if (
|
||||
state === "PENDING" ||
|
||||
state === "QUEUED" ||
|
||||
state === "REQUESTED" ||
|
||||
state === "WAITING" ||
|
||||
state === "EXPECTED"
|
||||
) {
|
||||
return "pending";
|
||||
}
|
||||
if (state === "SUCCESS") return "passed";
|
||||
if (
|
||||
state === "FAILURE" ||
|
||||
state === "TIMED_OUT" ||
|
||||
state === "CANCELLED" ||
|
||||
state === "ACTION_REQUIRED" ||
|
||||
state === "ERROR"
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
if (
|
||||
state === "SKIPPED" ||
|
||||
state === "NEUTRAL" ||
|
||||
state === "STALE" ||
|
||||
state === "NOT_REQUIRED" ||
|
||||
state === "NONE" ||
|
||||
state === ""
|
||||
) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
async function getCIChecksFromStatusRollup(pr: PRInfo): Promise<CICheck[]> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"statusCheckRollup",
|
||||
]);
|
||||
|
||||
const data: { statusCheckRollup?: unknown[] } = JSON.parse(raw);
|
||||
const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
|
||||
|
||||
return rollup
|
||||
.map((entry): CICheck | null => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const row = entry as Record<string, unknown>;
|
||||
const name =
|
||||
(typeof row["name"] === "string" && row["name"]) ||
|
||||
(typeof row["context"] === "string" && row["context"]);
|
||||
if (!name) return null;
|
||||
|
||||
const rawState =
|
||||
typeof row["conclusion"] === "string"
|
||||
? row["conclusion"]
|
||||
: typeof row["state"] === "string"
|
||||
? row["state"]
|
||||
: typeof row["status"] === "string"
|
||||
? row["status"]
|
||||
: undefined;
|
||||
|
||||
const url =
|
||||
(typeof row["link"] === "string" && row["link"]) ||
|
||||
(typeof row["detailsUrl"] === "string" && row["detailsUrl"]) ||
|
||||
(typeof row["targetUrl"] === "string" && row["targetUrl"]) ||
|
||||
undefined;
|
||||
|
||||
const startedAtRaw =
|
||||
typeof row["startedAt"] === "string"
|
||||
? row["startedAt"]
|
||||
: typeof row["createdAt"] === "string"
|
||||
? row["createdAt"]
|
||||
: undefined;
|
||||
const completedAtRaw =
|
||||
typeof row["completedAt"] === "string" ? row["completedAt"] : undefined;
|
||||
|
||||
const check: CICheck = {
|
||||
name,
|
||||
status: mapRawCheckStateToStatus(rawState),
|
||||
conclusion: typeof rawState === "string" ? rawState.toUpperCase() : undefined,
|
||||
startedAt: startedAtRaw ? new Date(startedAtRaw) : undefined,
|
||||
completedAt: completedAtRaw ? new Date(completedAtRaw) : undefined,
|
||||
};
|
||||
|
||||
if (url) {
|
||||
check.url = url;
|
||||
}
|
||||
|
||||
return check;
|
||||
})
|
||||
.filter((check): check is CICheck => check !== null);
|
||||
}
|
||||
|
||||
function parseProjectRepo(projectRepo: string): [string, string] {
|
||||
const parts = projectRepo.split("/");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
|
|
@ -279,29 +384,8 @@ function createGitHubSCM(): SCM {
|
|||
}> = JSON.parse(raw);
|
||||
|
||||
return checks.map((c) => {
|
||||
let status: CICheck["status"];
|
||||
const state = c.state?.toUpperCase();
|
||||
|
||||
// gh pr checks returns state directly: SUCCESS, FAILURE, PENDING, QUEUED, etc.
|
||||
if (state === "PENDING" || state === "QUEUED") {
|
||||
status = "pending";
|
||||
} else if (state === "IN_PROGRESS") {
|
||||
status = "running";
|
||||
} else if (state === "SUCCESS") {
|
||||
status = "passed";
|
||||
} else if (
|
||||
state === "FAILURE" ||
|
||||
state === "TIMED_OUT" ||
|
||||
state === "CANCELLED" ||
|
||||
state === "ACTION_REQUIRED"
|
||||
) {
|
||||
status = "failed";
|
||||
} else if (state === "SKIPPED" || state === "NEUTRAL") {
|
||||
status = "skipped";
|
||||
} else {
|
||||
// Unknown state on a check — fail closed for safety
|
||||
status = "failed";
|
||||
}
|
||||
const status = mapRawCheckStateToStatus(state);
|
||||
|
||||
return {
|
||||
name: c.name,
|
||||
|
|
@ -313,6 +397,9 @@ function createGitHubSCM(): SCM {
|
|||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUnsupportedPrChecksJsonError(err)) {
|
||||
return getCIChecksFromStatusRollup(pr);
|
||||
}
|
||||
// Propagate so callers (getCISummary) can decide how to handle.
|
||||
// Do NOT silently return [] — that causes a fail-open where CI
|
||||
// appears healthy when we simply failed to fetch check status.
|
||||
|
|
|
|||
|
|
@ -337,10 +337,12 @@ describe("scm-github plugin", () => {
|
|||
{ name: "queued", state: "QUEUED", link: "", startedAt: "", completedAt: "" },
|
||||
{ name: "cancelled", state: "CANCELLED", link: "", startedAt: "", completedAt: "" },
|
||||
{ name: "action_req", state: "ACTION_REQUIRED", link: "", startedAt: "", completedAt: "" },
|
||||
{ name: "stale", state: "STALE", link: "", startedAt: "", completedAt: "" },
|
||||
{ name: "unknown", state: "SOME_NEW_STATE", link: "", startedAt: "", completedAt: "" },
|
||||
]);
|
||||
|
||||
const checks = await scm.getCIChecks(pr);
|
||||
expect(checks).toHaveLength(10);
|
||||
expect(checks).toHaveLength(12);
|
||||
expect(checks[0].status).toBe("passed");
|
||||
expect(checks[0].url).toBe("https://ci/1");
|
||||
expect(checks[1].status).toBe("failed");
|
||||
|
|
@ -352,6 +354,8 @@ describe("scm-github plugin", () => {
|
|||
expect(checks[7].status).toBe("pending");
|
||||
expect(checks[8].status).toBe("failed"); // CANCELLED
|
||||
expect(checks[9].status).toBe("failed"); // ACTION_REQUIRED
|
||||
expect(checks[10].status).toBe("skipped");
|
||||
expect(checks[11].status).toBe("skipped");
|
||||
});
|
||||
|
||||
it("throws on error (fail-closed)", async () => {
|
||||
|
|
@ -371,6 +375,36 @@ describe("scm-github plugin", () => {
|
|||
expect(checks[0].startedAt).toBeUndefined();
|
||||
expect(checks[0].completedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to statusCheckRollup when gh pr checks --json is unsupported", async () => {
|
||||
mockGhError('gh pr checks failed: Unknown JSON field "state"');
|
||||
mockGh({
|
||||
statusCheckRollup: [
|
||||
{
|
||||
__typename: "CheckRun",
|
||||
name: "Test",
|
||||
status: "COMPLETED",
|
||||
conclusion: "SUCCESS",
|
||||
detailsUrl: "https://ci/test",
|
||||
startedAt: "2025-01-01T00:00:00Z",
|
||||
completedAt: "2025-01-01T00:05:00Z",
|
||||
},
|
||||
{
|
||||
__typename: "StatusContext",
|
||||
context: "Lint",
|
||||
state: "PENDING",
|
||||
targetUrl: "https://ci/lint",
|
||||
createdAt: "2025-01-01T00:01:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const checks = await scm.getCIChecks(pr);
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(checks[0]).toMatchObject({ name: "Test", status: "passed", url: "https://ci/test" });
|
||||
expect(checks[1]).toMatchObject({ name: "Lint", status: "pending", url: "https://ci/lint" });
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getCISummary ------------------------------------------------------
|
||||
|
|
@ -417,6 +451,32 @@ describe("scm-github plugin", () => {
|
|||
]);
|
||||
expect(await scm.getCISummary(pr)).toBe("none");
|
||||
});
|
||||
|
||||
it('returns "none" for stale/unknown check states instead of false failing', async () => {
|
||||
mockGh([
|
||||
{ name: "a", state: "STALE" },
|
||||
{ name: "b", state: "SOME_NEW_STATE" },
|
||||
]);
|
||||
expect(await scm.getCISummary(pr)).toBe("none");
|
||||
});
|
||||
|
||||
it('uses fallback checks source before reporting "failing"', async () => {
|
||||
mockGhError('gh pr checks failed: Unknown JSON field "state"');
|
||||
mockGh({
|
||||
statusCheckRollup: [
|
||||
{
|
||||
__typename: "CheckRun",
|
||||
name: "Build",
|
||||
status: "COMPLETED",
|
||||
conclusion: "SUCCESS",
|
||||
detailsUrl: "https://ci/build",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(await scm.getCISummary(pr)).toBe("passing");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getReviews --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -36,6 +36,70 @@ async function gh(args: string[]): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
function getErrorText(err: unknown): string {
|
||||
if (!(err instanceof Error)) return "";
|
||||
|
||||
const details: string[] = [err.message];
|
||||
const withIo = err as Error & { stderr?: string; stdout?: string; cause?: unknown };
|
||||
if (typeof withIo.stderr === "string") details.push(withIo.stderr);
|
||||
if (typeof withIo.stdout === "string") details.push(withIo.stdout);
|
||||
if (withIo.cause instanceof Error) details.push(getErrorText(withIo.cause));
|
||||
|
||||
return details.join("\n").toLowerCase();
|
||||
}
|
||||
|
||||
function isUnknownJsonFieldError(err: unknown, fieldName: string): boolean {
|
||||
const text = getErrorText(err);
|
||||
if (!text) return false;
|
||||
|
||||
const unknownFieldSignals =
|
||||
text.includes("unknown json field") ||
|
||||
text.includes("unknown field") ||
|
||||
text.includes("invalid field");
|
||||
|
||||
return unknownFieldSignals && text.includes(fieldName.toLowerCase());
|
||||
}
|
||||
|
||||
async function ghIssueViewJson(identifier: string, project: ProjectConfig): Promise<string> {
|
||||
const fieldsWithStateReason = "number,title,body,url,state,stateReason,labels,assignees";
|
||||
try {
|
||||
return await gh([
|
||||
"issue",
|
||||
"view",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
fieldsWithStateReason,
|
||||
]);
|
||||
} catch (err) {
|
||||
if (!isUnknownJsonFieldError(err, "stateReason")) throw err;
|
||||
return gh([
|
||||
"issue",
|
||||
"view",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
"number,title,body,url,state,labels,assignees",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function ghIssueListJson(args: string[]): Promise<string> {
|
||||
const withStateReason = [
|
||||
...args,
|
||||
"--json",
|
||||
"number,title,body,url,state,stateReason,labels,assignees",
|
||||
];
|
||||
try {
|
||||
return await gh(withStateReason);
|
||||
} catch (err) {
|
||||
if (!isUnknownJsonFieldError(err, "stateReason")) throw err;
|
||||
return gh([...args, "--json", "number,title,body,url,state,labels,assignees"]);
|
||||
}
|
||||
}
|
||||
|
||||
function mapState(ghState: string, stateReason?: string | null): Issue["state"] {
|
||||
const s = ghState.toUpperCase();
|
||||
if (s === "CLOSED") {
|
||||
|
|
@ -54,15 +118,7 @@ function createGitHubTracker(): Tracker {
|
|||
name: "github",
|
||||
|
||||
async getIssue(identifier: string, project: ProjectConfig): Promise<Issue> {
|
||||
const raw = await gh([
|
||||
"issue",
|
||||
"view",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
"number,title,body,url,state,stateReason,labels,assignees",
|
||||
]);
|
||||
const raw = await ghIssueViewJson(identifier, project);
|
||||
|
||||
const data: {
|
||||
number: number;
|
||||
|
|
@ -70,7 +126,7 @@ function createGitHubTracker(): Tracker {
|
|||
body: string;
|
||||
url: string;
|
||||
state: string;
|
||||
stateReason: string | null;
|
||||
stateReason?: string | null;
|
||||
labels: Array<{ name: string }>;
|
||||
assignees: Array<{ login: string }>;
|
||||
} = JSON.parse(raw);
|
||||
|
|
@ -153,8 +209,6 @@ function createGitHubTracker(): Tracker {
|
|||
"list",
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
"number,title,body,url,state,stateReason,labels,assignees",
|
||||
"--limit",
|
||||
String(filters.limit ?? 30),
|
||||
];
|
||||
|
|
@ -175,14 +229,14 @@ function createGitHubTracker(): Tracker {
|
|||
args.push("--assignee", filters.assignee);
|
||||
}
|
||||
|
||||
const raw = await gh(args);
|
||||
const raw = await ghIssueListJson(args);
|
||||
const issues: Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
state: string;
|
||||
stateReason: string | null;
|
||||
stateReason?: string | null;
|
||||
labels: Array<{ name: string }>;
|
||||
assignees: Array<{ login: string }>;
|
||||
}> = JSON.parse(raw);
|
||||
|
|
|
|||
|
|
@ -124,6 +124,41 @@ describe("tracker-github plugin", () => {
|
|||
await expect(tracker.getIssue("999", project)).rejects.toThrow("issue not found");
|
||||
});
|
||||
|
||||
it("falls back when gh does not support stateReason field", async () => {
|
||||
mockGhError('gh issue view failed: Unknown JSON field "stateReason"');
|
||||
mockGh({ ...sampleIssue, stateReason: undefined });
|
||||
|
||||
const issue = await tracker.getIssue("123", project);
|
||||
|
||||
expect(issue.state).toBe("open");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
expect(ghMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("state,stateReason")]),
|
||||
);
|
||||
expect(ghMock.mock.calls[1]?.[1]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("state,labels,assignees")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back on alternative unknown-field phrasing for stateReason", async () => {
|
||||
mockGhError("gh issue view failed: invalid field 'stateReason'");
|
||||
mockGh({ ...sampleIssue, stateReason: undefined });
|
||||
|
||||
const issue = await tracker.getIssue("123", project);
|
||||
|
||||
expect(issue.state).toBe("open");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not swallow unrelated unknown-field errors", async () => {
|
||||
mockGhError('gh issue view failed: Unknown JSON field "milestone"');
|
||||
|
||||
await expect(tracker.getIssue("123", project)).rejects.toThrow(
|
||||
'Unknown JSON field "milestone"',
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws on malformed JSON response", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "not json{" });
|
||||
await expect(tracker.getIssue("123", project)).rejects.toThrow();
|
||||
|
|
@ -279,6 +314,33 @@ describe("tracker-github plugin", () => {
|
|||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to legacy JSON fields when stateReason is unsupported", async () => {
|
||||
mockGhError('gh issue list failed: Unknown JSON field "stateReason"');
|
||||
mockGh([{ ...sampleIssue, stateReason: undefined }]);
|
||||
|
||||
const issues = await tracker.listIssues!({}, project);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.id).toBe("123");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
expect(ghMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("state,stateReason")]),
|
||||
);
|
||||
expect(ghMock.mock.calls[1]?.[1]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("state,labels,assignees")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back for list when stateReason error uses alternate wording", async () => {
|
||||
mockGhError("gh issue list failed: unknown field stateReason");
|
||||
mockGh([{ ...sampleIssue, stateReason: undefined }]);
|
||||
|
||||
const issues = await tracker.listIssues!({}, project);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- updateIssue -------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"dependencies": {
|
||||
"@composio/ao-core": "workspace:*",
|
||||
"@composio/ao-plugin-agent-claude-code": "workspace:*",
|
||||
"@composio/ao-plugin-agent-opencode": "workspace:*",
|
||||
"@composio/ao-plugin-runtime-tmux": "workspace:*",
|
||||
"@composio/ao-plugin-scm-github": "workspace:*",
|
||||
"@composio/ao-plugin-tracker-github": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
SessionNotFoundError,
|
||||
SessionNotRestorableError,
|
||||
SessionNotFoundError,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type SCM,
|
||||
} from "@composio/ao-core";
|
||||
import * as serialize from "@/lib/serialize";
|
||||
import { getSCM } from "@/lib/services";
|
||||
|
||||
// ── Mock Data ─────────────────────────────────────────────────────────
|
||||
// Provides test sessions covering the key states the dashboard needs.
|
||||
|
|
@ -73,20 +77,21 @@ const mockSessionManager: SessionManager = {
|
|||
),
|
||||
kill: vi.fn(async (id: string) => {
|
||||
if (!testSessions.find((s) => s.id === id)) {
|
||||
throw new Error(`Session ${id} not found`);
|
||||
throw new SessionNotFoundError(id);
|
||||
}
|
||||
}),
|
||||
send: vi.fn(async (id: string) => {
|
||||
if (!testSessions.find((s) => s.id === id)) {
|
||||
throw new Error(`Session ${id} not found`);
|
||||
throw new SessionNotFoundError(id);
|
||||
}
|
||||
}),
|
||||
cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
remap: vi.fn(async () => "ses_mock"),
|
||||
restore: vi.fn(async (id: string) => {
|
||||
const session = testSessions.find((s) => s.id === id);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${id} not found`);
|
||||
throw new SessionNotFoundError(id);
|
||||
}
|
||||
// Simulate SessionNotRestorableError for non-terminal sessions
|
||||
if (session.status === "working" && session.activity !== "exited") {
|
||||
|
|
@ -159,8 +164,10 @@ vi.mock("@/lib/services", () => ({
|
|||
import { GET as sessionsGET } from "@/app/api/sessions/route";
|
||||
import { POST as spawnPOST } from "@/app/api/spawn/route";
|
||||
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
|
||||
import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route";
|
||||
import { POST as killPOST } from "@/app/api/sessions/[id]/kill/route";
|
||||
import { POST as restorePOST } from "@/app/api/sessions/[id]/restore/route";
|
||||
import { POST as remapPOST } from "@/app/api/sessions/[id]/remap/route";
|
||||
import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route";
|
||||
import { GET as eventsGET } from "@/app/api/events/route";
|
||||
|
||||
|
|
@ -214,6 +221,24 @@ describe("API Routes", () => {
|
|||
expect(session).toHaveProperty("activity");
|
||||
expect(session).toHaveProperty("createdAt");
|
||||
});
|
||||
|
||||
it("skips PR enrichment when metadata enrichment hits timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const metadataSpy = vi
|
||||
.spyOn(serialize, "enrichSessionsMetadata")
|
||||
.mockImplementation(() => new Promise<void>(() => {}));
|
||||
|
||||
const responsePromise = sessionsGET(makeRequest("http://localhost:3000/api/sessions"));
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
const res = await responsePromise;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(getSCM).not.toHaveBeenCalled();
|
||||
|
||||
metadataSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/spawn ────────────────────────────────────────────────
|
||||
|
|
@ -286,7 +311,7 @@ describe("API Routes", () => {
|
|||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
(mockSessionManager.send as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Session nonexistent not found"),
|
||||
new SessionNotFoundError("nonexistent"),
|
||||
);
|
||||
const req = makeRequest("/api/sessions/nonexistent/send", {
|
||||
method: "POST",
|
||||
|
|
@ -332,6 +357,69 @@ describe("API Routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("POST /api/sessions/:id/message", () => {
|
||||
it("sends a message to a valid session", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: "Fix the tests" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
(mockSessionManager.send as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new SessionNotFoundError("nonexistent"),
|
||||
);
|
||||
|
||||
const req = makeRequest("/api/sessions/nonexistent/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: "hello" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const res = await messagePOST(req, { params: Promise.resolve({ id: "nonexistent" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when message is missing", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/message/);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON body", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/message", {
|
||||
method: "POST",
|
||||
body: "not json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for control-char-only message", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: "\x00\x01\x02" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/empty/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/sessions/:id/kill ────────────────────────────────────
|
||||
|
||||
describe("POST /api/sessions/:id/kill", () => {
|
||||
|
|
@ -346,7 +434,7 @@ describe("API Routes", () => {
|
|||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
(mockSessionManager.kill as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Session nonexistent not found"),
|
||||
new SessionNotFoundError("nonexistent"),
|
||||
);
|
||||
const req = makeRequest("/api/sessions/nonexistent/kill", { method: "POST" });
|
||||
const res = await killPOST(req, { params: Promise.resolve({ id: "nonexistent" }) });
|
||||
|
|
@ -381,6 +469,38 @@ describe("API Routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("POST /api/sessions/:id/remap", () => {
|
||||
it("remaps a valid session", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/remap", { method: "POST" });
|
||||
const res = await remapPOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.opencodeSessionId).toBe("ses_mock");
|
||||
expect(mockSessionManager.remap).toHaveBeenCalledWith("backend-3", true);
|
||||
});
|
||||
|
||||
it("returns 404 when session is missing", async () => {
|
||||
(mockSessionManager.remap as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new SessionNotFoundError("missing"),
|
||||
);
|
||||
const req = makeRequest("/api/sessions/missing/remap", { method: "POST" });
|
||||
const res = await remapPOST(req, { params: Promise.resolve({ id: "missing" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 422 for non-opencode sessions", async () => {
|
||||
(mockSessionManager.remap as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Session backend-3 is not using the opencode agent"),
|
||||
);
|
||||
const req = makeRequest("/api/sessions/backend-3/remap", { method: "POST" });
|
||||
const res = await remapPOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(422);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/not using the opencode agent/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/prs/:id/merge ────────────────────────────────────────
|
||||
|
||||
describe("POST /api/prs/:id/merge", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockLoadConfig,
|
||||
mockRegister,
|
||||
mockCreateSessionManager,
|
||||
mockRegistry,
|
||||
tmuxPlugin,
|
||||
claudePlugin,
|
||||
opencodePlugin,
|
||||
worktreePlugin,
|
||||
scmPlugin,
|
||||
trackerGithubPlugin,
|
||||
trackerLinearPlugin,
|
||||
} = vi.hoisted(() => {
|
||||
const mockLoadConfig = vi.fn();
|
||||
const mockRegister = vi.fn();
|
||||
const mockCreateSessionManager = vi.fn();
|
||||
const mockRegistry = {
|
||||
register: mockRegister,
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
loadBuiltins: vi.fn(),
|
||||
loadFromConfig: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
mockLoadConfig,
|
||||
mockRegister,
|
||||
mockCreateSessionManager,
|
||||
mockRegistry,
|
||||
tmuxPlugin: { manifest: { name: "tmux" } },
|
||||
claudePlugin: { manifest: { name: "claude-code" } },
|
||||
opencodePlugin: { manifest: { name: "opencode" } },
|
||||
worktreePlugin: { manifest: { name: "worktree" } },
|
||||
scmPlugin: { manifest: { name: "github" } },
|
||||
trackerGithubPlugin: { manifest: { name: "github" } },
|
||||
trackerLinearPlugin: { manifest: { name: "linear" } },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@composio/ao-core", () => ({
|
||||
loadConfig: mockLoadConfig,
|
||||
createPluginRegistry: () => mockRegistry,
|
||||
createSessionManager: mockCreateSessionManager,
|
||||
}));
|
||||
|
||||
vi.mock("@composio/ao-plugin-runtime-tmux", () => ({ default: tmuxPlugin }));
|
||||
vi.mock("@composio/ao-plugin-agent-claude-code", () => ({ default: claudePlugin }));
|
||||
vi.mock("@composio/ao-plugin-agent-opencode", () => ({ default: opencodePlugin }));
|
||||
vi.mock("@composio/ao-plugin-workspace-worktree", () => ({ default: worktreePlugin }));
|
||||
vi.mock("@composio/ao-plugin-scm-github", () => ({ default: scmPlugin }));
|
||||
vi.mock("@composio/ao-plugin-tracker-github", () => ({ default: trackerGithubPlugin }));
|
||||
vi.mock("@composio/ao-plugin-tracker-linear", () => ({ default: trackerLinearPlugin }));
|
||||
|
||||
describe("services", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockRegister.mockClear();
|
||||
mockCreateSessionManager.mockReset();
|
||||
mockLoadConfig.mockReset();
|
||||
mockLoadConfig.mockReturnValue({
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {},
|
||||
notifiers: {},
|
||||
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
||||
reactions: {},
|
||||
});
|
||||
mockCreateSessionManager.mockReturnValue({});
|
||||
delete (globalThis as typeof globalThis & { _aoServices?: unknown })._aoServices;
|
||||
delete (globalThis as typeof globalThis & { _aoServicesInit?: unknown })._aoServicesInit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as typeof globalThis & { _aoServices?: unknown })._aoServices;
|
||||
delete (globalThis as typeof globalThis & { _aoServicesInit?: unknown })._aoServicesInit;
|
||||
});
|
||||
|
||||
it("registers the OpenCode agent plugin with web services", async () => {
|
||||
const { getServices } = await import("../lib/services");
|
||||
|
||||
await getServices();
|
||||
|
||||
expect(mockRegister).toHaveBeenCalledWith(opencodePlugin);
|
||||
});
|
||||
|
||||
it("caches initialized services across repeated calls", async () => {
|
||||
const { getServices } = await import("../lib/services");
|
||||
|
||||
const first = await getServices();
|
||||
const second = await getServices();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(mockCreateSessionManager).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { SessionNotFoundError } from "@composio/ao-core";
|
||||
|
||||
/** POST /api/sessions/:id/kill — Kill a session */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
|
@ -15,8 +16,10 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
|
|||
await sessionManager.kill(id);
|
||||
return NextResponse.json({ ok: true, sessionId: id });
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 404 });
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Failed to kill session";
|
||||
const status = msg.includes("not found") ? 404 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { stripControlChars, validateIdentifier, validateString } from "@/lib/validation";
|
||||
import type { Runtime } from "@composio/ao-core";
|
||||
import { SessionNotFoundError } from "@composio/ao-core";
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 10_000;
|
||||
|
||||
|
|
@ -46,34 +46,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||
);
|
||||
}
|
||||
|
||||
const { sessionManager, registry } = await getServices();
|
||||
const session = await sessionManager.get(id);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!session.runtimeHandle) {
|
||||
return NextResponse.json({ error: "Session has no runtime handle" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the runtime plugin that was used to create this session
|
||||
// Use the runtime from the session handle, not from current project config
|
||||
const runtimeName = session.runtimeHandle.runtimeName;
|
||||
const runtime = registry.get<Runtime>("runtime", runtimeName);
|
||||
if (!runtime) {
|
||||
return NextResponse.json(
|
||||
{ error: `Runtime plugin '${runtimeName}' not found` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { sessionManager } = await getServices();
|
||||
try {
|
||||
// Use the Runtime plugin's sendMessage method which handles sanitization
|
||||
// and uses the correct runtime handle
|
||||
await runtime.sendMessage(session.runtimeHandle, message);
|
||||
await sessionManager.send(id, message);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 404 });
|
||||
}
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error("Failed to send message:", errorMsg);
|
||||
return NextResponse.json({ error: `Failed to send message: ${errorMsg}` }, { status: 500 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { SessionNotFoundError } from "@composio/ao-core";
|
||||
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const idErr = validateIdentifier(id, "id");
|
||||
if (idErr) {
|
||||
return NextResponse.json({ error: idErr }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
const opencodeSessionId = await sessionManager.remap(id, true);
|
||||
return NextResponse.json({ ok: true, sessionId: id, opencodeSessionId });
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 404 });
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Failed to remap session";
|
||||
if (msg.includes("not using the opencode agent") || msg.includes("mapping is missing")) {
|
||||
return NextResponse.json({ error: msg }, { status: 422 });
|
||||
}
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||
import { validateIdentifier } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
import { SessionNotRestorableError, WorkspaceMissingError } from "@composio/ao-core";
|
||||
import {
|
||||
SessionNotRestorableError,
|
||||
WorkspaceMissingError,
|
||||
SessionNotFoundError,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
/** POST /api/sessions/:id/restore — Restore a terminated session */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
|
@ -22,6 +26,9 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
|
|||
session: sessionToDashboard(restored),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 404 });
|
||||
}
|
||||
if (err instanceof SessionNotRestorableError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 409 });
|
||||
}
|
||||
|
|
@ -29,7 +36,6 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
|
|||
return NextResponse.json({ error: err.message }, { status: 422 });
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Failed to restore session";
|
||||
const status = msg.includes("not found") ? 404 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { validateIdentifier, validateString, stripControlChars } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { SessionNotFoundError } from "@composio/ao-core";
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 10_000;
|
||||
|
||||
|
|
@ -34,8 +35,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||
await sessionManager.send(id, message);
|
||||
return NextResponse.json({ ok: true, sessionId: id, message });
|
||||
} catch (err) {
|
||||
if (err instanceof SessionNotFoundError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 404 });
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Failed to send message";
|
||||
const status = msg.includes("not found") ? 404 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,25 @@ import {
|
|||
computeStats,
|
||||
} from "@/lib/serialize";
|
||||
|
||||
const METADATA_ENRICH_TIMEOUT_MS = 3_000;
|
||||
const PR_ENRICH_TIMEOUT_MS = 4_000;
|
||||
const PER_PR_ENRICH_TIMEOUT_MS = 1_500;
|
||||
|
||||
async function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<boolean>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(false), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise.then(() => true).catch(() => true), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/sessions — List all sessions with full state
|
||||
* Query params:
|
||||
* - active=true: Only return non-exited sessions
|
||||
|
|
@ -40,20 +59,30 @@ export async function GET(request: Request) {
|
|||
dashboardSessions = activeIndices.map((i) => dashboardSessions[i]);
|
||||
}
|
||||
|
||||
// Enrich metadata (issue labels, agent summaries, issue titles) — cap at 3s
|
||||
const metaTimeout = new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
||||
await Promise.race([enrichSessionsMetadata(workerSessions, dashboardSessions, config, registry), metaTimeout]);
|
||||
const metadataSettled = await settlesWithin(
|
||||
enrichSessionsMetadata(workerSessions, dashboardSessions, config, registry),
|
||||
METADATA_ENRICH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Enrich sessions that have PRs with live SCM data (CI, reviews, mergeability)
|
||||
const enrichPromises = workerSessions.map((core, i) => {
|
||||
if (!core.pr) return Promise.resolve();
|
||||
const project = resolveProject(core, config.projects);
|
||||
const scm = getSCM(registry, project);
|
||||
if (!scm) return Promise.resolve();
|
||||
return enrichSessionPR(dashboardSessions[i], scm, core.pr);
|
||||
});
|
||||
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 4_000));
|
||||
await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]);
|
||||
if (metadataSettled) {
|
||||
const prDeadlineAt = Date.now() + PR_ENRICH_TIMEOUT_MS;
|
||||
for (let i = 0; i < workerSessions.length; i++) {
|
||||
const core = workerSessions[i];
|
||||
if (!core?.pr) continue;
|
||||
|
||||
const remainingMs = prDeadlineAt - Date.now();
|
||||
if (remainingMs <= 0) break;
|
||||
|
||||
const project = resolveProject(core, config.projects);
|
||||
const scm = getSCM(registry, project);
|
||||
if (!scm) continue;
|
||||
|
||||
await settlesWithin(
|
||||
enrichSessionPR(dashboardSessions[i], scm, core.pr),
|
||||
Math.min(remainingMs, PER_PR_ENRICH_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: dashboardSessions,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ interface DirectTerminalProps {
|
|||
/** CSS height for the terminal container in normal (non-fullscreen) mode.
|
||||
* Defaults to "max(440px, calc(100vh - 440px))". */
|
||||
height?: string;
|
||||
isOpenCodeSession?: boolean;
|
||||
reloadCommand?: string;
|
||||
}
|
||||
|
||||
interface DirectTerminalLocation {
|
||||
|
|
@ -70,6 +72,8 @@ export function DirectTerminal({
|
|||
startFullscreen = false,
|
||||
variant = "agent",
|
||||
height = "max(440px, calc(100vh - 440px))",
|
||||
isOpenCodeSession = false,
|
||||
reloadCommand,
|
||||
}: DirectTerminalProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
|
@ -85,6 +89,8 @@ export function DirectTerminal({
|
|||
const [fullscreen, setFullscreen] = useState(startFullscreen);
|
||||
const [status, setStatus] = useState<"connecting" | "connected" | "error">("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [reloadError, setReloadError] = useState<string | null>(null);
|
||||
|
||||
// Update URL when fullscreen changes
|
||||
useEffect(() => {
|
||||
|
|
@ -100,6 +106,45 @@ export function DirectTerminal({
|
|||
router.replace(newUrl, { scroll: false });
|
||||
}, [fullscreen, pathname, router, searchParams]);
|
||||
|
||||
async function handleReload(): Promise<void> {
|
||||
if (!isOpenCodeSession || reloading) return;
|
||||
setReloadError(null);
|
||||
setReloading(true);
|
||||
try {
|
||||
let commandToSend = reloadCommand;
|
||||
|
||||
if (!commandToSend) {
|
||||
const remapRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/remap`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!remapRes.ok) {
|
||||
throw new Error(`Failed to remap OpenCode session: ${remapRes.status}`);
|
||||
}
|
||||
const remapData = (await remapRes.json()) as { opencodeSessionId?: unknown };
|
||||
if (
|
||||
typeof remapData.opencodeSessionId !== "string" ||
|
||||
remapData.opencodeSessionId.length === 0
|
||||
) {
|
||||
throw new Error("Missing OpenCode session id after remap");
|
||||
}
|
||||
commandToSend = `/exit\nopencode --session ${remapData.opencodeSessionId}\n`;
|
||||
}
|
||||
|
||||
const sendRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: commandToSend }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
throw new Error(`Failed to send reload command: ${sendRes.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setReloadError(err instanceof Error ? err.message : "Failed to reload OpenCode session");
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
|
|
@ -127,9 +172,7 @@ export function DirectTerminal({
|
|||
// agent = blue (#5b7ef8), orchestrator = violet (#a371f7)
|
||||
const cursorColor = variant === "orchestrator" ? "#a371f7" : "#5b7ef8";
|
||||
const selectionColor =
|
||||
variant === "orchestrator"
|
||||
? "rgba(163, 113, 247, 0.25)"
|
||||
: "rgba(91, 126, 248, 0.3)";
|
||||
variant === "orchestrator" ? "rgba(163, 113, 247, 0.25)" : "rgba(91, 126, 248, 0.3)";
|
||||
|
||||
// Initialize xterm.js Terminal
|
||||
const terminal = new Terminal({
|
||||
|
|
@ -143,22 +186,22 @@ export function DirectTerminal({
|
|||
cursorAccent: "#0a0a0f",
|
||||
selectionBackground: selectionColor,
|
||||
// ANSI colors — slightly warmer than pure defaults
|
||||
black: "#1a1a24",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#f59e0b",
|
||||
blue: "#5b7ef8",
|
||||
magenta: "#a371f7",
|
||||
cyan: "#22d3ee",
|
||||
white: "#d4d4d8",
|
||||
brightBlack: "#50506a",
|
||||
brightRed: "#f87171",
|
||||
brightGreen: "#4ade80",
|
||||
brightYellow: "#fbbf24",
|
||||
brightBlue: "#7b9cfb",
|
||||
black: "#1a1a24",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#f59e0b",
|
||||
blue: "#5b7ef8",
|
||||
magenta: "#a371f7",
|
||||
cyan: "#22d3ee",
|
||||
white: "#d4d4d8",
|
||||
brightBlack: "#50506a",
|
||||
brightRed: "#f87171",
|
||||
brightGreen: "#4ade80",
|
||||
brightYellow: "#fbbf24",
|
||||
brightBlue: "#7b9cfb",
|
||||
brightMagenta: "#c084fc",
|
||||
brightCyan: "#67e8f9",
|
||||
brightWhite: "#eeeef5",
|
||||
brightCyan: "#67e8f9",
|
||||
brightWhite: "#eeeef5",
|
||||
},
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
|
|
@ -239,7 +282,10 @@ export function DirectTerminal({
|
|||
const MAX_BUFFER_BYTES = 1_048_576; // 1 MB
|
||||
|
||||
const flushWriteBuffer = () => {
|
||||
if (safetyTimer) { clearTimeout(safetyTimer); safetyTimer = null; }
|
||||
if (safetyTimer) {
|
||||
clearTimeout(safetyTimer);
|
||||
safetyTimer = null;
|
||||
}
|
||||
if (writeBuffer.length > 0) {
|
||||
terminal.write(writeBuffer.join(""));
|
||||
writeBuffer.length = 0;
|
||||
|
|
@ -502,7 +548,8 @@ export function DirectTerminal({
|
|||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
const accentColor = variant === "orchestrator" ? "var(--color-accent-violet)" : "var(--color-accent)";
|
||||
const accentColor =
|
||||
variant === "orchestrator" ? "var(--color-accent-violet)" : "var(--color-accent)";
|
||||
|
||||
const statusDotClass =
|
||||
status === "connected"
|
||||
|
|
@ -512,11 +559,7 @@ export function DirectTerminal({
|
|||
: "bg-[var(--color-status-attention)] animate-[pulse_1.5s_ease-in-out_infinite]";
|
||||
|
||||
const statusText =
|
||||
status === "connected"
|
||||
? "Connected"
|
||||
: status === "error"
|
||||
? (error ?? "Error")
|
||||
: "Connecting…";
|
||||
status === "connected" ? "Connected" : status === "error" ? (error ?? "Error") : "Connecting…";
|
||||
|
||||
const statusTextColor =
|
||||
status === "connected"
|
||||
|
|
@ -536,13 +579,12 @@ export function DirectTerminal({
|
|||
{/* Terminal chrome bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] px-3 py-2">
|
||||
<div className={cn("h-2 w-2 shrink-0 rounded-full", statusDotClass)} />
|
||||
<span
|
||||
className="font-[var(--font-mono)] text-[11px]"
|
||||
style={{ color: accentColor }}
|
||||
>
|
||||
<span className="font-[var(--font-mono)] text-[11px]" style={{ color: accentColor }}>
|
||||
{sessionId}
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}>
|
||||
<span
|
||||
className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
{/* XDA clipboard badge */}
|
||||
|
|
@ -555,20 +597,81 @@ export function DirectTerminal({
|
|||
>
|
||||
XDA
|
||||
</span>
|
||||
{isOpenCodeSession ? (
|
||||
<button
|
||||
onClick={handleReload}
|
||||
disabled={reloading || status !== "connected"}
|
||||
title="Restart OpenCode session (/exit then resume mapped session)"
|
||||
aria-label="Restart OpenCode session"
|
||||
className="ml-auto flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{reloading ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 3a9 9 0 109 9" />
|
||||
</svg>
|
||||
restarting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21 12a9 9 0 11-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
restart
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{reloadError ? (
|
||||
<span
|
||||
className="max-w-[40ch] truncate text-[10px] font-medium text-[var(--color-status-error)]"
|
||||
title={reloadError}
|
||||
>
|
||||
{reloadError}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="ml-auto flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]"
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
|
||||
!isOpenCodeSession && "ml-auto",
|
||||
)}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3" />
|
||||
</svg>
|
||||
exit fullscreen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" />
|
||||
</svg>
|
||||
fullscreen
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@ interface SessionDetailProps {
|
|||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const activityMeta: Record<string, { label: string; color: string }> = {
|
||||
active: { label: "Active", color: "var(--color-status-working)" },
|
||||
ready: { label: "Ready", color: "var(--color-status-ready)" },
|
||||
idle: { label: "Idle", color: "var(--color-status-idle)" },
|
||||
active: { label: "Active", color: "var(--color-status-working)" },
|
||||
ready: { label: "Ready", color: "var(--color-status-ready)" },
|
||||
idle: { label: "Idle", color: "var(--color-status-idle)" },
|
||||
waiting_input: { label: "Waiting for input", color: "var(--color-status-attention)" },
|
||||
blocked: { label: "Blocked", color: "var(--color-status-error)" },
|
||||
exited: { label: "Exited", color: "var(--color-status-error)" },
|
||||
blocked: { label: "Blocked", color: "var(--color-status-error)" },
|
||||
exited: { label: "Exited", color: "var(--color-status-error)" },
|
||||
};
|
||||
|
||||
function humanizeStatus(status: string): string {
|
||||
|
|
@ -125,20 +125,23 @@ function OrchestratorStatusStrip({
|
|||
}, [createdAt]);
|
||||
|
||||
const stats: Array<{ value: number; label: string; color: string; bg: string }> = [
|
||||
{ value: zones.merge, label: "merge-ready", color: "#3fb950", bg: "rgba(63,185,80,0.1)" },
|
||||
{ value: zones.respond, label: "responding", color: "#f85149", bg: "rgba(248,81,73,0.1)" },
|
||||
{ value: zones.review, label: "review", color: "#d18616", bg: "rgba(209,134,22,0.1)" },
|
||||
{ value: zones.working, label: "working", color: "#58a6ff", bg: "rgba(88,166,255,0.1)" },
|
||||
{ value: zones.pending, label: "pending", color: "#d29922", bg: "rgba(210,153,34,0.1)" },
|
||||
{ value: zones.done, label: "done", color: "#484f58", bg: "rgba(72,79,88,0.15)" },
|
||||
{ value: zones.merge, label: "merge-ready", color: "#3fb950", bg: "rgba(63,185,80,0.1)" },
|
||||
{ value: zones.respond, label: "responding", color: "#f85149", bg: "rgba(248,81,73,0.1)" },
|
||||
{ value: zones.review, label: "review", color: "#d18616", bg: "rgba(209,134,22,0.1)" },
|
||||
{ value: zones.working, label: "working", color: "#58a6ff", bg: "rgba(88,166,255,0.1)" },
|
||||
{ value: zones.pending, label: "pending", color: "#d29922", bg: "rgba(210,153,34,0.1)" },
|
||||
{ value: zones.done, label: "done", color: "#484f58", bg: "rgba(72,79,88,0.15)" },
|
||||
].filter((s) => s.value > 0);
|
||||
|
||||
const total = zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done;
|
||||
const total =
|
||||
zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[var(--color-border-subtle)] px-8 py-4"
|
||||
style={{ background: "linear-gradient(to bottom, rgba(88,166,255,0.04) 0%, transparent 100%)" }}
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, rgba(88,166,255,0.04) 0%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-[900px] items-center gap-3 flex-wrap">
|
||||
{/* Total count */}
|
||||
|
|
@ -152,20 +155,25 @@ function OrchestratorStatusStrip({
|
|||
<div className="h-5 w-px bg-[var(--color-border-subtle)] mr-1" />
|
||||
|
||||
{/* Per-zone pills */}
|
||||
{stats.length > 0 ? stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="flex items-center gap-1.5 rounded-full px-2.5 py-1"
|
||||
style={{ background: s.bg }}
|
||||
>
|
||||
<span className="text-[15px] font-bold leading-none tabular-nums" style={{ color: s.color }}>
|
||||
{s.value}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium" style={{ color: s.color, opacity: 0.8 }}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
{stats.length > 0 ? (
|
||||
stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="flex items-center gap-1.5 rounded-full px-2.5 py-1"
|
||||
style={{ background: s.bg }}
|
||||
>
|
||||
<span
|
||||
className="text-[15px] font-bold leading-none tabular-nums"
|
||||
style={{ color: s.color }}
|
||||
>
|
||||
{s.value}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium" style={{ color: s.color, opacity: 0.8 }}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[12px] text-[var(--color-text-tertiary)]">no active agents</span>
|
||||
)}
|
||||
|
||||
|
|
@ -181,7 +189,11 @@ function OrchestratorStatusStrip({
|
|||
|
||||
// ── Main component ────────────────────────────────────────────────────
|
||||
|
||||
export function SessionDetail({ session, isOrchestrator = false, orchestratorZones }: SessionDetailProps) {
|
||||
export function SessionDetail({
|
||||
session,
|
||||
isOrchestrator = false,
|
||||
orchestratorZones,
|
||||
}: SessionDetailProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const startFullscreen = searchParams.get("fullscreen") === "true";
|
||||
const pr = session.pr;
|
||||
|
|
@ -193,9 +205,16 @@ export function SessionDetail({ session, isOrchestrator = false, orchestratorZon
|
|||
const accentColor = "var(--color-accent)";
|
||||
const terminalVariant = isOrchestrator ? "orchestrator" : "agent";
|
||||
|
||||
const terminalHeight = isOrchestrator
|
||||
? "calc(100vh - 240px)"
|
||||
: "max(440px, calc(100vh - 440px))";
|
||||
const terminalHeight = isOrchestrator ? "calc(100vh - 240px)" : "max(440px, calc(100vh - 440px))";
|
||||
const isOpenCodeSession = session.metadata["agent"] === "opencode";
|
||||
const opencodeSessionId =
|
||||
typeof session.metadata["opencodeSessionId"] === "string" &&
|
||||
session.metadata["opencodeSessionId"].length > 0
|
||||
? session.metadata["opencodeSessionId"]
|
||||
: undefined;
|
||||
const reloadCommand = opencodeSessionId
|
||||
? `/exit\nopencode --session ${opencodeSessionId}\n`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-base)]">
|
||||
|
|
@ -206,7 +225,13 @@ export function SessionDetail({ session, isOrchestrator = false, orchestratorZon
|
|||
href="/"
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] hover:no-underline"
|
||||
>
|
||||
<svg className="h-3 w-3 opacity-60" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="h-3 w-3 opacity-60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
Orchestrator
|
||||
|
|
@ -240,9 +265,7 @@ export function SessionDetail({ session, isOrchestrator = false, orchestratorZon
|
|||
<div
|
||||
className="detail-card mb-6 rounded-[8px] border border-[var(--color-border-default)] p-5"
|
||||
style={{
|
||||
borderLeft: isOrchestrator
|
||||
? `3px solid ${accentColor}`
|
||||
: `3px solid ${activity.color}`,
|
||||
borderLeft: isOrchestrator ? `3px solid ${accentColor}` : `3px solid ${activity.color}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -372,6 +395,8 @@ export function SessionDetail({ session, isOrchestrator = false, orchestratorZon
|
|||
startFullscreen={startFullscreen}
|
||||
variant={terminalVariant}
|
||||
height={terminalHeight}
|
||||
isOpenCodeSession={isOpenCodeSession}
|
||||
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -435,31 +460,55 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
|
|||
}, []);
|
||||
|
||||
const handleAskAgentToFix = async (comment: { url: string; path: string; body: string }) => {
|
||||
setSentComments((prev) => { const next = new Set(prev); next.delete(comment.url); return next; });
|
||||
setErrorComments((prev) => { const next = new Set(prev); next.delete(comment.url); return next; });
|
||||
setSentComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setErrorComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setSendingComments((prev) => new Set(prev).add(comment.url));
|
||||
|
||||
await askAgentToFix(
|
||||
sessionId,
|
||||
comment,
|
||||
() => {
|
||||
setSendingComments((prev) => { const next = new Set(prev); next.delete(comment.url); return next; });
|
||||
setSendingComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setSentComments((prev) => new Set(prev).add(comment.url));
|
||||
const existing = timersRef.current.get(comment.url);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
setSentComments((prev) => { const next = new Set(prev); next.delete(comment.url); return next; });
|
||||
setSentComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
timersRef.current.delete(comment.url);
|
||||
}, 3000);
|
||||
timersRef.current.set(comment.url, timer);
|
||||
},
|
||||
() => {
|
||||
setSendingComments((prev) => { const next = new Set(prev); next.delete(comment.url); return next; });
|
||||
setSendingComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
setErrorComments((prev) => new Set(prev).add(comment.url));
|
||||
const existing = timersRef.current.get(comment.url);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
setErrorComments((prev) => { const next = new Set(prev); next.delete(comment.url); return next; });
|
||||
setErrorComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
return next;
|
||||
});
|
||||
timersRef.current.delete(comment.url);
|
||||
}, 3000);
|
||||
timersRef.current.set(comment.url, timer);
|
||||
|
|
@ -478,10 +527,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
|
|||
: "var(--color-border-default)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="detail-card mb-6 overflow-hidden rounded-[8px] border"
|
||||
style={{ borderColor }}
|
||||
>
|
||||
<div className="detail-card mb-6 overflow-hidden rounded-[8px] border" style={{ borderColor }}>
|
||||
{/* Title row */}
|
||||
<div className="border-b border-[var(--color-border-subtle)] px-5 py-3.5">
|
||||
<a
|
||||
|
|
@ -522,7 +568,13 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
|
|||
{/* Ready-to-merge banner */}
|
||||
{allGreen ? (
|
||||
<div className="flex items-center gap-2 rounded-[5px] border border-[rgba(63,185,80,0.25)] bg-[rgba(63,185,80,0.07)] px-3.5 py-2.5">
|
||||
<svg className="h-4 w-4 shrink-0 text-[var(--color-status-ready)]" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="h-4 w-4 shrink-0 text-[var(--color-status-ready)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
<span className="text-[13px] font-semibold text-[var(--color-status-ready)]">
|
||||
|
|
@ -536,7 +588,10 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
|
|||
{/* CI Checks */}
|
||||
{pr.ciChecks.length > 0 && (
|
||||
<div className="mt-4 border-t border-[var(--color-border-subtle)] pt-4">
|
||||
<CICheckList checks={pr.ciChecks} layout={failedChecks.length > 0 ? "expanded" : "inline"} />
|
||||
<CICheckList
|
||||
checks={pr.ciChecks}
|
||||
layout={failedChecks.length > 0 ? "expanded" : "inline"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -567,7 +622,9 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
|
|||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="font-medium text-[var(--color-text-secondary)]">{title}</span>
|
||||
<span className="font-medium text-[var(--color-text-secondary)]">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-tertiary)]">· {c.author}</span>
|
||||
<a
|
||||
href={c.url}
|
||||
|
|
@ -628,9 +685,10 @@ function IssuesList({ pr }: { pr: DashboardPR }) {
|
|||
issues.push({
|
||||
icon: "✗",
|
||||
color: "var(--color-status-error)",
|
||||
text: failCount > 0
|
||||
? `CI failing — ${failCount} check${failCount !== 1 ? "s" : ""} failed`
|
||||
: "CI failing",
|
||||
text:
|
||||
failCount > 0
|
||||
? `CI failing — ${failCount} check${failCount !== 1 ? "s" : ""} failed`
|
||||
: "CI failing",
|
||||
});
|
||||
} else if (pr.ciStatus === CI_STATUS.PENDING) {
|
||||
issues.push({ icon: "●", color: "var(--color-status-attention)", text: "CI pending" });
|
||||
|
|
@ -639,7 +697,11 @@ function IssuesList({ pr }: { pr: DashboardPR }) {
|
|||
if (pr.reviewDecision === "changes_requested") {
|
||||
issues.push({ icon: "✗", color: "var(--color-status-error)", text: "Changes requested" });
|
||||
} else if (!pr.mergeability.approved) {
|
||||
issues.push({ icon: "○", color: "var(--color-text-tertiary)", text: "Not approved — awaiting reviewer" });
|
||||
issues.push({
|
||||
icon: "○",
|
||||
color: "var(--color-text-tertiary)",
|
||||
text: "Not approved — awaiting reviewer",
|
||||
});
|
||||
}
|
||||
|
||||
if (pr.state !== "merged" && !pr.mergeability.noConflicts) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
createSessionManager,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type SessionManager,
|
||||
type OpenCodeSessionManager,
|
||||
type SCM,
|
||||
type ProjectConfig,
|
||||
} from "@composio/ao-core";
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
// Static plugin imports — webpack needs these to be string literals
|
||||
import pluginRuntimeTmux from "@composio/ao-plugin-runtime-tmux";
|
||||
import pluginAgentClaudeCode from "@composio/ao-plugin-agent-claude-code";
|
||||
import pluginAgentOpencode from "@composio/ao-plugin-agent-opencode";
|
||||
import pluginWorkspaceWorktree from "@composio/ao-plugin-workspace-worktree";
|
||||
import pluginScmGithub from "@composio/ao-plugin-scm-github";
|
||||
import pluginTrackerGithub from "@composio/ao-plugin-tracker-github";
|
||||
|
|
@ -32,7 +33,7 @@ import pluginTrackerLinear from "@composio/ao-plugin-tracker-linear";
|
|||
export interface Services {
|
||||
config: OrchestratorConfig;
|
||||
registry: PluginRegistry;
|
||||
sessionManager: SessionManager;
|
||||
sessionManager: OpenCodeSessionManager;
|
||||
}
|
||||
|
||||
// Cache in globalThis for Next.js HMR stability
|
||||
|
|
@ -64,6 +65,7 @@ async function initServices(): Promise<Services> {
|
|||
// Register plugins explicitly (webpack can't handle dynamic import() in core)
|
||||
registry.register(pluginRuntimeTmux);
|
||||
registry.register(pluginAgentClaudeCode);
|
||||
registry.register(pluginAgentOpencode);
|
||||
registry.register(pluginWorkspaceWorktree);
|
||||
registry.register(pluginScmGithub);
|
||||
registry.register(pluginTrackerGithub);
|
||||
|
|
@ -81,4 +83,3 @@ export function getSCM(registry: PluginRegistry, project: ProjectConfig | undefi
|
|||
if (!project?.scm) return null;
|
||||
return registry.get<SCM>("scm", project.scm.plugin);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,11 @@ export function validateIdentifier(
|
|||
* Strip control characters (U+0000–U+001F, U+007F–U+009F) from a string.
|
||||
* Critical for messages that may be passed to shell-based runtimes (tmux send-keys, etc.)
|
||||
* to prevent command injection via control sequences.
|
||||
*
|
||||
* NOTE: Newline (0x0a) and carriage return (0x0d) are explicitly excluded from stripping
|
||||
* to allow reload commands (e.g., "reload\nconfirm") to work correctly.
|
||||
*/
|
||||
export function stripControlChars(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
||||
return value.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
||||
}
|
||||
|
|
|
|||
289
pnpm-lock.yaml
289
pnpm-lock.yaml
|
|
@ -53,6 +53,9 @@ importers:
|
|||
'@composio/ao-plugin-agent-codex':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/agent-codex
|
||||
'@composio/ao-plugin-agent-opencode':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/agent-opencode
|
||||
'@composio/ao-plugin-notifier-composio':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/notifier-composio
|
||||
|
|
@ -108,6 +111,9 @@ importers:
|
|||
'@types/node':
|
||||
specifier: ^25.2.3
|
||||
version: 25.2.3
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(vitest@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.21.0
|
||||
|
|
@ -130,6 +136,9 @@ importers:
|
|||
'@types/node':
|
||||
specifier: ^25.2.3
|
||||
version: 25.2.3
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
|
@ -191,6 +200,9 @@ importers:
|
|||
'@types/node':
|
||||
specifier: ^25.2.3
|
||||
version: 25.2.3
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(vitest@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
|
@ -481,6 +493,9 @@ importers:
|
|||
'@composio/ao-plugin-agent-claude-code':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/agent-claude-code
|
||||
'@composio/ao-plugin-agent-opencode':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/agent-opencode
|
||||
'@composio/ao-plugin-runtime-tmux':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/runtime-tmux
|
||||
|
|
@ -598,6 +613,10 @@ packages:
|
|||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
|
||||
|
|
@ -688,6 +707,10 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@cfworker/json-schema@4.1.1':
|
||||
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
|
||||
|
||||
|
|
@ -1345,6 +1368,10 @@ packages:
|
|||
resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@isaacs/cliui@9.0.0':
|
||||
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1353,6 +1380,10 @@ packages:
|
|||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@istanbuljs/schema@0.1.3':
|
||||
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
|
|
@ -1491,6 +1522,10 @@ packages:
|
|||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
|
|
@ -1864,6 +1899,24 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/coverage-v8@3.2.4':
|
||||
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 3.2.4
|
||||
vitest: 3.2.4
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.0.18':
|
||||
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.18
|
||||
vitest: 4.0.18
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
|
|
@ -2008,6 +2061,10 @@ packages:
|
|||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
argparse@1.0.10:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
|
|
@ -2029,6 +2086,9 @@ packages:
|
|||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
|
|
@ -2263,6 +2323,9 @@ packages:
|
|||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
electron-to-chromium@1.5.286:
|
||||
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
|
||||
|
||||
|
|
@ -2272,6 +2335,9 @@ packages:
|
|||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
encoding@0.1.13:
|
||||
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
|
||||
|
||||
|
|
@ -2472,6 +2538,10 @@ packages:
|
|||
debug:
|
||||
optional: true
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -2532,6 +2602,11 @@ packages:
|
|||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@13.0.3:
|
||||
resolution: {integrity: sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
|
@ -2567,6 +2642,9 @@ packages:
|
|||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
|
|
@ -2682,6 +2760,25 @@ packages:
|
|||
resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jackspeak@4.2.3:
|
||||
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
|
@ -2693,6 +2790,9 @@ packages:
|
|||
js-tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
|
@ -2876,6 +2976,16 @@ packages:
|
|||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.3.5:
|
||||
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
|
||||
|
||||
magicast@0.5.2:
|
||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
make-fetch-happen@15.0.3:
|
||||
resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
|
@ -2912,6 +3022,10 @@ packages:
|
|||
resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
minimatch@10.2.4:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
|
@ -3112,6 +3226,9 @@ packages:
|
|||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
package-manager-detector@0.2.11:
|
||||
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
|
||||
|
||||
|
|
@ -3134,6 +3251,10 @@ packages:
|
|||
resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
path-scurry@2.0.1:
|
||||
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
|
@ -3388,6 +3509,10 @@ packages:
|
|||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@5.1.2:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -3450,6 +3575,10 @@ packages:
|
|||
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
test-exclude@7.0.2:
|
||||
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
|
|
@ -3803,6 +3932,10 @@ packages:
|
|||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ws@8.19.0:
|
||||
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
|
@ -3899,6 +4032,11 @@ snapshots:
|
|||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
|
|
@ -4021,6 +4159,8 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@cfworker/json-schema@4.1.1': {}
|
||||
|
||||
'@changesets/apply-release-plan@7.0.14':
|
||||
|
|
@ -4596,12 +4736,23 @@ snapshots:
|
|||
dependencies:
|
||||
mute-stream: 1.0.0
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@isaacs/cliui@9.0.0': {}
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
|
@ -4748,6 +4899,9 @@ snapshots:
|
|||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||
|
|
@ -5101,6 +5255,39 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
ast-v8-to-istanbul: 0.3.12
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-reports: 3.2.0
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.3.5
|
||||
std-env: 3.10.0
|
||||
test-exclude: 7.0.2
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
ast-v8-to-istanbul: 0.3.12
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.2
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
|
|
@ -5267,6 +5454,8 @@ snapshots:
|
|||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
|
|
@ -5283,6 +5472,12 @@ snapshots:
|
|||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.13.5:
|
||||
|
|
@ -5509,12 +5704,16 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
electron-to-chromium@1.5.286: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
encoding@0.1.13:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
|
|
@ -5757,6 +5956,11 @@ snapshots:
|
|||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
|
|
@ -5825,6 +6029,15 @@ snapshots:
|
|||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob@10.5.0:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.5
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
glob@13.0.3:
|
||||
dependencies:
|
||||
minimatch: 10.2.0
|
||||
|
|
@ -5860,6 +6073,8 @@ snapshots:
|
|||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
|
|
@ -5949,6 +6164,33 @@ snapshots:
|
|||
|
||||
isexe@4.0.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jackspeak@4.2.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 9.0.0
|
||||
|
|
@ -5959,6 +6201,8 @@ snapshots:
|
|||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
|
@ -6134,6 +6378,22 @@ snapshots:
|
|||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
magicast@0.5.2:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
make-fetch-happen@15.0.3:
|
||||
dependencies:
|
||||
'@npmcli/agent': 4.0.0
|
||||
|
|
@ -6173,6 +6433,10 @@ snapshots:
|
|||
dependencies:
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
minimatch@9.0.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
|
@ -6368,6 +6632,8 @@ snapshots:
|
|||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
package-manager-detector@0.2.11:
|
||||
dependencies:
|
||||
quansync: 0.2.11
|
||||
|
|
@ -6386,6 +6652,11 @@ snapshots:
|
|||
dependencies:
|
||||
path-root-regex: 0.1.2
|
||||
|
||||
path-scurry@1.11.1:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.2
|
||||
|
||||
path-scurry@2.0.1:
|
||||
dependencies:
|
||||
lru-cache: 11.2.6
|
||||
|
|
@ -6645,6 +6916,12 @@ snapshots:
|
|||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@5.1.2:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
|
|
@ -6700,6 +6977,12 @@ snapshots:
|
|||
|
||||
term-size@2.2.1: {}
|
||||
|
||||
test-exclude@7.0.2:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 10.5.0
|
||||
minimatch: 10.2.4
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
|
@ -7035,6 +7318,12 @@ snapshots:
|
|||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue