refactor: move agent-specific logic from CLI into agent plugins

detectActivity, introspect, and getLaunchCommand now live in the agent
plugins (claude-code, codex, aider) instead of being hardcoded in the
CLI commands. The Agent interface's detectActivity takes a plain string
of terminal output instead of a Session object, making plugins trivially
unit-testable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-14 07:26:09 +05:30
parent 56e3f0b345
commit ac424c1117
14 changed files with 459 additions and 237 deletions

View File

@ -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

View File

@ -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<string, unknown> | 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(() => {

View File

@ -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<string, unknown> | 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(() => {

View File

@ -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",

View File

@ -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<boolean> {
const result = await tmux("has-session", "-t", session);
return result !== null;
}
async function isBusy(session: string): Promise<boolean> {
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<string> {
const output = await tmux("capture-pane", "-t", session, "-p", "-S", String(-lines));
return output || "";
}
async function isProcessing(session: string): Promise<boolean> {
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<boolean> {
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<void> {
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;
}

View File

@ -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)");

View File

@ -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"]);

View File

@ -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<SessionInfo> {
async function gatherSessionInfo(
sessionName: string,
sessionDir: string,
agent: Agent,
): Promise<SessionInfo> {
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<void> {
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();
}

View File

@ -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<string, { create(): Agent }> = {
"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();
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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>("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";

View File

@ -182,8 +182,8 @@ export interface Agent {
/** Get environment variables for the agent process */
getEnvironment(config: AgentLaunchConfig): Record<string, string>;
/** Detect what the agent is currently doing */
detectActivity(session: Session): Promise<ActivityState>;
/** 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<boolean>;

View File

@ -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