Merge main into feat/openclaw-dx - resolve conflicts

Conflicts resolved in 3 files:
- plugin-marketplace.ts: take main's createRequire approach, keep isAbsolute fix
- plugin-scaffold.ts: take main's newline escaping (superset of backslash fix)
- plugin.test.ts: take main's importOriginal pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dhruv Sharma 2026-03-31 20:05:16 +05:30
commit 124f7d30ee
12 changed files with 585 additions and 126 deletions

View File

@ -25,7 +25,7 @@ const {
}));
vi.mock("@composio/ao-core", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
const actual = await importOriginal();
return {
...actual,
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),

View File

@ -25,7 +25,7 @@
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"build": "tsc && cp -r src/assets dist/",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",

View File

@ -1,4 +1,6 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@composio/ao-core";
@ -58,11 +60,21 @@ export function registerDashboard(program: Command): void {
config.directTerminalPort,
);
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
});
// In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND
// the terminal WebSocket servers via concurrently. Without the WS servers,
// the live terminal in the dashboard won't work.
const isDevMode = existsSync(resolve(webDir, "server"));
const child = isDevMode
? spawn("pnpm", ["run", "dev"], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
})
: spawn("npx", ["next", "dev", "-p", String(port)], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
});
const stderrChunks: string[] = [];

View File

@ -1,14 +1,11 @@
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { InstalledPluginConfig, PluginModule, PluginSlot } from "@composio/ao-core";
import { pathToFileURL } from "node:url";
import { resolveLocalPluginEntrypoint, type InstalledPluginConfig, type PluginSlot } from "@composio/ao-core";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Resolve registry from package root (works from both src/lib/ and dist/lib/)
const registryPath = resolve(__dirname, "../../src/assets/plugin-registry.json");
const registryData = JSON.parse(readFileSync(registryPath, "utf-8")) as unknown[];
const registryData = createRequire(import.meta.url)("../assets/plugin-registry.json") as unknown[];
export interface MarketplacePluginEntry {
id: string;
@ -21,12 +18,11 @@ export interface MarketplacePluginEntry {
}
export const BUNDLED_MARKETPLACE_PLUGIN_CATALOG = registryData as MarketplacePluginEntry[];
export const MARKETPLACE_PLUGIN_CATALOG = BUNDLED_MARKETPLACE_PLUGIN_CATALOG;
export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL =
"https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json";
const LOCAL_PLUGIN_ENTRY_CANDIDATES = ["dist/index.js", "index.js"] as const;
const MARKETPLACE_CACHE_FILE = "plugin-registry.json";
const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
function isPluginSlot(value: unknown): value is PluginSlot {
return (
@ -117,7 +113,7 @@ export function loadMarketplaceCatalog(): MarketplacePluginEntry[] {
export async function refreshMarketplaceCatalog(
url = process.env["AO_PLUGIN_REGISTRY_URL"] ?? DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL,
): Promise<MarketplacePluginEntry[]> {
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) });
if (!response.ok) {
throw new Error(`Failed to fetch marketplace registry from ${url} (HTTP ${response.status}).`);
}
@ -129,78 +125,7 @@ export async function refreshMarketplaceCatalog(
return mergedEntries;
}
function isPluginModule(value: unknown): value is PluginModule {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PluginModule>;
return Boolean(candidate.manifest && typeof candidate.create === "function");
}
export function normalizeImportedPluginModule(value: unknown): PluginModule | null {
if (isPluginModule(value)) return value;
if (value && typeof value === "object" && "default" in value) {
const defaultExport = (value as { default?: unknown }).default;
if (isPluginModule(defaultExport)) return defaultExport;
}
return null;
}
function resolvePackageExportsEntry(exportsField: unknown): string | null {
if (typeof exportsField === "string") return exportsField;
if (!exportsField || typeof exportsField !== "object") return null;
const exportsRecord = exportsField as Record<string, unknown>;
const dotEntry = exportsRecord["."];
if (typeof dotEntry === "string") return dotEntry;
if (dotEntry && typeof dotEntry === "object") {
const importEntry = (dotEntry as Record<string, unknown>)["import"];
if (typeof importEntry === "string") return importEntry;
const defaultEntry = (dotEntry as Record<string, unknown>)["default"];
if (typeof defaultEntry === "string") return defaultEntry;
}
const importEntry = exportsRecord["import"];
if (typeof importEntry === "string") return importEntry;
return null;
}
function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
if (!existsSync(pluginPath)) return null;
const stat = statSync(pluginPath);
if (stat.isFile()) return pluginPath;
if (!stat.isDirectory()) return null;
const packageJsonPath = join(pluginPath, "package.json");
if (existsSync(packageJsonPath)) {
try {
const raw = readFileSync(packageJsonPath, "utf-8");
const packageJson = JSON.parse(raw) as { exports?: unknown; main?: unknown; module?: unknown };
const exportsEntry = resolvePackageExportsEntry(packageJson.exports);
if (exportsEntry) {
const resolvedExportsEntry = resolve(pluginPath, exportsEntry);
if (existsSync(resolvedExportsEntry)) return resolvedExportsEntry;
}
if (typeof packageJson.module === "string") {
const moduleEntry = resolve(pluginPath, packageJson.module);
if (existsSync(moduleEntry)) return moduleEntry;
}
if (typeof packageJson.main === "string") {
const mainEntry = resolve(pluginPath, packageJson.main);
if (existsSync(mainEntry)) return mainEntry;
}
} catch {
// Fall through to common entrypoints below.
}
}
for (const candidate of LOCAL_PLUGIN_ENTRY_CANDIDATES) {
const entry = join(pluginPath, candidate);
if (existsSync(entry)) return entry;
}
return null;
}
export { normalizeImportedPluginModule } from "@composio/ao-core";
export function isLocalPluginReference(reference: string): boolean {
return (

View File

@ -119,8 +119,8 @@ function buildTsConfig(): string {
function buildIndexTs(input: PluginScaffoldInput): string {
const manifestName = normalizePluginName(input.displayName);
const displayName = input.displayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const description = input.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const displayName = input.displayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
const description = input.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `import type { PluginModule } from "@composio/ao-core";

View File

@ -117,16 +117,6 @@ export function tryResolveInstalledPluginSpecifier(packageName: string): string
}
}
export function resolveInstalledPluginSpecifier(packageName: string): string {
const specifier = tryResolveInstalledPluginSpecifier(packageName);
if (!specifier) {
throw new Error(
`Package ${packageName} is not installed in the AO plugin store (${getPluginStoreRoot()}).`,
);
}
return specifier;
}
export async function importPluginModuleFromSource(specifier: string): Promise<unknown> {
if (isPackageSpecifier(specifier)) {
const storeSpecifier = tryResolveInstalledPluginSpecifier(specifier);

View File

@ -4,8 +4,8 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { readMetadataRaw } from "../metadata.js";
import { getSessionsDir } from "../paths.js";
import { escalateSession, recoverSession } from "../recovery/actions.js";
import { getSessionsDir, getProjectBaseDir } from "../paths.js";
import { cleanupSession, escalateSession, recoverSession } from "../recovery/actions.js";
import { runRecovery } from "../recovery/manager.js";
import { getRecoveryLogPath, scanAllSessions } from "../recovery/scanner.js";
import {
@ -13,7 +13,7 @@ import {
type RecoveryAssessment,
type RecoveryContext,
} from "../recovery/types.js";
import type { OrchestratorConfig, PluginRegistry } from "../types.js";
import type { OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js";
function makeConfig(rootDir: string): OrchestratorConfig {
return {
@ -101,6 +101,12 @@ describe("recoverSession", () => {
afterEach(() => {
if (rootDir) {
const configPath = join(rootDir, "agent-orchestrator.yaml");
const projectPath = join(rootDir, "project");
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
if (existsSync(projectBaseDir)) {
rmSync(projectBaseDir, { recursive: true, force: true });
}
rmSync(rootDir, { recursive: true, force: true });
}
});
@ -215,6 +221,12 @@ describe("escalateSession", () => {
afterEach(() => {
if (rootDir) {
const configPath = join(rootDir, "agent-orchestrator.yaml");
const projectPath = join(rootDir, "project");
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
if (existsSync(projectBaseDir)) {
rmSync(projectBaseDir, { recursive: true, force: true });
}
rmSync(rootDir, { recursive: true, force: true });
}
});
@ -242,11 +254,135 @@ describe("escalateSession", () => {
});
});
describe("cleanupSession", () => {
let rootDir: string;
afterEach(() => {
if (rootDir) {
const configPath = join(rootDir, "agent-orchestrator.yaml");
const projectPath = join(rootDir, "project");
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
if (existsSync(projectBaseDir)) {
rmSync(projectBaseDir, { recursive: true, force: true });
}
rmSync(rootDir, { recursive: true, force: true });
}
});
it("continues cleanup and calls deleteMetadata even when workspace.destroy throws", async () => {
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
mkdirSync(join(rootDir, "project"), { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const config = makeConfig(rootDir);
const workspacePath = join(rootDir, "worktree");
const mockWorkspace: Workspace = {
name: "worktree",
create: vi.fn(),
destroy: vi.fn().mockRejectedValue(new Error("Workspace destroy failed")),
list: vi.fn(),
exists: vi.fn(),
};
const registry: PluginRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "workspace") return mockWorkspace;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
const assessment = makeAssessment({
action: "cleanup",
classification: "dead",
runtimeAlive: false,
workspaceExists: true,
workspacePath,
rawMetadata: {
...makeAssessment().rawMetadata,
worktree: workspacePath,
},
});
const context = makeContext(rootDir);
const result = await cleanupSession(assessment, config, registry, context);
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
expect(mockWorkspace.destroy).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
});
it("continues cleanup and calls workspace.destroy and deleteMetadata even when runtime.destroy throws", async () => {
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
mkdirSync(join(rootDir, "project"), { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const config = makeConfig(rootDir);
const workspacePath = join(rootDir, "worktree");
const mockRuntime: Runtime = {
name: "tmux",
create: vi.fn(),
destroy: vi.fn().mockRejectedValue(new Error("Runtime destroy failed")),
sendMessage: vi.fn(),
getOutput: vi.fn(),
isAlive: vi.fn(),
};
const mockWorkspace: Workspace = {
name: "worktree",
create: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
list: vi.fn(),
exists: vi.fn(),
};
const registry: PluginRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "workspace") return mockWorkspace;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
const assessment = makeAssessment({
action: "cleanup",
classification: "partial",
runtimeAlive: true,
workspaceExists: true,
workspacePath,
rawMetadata: {
...makeAssessment().rawMetadata,
worktree: workspacePath,
},
});
const context = makeContext(rootDir);
const result = await cleanupSession(assessment, config, registry, context);
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
expect(mockRuntime.destroy).toHaveBeenCalled();
expect(mockWorkspace.destroy).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
});
});
describe("recovery manager and scanner", () => {
let rootDir: string;
afterEach(() => {
if (rootDir) {
const configPath = join(rootDir, "agent-orchestrator.yaml");
const projectPath = join(rootDir, "project");
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
if (existsSync(projectBaseDir)) {
rmSync(projectBaseDir, { recursive: true, force: true });
}
rmSync(rootDir, { recursive: true, force: true });
}
});

View File

@ -121,4 +121,94 @@ describe("recovery validator", () => {
expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled();
expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled();
});
it("sets runtimeAlive to false when runtime.isAlive throws an error", async () => {
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
const projectPath = join(rootDir, "project");
mkdirSync(projectPath, { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const mockRuntime: Runtime = {
name: "tmux",
create: vi.fn(),
destroy: vi.fn(),
sendMessage: vi.fn(),
getOutput: vi.fn(),
isAlive: vi.fn().mockRejectedValue(new Error("Runtime check failed")),
};
const mockWorkspace: Workspace = {
name: "worktree",
create: vi.fn(),
destroy: vi.fn(),
list: vi.fn(),
exists: vi.fn().mockResolvedValue(true),
};
const mockAgent: Agent = {
name: "mock-agent",
processName: "mock-agent",
getLaunchCommand: vi.fn(),
getEnvironment: vi.fn(),
detectActivity: vi.fn(),
getActivityState: vi.fn(),
isProcessRunning: vi.fn().mockResolvedValue(false),
getSessionInfo: vi.fn(),
};
const registry: PluginRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "workspace") return mockWorkspace;
if (slot === "agent") return mockAgent;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
const config: OrchestratorConfig = {
configPath: join(rootDir, "agent-orchestrator.yaml"),
port: 3000,
readyThresholdMs: 300_000,
defaults: {
runtime: "tmux",
agent: "mock-agent",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
app: {
name: "app",
repo: "org/repo",
path: projectPath,
defaultBranch: "main",
sessionPrefix: "app",
},
},
notifiers: {},
notificationRouting: {
urgent: ["desktop"],
action: ["desktop"],
warning: [],
info: [],
},
reactions: {},
};
const scanned: ScannedSession = {
sessionId: "app-1",
projectId: "app",
project: config.projects.app,
sessionsDir: getSessionsDir(config.configPath, projectPath),
rawMetadata: {
worktree: projectPath,
status: "working",
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
},
};
const assessment = await validateSession(scanned, config, registry);
expect(mockRuntime.isAlive).toHaveBeenCalled();
expect(assessment.runtimeAlive).toBe(false);
});
});

View File

@ -0,0 +1,283 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createSessionManager } from "../session-manager.js";
import { writeMetadata } from "../metadata.js";
import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js";
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js";
// Mock child_process module with custom promisify
vi.mock("node:child_process", () => {
const execFileMock = vi.fn() as any;
// Implement custom promisify to return { stdout, stderr } objects
execFileMock[Symbol.for("nodejs.util.promisify.custom")] = (...args: any[]) => {
return new Promise((resolve, reject) => {
execFileMock(...args, (error: any, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stdout, stderr }));
} else {
resolve({ stdout, stderr });
}
});
});
};
return {
execFile: execFileMock,
};
});
let ctx: TestContext;
let sessionsDir: string;
let mockRegistry: PluginRegistry;
let config: OrchestratorConfig;
beforeEach(() => {
ctx = setupTestContext();
({ sessionsDir, mockRegistry, config } = ctx);
// Create an opencode agent mock
const opencodeAgent: Agent = {
name: "opencode",
processName: "opencode",
getLaunchCommand: vi.fn().mockReturnValue("opencode start"),
getEnvironment: vi.fn().mockReturnValue({}),
detectActivity: vi.fn().mockReturnValue("active"),
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
isProcessRunning: vi.fn().mockResolvedValue(true),
getSessionInfo: vi.fn().mockResolvedValue(null),
};
// Update registry to include opencode agent
const originalGet = mockRegistry.get;
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
if (slot === "agent" && name === "opencode") {
return opencodeAgent;
}
return (originalGet as any)(slot, name);
});
// Set project to use opencode agent
config.projects["my-app"]!.agent = "opencode";
});
afterEach(() => {
teardownTestContext(ctx);
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("deleteSession retry loop", () => {
it("verifies retry count - calls execFileAsync 3 times when all attempts fail", async () => {
const { execFile } = await import("node:child_process");
// Setup: Create a session with opencode agent
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
opencodeSessionId: "ses_test_123",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
let deleteCallCount = 0;
const mockError = new Error("OpenCode delete failed");
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
const cb = typeof options === "function" ? options : callback;
if (!cb) return null as any;
const argsArray = Array.isArray(args) ? args : [];
if (argsArray[1] === "delete") {
deleteCallCount++;
cb(mockError, "", "");
} else if (argsArray[1] === "list") {
cb(null, "[]", "");
}
return null as any;
}) as any);
const sm = createSessionManager({ config, registry: mockRegistry });
// Execute kill with purgeOpenCode option
await sm.kill("app-1", { purgeOpenCode: true });
// Verify delete was called 3 times (one for each retry)
expect(deleteCallCount).toBe(3);
});
it("verifies retry delays - confirms delays are 0ms, 200ms, 600ms", async () => {
const { execFile } = await import("node:child_process");
vi.useFakeTimers();
writeMetadata(sessionsDir, "app-2", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
opencodeSessionId: "ses_test_456",
runtimeHandle: JSON.stringify(makeHandle("rt-2")),
});
const callTimes: number[] = [];
const mockError = new Error("OpenCode delete failed");
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
const cb = typeof options === "function" ? options : callback;
if (!cb) return null as any;
const argsArray = Array.isArray(args) ? args : [];
if (argsArray[1] === "delete") {
callTimes.push(Date.now());
cb(mockError, "", "");
} else if (argsArray[1] === "list") {
cb(null, "[]", "");
}
return null as any;
}) as any);
const sm = createSessionManager({ config, registry: mockRegistry });
const killPromise = sm.kill("app-2", { purgeOpenCode: true });
// Run all timers to completion
await vi.runAllTimersAsync();
await killPromise;
// Verify we have 3 calls
expect(callTimes).toHaveLength(3);
// Calculate delays between calls
const delay1 = callTimes[1]! - callTimes[0]!; // Should be 200ms
const delay2 = callTimes[2]! - callTimes[1]!; // Should be 600ms
expect(delay1).toBe(200);
expect(delay2).toBe(600);
vi.useRealTimers();
});
it("verifies all retries are attempted when deletion fails", async () => {
const { execFile } = await import("node:child_process");
writeMetadata(sessionsDir, "app-3", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
opencodeSessionId: "ses_test_789",
runtimeHandle: JSON.stringify(makeHandle("rt-3")),
});
const lastError = new Error("Final error after retries");
let deleteCallCount = 0;
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
const cb = typeof options === "function" ? options : callback;
if (!cb) return null as any;
const argsArray = Array.isArray(args) ? args : [];
if (argsArray[1] === "delete") {
deleteCallCount++;
const error = deleteCallCount === 3 ? lastError : new Error(`Error ${deleteCallCount}`);
cb(error, "", "");
} else if (argsArray[1] === "list") {
cb(null, "[]", "");
}
return null as any;
}) as any);
const sm = createSessionManager({ config, registry: mockRegistry });
// The kill function catches and ignores deleteOpenCodeSession() failures,
// so this test verifies that all retry attempts are made despite errors
await sm.kill("app-3", { purgeOpenCode: true });
// Verify all 3 delete attempts were made
expect(deleteCallCount).toBe(3);
});
it("verifies early success exit - stops after first success without unnecessary retries", async () => {
const { execFile } = await import("node:child_process");
writeMetadata(sessionsDir, "app-4", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
opencodeSessionId: "ses_test_abc",
runtimeHandle: JSON.stringify(makeHandle("rt-4")),
});
let deleteCallCount = 0;
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
const cb = typeof options === "function" ? options : callback;
if (!cb) return null as any;
const argsArray = Array.isArray(args) ? args : [];
if (argsArray[1] === "delete") {
deleteCallCount++;
if (deleteCallCount === 1) {
// First attempt fails
cb(new Error("First attempt failed"), "", "");
} else {
// Second attempt succeeds
cb(null, "", "");
}
} else if (argsArray[1] === "list") {
cb(null, "[]", "");
}
return null as any;
}) as any);
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.kill("app-4", { purgeOpenCode: true });
// Verify delete was called exactly 2 times (failed once, succeeded on second)
expect(deleteCallCount).toBe(2);
});
it("verifies session-not-found handling - exits gracefully without retrying", async () => {
const { execFile } = await import("node:child_process");
writeMetadata(sessionsDir, "app-5", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
opencodeSessionId: "ses_test_def",
runtimeHandle: JSON.stringify(makeHandle("rt-5")),
});
const notFoundError = new Error("Session not found: ses_test_def") as Error & {
stderr?: string;
stdout?: string;
};
notFoundError.stderr = "Error: session not found: ses_test_def";
let deleteCallCount = 0;
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
const cb = typeof options === "function" ? options : callback;
if (!cb) return null as any;
const argsArray = Array.isArray(args) ? args : [];
if (argsArray[1] === "delete") {
deleteCallCount++;
cb(notFoundError, "", "");
} else if (argsArray[1] === "list") {
cb(null, "[]", "");
}
return null as any;
}) as any);
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.kill("app-5", { purgeOpenCode: true });
// Verify delete was called only once - no retries for "not found" errors
expect(deleteCallCount).toBe(1);
});
});

View File

@ -215,6 +215,9 @@ describe("send", () => {
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
vi.mocked(mockRuntime.isAlive).mockResolvedValue(true);
vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after");
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.send("app-1", "hello");
@ -247,6 +250,9 @@ describe("send", () => {
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
vi.mocked(mockRuntime.isAlive).mockResolvedValue(true);
vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after");
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.send("app-1", "hello");
@ -351,7 +357,7 @@ describe("send", () => {
makeHandle("rt-1"),
"do not confirm on visibility",
);
});
}, 10000);
});
describe("remap", () => {

View File

@ -19,7 +19,13 @@ export {
} from "./config.js";
// Plugin registry
export { createPluginRegistry } from "./plugin-registry.js";
export {
createPluginRegistry,
isPluginModule,
normalizeImportedPluginModule,
resolveLocalPluginEntrypoint,
resolvePackageExportsEntry,
} from "./plugin-registry.js";
// Metadata — flat-file session metadata read/write
export {

View File

@ -84,13 +84,13 @@ function extractPluginConfig(
return undefined;
}
function isPluginModule(value: unknown): value is PluginModule {
export function isPluginModule(value: unknown): value is PluginModule {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PluginModule>;
return Boolean(candidate.manifest && typeof candidate.create === "function");
}
function normalizeImportedPluginModule(value: unknown): PluginModule | null {
export function normalizeImportedPluginModule(value: unknown): PluginModule | null {
if (isPluginModule(value)) return value;
if (value && typeof value === "object" && "default" in value) {
@ -107,7 +107,7 @@ function resolveConfigRelativePath(targetPath: string, configPath?: string): str
return resolve(baseDir, targetPath);
}
function resolvePackageExportsEntry(exportsField: unknown): string | null {
export function resolvePackageExportsEntry(exportsField: unknown): string | null {
if (typeof exportsField === "string") return exportsField;
if (!exportsField || typeof exportsField !== "object") return null;
@ -131,18 +131,19 @@ function resolvePackageExportsEntry(exportsField: unknown): string | null {
return null;
}
function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
export function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
if (!existsSync(pluginPath)) return null;
const stat = statSync(pluginPath);
if (stat.isFile()) {
return pluginPath;
}
if (!stat.isDirectory()) {
let stat;
try {
stat = statSync(pluginPath);
} catch {
return null;
}
if (stat.isFile()) return pluginPath;
if (!stat.isDirectory()) return null;
const packageJsonPath = join(pluginPath, "package.json");
if (existsSync(packageJsonPath)) {
try {
@ -183,6 +184,7 @@ function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
function inferPackageSpecifier(value: string | undefined): string | null {
if (!value) return null;
if (value.startsWith(".") || value.startsWith("/")) return null;
return value.startsWith("@") || value.includes("/") ? value : null;
}
@ -190,13 +192,19 @@ function resolvePluginSpecifier(
plugin: InstalledPluginConfig,
config: OrchestratorConfig,
): string | null {
if (plugin.path) {
const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath);
const entrypoint = resolveLocalPluginEntrypoint(absolutePath);
return entrypoint ? pathToFileURL(entrypoint).href : null;
switch (plugin.source) {
case "local": {
if (!plugin.path) return null;
const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath);
const entrypoint = resolveLocalPluginEntrypoint(absolutePath);
return entrypoint ? pathToFileURL(entrypoint).href : null;
}
case "registry":
case "npm":
return plugin.package ?? inferPackageSpecifier(plugin.name);
default:
return null;
}
return plugin.package ?? inferPackageSpecifier(plugin.name);
}
export function createPluginRegistry(): PluginRegistry {
@ -258,7 +266,10 @@ export function createPluginRegistry(): PluginRegistry {
if (plugin.enabled === false) continue;
const specifier = resolvePluginSpecifier(plugin, config);
if (!specifier) continue;
if (!specifier) {
console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`);
continue;
}
try {
const mod = normalizeImportedPluginModule(await doImport(specifier));
@ -266,8 +277,8 @@ export function createPluginRegistry(): PluginRegistry {
const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config);
this.register(mod, pluginConfig);
} catch {
// External plugin unavailable or invalid — continue loading the rest.
} catch (error) {
console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error);
}
}
},