diff --git a/packages/cli/__tests__/commands/send.test.ts b/packages/cli/__tests__/commands/send.test.ts index f625754e2..75bec3e7d 100644 --- a/packages/cli/__tests__/commands/send.test.ts +++ b/packages/cli/__tests__/commands/send.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -const { mockTmux, mockExec } = vi.hoisted(() => ({ +const { mockTmux, mockExec, mockDetectActivity } = vi.hoisted(() => ({ mockTmux: vi.fn(), mockExec: vi.fn(), + mockDetectActivity: vi.fn(), })); vi.mock("../../src/lib/shell.js", () => ({ @@ -13,6 +14,29 @@ vi.mock("../../src/lib/shell.js", () => ({ gh: vi.fn(), })); +vi.mock("../../src/lib/plugins.js", () => ({ + getAgent: () => ({ + name: "claude-code", + processName: "claude", + detectActivity: mockDetectActivity, + }), + getAgentByName: () => ({ + name: "claude-code", + processName: "claude", + detectActivity: mockDetectActivity, + }), +})); + +vi.mock("../../src/lib/session-utils.js", () => ({ + findProjectForSession: () => null, +})); + +vi.mock("@agent-orchestrator/core", () => ({ + loadConfig: () => { + throw new Error("no config"); + }, +})); + import { Command } from "commander"; import { registerSend } from "../../src/commands/send.js"; @@ -33,6 +57,7 @@ beforeEach(() => { }); mockTmux.mockReset(); mockExec.mockReset(); + mockDetectActivity.mockReset(); mockExec.mockResolvedValue({ stdout: "", stderr: "" }); }); @@ -57,36 +82,23 @@ describe("send command", () => { }); describe("busy detection", () => { - it("detects idle session (prompt character)", async () => { + it("detects idle session via agent plugin", async () => { // has-session succeeds mockTmux.mockImplementation(async (...args: string[]) => { if (args[0] === "has-session") return ""; if (args[0] === "capture-pane") { - // Check which -S value to determine context const sIdx = args.indexOf("-S"); - if (sIdx >= 0 && args[sIdx + 1] === "-5") { - return "some output\n❯ "; - } + if (sIdx >= 0 && args[sIdx + 1] === "-5") return "some output\n❯ "; + if (sIdx >= 0 && args[sIdx + 1] === "-10") return "esc to interrupt\nThinking"; return ""; } return ""; }); - // isProcessing should detect processing after send - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "has-session") return ""; - if (args[0] === "capture-pane") { - const sIdx = args.indexOf("-S"); - if (sIdx >= 0 && args[sIdx + 1] === "-5") { - return "some output\n❯ "; - } - if (sIdx >= 0 && args[sIdx + 1] === "-10") { - return "Thinking about your request\nesc to interrupt"; - } - return ""; - } - return ""; - }); + // Agent detects idle for wait-for-idle, then active for verification + mockDetectActivity + .mockReturnValueOnce("idle") // wait-for-idle check + .mockReturnValueOnce("active"); // verification check await program.parseAsync(["node", "test", "send", "my-session", "hello", "world"]); @@ -105,34 +117,19 @@ describe("send command", () => { ); }); - it("detects busy session (esc to interrupt)", async () => { - let callCount = 0; + it("detects busy session and waits via agent plugin", async () => { mockTmux.mockImplementation(async (...args: string[]) => { if (args[0] === "has-session") return ""; - if (args[0] === "capture-pane") { - callCount++; - const sIdx = args.indexOf("-S"); - // First few calls: session is busy - if (callCount <= 2) { - if (sIdx >= 0 && args[sIdx + 1] === "-5") { - return "Working on something..."; - } - if (sIdx >= 0 && args[sIdx + 1] === "-3") { - return "esc to interrupt"; - } - } - // Then idle - if (sIdx >= 0 && args[sIdx + 1] === "-5") { - return "Done\n❯ "; - } - if (sIdx >= 0 && args[sIdx + 1] === "-10") { - return "Thinking\nesc to interrupt"; - } - return ""; - } + if (args[0] === "capture-pane") return "some output"; return ""; }); + // First call: active (busy), second call: idle, third call: active (verification) + mockDetectActivity + .mockReturnValueOnce("active") // busy + .mockReturnValueOnce("idle") // now idle + .mockReturnValueOnce("active"); // verification: processing + await program.parseAsync(["node", "test", "send", "my-session", "fix", "the", "bug"]); // Should have eventually sent the message @@ -148,16 +145,13 @@ describe("send command", () => { it("skips busy detection with --no-wait", async () => { mockTmux.mockImplementation(async (...args: string[]) => { if (args[0] === "has-session") return ""; - if (args[0] === "capture-pane") { - const sIdx = args.indexOf("-S"); - if (sIdx >= 0 && args[sIdx + 1] === "-10") { - return "Thinking\nesc to interrupt"; - } - return "busy\nesc to interrupt"; - } + if (args[0] === "capture-pane") return "Thinking\nesc to interrupt"; return ""; }); + // Agent detects active for verification + mockDetectActivity.mockReturnValue("active"); + await program.parseAsync(["node", "test", "send", "--no-wait", "my-session", "urgent"]); // Should have sent the message without waiting @@ -169,17 +163,17 @@ describe("send command", () => { if (args[0] === "has-session") return ""; if (args[0] === "capture-pane") { const sIdx = args.indexOf("-S"); - if (sIdx >= 0 && args[sIdx + 1] === "-5") { - return "Output\n❯ \nPress up to edit queued messages"; - } - if (sIdx >= 0 && args[sIdx + 1] === "-10") { - return ""; - } + if (sIdx >= 0 && args[sIdx + 1] === "-5") return "Output\n❯ "; + if (sIdx >= 0 && args[sIdx + 1] === "-10") + return "Output\nPress up to edit queued messages"; return ""; } return ""; }); + // Agent detects idle for wait-for-idle, then idle for verification (not processing) + mockDetectActivity.mockReturnValue("idle"); + await program.parseAsync(["node", "test", "send", "my-session", "hello"]); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Message queued")); @@ -190,15 +184,14 @@ describe("send command", () => { it("uses load-buffer for long messages", async () => { mockTmux.mockImplementation(async (...args: string[]) => { if (args[0] === "has-session") return ""; - if (args[0] === "capture-pane") { - const sIdx = args.indexOf("-S"); - if (sIdx >= 0 && args[sIdx + 1] === "-5") return "❯ "; - if (sIdx >= 0 && args[sIdx + 1] === "-10") return "esc to interrupt"; - return ""; - } + if (args[0] === "capture-pane") return "❯ "; return ""; }); + mockDetectActivity + .mockReturnValueOnce("idle") // wait-for-idle + .mockReturnValueOnce("active"); // verification + const longMsg = "x".repeat(250); await program.parseAsync(["node", "test", "send", "my-session", longMsg]); @@ -210,15 +203,14 @@ describe("send command", () => { it("uses send-keys for short messages", async () => { mockTmux.mockImplementation(async (...args: string[]) => { if (args[0] === "has-session") return ""; - if (args[0] === "capture-pane") { - const sIdx = args.indexOf("-S"); - if (sIdx >= 0 && args[sIdx + 1] === "-5") return "❯ "; - if (sIdx >= 0 && args[sIdx + 1] === "-10") return "esc to interrupt"; - return ""; - } + if (args[0] === "capture-pane") return "❯ "; return ""; }); + mockDetectActivity + .mockReturnValueOnce("idle") // wait-for-idle + .mockReturnValueOnce("active"); // verification + await program.parseAsync(["node", "test", "send", "my-session", "short", "msg"]); expect(mockExec).toHaveBeenCalledWith("tmux", ["send-keys", "-t", "my-session", "-l", "short msg"]); @@ -227,15 +219,14 @@ describe("send command", () => { it("clears partial input before sending", async () => { mockTmux.mockImplementation(async (...args: string[]) => { if (args[0] === "has-session") return ""; - if (args[0] === "capture-pane") { - const sIdx = args.indexOf("-S"); - if (sIdx >= 0 && args[sIdx + 1] === "-5") return "❯ "; - if (sIdx >= 0 && args[sIdx + 1] === "-10") return "esc to interrupt"; - return ""; - } + if (args[0] === "capture-pane") return "❯ "; return ""; }); + mockDetectActivity + .mockReturnValueOnce("idle") // wait-for-idle + .mockReturnValueOnce("active"); // verification + await program.parseAsync(["node", "test", "send", "my-session", "hello"]); // C-u should be called to clear input diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 8cf89f58d..bd44c75fd 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -3,11 +3,12 @@ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync import { join } from "node:path"; import { tmpdir } from "node:os"; -const { mockTmux, mockExec, mockGit, mockConfigRef } = vi.hoisted(() => ({ +const { mockTmux, mockExec, mockGit, mockConfigRef, mockGetAgent } = vi.hoisted(() => ({ mockTmux: vi.fn(), mockExec: vi.fn(), mockGit: vi.fn(), mockConfigRef: { current: null as Record | null }, + mockGetAgent: vi.fn(), })); vi.mock("../../src/lib/shell.js", () => ({ @@ -43,6 +44,11 @@ vi.mock("@agent-orchestrator/core", () => ({ loadConfig: () => mockConfigRef.current, })); +vi.mock("../../src/lib/plugins.js", () => ({ + getAgent: mockGetAgent, + getAgentByName: mockGetAgent, +})); + let tmpDir: string; import { Command } from "commander"; @@ -94,7 +100,15 @@ beforeEach(() => { mockTmux.mockReset(); mockExec.mockReset(); mockGit.mockReset(); + mockGetAgent.mockReset(); mockExec.mockResolvedValue({ stdout: "", stderr: "" }); + mockGetAgent.mockReturnValue({ + name: "claude-code", + processName: "claude", + getLaunchCommand: () => "unset CLAUDECODE && claude", + getEnvironment: () => ({}), + detectActivity: () => "idle", + }); }); afterEach(() => { diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 72287601d..d460ab051 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -3,10 +3,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -const { mockTmux, mockGit, mockConfigRef } = vi.hoisted(() => ({ +const { mockTmux, mockGit, mockConfigRef, mockIntrospect } = vi.hoisted(() => ({ mockTmux: vi.fn(), mockGit: vi.fn(), mockConfigRef: { current: null as Record | null }, + mockIntrospect: vi.fn(), })); vi.mock("../../src/lib/shell.js", () => ({ @@ -32,6 +33,21 @@ vi.mock("@agent-orchestrator/core", () => ({ loadConfig: () => mockConfigRef.current, })); +vi.mock("../../src/lib/plugins.js", () => ({ + getAgent: () => ({ + name: "claude-code", + processName: "claude", + detectActivity: () => "idle", + introspect: mockIntrospect, + }), + getAgentByName: () => ({ + name: "claude-code", + processName: "claude", + detectActivity: () => "idle", + introspect: mockIntrospect, + }), +})); + let tmpDir: string; import { Command } from "commander"; @@ -76,6 +92,8 @@ beforeEach(() => { }); mockTmux.mockReset(); mockGit.mockReset(); + mockIntrospect.mockReset(); + mockIntrospect.mockResolvedValue(null); }); afterEach(() => { diff --git a/packages/cli/package.json b/packages/cli/package.json index 154115596..abb7487be 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,6 +17,9 @@ }, "dependencies": { "@agent-orchestrator/core": "workspace:*", + "@agent-orchestrator/plugin-agent-claude-code": "workspace:*", + "@agent-orchestrator/plugin-agent-codex": "workspace:*", + "@agent-orchestrator/plugin-agent-aider": "workspace:*", "chalk": "^5.4.0", "commander": "^13.0.0", "ora": "^8.1.0", diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index 0f3574d95..c8fb35315 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -3,50 +3,52 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; +import { type Agent, loadConfig } from "@agent-orchestrator/core"; import { exec, tmux } from "../lib/shell.js"; +import { getAgent, getAgentByName } from "../lib/plugins.js"; +import { findProjectForSession } from "../lib/session-utils.js"; async function sessionExists(session: string): Promise { const result = await tmux("has-session", "-t", session); return result !== null; } -async function isBusy(session: string): Promise { - const output = await tmux("capture-pane", "-t", session, "-p", "-S", "-5"); - if (!output) return false; - - const lines = output.split("\n").filter(Boolean); - const lastLine = lines[lines.length - 1] || ""; - - // Idle indicators - if (/[❯$⏵]|bypass permissions/.test(lastLine)) { - return false; - } - - // Active indicators - const recentOutput = await tmux("capture-pane", "-t", session, "-p", "-S", "-3"); - if (recentOutput && recentOutput.includes("esc to interrupt")) { - return true; - } - - return false; +async function captureOutput(session: string, lines: number): Promise { + const output = await tmux("capture-pane", "-t", session, "-p", "-S", String(-lines)); + return output || ""; } -async function isProcessing(session: string): Promise { - const output = await tmux("capture-pane", "-t", session, "-p", "-S", "-10"); - if (!output) return false; - return /Thinking|Running|esc to interrupt|⏺/.test(output); +function isBusy(agent: Agent, terminalOutput: string): boolean { + const state = agent.detectActivity(terminalOutput); + return state === "active"; } -async function hasQueuedMessage(session: string): Promise { - const output = await tmux("capture-pane", "-t", session, "-p", "-S", "-5"); - if (!output) return false; - return output.includes("Press up to edit queued messages"); +function isProcessing(agent: Agent, terminalOutput: string): boolean { + const state = agent.detectActivity(terminalOutput); + return state === "active"; +} + +function hasQueuedMessage(terminalOutput: string): boolean { + return terminalOutput.includes("Press up to edit queued messages"); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function resolveAgent(sessionName: string): Agent { + try { + const config = loadConfig(); + const projectId = findProjectForSession(config, sessionName); + if (projectId) { + return getAgent(config, projectId); + } + } catch { + // No config or project — fall back to default + } + return getAgentByName("claude-code"); +} + export function registerSend(program: Command): void { program .command("send") @@ -67,6 +69,8 @@ export function registerSend(program: Command): void { process.exit(1); } + const agent = resolveAgent(session); + const parsedTimeout = parseInt(opts.timeout || "600", 10); const timeoutMs = (isNaN(parsedTimeout) || parsedTimeout <= 0 ? 600 : parsedTimeout) * 1000; @@ -74,7 +78,7 @@ export function registerSend(program: Command): void { if (opts.wait !== false) { const start = Date.now(); let warned = false; - while (await isBusy(session)) { + while (isBusy(agent, await captureOutput(session, 5))) { if (!warned) { console.log(chalk.dim(`Waiting for ${session} to become idle...`)); warned = true; @@ -136,11 +140,12 @@ export function registerSend(program: Command): void { // Verify delivery with retries for (let attempt = 1; attempt <= 3; attempt++) { await sleep(2000); - if (await isProcessing(session)) { + const output = await captureOutput(session, 10); + if (isProcessing(agent, output)) { console.log(chalk.green("Message sent and processing")); return; } - if (await hasQueuedMessage(session)) { + if (hasQueuedMessage(output)) { console.log(chalk.green("Message queued (session finishing previous task)")); return; } diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 40e988bc5..c2f9535e3 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -4,6 +4,7 @@ import { loadConfig, type OrchestratorConfig } from "@agent-orchestrator/core"; import { tmux, git, gh, getTmuxSessions, getTmuxActivity } from "../lib/shell.js"; import { getSessionDir, readMetadata, archiveMetadata } from "../lib/metadata.js"; import { formatAge } from "../lib/format.js"; +import { findProjectForSession } from "../lib/session-utils.js"; async function killSession( config: OrchestratorConfig, @@ -39,16 +40,6 @@ async function killSession( console.log(chalk.green(` Archived metadata`)); } -function findProjectForSession(config: OrchestratorConfig, sessionName: string): string | null { - for (const [id, project] of Object.entries(config.projects)) { - const prefix = project.sessionPrefix || id; - if (sessionName.startsWith(`${prefix}-`)) { - return id; - } - } - return null; -} - export function registerSession(program: Command): void { const session = program.command("session").description("Session management (ls, kill, cleanup)"); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 6c9bb7cee..b6095b9fb 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -7,6 +7,7 @@ import { loadConfig, type OrchestratorConfig, type ProjectConfig } from "@agent- import { exec, git, getTmuxSessions } from "../lib/shell.js"; import { getSessionDir, writeMetadata, findSessionForIssue } from "../lib/metadata.js"; import { banner } from "../lib/format.js"; +import { getAgent } from "../lib/plugins.js"; function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -134,20 +135,14 @@ async function spawnSession( await new Promise((resolve) => setTimeout(resolve, 1000)); } - // Start agent - const agentName = project.agent || config.defaults.agent; - let launchCmd: string; - if (agentName === "claude-code") { - const perms = - project.agentConfig?.permissions === "skip" ? " --dangerously-skip-permissions" : ""; - launchCmd = `unset CLAUDECODE && claude${perms}`; - } else if (agentName === "codex") { - launchCmd = "codex"; - } else if (agentName === "aider") { - launchCmd = "aider"; - } else { - launchCmd = agentName; - } + // Start agent via plugin + const agent = getAgent(config, projectId); + const launchCmd = agent.getLaunchCommand({ + sessionId: sessionName, + projectConfig: project, + issueId, + permissions: project.agentConfig?.permissions, + }); await exec("tmux", ["send-keys", "-t", sessionName, launchCmd, "Enter"]); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 6b5fb809f..bde16a818 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,11 +1,16 @@ -import { readFileSync, readdirSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs"; -import { join } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; -import { loadConfig, type OrchestratorConfig } from "@agent-orchestrator/core"; -import { exec, git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js"; +import { + type Agent, + type OrchestratorConfig, + type Session, + type RuntimeHandle, + loadConfig, +} from "@agent-orchestrator/core"; +import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js"; import { getSessionDir, readMetadata } from "../lib/metadata.js"; import { banner, header, formatAge, statusColor } from "../lib/format.js"; +import { getAgent, getAgentByName } from "../lib/plugins.js"; interface SessionInfo { name: string; @@ -20,95 +25,37 @@ interface SessionInfo { } /** - * Extracts Claude's auto-generated summary from its internal session data. - * Maps: tmux session → TTY → Claude PID → CWD → .claude/projects/ → JSONL summary + * Build a minimal Session object for agent.introspect(). + * Only runtimeHandle and workspacePath are needed by the introspection logic. */ -async function getClaudeSessionInfo( - sessionName: string, -): Promise<{ summary: string | null; sessionId: string | null }> { - try { - // Get the TTY for this tmux session's pane - const ttyOutput = await exec("tmux", [ - "display-message", - "-t", - sessionName, - "-p", - "#{pane_tty}", - ]); - const tty = ttyOutput.stdout.trim(); - if (!tty) return { summary: null, sessionId: null }; - - // Find Claude PID running on that TTY (column-based match to avoid pts/1 matching pts/10) - const psOutput = await exec("ps", ["-eo", "pid,tty,comm"]); - const ttyShort = tty.replace("/dev/", ""); - const pidLine = psOutput.stdout.split("\n").find((line) => { - const cols = line.trim().split(/\s+/); - return cols.length >= 3 && cols[1] === ttyShort && cols[2].includes("claude"); - }); - const pid = pidLine?.trim().split(/\s+/)[0]; - if (!pid) return { summary: null, sessionId: null }; - - // Get Claude's working directory - const cwdOutput = await exec("lsof", ["-p", pid, "-d", "cwd", "-Fn"]); - const cwdMatch = cwdOutput.stdout.match(/n(.+)/); - const cwd = cwdMatch?.[1]; - if (!cwd) return { summary: null, sessionId: null }; - - // Encode path for Claude's project directory naming - const encodedPath = cwd.replace(/\//g, "-").replace(/^-/, ""); - const claudeProjectDir = join(process.env.HOME || "~", ".claude", "projects", encodedPath); - - if (!existsSync(claudeProjectDir)) return { summary: null, sessionId: null }; - - // Find the most recent session file - const files = readdirSync(claudeProjectDir) - .filter((f) => f.endsWith(".jsonl")) - .sort() - .reverse(); - - if (files.length === 0) return { summary: null, sessionId: null }; - - const sessionFile = join(claudeProjectDir, files[0]); - const sessionId = files[0].replace(".jsonl", "").slice(0, 8); - - // Read only the tail of the file (last ~8KB) to avoid loading large JSONL files - const fileSize = statSync(sessionFile).size; - const tailSize = Math.min(fileSize, 8192); - let tail: string; - if (tailSize === fileSize) { - tail = readFileSync(sessionFile, "utf-8"); - } else { - const buf = Buffer.alloc(tailSize); - const fd = openSync(sessionFile, "r"); - try { - readSync(fd, buf, 0, tailSize, fileSize - tailSize); - } finally { - closeSync(fd); - } - tail = buf.toString("utf-8"); - } - const lines = tail.trim().split("\n").slice(-20); - let summary: string | null = null; - - for (const line of lines.reverse()) { - try { - const entry = JSON.parse(line); - if (entry.type === "summary" || entry.summary) { - summary = entry.summary || entry.message; - break; - } - } catch { - // Skip non-JSON lines - } - } - - return { summary, sessionId }; - } catch { - return { summary: null, sessionId: null }; - } +function buildSessionForIntrospect(sessionName: string, workspacePath?: string): Session { + const handle: RuntimeHandle = { + id: sessionName, + runtimeName: "tmux", + data: {}, + }; + return { + id: sessionName, + projectId: "", + status: "working", + activity: "idle", + branch: null, + issueId: null, + pr: null, + workspacePath: workspacePath || null, + runtimeHandle: handle, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; } -async function gatherSessionInfo(sessionName: string, sessionDir: string): Promise { +async function gatherSessionInfo( + sessionName: string, + sessionDir: string, + agent: Agent, +): Promise { const metaFile = `${sessionDir}/${sessionName}`; const meta = readMetadata(metaFile); @@ -130,15 +77,22 @@ async function gatherSessionInfo(sessionName: string, sessionDir: string): Promi const activityTs = await getTmuxActivity(sessionName); const lastActivity = activityTs ? formatAge(activityTs) : "-"; - // Get Claude's auto-generated summary - const claudeInfo = await getClaudeSessionInfo(sessionName); + // Get agent's auto-generated summary via introspection + let claudeSummary: string | null = null; + try { + const session = buildSessionForIntrospect(sessionName, worktree); + const introspection = await agent.introspect(session); + claudeSummary = introspection?.summary ?? null; + } catch { + // Introspection failed — not critical + } return { name: sessionName, branch, status, summary, - claudeSummary: claudeInfo.summary, + claudeSummary, pr, issue, lastActivity, @@ -178,7 +132,6 @@ export function registerStatus(program: Command): void { } catch { console.log(chalk.yellow("No config found. Run `ao init` first.")); console.log(chalk.dim("Falling back to session discovery...\n")); - // Fall back to finding sessions without config await showFallbackStatus(); return; } @@ -206,6 +159,9 @@ export function registerStatus(program: Command): void { const sessionDir = getSessionDir(config.dataDir, projectId); const projectSessions = allTmux.filter((s) => s.startsWith(`${prefix}-`)); + // Resolve agent for this project + const agent = getAgent(config, projectId); + if (!opts.json) { console.log(header(projectConfig.name || projectId)); } @@ -221,7 +177,7 @@ export function registerStatus(program: Command): void { totalSessions += projectSessions.length; for (const session of projectSessions.sort()) { - const info = await gatherSessionInfo(session, sessionDir); + const info = await gatherSessionInfo(session, sessionDir, agent); if (opts.json) { jsonOutput.push(info); } else { @@ -257,10 +213,24 @@ async function showFallbackStatus(): Promise { chalk.dim(` ${allTmux.length} tmux session${allTmux.length !== 1 ? "s" : ""} found\n`), ); + // Use claude-code as default agent for fallback introspection + const agent = getAgentByName("claude-code"); + for (const session of allTmux.sort()) { const activityTs = await getTmuxActivity(session); const lastActivity = activityTs ? formatAge(activityTs) : "-"; console.log(` ${chalk.green(session)} ${chalk.dim(`(${lastActivity})`)}`); + + // Try introspection even without config + try { + const sessionObj = buildSessionForIntrospect(session); + const introspection = await agent.introspect(sessionObj); + if (introspection?.summary) { + console.log(` ${chalk.dim("Claude:")} ${introspection.summary.slice(0, 65)}`); + } + } catch { + // Not critical + } } console.log(); } diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts new file mode 100644 index 000000000..b5056e98b --- /dev/null +++ b/packages/cli/src/lib/plugins.ts @@ -0,0 +1,33 @@ +import type { Agent, OrchestratorConfig } from "@agent-orchestrator/core"; +import claudeCodePlugin from "@agent-orchestrator/plugin-agent-claude-code"; +import codexPlugin from "@agent-orchestrator/plugin-agent-codex"; +import aiderPlugin from "@agent-orchestrator/plugin-agent-aider"; + +const agentPlugins: Record = { + "claude-code": claudeCodePlugin, + codex: codexPlugin, + aider: aiderPlugin, +}; + +/** + * Resolve the Agent plugin for a project (or fall back to the config default). + * Direct import — no dynamic loading needed since the CLI depends on all agent plugins. + */ +export function getAgent(config: OrchestratorConfig, projectId?: string): Agent { + const agentName = + (projectId ? config.projects[projectId]?.agent : undefined) || config.defaults.agent; + const plugin = agentPlugins[agentName]; + if (!plugin) { + throw new Error(`Unknown agent plugin: ${agentName}`); + } + return plugin.create(); +} + +/** Get an agent by name directly (for fallback/no-config scenarios). */ +export function getAgentByName(name: string): Agent { + const plugin = agentPlugins[name]; + if (!plugin) { + throw new Error(`Unknown agent plugin: ${name}`); + } + return plugin.create(); +} diff --git a/packages/cli/src/lib/session-utils.ts b/packages/cli/src/lib/session-utils.ts new file mode 100644 index 000000000..a5c5e0a49 --- /dev/null +++ b/packages/cli/src/lib/session-utils.ts @@ -0,0 +1,15 @@ +import type { OrchestratorConfig } from "@agent-orchestrator/core"; + +/** Find which project a session belongs to by matching its name against session prefixes. */ +export function findProjectForSession( + config: OrchestratorConfig, + sessionName: string, +): string | null { + for (const [id, project] of Object.entries(config.projects)) { + const prefix = project.sessionPrefix || id; + if (sessionName.startsWith(`${prefix}-`)) { + return id; + } + } + return null; +} diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index a0d22dc4b..97f772135 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -67,7 +67,7 @@ beforeEach(() => { create: vi.fn(), destroy: vi.fn(), sendMessage: vi.fn().mockResolvedValue(undefined), - getOutput: vi.fn(), + getOutput: vi.fn().mockResolvedValue(""), isAlive: vi.fn().mockResolvedValue(true), }; @@ -76,7 +76,7 @@ beforeEach(() => { processName: "mock", getLaunchCommand: vi.fn(), getEnvironment: vi.fn(), - detectActivity: vi.fn().mockResolvedValue("active" as ActivityState), + detectActivity: vi.fn().mockReturnValue("active" as ActivityState), isProcessRunning: vi.fn(), isProcessing: vi.fn().mockResolvedValue(false), getSessionInfo: vi.fn().mockResolvedValue(null), @@ -208,7 +208,7 @@ describe("check (single session)", () => { }); it("detects stuck state from agent", async () => { - vi.mocked(mockAgent.detectActivity).mockResolvedValue("blocked"); + vi.mocked(mockAgent.detectActivity).mockReturnValue("blocked"); const session = makeSession({ status: "working" }); vi.mocked(mockSessionManager.get).mockResolvedValue(session); @@ -232,7 +232,7 @@ describe("check (single session)", () => { }); it("detects needs_input from agent", async () => { - vi.mocked(mockAgent.detectActivity).mockResolvedValue("waiting_input"); + vi.mocked(mockAgent.detectActivity).mockReturnValue("waiting_input"); const session = makeSession({ status: "working" }); vi.mocked(mockSessionManager.get).mockResolvedValue(session); @@ -256,7 +256,9 @@ describe("check (single session)", () => { }); it("preserves stuck state when detectActivity throws", async () => { - vi.mocked(mockAgent.detectActivity).mockRejectedValue(new Error("probe failed")); + vi.mocked(mockAgent.detectActivity).mockImplementation(() => { + throw new Error("probe failed"); + }); const session = makeSession({ status: "stuck" }); vi.mocked(mockSessionManager.get).mockResolvedValue(session); @@ -281,7 +283,9 @@ describe("check (single session)", () => { }); it("preserves needs_input state when detectActivity throws", async () => { - vi.mocked(mockAgent.detectActivity).mockRejectedValue(new Error("probe failed")); + vi.mocked(mockAgent.detectActivity).mockImplementation(() => { + throw new Error("probe failed"); + }); const session = makeSession({ status: "needs_input" }); vi.mocked(mockSessionManager.get).mockResolvedValue(session); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index ca3094a0d..98c7ae10a 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -190,10 +190,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } - // 2. Check agent activity - if (agent) { + // 2. Check agent activity via terminal output + if (agent && session.runtimeHandle) { try { - const activity = await agent.detectActivity(session); + const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); + const terminalOutput = runtime + ? await runtime.getOutput(session.runtimeHandle, 10).catch(() => "") + : ""; + const activity = agent.detectActivity(terminalOutput); if (activity === "exited") return "killed"; if (activity === "blocked") return "stuck"; if (activity === "waiting_input") return "needs_input"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6e779735e..7841c404c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -182,8 +182,8 @@ export interface Agent { /** Get environment variables for the agent process */ getEnvironment(config: AgentLaunchConfig): Record; - /** Detect what the agent is currently doing */ - detectActivity(session: Session): Promise; + /** Detect what the agent is currently doing from terminal output */ + detectActivity(terminalOutput: string): ActivityState; /** Check if agent process is running (given runtime handle) */ isProcessRunning(handle: RuntimeHandle): Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 549c11102..44e30e099 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,15 @@ importers: '@agent-orchestrator/core': specifier: workspace:* version: link:../core + '@agent-orchestrator/plugin-agent-aider': + specifier: workspace:* + version: link:../plugins/agent-aider + '@agent-orchestrator/plugin-agent-claude-code': + specifier: workspace:* + version: link:../plugins/agent-claude-code + '@agent-orchestrator/plugin-agent-codex': + specifier: workspace:* + version: link:../plugins/agent-codex chalk: specifier: ^5.4.0 version: 5.6.2 @@ -73,6 +82,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/agent-aider: dependencies: @@ -833,6 +845,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -924,6 +939,9 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -935,21 +953,47 @@ packages: vite: optional: true + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -996,6 +1040,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1285,6 +1333,9 @@ packages: sass: optional: true + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1443,6 +1494,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1455,6 +1510,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -1568,6 +1627,40 @@ packages: jsdom: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1926,6 +2019,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2054,6 +2149,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -2062,32 +2166,62 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.0.18': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2131,6 +2265,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@5.6.2: {} check-error@2.1.3: {} @@ -2409,6 +2545,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + obug@2.1.1: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2596,6 +2734,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -2605,6 +2745,8 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -2719,6 +2861,43 @@ snapshots: - tsx - yaml + vitest@4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0