diff --git a/.gitignore b/.gitignore
index e5dfed62e..f3bc5833c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,4 @@ agent-orchestrator.yaml
# OS-specific files
.DS_Store
Thumbs.db
+.gstack/
diff --git a/README.md b/README.md
index 4d67f1c3f..22d8f0920 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@
Spawn parallel AI coding agents, each in its own git worktree. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise from one dashboard.
[](https://github.com/ComposioHQ/agent-orchestrator/stargazers)
+[](https://www.npmjs.com/package/@composio/ao)
[](LICENSE)
[](https://github.com/ComposioHQ/agent-orchestrator/pulls?q=is%3Amerged)
[](https://github.com/ComposioHQ/agent-orchestrator/releases/tag/metrics-v1)
diff --git a/packages/cli/__tests__/commands/dashboard.test.ts b/packages/cli/__tests__/commands/dashboard.test.ts
index 4971c0509..a3940890c 100644
--- a/packages/cli/__tests__/commands/dashboard.test.ts
+++ b/packages/cli/__tests__/commands/dashboard.test.ts
@@ -89,45 +89,83 @@ describe("findRunningDashboardPid", () => {
});
});
-describe("findProcessWebDir", () => {
- it("extracts cwd from lsof output", async () => {
- const webDir = join(tmpDir, "web");
- mkdirSync(webDir, { recursive: true });
- writeFileSync(join(webDir, "package.json"), "{}");
+describe("isInstalledUnderNodeModules", () => {
+ it("returns true for a Unix node_modules path segment", async () => {
+ const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");
- // Simulate lsof -p -Fn output
- mockExecSilent.mockResolvedValue(
- `p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`,
- );
-
- const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
-
- const result = await findProcessWebDir("12345");
- expect(result).toBe(webDir);
+ expect(isInstalledUnderNodeModules("/usr/local/lib/node_modules/@composio/ao-web")).toBe(true);
});
- it("returns null when cwd has no package.json", async () => {
- const webDir = join(tmpDir, "web");
- mkdirSync(webDir, { recursive: true });
- // No package.json
+ it("returns true for a Windows node_modules path segment", async () => {
+ const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");
- mockExecSilent.mockResolvedValue(
- `p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`,
- );
-
- const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
-
- const result = await findProcessWebDir("12345");
- expect(result).toBeNull();
+ expect(isInstalledUnderNodeModules("C:\\Users\\me\\node_modules\\@composio\\ao-web")).toBe(true);
});
- it("returns null when lsof fails", async () => {
- mockExecSilent.mockResolvedValue(null);
+ it("returns false for source paths containing node_modules as plain text", async () => {
+ const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");
- const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
+ expect(
+ isInstalledUnderNodeModules("/home/user/node_modules_backup/agent-orchestrator/packages/web"),
+ ).toBe(false);
+ });
+});
- const result = await findProcessWebDir("12345");
- expect(result).toBeNull();
+describe("assertDashboardRebuildSupported", () => {
+ it("passes for a source checkout", async () => {
+ const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js");
+
+ expect(() =>
+ assertDashboardRebuildSupported("/home/user/agent-orchestrator/packages/web"),
+ ).not.toThrow();
+ });
+
+ it("throws for an npm-installed package path", async () => {
+ const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js");
+
+ expect(() =>
+ assertDashboardRebuildSupported("/usr/local/lib/node_modules/@composio/ao-web"),
+ ).toThrow("Dashboard rebuild is only available from a source checkout");
+ });
+});
+
+describe("rebuildDashboardProductionArtifacts", () => {
+ it("cleans .next and runs pnpm build on success", async () => {
+ const webDir = join(tmpDir, "packages", "web");
+ mkdirSync(webDir, { recursive: true });
+ mkdirSync(join(webDir, ".next"), { recursive: true });
+
+ mockExec.mockResolvedValue({ stdout: "", stderr: "" });
+
+ const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js");
+
+ await rebuildDashboardProductionArtifacts(webDir);
+
+ // .next should be cleaned
+ expect(existsSync(join(webDir, ".next"))).toBe(false);
+ // pnpm build should be called from workspace root (../../ relative to webDir)
+ expect(mockExec).toHaveBeenCalledWith("pnpm", ["build"], { cwd: tmpDir });
+ });
+
+ it("throws when pnpm build fails", async () => {
+ const webDir = join(tmpDir, "packages", "web");
+ mkdirSync(webDir, { recursive: true });
+
+ mockExec.mockRejectedValue(new Error("build failed"));
+
+ const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js");
+
+ await expect(rebuildDashboardProductionArtifacts(webDir)).rejects.toThrow(
+ "Failed to rebuild dashboard production artifacts",
+ );
+ });
+
+ it("throws when called from an npm-installed path", async () => {
+ const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js");
+
+ await expect(
+ rebuildDashboardProductionArtifacts("/usr/local/lib/node_modules/@composio/ao-web"),
+ ).rejects.toThrow("Dashboard rebuild is only available from a source checkout");
});
});
diff --git a/packages/cli/__tests__/commands/session.test.ts b/packages/cli/__tests__/commands/session.test.ts
index 8909add8d..86ad95582 100644
--- a/packages/cli/__tests__/commands/session.test.ts
+++ b/packages/cli/__tests__/commands/session.test.ts
@@ -354,7 +354,7 @@ describe("session kill", () => {
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("Session app-1 killed.");
- expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true });
+ expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false });
});
it("calls session manager kill with the session name", async () => {
@@ -364,7 +364,7 @@ describe("session kill", () => {
await program.parseAsync(["node", "test", "session", "kill", "app-1"]);
- expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true });
+ expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false });
});
it("passes purge flag for OpenCode cleanup", async () => {
@@ -374,22 +374,6 @@ describe("session kill", () => {
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true });
});
-
- it("passes keep-session flag to prevent OpenCode purge", async () => {
- mockSessionManager.kill.mockResolvedValue(undefined);
-
- await program.parseAsync(["node", "test", "session", "kill", "app-1", "--keep-session"]);
-
- expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false });
- });
-
- it("defaults to purge OpenCode session when neither flag is set", async () => {
- mockSessionManager.kill.mockResolvedValue(undefined);
-
- await program.parseAsync(["node", "test", "session", "kill", "app-1"]);
-
- expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true });
- });
});
describe("session attach", () => {
diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts
index ef399e226..70b2c040d 100644
--- a/packages/cli/__tests__/commands/setup.test.ts
+++ b/packages/cli/__tests__/commands/setup.test.ts
@@ -56,9 +56,7 @@ import { registerSetup } from "../../src/commands/setup.js";
const MINIMAL_CONFIG = `
port: 3000
-defaults:
- notifiers:
- - desktop
+defaults: {}
projects:
my-app:
name: my-app
@@ -70,7 +68,6 @@ const CONFIG_WITH_OPENCLAW = `
port: 3000
defaults:
notifiers:
- - desktop
- openclaw
notifiers:
openclaw:
@@ -235,8 +232,36 @@ describe("setup openclaw command", () => {
const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
expect(writtenYaml).toContain("openclaw");
- // Should have both desktop and openclaw in defaults.notifiers
- expect(writtenYaml).toContain("desktop");
+ expect(writtenYaml).not.toContain("desktop");
+ });
+
+ it("does not add desktop to defaults.notifiers when initializing notifiers", async () => {
+ // Config with no notifiers at all
+ mockReadFileSync.mockReturnValue(`
+port: 3000
+defaults: {}
+projects:
+ my-app:
+ name: my-app
+`);
+ const program = createProgram();
+
+ await program.parseAsync([
+ "node",
+ "test",
+ "setup",
+ "openclaw",
+ "--url",
+ "http://127.0.0.1:18789/hooks/agent",
+ "--token",
+ "tok",
+ "--non-interactive",
+ ]);
+
+ const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string;
+ const parsed = parseYaml(writtenYaml) as { defaults?: { notifiers?: string[] } };
+ expect(parsed.defaults?.notifiers).not.toContain("desktop");
+ expect(parsed.defaults?.notifiers).toContain("openclaw");
});
it("does not duplicate openclaw in defaults.notifiers", async () => {
diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts
index a869dc56a..161d0d67d 100644
--- a/packages/cli/__tests__/commands/start.test.ts
+++ b/packages/cli/__tests__/commands/start.test.ts
@@ -6,9 +6,10 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
+import { parse as parseYaml } from "yaml";
import type { SessionManager } from "@composio/ao-core";
// ---------------------------------------------------------------------------
@@ -48,6 +49,10 @@ const { mockDetectOpenClawInstallation } = vi.hoisted(() => ({
mockDetectOpenClawInstallation: vi.fn(),
}));
+const { mockProcessCwd } = vi.hoisted(() => ({
+ mockProcessCwd: vi.fn<[], string>(),
+}));
+
vi.mock("../../src/lib/shell.js", () => ({
tmux: vi.fn(),
exec: mockExec,
@@ -64,6 +69,7 @@ vi.mock("ora", () => ({
stop: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
+ info: vi.fn().mockReturnThis(),
text: "",
}),
}));
@@ -107,9 +113,8 @@ vi.mock("../../src/lib/web-dir.js", () => ({
}));
vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
- cleanNextCache: vi.fn(),
findRunningDashboardPid: vi.fn().mockResolvedValue(null),
- findProcessWebDir: vi.fn().mockResolvedValue(null),
+ rebuildDashboardProductionArtifacts: vi.fn().mockResolvedValue(undefined),
waitForPortFree: vi.fn(),
}));
@@ -148,7 +153,7 @@ vi.mock("../../src/lib/detect-agent.js", () => ({
}));
vi.mock("../../src/lib/project-detection.js", () => ({
- detectProjectType: vi.fn().mockReturnValue(null),
+ detectProjectType: vi.fn().mockReturnValue({ languages: [], frameworks: [] }),
generateRulesFromTemplates: vi.fn().mockReturnValue(null),
formatProjectTypeForDisplay: vi.fn().mockReturnValue(""),
}));
@@ -167,12 +172,26 @@ vi.mock("node:child_process", async (importOriginal) => {
};
});
+// Mock node:process so that `import { cwd } from "node:process"` in start.ts
+// can be intercepted per-test via mockProcessCwd.
+vi.mock("node:process", async (importOriginal) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ cwd: () => {
+ const override = mockProcessCwd();
+ return override ?? actual.cwd();
+ },
+ };
+});
+
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
import { Command } from "commander";
-import { registerStart, registerStop } from "../../src/commands/start.js";
+import { registerStart, registerStop, createConfigOnly } from "../../src/commands/start.js";
let tmpDir: string;
let program: Command;
@@ -196,6 +215,8 @@ beforeEach(() => {
const fakeChild = { on: vi.fn(), kill: vi.fn(), emit: vi.fn(), stdout: null, stderr: null };
mockSpawn.mockReturnValue(fakeChild);
+ mockSessionManager.list.mockReset();
+ mockSessionManager.list.mockResolvedValue([]);
mockSessionManager.get.mockReset();
mockSessionManager.spawnOrchestrator.mockReset();
mockSessionManager.kill.mockReset();
@@ -230,6 +251,7 @@ beforeEach(() => {
probe: { reachable: false, error: "not running" },
});
mockSpawn.mockClear();
+ mockProcessCwd.mockReset();
});
afterEach(() => {
@@ -250,7 +272,7 @@ function makeConfig(projects: Record>): Record {
expect(output).not.toContain("reused existing session");
},
);
+
+ it("handles existing orchestrator sessions by auto-selecting when --no-dashboard", async () => {
+ mockConfigRef.current = makeConfig({ "my-app": makeProject() });
+
+ // Return an existing orchestrator session
+ mockSessionManager.list.mockResolvedValue([
+ {
+ id: "app-orchestrator",
+ projectId: "my-app",
+ metadata: { role: "orchestrator" },
+ lastActivityAt: new Date(),
+ runtimeHandle: { id: "tmux-session-existing" },
+ },
+ ]);
+
+ await program.parseAsync(["node", "test", "start", "--no-dashboard"]);
+
+ const output = getLoggedOutput();
+ // When --no-dashboard is used, auto-selects the most recent orchestrator
+ // and shows the tmux attach command (not the dashboard selection message)
+ expect(output).toContain("tmux attach -t tmux-session-existing");
+ expect(output).not.toContain("existing sessions found — select one in the dashboard");
+
+ // Should NOT spawn a new orchestrator when existing ones exist
+ expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled();
+ });
+
+ it("navigates directly to session page when one existing orchestrator found with dashboard enabled", async () => {
+ mockConfigRef.current = makeConfig({ "my-app": makeProject() });
+
+ // Mock findWebDir
+ const { findWebDir } = await import("../../src/lib/web-dir.js");
+ vi.mocked(findWebDir).mockReturnValue(tmpDir);
+ writeFileSync(join(tmpDir, "package.json"), "{}");
+
+ const fakeDashboard = {
+ on: vi.fn(),
+ kill: vi.fn(),
+ emit: vi.fn(),
+ };
+ mockSpawn.mockReturnValue(fakeDashboard);
+
+ // Return a single existing orchestrator session
+ mockSessionManager.list.mockResolvedValue([
+ {
+ id: "app-orchestrator",
+ projectId: "my-app",
+ metadata: { role: "orchestrator" },
+ lastActivityAt: new Date(),
+ runtimeHandle: { id: "tmux-session-existing" },
+ },
+ ]);
+
+ await program.parseAsync(["node", "test", "start"]);
+
+ const output = getLoggedOutput();
+ // With one orchestrator, goes directly to the session page — shows tmux attach, no selection message
+ expect(output).toContain("tmux attach -t tmux-session-existing");
+ expect(output).not.toContain("multiple sessions found");
+ expect(output).not.toContain("select one in the dashboard");
+
+ // Should NOT spawn a new orchestrator when existing one exists
+ expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled();
+ });
+
+ it("opens orchestrator selection page when multiple existing orchestrators found with dashboard enabled", async () => {
+ mockConfigRef.current = makeConfig({ "my-app": makeProject() });
+
+ // Mock findWebDir
+ const { findWebDir } = await import("../../src/lib/web-dir.js");
+ vi.mocked(findWebDir).mockReturnValue(tmpDir);
+ writeFileSync(join(tmpDir, "package.json"), "{}");
+
+ const fakeDashboard = {
+ on: vi.fn(),
+ kill: vi.fn(),
+ emit: vi.fn(),
+ };
+ mockSpawn.mockReturnValue(fakeDashboard);
+
+ const now = new Date();
+ // Return two existing orchestrator sessions
+ mockSessionManager.list.mockResolvedValue([
+ {
+ id: "app-orchestrator-1",
+ projectId: "my-app",
+ metadata: { role: "orchestrator" },
+ lastActivityAt: new Date(now.getTime() - 1000),
+ runtimeHandle: { id: "tmux-session-1" },
+ },
+ {
+ id: "app-orchestrator-2",
+ projectId: "my-app",
+ metadata: { role: "orchestrator" },
+ lastActivityAt: now,
+ runtimeHandle: { id: "tmux-session-2" },
+ },
+ ]);
+
+ await program.parseAsync(["node", "test", "start"]);
+
+ const output = getLoggedOutput();
+ // With multiple orchestrators, shows selection message so user can choose or spawn a new one
+ expect(output).toContain("multiple sessions found — select one in the dashboard");
+
+ // Should NOT spawn a new orchestrator when existing ones exist
+ expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled();
+ });
+
+ it("fails and cleans up dashboard when orchestrator setup throws", async () => {
+ mockConfigRef.current = makeConfig({ "my-app": makeProject() });
+
+ // Mock findWebDir
+ const { findWebDir } = await import("../../src/lib/web-dir.js");
+ vi.mocked(findWebDir).mockReturnValue(tmpDir);
+ writeFileSync(join(tmpDir, "package.json"), "{}");
+
+ const fakeDashboard = {
+ on: vi.fn(),
+ kill: vi.fn(),
+ emit: vi.fn(),
+ };
+ mockSpawn.mockReturnValue(fakeDashboard);
+
+ mockSessionManager.list.mockResolvedValue([]);
+ mockSessionManager.spawnOrchestrator.mockRejectedValue(new Error("Spawn failed"));
+
+ await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)");
+
+ const errors = vi
+ .mocked(console.error)
+ .mock.calls.map((c) => c.join(" "))
+ .join("\n");
+ expect(errors).toContain("Failed to setup orchestrator: Spawn failed");
+
+ // Should have killed the dashboard
+ expect(fakeDashboard.kill).toHaveBeenCalled();
+ });
});
// ---------------------------------------------------------------------------
@@ -875,7 +1035,7 @@ describe("stop command", () => {
await program.parseAsync(["node", "test", "stop"]);
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", {
- purgeOpenCode: true,
+ purgeOpenCode: false,
});
expect(mockStopLifecycleWorker).toHaveBeenCalledWith(
expect.objectContaining({ configPath: expect.any(String) }),
@@ -907,30 +1067,6 @@ describe("stop command", () => {
expect(output).toContain("is not running");
});
- it("defaults to purge OpenCode session when stopping orchestrator", async () => {
- mockConfigRef.current = makeConfig({ "my-app": makeProject() });
- mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" });
- mockSessionManager.kill.mockResolvedValue(undefined);
-
- await program.parseAsync(["node", "test", "stop"]);
-
- expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", {
- purgeOpenCode: true,
- });
- });
-
- it("keeps OpenCode session when stopping with --keep-session", async () => {
- mockConfigRef.current = makeConfig({ "my-app": makeProject() });
- mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" });
- mockSessionManager.kill.mockResolvedValue(undefined);
-
- await program.parseAsync(["node", "test", "stop", "--keep-session"]);
-
- expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", {
- purgeOpenCode: false,
- });
- });
-
it("passes purge flag when stopping orchestrator with --purge-session", async () => {
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" });
@@ -943,3 +1079,48 @@ describe("stop command", () => {
});
});
});
+
+// ---------------------------------------------------------------------------
+// autoCreateConfig — config generation defaults
+// ---------------------------------------------------------------------------
+
+describe("start command — autoCreateConfig", () => {
+ it("generates config with empty notifiers array (no desktop notifier added by default)", async () => {
+ const { detectEnvironment } = await import("../../src/lib/detect-env.js");
+ vi.mocked(detectEnvironment).mockResolvedValue({
+ isGitRepo: false,
+ gitRemote: null,
+ ownerRepo: null,
+ currentBranch: null,
+ defaultBranch: null,
+ hasTmux: true,
+ hasGh: false,
+ ghAuthed: false,
+ hasLinearKey: false,
+ hasSlackWebhook: false,
+ });
+
+ const { detectProjectType } = await import("../../src/lib/project-detection.js");
+ vi.mocked(detectProjectType).mockReturnValue({ languages: [], frameworks: [] });
+
+ const { detectAvailableAgents, detectAgentRuntime } = await import("../../src/lib/detect-agent.js");
+ vi.mocked(detectAvailableAgents).mockResolvedValue([]);
+ vi.mocked(detectAgentRuntime).mockResolvedValue("claude-code");
+
+ const { findFreePort } = await import("../../src/lib/web-dir.js");
+ vi.mocked(findFreePort).mockResolvedValue(3000);
+
+ // start.ts uses `import { cwd } from "node:process"` which is intercepted
+ // by the node:process mock defined at the top of this file.
+ mockProcessCwd.mockReturnValue(tmpDir);
+
+ await createConfigOnly();
+
+ const configPath = join(tmpDir, "agent-orchestrator.yaml");
+ expect(existsSync(configPath)).toBe(true);
+
+ const content = readFileSync(configPath, "utf-8");
+ const parsed = parseYaml(content) as { defaults?: { notifiers?: unknown[] } };
+ expect(parsed.defaults?.notifiers).toEqual([]);
+ });
+});
diff --git a/packages/cli/__tests__/index.test.ts b/packages/cli/__tests__/index.test.ts
new file mode 100644
index 000000000..01f781616
--- /dev/null
+++ b/packages/cli/__tests__/index.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it, vi } from "vitest";
+
+const parse = vi.fn();
+
+vi.mock("../src/program.js", () => ({
+ createProgram: () => ({ parse }),
+}));
+
+describe("cli entrypoint", () => {
+ it("parses the created program", async () => {
+ await import("../src/index.js");
+ expect(parse).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/cli/__tests__/lib/preflight.test.ts b/packages/cli/__tests__/lib/preflight.test.ts
index dc7536ce4..42a64770f 100644
--- a/packages/cli/__tests__/lib/preflight.test.ts
+++ b/packages/cli/__tests__/lib/preflight.test.ts
@@ -18,6 +18,11 @@ vi.mock("node:fs", () => ({
existsSync: mockExistsSync,
}));
+vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
+ isInstalledUnderNodeModules: (path: string) =>
+ path.includes("/node_modules/") || path.includes("\\node_modules\\"),
+}));
+
import { preflight } from "../../src/lib/preflight.js";
beforeEach(() => {
@@ -58,9 +63,12 @@ describe("preflight.checkBuilt", () => {
// /web/node_modules/@composio/ao-core — miss
// /node_modules/@composio/ao-core — hit
// /node_modules/@composio/ao-core/dist/index.js — exists
+ // /web/.next/BUILD_ID and /web/dist-server/start-all.js — exist
mockExistsSync
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(true)
.mockReturnValueOnce(true);
await expect(preflight.checkBuilt("/web")).resolves.toBeUndefined();
});
@@ -88,6 +96,38 @@ describe("preflight.checkBuilt", () => {
"Packages not built",
);
});
+
+ it("throws when web production artifacts are missing", async () => {
+ // findPackageUp finds ao-core, dist/index.js exists, but .next/BUILD_ID missing
+ mockExistsSync
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(false);
+ await expect(preflight.checkBuilt("/web")).rejects.toThrow(
+ "Packages not built",
+ );
+ });
+
+ it("throws npm hint when web artifacts missing in global install", async () => {
+ // ao-core found at first check, dist exists, but .next/BUILD_ID missing
+ mockExistsSync
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(false);
+ await expect(
+ preflight.checkBuilt("/usr/local/lib/node_modules/@composio/ao-web"),
+ ).rejects.toThrow("npm install -g @composio/ao@latest");
+ });
+
+ it("throws npm hint when ao-core dist is missing in global install", async () => {
+ // ao-core found, but dist/index.js missing
+ mockExistsSync
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(false);
+ await expect(
+ preflight.checkBuilt("/usr/local/lib/node_modules/@composio/ao-web"),
+ ).rejects.toThrow("npm install -g @composio/ao@latest");
+ });
});
describe("preflight.checkTmux", () => {
diff --git a/packages/cli/__tests__/lib/session-utils.test.ts b/packages/cli/__tests__/lib/session-utils.test.ts
index 5d42dadab..a2d072736 100644
--- a/packages/cli/__tests__/lib/session-utils.test.ts
+++ b/packages/cli/__tests__/lib/session-utils.test.ts
@@ -146,4 +146,49 @@ describe("isOrchestratorSessionName", () => {
const config = makeConfig({ "my-app": { sessionPrefix: "app" } });
expect(isOrchestratorSessionName(config, "app-12", "my-app")).toBe(false);
});
+
+ it("matches worktree orchestrator IDs (orchestrator-N) for a known project", () => {
+ const config = makeConfig({ "my-app": { sessionPrefix: "app" } });
+ expect(isOrchestratorSessionName(config, "app-orchestrator-1", "my-app")).toBe(true);
+ expect(isOrchestratorSessionName(config, "app-orchestrator-42", "my-app")).toBe(true);
+ });
+
+ it("matches worktree orchestrator IDs without an explicit project", () => {
+ const config = makeConfig({ "my-app": { sessionPrefix: "app" } });
+ expect(isOrchestratorSessionName(config, "app-orchestrator-1")).toBe(true);
+ });
+
+ it("does not false-positive on a worker when prefix ends with -orchestrator", () => {
+ const config = makeConfig({ "my-app": { sessionPrefix: "my-orchestrator" } });
+ // my-orchestrator-1 is a worker session, not a worktree orchestrator
+ expect(isOrchestratorSessionName(config, "my-orchestrator-1", "my-app")).toBe(false);
+ // my-orchestrator-orchestrator is the canonical orchestrator
+ expect(isOrchestratorSessionName(config, "my-orchestrator-orchestrator", "my-app")).toBe(true);
+ });
+
+ it("does not cross-project false-positive when one prefix is another's {prefix}-orchestrator", () => {
+ // project A prefix "app", project B prefix "app-orchestrator"
+ // "app-orchestrator-1" is a worker of B, NOT an orchestrator of A
+ const config = makeConfig({
+ "project-a": { sessionPrefix: "app" },
+ "project-b": { sessionPrefix: "app-orchestrator" },
+ });
+ expect(isOrchestratorSessionName(config, "app-orchestrator-1")).toBe(false);
+ // "app-orchestrator-orchestrator-1" IS an orchestrator of B
+ expect(isOrchestratorSessionName(config, "app-orchestrator-orchestrator-1")).toBe(true);
+ });
+
+ it("does not cross-project false-positive when projectId is provided", () => {
+ // project A prefix "app", project B prefix "app-orchestrator"
+ // "app-orchestrator-1" is a worker of B — must not be classified as orchestrator of A
+ // even when called with projectId="project-a"
+ const config = makeConfig({
+ "project-a": { sessionPrefix: "app" },
+ "project-b": { sessionPrefix: "app-orchestrator" },
+ });
+ expect(isOrchestratorSessionName(config, "app-orchestrator-1", "project-a")).toBe(false);
+ // The canonical orchestrator of A is still recognized
+ expect(isOrchestratorSessionName(config, "app-orchestrator", "project-a")).toBe(true);
+ expect(isOrchestratorSessionName(config, "app-orchestrator-2", "project-a")).toBe(false);
+ });
});
diff --git a/packages/cli/__tests__/options/version.test.ts b/packages/cli/__tests__/options/version.test.ts
new file mode 100644
index 000000000..506c1494e
--- /dev/null
+++ b/packages/cli/__tests__/options/version.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from "vitest";
+import packageJson from "../../package.json" with { type: "json" };
+import { getCliVersion } from "../../src/options/version.js";
+
+describe("getCliVersion", () => {
+ it("matches the CLI package version", () => {
+ expect(getCliVersion()).toBe(packageJson.version);
+ });
+});
diff --git a/packages/cli/__tests__/program.test.ts b/packages/cli/__tests__/program.test.ts
new file mode 100644
index 000000000..656ac51be
--- /dev/null
+++ b/packages/cli/__tests__/program.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from "vitest";
+import packageJson from "../package.json" with { type: "json" };
+import { createProgram } from "../src/program.js";
+
+describe("createProgram", () => {
+ it("uses the CLI package version", () => {
+ expect(createProgram().version()).toBe(packageJson.version);
+ });
+});
diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts
index 8f494c77d..cb32cabce 100644
--- a/packages/cli/src/commands/dashboard.ts
+++ b/packages/cli/src/commands/dashboard.ts
@@ -1,11 +1,16 @@
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";
import { findWebDir, buildDashboardEnv, waitForPortAndOpen } from "../lib/web-dir.js";
-import { cleanNextCache, findRunningDashboardPid, findProcessWebDir, waitForPortFree } from "../lib/dashboard-rebuild.js";
+import {
+ findRunningDashboardPid,
+ isInstalledUnderNodeModules,
+ rebuildDashboardProductionArtifacts,
+ waitForPortFree,
+} from "../lib/dashboard-rebuild.js";
+import { preflight } from "../lib/preflight.js";
export function registerDashboard(program: Command): void {
program
@@ -14,6 +19,7 @@ export function registerDashboard(program: Command): void {
.option("-p, --port ", "Port to listen on")
.option("--no-open", "Don't open browser automatically")
.option("--rebuild", "Clean stale build artifacts and rebuild before starting")
+ /* c8 ignore start -- process-spawning startup code, tested via integration/onboarding */
.action(async (opts: { port?: string; open?: boolean; rebuild?: boolean }) => {
const config = loadConfig();
const port = opts.port ? parseInt(opts.port, 10) : (config.port ?? 3000);
@@ -28,11 +34,9 @@ export function registerDashboard(program: Command): void {
if (opts.rebuild) {
// Check if a dashboard is already running on this port.
const runningPid = await findRunningDashboardPid(port);
- const runningWebDir = runningPid ? await findProcessWebDir(runningPid) : null;
- const targetWebDir = runningWebDir ?? localWebDir;
if (runningPid) {
- // Kill the running server, clean .next, then start fresh below.
+ // Stop the running server before rebuilding or restarting below.
console.log(
chalk.dim(`Stopping dashboard (PID ${runningPid}) on port ${port}...`),
);
@@ -45,8 +49,10 @@ export function registerDashboard(program: Command): void {
await waitForPortFree(port, 5000);
}
- await cleanNextCache(targetWebDir);
+ await rebuildDashboardProductionArtifacts(localWebDir);
// Fall through to start the dashboard on this port.
+ } else {
+ await preflight.checkBuilt(localWebDir);
}
const webDir = localWebDir;
@@ -60,21 +66,12 @@ export function registerDashboard(program: Command): void {
config.directTerminalPort,
);
- // 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 startScript = resolve(webDir, "dist-server", "start-all.js");
+ const child = spawn("node", [startScript], {
+ cwd: webDir,
+ stdio: ["inherit", "inherit", "pipe"],
+ env,
+ });
const stderrChunks: string[] = [];
@@ -108,10 +105,13 @@ export function registerDashboard(program: Command): void {
if (code !== 0 && code !== null && !opts.rebuild) {
const stderr = stderrChunks.join("");
if (looksLikeStaleBuild(stderr)) {
+ const recoveryCommand = isInstalledUnderNodeModules(webDir)
+ ? "npm install -g @composio/ao@latest"
+ : "ao dashboard --rebuild";
console.error(
chalk.yellow(
"\nThis looks like a stale build cache issue. Try:\n\n" +
- ` ${chalk.cyan("ao dashboard --rebuild")}\n`,
+ ` ${chalk.cyan(recoveryCommand)}\n`,
),
);
}
@@ -120,6 +120,7 @@ export function registerDashboard(program: Command): void {
process.exit(code ?? 0);
});
});
+ /* c8 ignore stop */
}
/**
diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts
index d0e7fcc07..9edd697b9 100644
--- a/packages/cli/src/commands/doctor.ts
+++ b/packages/cli/src/commands/doctor.ts
@@ -343,7 +343,12 @@ async function sendTestNotifications(
const targets = new Map();
for (const [name, notifierConfig] of configuredNotifiers) {
- targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin });
+ if (notifierConfig.plugin) {
+ targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin });
+ } else {
+ // External plugin without explicit plugin name - manifest.name not yet resolved
+ warn(`${name}: notifier plugin name not resolved (external plugin may not be loaded yet)`);
+ }
}
for (const name of activeNotifierNames) {
diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts
index 12e486216..ca1df02e8 100644
--- a/packages/cli/src/commands/session.ts
+++ b/packages/cli/src/commands/session.ts
@@ -125,23 +125,19 @@ export function registerSession(program: Command): void {
.command("kill")
.description("Kill a session and remove its worktree")
.argument("", "Session name to kill")
- .option("--keep-session", "Keep mapped OpenCode session after kill")
.option("--purge-session", "Delete mapped OpenCode session during kill")
- .action(
- async (sessionName: string, opts: { keepSession?: boolean; purgeSession?: boolean }) => {
- const config = loadConfig();
- const sm = await getSessionManager(config);
+ .action(async (sessionName: string, opts: { purgeSession?: boolean }) => {
+ const config = loadConfig();
+ const sm = await getSessionManager(config);
- try {
- const purgeOpenCode = opts.purgeSession === true ? true : opts.keepSession !== true;
- await sm.kill(sessionName, { purgeOpenCode });
- console.log(chalk.green(`\nSession ${sessionName} killed.`));
- } catch (err) {
- console.error(chalk.red(`Failed to kill session ${sessionName}: ${err}`));
- process.exit(1);
- }
- },
- );
+ try {
+ await sm.kill(sessionName, { purgeOpenCode: opts.purgeSession === true });
+ console.log(chalk.green(`\nSession ${sessionName} killed.`));
+ } catch (err) {
+ console.error(chalk.red(`Failed to kill session ${sessionName}: ${err}`));
+ process.exit(1);
+ }
+ });
session
.command("cleanup")
diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts
index 31388df8e..96cfa6faf 100644
--- a/packages/cli/src/commands/setup.ts
+++ b/packages/cli/src/commands/setup.ts
@@ -290,7 +290,7 @@ function writeOpenClawConfig(
// Add "openclaw" to defaults.notifiers if not already present
if (!rawConfig.defaults) rawConfig.defaults = {};
- if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = ["desktop"];
+ if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = [];
if (!Array.isArray(rawConfig.defaults.notifiers)) {
rawConfig.defaults.notifiers = [rawConfig.defaults.notifiers];
}
diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts
index f1c14a95a..5eb257ffd 100644
--- a/packages/cli/src/commands/start.ts
+++ b/packages/cli/src/commands/start.ts
@@ -28,6 +28,8 @@ import {
generateConfigFromUrl,
configToYaml,
normalizeOrchestratorSessionStrategy,
+ isOrchestratorSession,
+ isTerminalSession,
ConfigNotFoundError,
type OrchestratorConfig,
type ProjectConfig,
@@ -46,7 +48,7 @@ import {
findFreePort,
MAX_PORT_SCAN,
} from "../lib/web-dir.js";
-import { cleanNextCache } from "../lib/dashboard-rebuild.js";
+import { rebuildDashboardProductionArtifacts } from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";
import { register, unregister, isAlreadyRunning, getRunning, waitForExit } from "../lib/running-state.js";
import { isHumanCaller } from "../lib/caller-context.js";
@@ -580,7 +582,7 @@ async function autoCreateConfig(workingDir: string): Promise
runtime: "tmux",
agent,
workspace: "worktree",
- notifiers: ["desktop"],
+ notifiers: [],
},
projects: {
[projectId]: {
@@ -747,22 +749,29 @@ export async function createConfigOnly(): Promise {
* Start dashboard server in the background.
* Returns the child process handle for cleanup.
*/
+/* c8 ignore start -- process-spawning startup code, tested via integration/onboarding */
async function startDashboard(
port: number,
webDir: string,
configPath: string | null,
terminalPort?: number,
directTerminalPort?: number,
+ devMode?: boolean,
): Promise {
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);
- // Detect dev vs production: the `server/` source directory only exists in the
- // monorepo. Published npm packages only have `dist-server/`.
- const isDevMode = existsSync(resolve(webDir, "server"));
+ // Detect monorepo vs npm install: the `server/` source directory only exists
+ // in the monorepo. Published npm packages only have `dist-server/`.
+ const isMonorepo = existsSync(resolve(webDir, "server"));
+
+ // In monorepo: use HMR dev server only when --dev is passed explicitly.
+ // Default is optimized production server for faster loading.
+ const useDevServer = isMonorepo && devMode === true;
let child: ChildProcess;
- if (isDevMode) {
- // Monorepo development: use pnpm run dev (tsx, HMR, etc.)
+ if (useDevServer) {
+ // Monorepo with --dev: use pnpm run dev (tsx watch, HMR, etc.)
+ console.log(chalk.dim(" Mode: development (HMR enabled)"));
child = spawn("pnpm", ["run", "dev"], {
cwd: webDir,
stdio: "inherit",
@@ -770,8 +779,13 @@ async function startDashboard(
env,
});
} else {
- // Production (installed from npm): use pre-built start-all script
- child = spawn("node", [resolve(webDir, "dist-server", "start-all.js")], {
+ // Production: use pre-built start-all script.
+ if (isMonorepo) {
+ console.log(chalk.dim(" Mode: optimized (production bundles)"));
+ console.log(chalk.dim(" Tip: use --dev for hot reload when editing dashboard UI\n"));
+ }
+ const startScript = resolve(webDir, "dist-server", "start-all.js");
+ child = spawn("node", [startScript], {
cwd: webDir,
stdio: "inherit",
detached: false,
@@ -780,8 +794,8 @@ async function startDashboard(
}
child.on("error", (err) => {
- const cmd = isDevMode ? "pnpm" : "node";
- const args = isDevMode ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")];
+ const cmd = useDevServer ? "pnpm" : "node";
+ const args = useDevServer ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")];
const formatted = formatCommandError(err, {
cmd,
args,
@@ -795,6 +809,7 @@ async function startDashboard(
return child;
}
+/* c8 ignore stop */
/**
* Ensure tmux is available — interactive install with user consent if missing.
@@ -897,7 +912,7 @@ async function runStartup(
config: OrchestratorConfig,
projectId: string,
project: ProjectConfig,
- opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean },
+ opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; dev?: boolean },
): Promise {
// Ensure tmux is available before doing anything — covers all entry paths
// (normal start, URL start, retry with existing config)
@@ -948,10 +963,15 @@ async function runStartup(
port = newPort;
}
const webDir = findWebDir(); // throws with install-specific guidance if not found
- await preflight.checkBuilt(webDir);
-
+ // Dev mode (HMR) only works in the monorepo where `server/` source exists.
+ // For npm installs, --dev is silently ignored and production server runs,
+ // so preflight must still verify production artifacts exist.
+ const isMonorepo = existsSync(resolve(webDir, "server"));
+ const willUseDevServer = isMonorepo && opts?.dev === true;
if (opts?.rebuild) {
- await cleanNextCache(webDir);
+ await rebuildDashboardProductionArtifacts(webDir);
+ } else if (!willUseDevServer) {
+ await preflight.checkBuilt(webDir);
}
spinner.start("Starting dashboard");
@@ -961,6 +981,7 @@ async function runStartup(
config.configPath,
config.terminalPort,
config.directTerminalPort,
+ opts?.dev,
);
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
@@ -987,32 +1008,83 @@ async function runStartup(
}
}
- // Create orchestrator session (unless --no-orchestrator or already exists)
+ // Create orchestrator session (unless --no-orchestrator or existing orchestrators found)
let tmuxTarget = sessionId;
+ let hasExistingOrchestrators = false;
+ let selectedOrchestratorId: string | null = null;
+
if (opts?.orchestrator !== false) {
const sm = await getSessionManager(config);
+ // Check for existing orchestrator sessions for this project
+ let allSessions;
try {
- spinner.start("Creating orchestrator session");
- const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
- const session = await sm.spawnOrchestrator({ projectId, systemPrompt });
- if (session.runtimeHandle?.id) {
- tmuxTarget = session.runtimeHandle.id;
- }
- reused =
- orchestratorSessionStrategy === "reuse" &&
- session.metadata?.["orchestratorSessionReused"] === "true";
- spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created");
+ allSessions = await sm.list(projectId);
} catch (err) {
- spinner.fail("Orchestrator setup failed");
+ spinner.fail("Failed to list sessions");
if (dashboardProcess) {
dashboardProcess.kill();
}
throw new Error(
- `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
+ `Failed to list sessions: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
+ const allSessionPrefixes = Object.entries(config.projects).map(
+ ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
+ );
+ const existingOrchestrators = allSessions.filter(
+ (s) =>
+ isOrchestratorSession(s, project.sessionPrefix ?? projectId, allSessionPrefixes) &&
+ !isTerminalSession(s),
+ );
+
+ if (existingOrchestrators.length > 0) {
+ // Existing orchestrators found — always auto-select the most recently active one.
+ // With a single orchestrator, navigate directly to its session page.
+ // With multiple orchestrators, keep the selection page so the user can choose or spawn a
+ // new one — the dashboard only links to one orchestrator per project, so the selection page
+ // is the only startup path for multi-orchestrator projects.
+ const sortedOrchestrators = [...existingOrchestrators].sort(
+ (a, b) => (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0),
+ );
+ const selected = sortedOrchestrators[0];
+ selectedOrchestratorId = selected.id;
+ // Use runtimeHandle.id if available, otherwise fall back to the session ID
+ tmuxTarget = selected.runtimeHandle?.id ?? selected.id;
+ if (opts?.dashboard !== false && existingOrchestrators.length > 1) {
+ hasExistingOrchestrators = true;
+ }
+ spinner.succeed(
+ `Using existing orchestrator session: ${selected.id}` +
+ (existingOrchestrators.length > 1
+ ? ` (${existingOrchestrators.length - 1} other session(s) available)` : ""),
+ );
+ } else {
+ // No existing orchestrators — spawn a new one
+ try {
+ spinner.start("Creating orchestrator session");
+ const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
+ const session = await sm.spawnOrchestrator({ projectId, systemPrompt });
+ selectedOrchestratorId = session.id;
+ if (session.runtimeHandle?.id) {
+ tmuxTarget = session.runtimeHandle.id;
+ }
+ reused =
+ orchestratorSessionStrategy === "reuse" &&
+ session.metadata?.["orchestratorSessionReused"] === "true";
+ spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created");
+ } catch (err) {
+ spinner.fail("Orchestrator setup failed");
+ if (dashboardProcess) {
+ dashboardProcess.kill();
+ }
+ throw new Error(
+ `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
+ { cause: err },
+ );
+ }
+ }
}
// Print summary
@@ -1030,7 +1102,12 @@ async function runStartup(
console.log(chalk.cyan("Lifecycle:"), lifecycleTarget);
}
- if (opts?.orchestrator !== false && !reused) {
+ if (hasExistingOrchestrators) {
+ console.log(
+ chalk.cyan("Orchestrator:"),
+ "multiple sessions found — select one in the dashboard",
+ );
+ } else if (opts?.orchestrator !== false && !reused) {
console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`);
} else if (reused) {
console.log(chalk.cyan("Orchestrator:"), `reused existing session (${sessionId})`);
@@ -1038,21 +1115,28 @@ async function runStartup(
console.log(chalk.dim(`Config: ${config.configPath}`));
- // Show next step hint
- const projectIds = Object.keys(config.projects);
- if (projectIds.length > 0) {
- console.log(chalk.bold("\nNext step:\n"));
- console.log(` Spawn an agent session:`);
- console.log(chalk.cyan(` ao spawn \n`));
+ // Show next step hint (only if no existing orchestrators requiring selection)
+ if (!hasExistingOrchestrators) {
+ const projectIds = Object.keys(config.projects);
+ if (projectIds.length > 0) {
+ console.log(chalk.bold("\nNext step:\n"));
+ console.log(` Spawn an agent session:`);
+ console.log(chalk.cyan(` ao spawn \n`));
+ }
}
- // Auto-open browser to orchestrator session page once the server is accepting connections.
+ // Auto-open browser once the server is ready.
+ // With a single orchestrator (or a newly created one), navigate directly to the session page.
+ // With multiple existing orchestrators, open the selection page so the user can choose or
+ // spawn a new one — the dashboard only links one orchestrator per project.
// Polls the port instead of using a fixed delay — deterministic and works regardless of
// how long Next.js takes to compile. AbortController cancels polling on early exit.
let openAbort: AbortController | undefined;
if (opts?.dashboard !== false) {
openAbort = new AbortController();
- const orchestratorUrl = `http://localhost:${port}/sessions/${sessionId}`;
+ const orchestratorUrl = hasExistingOrchestrators
+ ? `http://localhost:${port}/orchestrators?project=${projectId}`
+ : `http://localhost:${port}/sessions/${selectedOrchestratorId ?? sessionId}`;
void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal);
}
@@ -1109,6 +1193,7 @@ export function registerStart(program: Command): void {
.option("--no-dashboard", "Skip starting the dashboard server")
.option("--no-orchestrator", "Skip starting the orchestrator agent")
.option("--rebuild", "Clean and rebuild dashboard before starting")
+ .option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)")
.option("--interactive", "Prompt to configure config settings")
.action(
async (
@@ -1117,6 +1202,7 @@ export function registerStart(program: Command): void {
dashboard?: boolean;
orchestrator?: boolean;
rebuild?: boolean;
+ dev?: boolean;
interactive?: boolean;
},
) => {
@@ -1320,13 +1406,12 @@ export function registerStop(program: Command): void {
program
.command("stop [project]")
.description("Stop orchestrator agent and dashboard")
- .option("--keep-session", "Keep mapped OpenCode session after stopping")
.option("--purge-session", "Delete mapped OpenCode session when stopping")
.option("--all", "Stop all running AO instances")
.action(
async (
projectArg?: string,
- opts: { keepSession?: boolean; purgeSession?: boolean; all?: boolean } = {},
+ opts: { purgeSession?: boolean; all?: boolean } = {},
) => {
try {
// Check running.json first
@@ -1364,7 +1449,7 @@ export function registerStop(program: Command): void {
if (existing) {
const spinner = ora("Stopping orchestrator session").start();
- const purgeOpenCode = opts.purgeSession === true ? true : opts.keepSession !== true;
+ const purgeOpenCode = opts?.purgeSession === true;
await sm.kill(sessionId, { purgeOpenCode });
spinner.succeed("Orchestrator session stopped");
} else {
@@ -1405,6 +1490,5 @@ export function registerStop(program: Command): void {
}
process.exit(1);
}
- },
- );
+ });
}
diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts
index 625e06370..a65499cf3 100644
--- a/packages/cli/src/commands/status.ts
+++ b/packages/cli/src/commands/status.ts
@@ -74,7 +74,11 @@ async function gatherSessionInfo(
scm: SCM,
projectConfig: ReturnType,
): Promise {
- const suppressPROwnership = isOrchestratorSession(session);
+ const sessionPrefix = projectConfig.projects[session.projectId]?.sessionPrefix ?? session.projectId;
+ const allSessionPrefixes = Object.entries(projectConfig.projects).map(
+ ([id, p]) => p.sessionPrefix ?? id,
+ );
+ const suppressPROwnership = isOrchestratorSession(session, sessionPrefix, allSessionPrefixes);
let branch = session.branch;
const status = session.status;
const summary = session.metadata["summary"] ?? null;
@@ -144,7 +148,7 @@ async function gatherSessionInfo(
return {
name: session.id,
- role: isOrchestratorSession(session) ? "orchestrator" : "worker",
+ role: isOrchestratorSession(session, sessionPrefix, allSessionPrefixes) ? "orchestrator" : "worker",
branch,
status,
summary,
@@ -386,7 +390,7 @@ export function registerStatus(program: Command): void {
let unverifiedTotal = 0;
for (const projectId of projectIds) {
const project: ProjectConfig | undefined = config.projects[projectId];
- if (!project?.tracker) continue;
+ if (!project?.tracker?.plugin) continue;
const tracker = registry.get("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
try {
diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts
index 40d6ee043..c82223a87 100644
--- a/packages/cli/src/commands/verify.ts
+++ b/packages/cli/src/commands/verify.ts
@@ -60,6 +60,10 @@ async function getTracker(
const registry = createPluginRegistry();
await registry.loadFromConfig(config, importPluginModuleFromSource);
+ if (!project.tracker.plugin) {
+ console.error(chalk.red("Project tracker plugin not configured."));
+ process.exit(1);
+ }
const tracker = registry.get("tracker", project.tracker.plugin);
if (!tracker) {
console.error(chalk.red(`Tracker plugin "${project.tracker.plugin}" not found.`));
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 8fb960663..b7791ffbc 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -1,53 +1,5 @@
#!/usr/bin/env node
-import { Command } from "commander";
-import { registerInit } from "./commands/init.js";
-import { registerStatus } from "./commands/status.js";
-import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js";
-import { registerSession } from "./commands/session.js";
-import { registerSend } from "./commands/send.js";
-import { registerReviewCheck } from "./commands/review-check.js";
-import { registerDashboard } from "./commands/dashboard.js";
-import { registerOpen } from "./commands/open.js";
-import { registerStart, registerStop } from "./commands/start.js";
-import { registerLifecycleWorker } from "./commands/lifecycle-worker.js";
-import { registerVerify } from "./commands/verify.js";
-import { registerDoctor } from "./commands/doctor.js";
-import { registerUpdate } from "./commands/update.js";
-import { registerSetup } from "./commands/setup.js";
-import { registerPlugin } from "./commands/plugin.js";
-import { getConfigInstruction } from "./lib/config-instruction.js";
+import { createProgram } from "./program.js";
-const program = new Command();
-
-program
- .name("ao")
- .description("Agent Orchestrator — manage parallel AI coding agents")
- .version("0.1.0");
-
-registerInit(program);
-registerStart(program);
-registerStop(program);
-registerStatus(program);
-registerSpawn(program);
-registerBatchSpawn(program);
-registerSession(program);
-registerSend(program);
-registerReviewCheck(program);
-registerDashboard(program);
-registerOpen(program);
-registerLifecycleWorker(program);
-registerVerify(program);
-registerDoctor(program);
-registerUpdate(program);
-registerSetup(program);
-registerPlugin(program);
-
-program
- .command("config-help")
- .description("Show config schema and guide for creating agent-orchestrator.yaml")
- .action(() => {
- console.log(getConfigInstruction());
- });
-
-program.parse();
+createProgram().parse();
diff --git a/packages/cli/src/lib/dashboard-rebuild.ts b/packages/cli/src/lib/dashboard-rebuild.ts
index b6de94011..ad38d3869 100644
--- a/packages/cli/src/lib/dashboard-rebuild.ts
+++ b/packages/cli/src/lib/dashboard-rebuild.ts
@@ -1,12 +1,33 @@
/**
- * Dashboard cache utilities — cleans stale .next artifacts and detects
- * running dashboard processes.
+ * Dashboard cache utilities — cleans stale .next artifacts, detects
+ * running dashboard processes, and rebuilds production artifacts.
*/
import { resolve } from "node:path";
import { existsSync, rmSync } from "node:fs";
import ora from "ora";
-import { execSilent } from "./shell.js";
+import { exec, execSilent } from "./shell.js";
+
+/**
+ * Check if the web directory is inside a node_modules tree (npm/yarn global install).
+ * Matches node_modules as a path segment, not just a substring.
+ */
+export function isInstalledUnderNodeModules(path: string): boolean {
+ return path.includes("/node_modules/") || path.includes("\\node_modules\\");
+}
+
+/**
+ * Guard: rebuilds are only possible from a source checkout.
+ * Global npm installs ship prebuilt artifacts and cannot rebuild in place.
+ */
+export function assertDashboardRebuildSupported(webDir: string): void {
+ if (isInstalledUnderNodeModules(webDir)) {
+ throw new Error(
+ "Dashboard rebuild is only available from a source checkout. " +
+ "Run `ao update`, or reinstall with `npm install -g @composio/ao@latest`.",
+ );
+ }
+}
/**
* Find the PID of a process listening on the given port.
@@ -21,28 +42,6 @@ export async function findRunningDashboardPid(port: number): Promise {
- const lsofDetail = await execSilent("lsof", ["-p", pid, "-Ffn"]);
- if (!lsofDetail) return null;
-
- // lsof -Fn outputs lines like "n/path/to/cwd" — the cwd entry follows "fcwd"
- const lines = lsofDetail.split("\n");
- for (let i = 0; i < lines.length; i++) {
- if (lines[i] === "fcwd" && i + 1 < lines.length && lines[i + 1]?.startsWith("n/")) {
- const cwd = lines[i + 1].slice(1);
- if (existsSync(resolve(cwd, "package.json"))) {
- return cwd;
- }
- }
- }
-
- return null;
-}
-
/**
* Wait for a port to be free (no process listening).
* Throws if the port is still busy after the timeout.
@@ -58,9 +57,7 @@ export async function waitForPortFree(port: number, timeoutMs: number): Promise<
}
/**
- * Clean just the .next cache directory. Use when a dev server is running —
- * it will recompile on next request. Does NOT run pnpm build (which would
- * create a production .next that the dev server can't use).
+ * Remove the .next directory before a rebuild.
*/
export async function cleanNextCache(webDir: string): Promise {
const nextDir = resolve(webDir, ".next");
@@ -72,3 +69,27 @@ export async function cleanNextCache(webDir: string): Promise {
}
}
+/**
+ * Rebuild dashboard production artifacts (Next.js build + server compilation)
+ * from a source checkout. Throws if called from an npm global install.
+ */
+export async function rebuildDashboardProductionArtifacts(webDir: string): Promise {
+ assertDashboardRebuildSupported(webDir);
+
+ await cleanNextCache(webDir);
+
+ const workspaceRoot = resolve(webDir, "../..");
+ const spinner = ora("Rebuilding dashboard production artifacts").start();
+
+ try {
+ await exec("pnpm", ["build"], { cwd: workspaceRoot });
+ spinner.succeed("Rebuilt dashboard production artifacts");
+ } catch (error) {
+ spinner.fail("Dashboard rebuild failed");
+ throw new Error(
+ "Failed to rebuild dashboard production artifacts. Run `pnpm build` and try again.",
+ { cause: error },
+ );
+ }
+}
+
diff --git a/packages/cli/src/lib/preflight.ts b/packages/cli/src/lib/preflight.ts
index 0f7721f85..e22cbadb3 100644
--- a/packages/cli/src/lib/preflight.ts
+++ b/packages/cli/src/lib/preflight.ts
@@ -11,6 +11,7 @@ import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { isPortAvailable } from "./web-dir.js";
import { exec } from "./shell.js";
+import { isInstalledUnderNodeModules } from "./dashboard-rebuild.js";
/**
* Check that the dashboard port is free.
@@ -32,16 +33,26 @@ async function checkPort(port: number): Promise {
* installs (hoisted to a parent node_modules).
*/
async function checkBuilt(webDir: string): Promise {
+ const isNpmInstall = isInstalledUnderNodeModules(webDir);
const corePkgDir = findPackageUp(webDir, "@composio", "ao-core");
if (!corePkgDir) {
- const hint = webDir.includes("node_modules")
+ const hint = isNpmInstall
? "Run: npm install -g @composio/ao@latest"
: "Run: pnpm install && pnpm build";
throw new Error(`Dependencies not installed. ${hint}`);
}
const coreEntry = resolve(corePkgDir, "dist", "index.js");
if (!existsSync(coreEntry)) {
- const hint = webDir.includes("node_modules")
+ const hint = isNpmInstall
+ ? "Run: npm install -g @composio/ao@latest"
+ : "Run: pnpm build";
+ throw new Error(`Packages not built. ${hint}`);
+ }
+
+ const webBuildId = resolve(webDir, ".next", "BUILD_ID");
+ const startAllEntry = resolve(webDir, "dist-server", "start-all.js");
+ if (!existsSync(webBuildId) || !existsSync(startAllEntry)) {
+ const hint = isNpmInstall
? "Run: npm install -g @composio/ao@latest"
: "Run: pnpm build";
throw new Error(`Packages not built. ${hint}`);
diff --git a/packages/cli/src/lib/session-utils.ts b/packages/cli/src/lib/session-utils.ts
index 2837afdb2..7f8e0c4b8 100644
--- a/packages/cli/src/lib/session-utils.ts
+++ b/packages/cli/src/lib/session-utils.ts
@@ -30,10 +30,27 @@ export function isOrchestratorSessionName(
sessionName: string,
projectId?: string,
): boolean {
+ // If sessionName is a numbered worker for any configured project, it is not an orchestrator.
+ // This guard runs first to prevent cross-project false positives: e.g. prefix "app" would
+ // match "app-orchestrator-1" as an orchestrator pattern, but if another project has prefix
+ // "app-orchestrator" then "app-orchestrator-1" is a worker, not an orchestrator.
+ for (const [id, project] of Object.entries(config.projects) as Array<
+ [string, OrchestratorConfig["projects"][string]]
+ >) {
+ const prefix = project.sessionPrefix || id;
+ if (matchesPrefix(sessionName, prefix)) return false;
+ }
+
if (projectId) {
const project = config.projects[projectId];
- if (project && sessionName === `${project.sessionPrefix || projectId}-orchestrator`) {
- return true;
+ if (project) {
+ const prefix = project.sessionPrefix || projectId;
+ if (
+ sessionName === `${prefix}-orchestrator` ||
+ new RegExp(`^${escapeRegex(prefix)}-orchestrator-\\d+$`).test(sessionName)
+ ) {
+ return true;
+ }
}
}
@@ -41,7 +58,10 @@ export function isOrchestratorSessionName(
[string, OrchestratorConfig["projects"][string]]
>) {
const prefix = project.sessionPrefix || id;
- if (sessionName === `${prefix}-orchestrator`) {
+ if (
+ sessionName === `${prefix}-orchestrator` ||
+ new RegExp(`^${escapeRegex(prefix)}-orchestrator-\\d+$`).test(sessionName)
+ ) {
return true;
}
}
diff --git a/packages/cli/src/options/version.ts b/packages/cli/src/options/version.ts
new file mode 100644
index 000000000..c8f49a58e
--- /dev/null
+++ b/packages/cli/src/options/version.ts
@@ -0,0 +1,8 @@
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+
+export function getCliVersion(): string {
+ const packageJson = require("../../package.json") as { version: string };
+ return packageJson.version;
+}
diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts
new file mode 100644
index 000000000..883ba0f85
--- /dev/null
+++ b/packages/cli/src/program.ts
@@ -0,0 +1,54 @@
+import { Command } from "commander";
+import { registerInit } from "./commands/init.js";
+import { registerStatus } from "./commands/status.js";
+import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js";
+import { registerSession } from "./commands/session.js";
+import { registerSend } from "./commands/send.js";
+import { registerReviewCheck } from "./commands/review-check.js";
+import { registerDashboard } from "./commands/dashboard.js";
+import { registerOpen } from "./commands/open.js";
+import { registerStart, registerStop } from "./commands/start.js";
+import { registerLifecycleWorker } from "./commands/lifecycle-worker.js";
+import { registerVerify } from "./commands/verify.js";
+import { registerDoctor } from "./commands/doctor.js";
+import { registerUpdate } from "./commands/update.js";
+import { registerSetup } from "./commands/setup.js";
+import { registerPlugin } from "./commands/plugin.js";
+import { getConfigInstruction } from "./lib/config-instruction.js";
+import { getCliVersion } from "./options/version.js";
+
+export function createProgram(): Command {
+ const program = new Command();
+
+ program
+ .name("ao")
+ .description("Agent Orchestrator — manage parallel AI coding agents")
+ .version(getCliVersion());
+
+ registerInit(program);
+ registerStart(program);
+ registerStop(program);
+ registerStatus(program);
+ registerSpawn(program);
+ registerBatchSpawn(program);
+ registerSession(program);
+ registerSend(program);
+ registerReviewCheck(program);
+ registerDashboard(program);
+ registerOpen(program);
+ registerLifecycleWorker(program);
+ registerVerify(program);
+ registerDoctor(program);
+ registerUpdate(program);
+ registerSetup(program);
+ registerPlugin(program);
+
+ program
+ .command("config-help")
+ .description("Show config schema and guide for creating agent-orchestrator.yaml")
+ .action(() => {
+ console.log(getConfigInstruction());
+ });
+
+ return program;
+}
diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts
index 8379aa72b..a8c6fa709 100644
--- a/packages/cli/vitest.config.ts
+++ b/packages/cli/vitest.config.ts
@@ -1,3 +1,4 @@
+import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
@@ -16,4 +17,40 @@ export default defineConfig({
reporter: ["lcov"],
},
},
+ resolve: {
+ alias: [
+ {
+ find: "@composio/ao-core/scm-webhook-utils",
+ replacement: resolve(__dirname, "../core/src/scm-webhook-utils.ts"),
+ },
+ {
+ find: "@composio/ao-core/types",
+ replacement: resolve(__dirname, "../core/src/types.ts"),
+ },
+ {
+ find: "@composio/ao-core",
+ replacement: resolve(__dirname, "../core/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-agent-claude-code",
+ replacement: resolve(__dirname, "../plugins/agent-claude-code/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-agent-codex",
+ replacement: resolve(__dirname, "../plugins/agent-codex/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-agent-aider",
+ replacement: resolve(__dirname, "../plugins/agent-aider/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-agent-opencode",
+ replacement: resolve(__dirname, "../plugins/agent-opencode/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-scm-github",
+ replacement: resolve(__dirname, "../plugins/scm-github/src/index.ts"),
+ },
+ ],
+ },
});
diff --git a/packages/core/src/__tests__/config-generator.test.ts b/packages/core/src/__tests__/config-generator.test.ts
index b86cd60d4..cd818af83 100644
--- a/packages/core/src/__tests__/config-generator.test.ts
+++ b/packages/core/src/__tests__/config-generator.test.ts
@@ -255,7 +255,7 @@ describe("generateConfigFromUrl", () => {
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
- notifiers: ["desktop"],
+ notifiers: [],
});
// Check project config
diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts
index 0ba53c0cc..9de7d3dfb 100644
--- a/packages/core/src/__tests__/config-validation.test.ts
+++ b/packages/core/src/__tests__/config-validation.test.ts
@@ -1,5 +1,5 @@
/**
- * Unit tests for config validation (project uniqueness, prefix collisions).
+ * Unit tests for config validation (project uniqueness, prefix collisions, external plugins).
*/
import { describe, it, expect } from "vitest";
@@ -582,3 +582,508 @@ describe("Config Defaults", () => {
expect(validated.projects.proj1.scm).toEqual({ plugin: "gitlab" });
});
});
+
+describe("Config Validation - External Plugin Schema", () => {
+ it("accepts tracker with plugin only (built-in)", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ plugin: "github",
+ },
+ },
+ },
+ };
+
+ const validated = validateConfig(config);
+ expect(validated.projects.proj1.tracker?.plugin).toBe("github");
+ });
+
+ it("accepts tracker with package only (external npm)", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "@acme/ao-plugin-tracker-jira",
+ teamId: "TEAM-123",
+ },
+ },
+ },
+ };
+
+ const validated = validateConfig(config);
+ // Plugin name should be auto-generated from package
+ expect(validated.projects.proj1.tracker?.plugin).toBe("jira");
+ expect(validated.projects.proj1.tracker?.package).toBe("@acme/ao-plugin-tracker-jira");
+ });
+
+ it("accepts tracker with path only (local plugin)", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ path: "./plugins/my-tracker",
+ },
+ },
+ },
+ };
+
+ const validated = validateConfig(config);
+ // Plugin name should be auto-generated from path
+ expect(validated.projects.proj1.tracker?.plugin).toBe("my-tracker");
+ expect(validated.projects.proj1.tracker?.path).toBe("./plugins/my-tracker");
+ });
+
+ it("accepts tracker with both plugin and package (explicit naming)", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ plugin: "jira",
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ },
+ },
+ };
+
+ const validated = validateConfig(config);
+ expect(validated.projects.proj1.tracker?.plugin).toBe("jira");
+ expect(validated.projects.proj1.tracker?.package).toBe("@acme/ao-plugin-tracker-jira");
+ });
+
+ it("rejects tracker with neither plugin nor package/path", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ teamId: "TEAM-123",
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow(/plugin.*package.*path/i);
+ });
+
+ it("rejects tracker with both package and path", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "@acme/ao-plugin-tracker-jira",
+ path: "./plugins/my-tracker",
+ },
+ },
+ },
+ };
+
+ expect(() => validateConfig(config)).toThrow(/cannot have both/i);
+ });
+
+ it("accepts scm with package only", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ scm: {
+ package: "@acme/ao-plugin-scm-bitbucket",
+ },
+ },
+ },
+ };
+
+ const validated = validateConfig(config);
+ expect(validated.projects.proj1.scm?.plugin).toBe("bitbucket");
+ });
+
+ it("accepts notifier with package only", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ },
+ },
+ notifiers: {
+ teams: {
+ package: "@acme/ao-plugin-notifier-teams",
+ webhookUrl: "https://teams.webhook.url",
+ },
+ },
+ };
+
+ const validated = validateConfig(config);
+ expect(validated.notifiers["teams"]?.plugin).toBe("teams");
+ expect(validated.notifiers["teams"]?.package).toBe("@acme/ao-plugin-notifier-teams");
+ });
+
+ it("preserves plugin-specific config alongside package", () => {
+ const config = {
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "@acme/ao-plugin-tracker-jira",
+ host: "https://jira.company.com",
+ teamId: "TEAM-123",
+ },
+ },
+ },
+ };
+
+ const validated = validateConfig(config);
+ expect(validated.projects.proj1.tracker?.host).toBe("https://jira.company.com");
+ expect(validated.projects.proj1.tracker?.teamId).toBe("TEAM-123");
+ });
+});
+
+describe("collectExternalPluginConfigs", () => {
+ // Note: validateConfig() internally calls collectExternalPluginConfigs() and stores
+ // the results in config._externalPluginEntries. We test this by checking the stored entries.
+
+ it("collects tracker with explicit plugin (validates manifest.name)", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ plugin: "jira",
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ },
+ },
+ });
+
+ // Check entries stored by validateConfig
+ const entries = config._externalPluginEntries ?? [];
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ source: "projects.proj1.tracker",
+ location: { kind: "project", projectId: "proj1", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ expectedPluginName: "jira", // User explicitly specified plugin - will be validated
+ });
+ });
+
+ it("collects scm with path (no explicit plugin - infers from manifest)", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ scm: {
+ path: "./plugins/my-scm",
+ },
+ },
+ },
+ });
+
+ // Check entries stored by validateConfig
+ const entries = config._externalPluginEntries ?? [];
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ source: "projects.proj1.scm",
+ location: { kind: "project", projectId: "proj1", configType: "scm" },
+ slot: "scm",
+ path: "./plugins/my-scm",
+ });
+ // expectedPluginName should be undefined when plugin is not explicitly specified
+ // This allows any manifest.name to be accepted
+ expect(entries[0].expectedPluginName).toBeUndefined();
+ });
+
+ it("collects notifier with package (no explicit plugin - infers from manifest)", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ },
+ },
+ notifiers: {
+ teams: {
+ package: "@acme/ao-plugin-notifier-teams",
+ },
+ },
+ });
+
+ // Check entries stored by validateConfig
+ const entries = config._externalPluginEntries ?? [];
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ source: "notifiers.teams",
+ location: { kind: "notifier", notifierId: "teams" },
+ slot: "notifier",
+ package: "@acme/ao-plugin-notifier-teams",
+ });
+ // expectedPluginName should be undefined when plugin is not explicitly specified
+ expect(entries[0].expectedPluginName).toBeUndefined();
+ });
+
+ it("collects multiple external plugins", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ scm: {
+ path: "./plugins/my-scm",
+ },
+ },
+ },
+ notifiers: {
+ teams: {
+ package: "@acme/ao-plugin-notifier-teams",
+ },
+ },
+ });
+
+ // Check entries stored by validateConfig
+ const entries = config._externalPluginEntries ?? [];
+ expect(entries).toHaveLength(3);
+ });
+
+ it("ignores built-in plugins (plugin only)", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ plugin: "github",
+ },
+ scm: {
+ plugin: "github",
+ },
+ },
+ },
+ });
+
+ // No external plugins when only plugin name is specified (no package/path)
+ const entries = config._externalPluginEntries ?? [];
+ expect(entries).toHaveLength(0);
+ });
+
+ it("auto-generates plugins array entries from external plugin configs", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ },
+ },
+ });
+
+ // plugins array should be auto-populated
+ expect(config.plugins).toBeDefined();
+ expect(config.plugins).toContainEqual(
+ expect.objectContaining({
+ source: "npm",
+ package: "@acme/ao-plugin-tracker-jira",
+ enabled: true,
+ }),
+ );
+ });
+
+ it("stores external plugin entries on config for validation", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ plugin: "jira",
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ },
+ },
+ });
+
+ expect(config._externalPluginEntries).toBeDefined();
+ expect(config._externalPluginEntries).toHaveLength(1);
+ expect(config._externalPluginEntries?.[0]).toMatchObject({
+ source: "projects.proj1.tracker",
+ expectedPluginName: "jira",
+ });
+ });
+});
+
+describe("External Plugin Name Generation", () => {
+ it("extracts plugin name from scoped npm package", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ },
+ },
+ });
+
+ expect(config.projects.proj1.tracker?.plugin).toBe("jira");
+ });
+
+ it("extracts plugin name from unscoped npm package", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "ao-plugin-tracker-jira",
+ },
+ },
+ },
+ });
+
+ expect(config.projects.proj1.tracker?.plugin).toBe("jira");
+ });
+
+ it("extracts plugin name from local path", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ path: "./plugins/my-custom-tracker",
+ },
+ },
+ },
+ });
+
+ expect(config.projects.proj1.tracker?.plugin).toBe("my-custom-tracker");
+ });
+
+ it("extracts plugin name from absolute path", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ path: "/home/user/plugins/custom-tracker",
+ },
+ },
+ },
+ });
+
+ expect(config.projects.proj1.tracker?.plugin).toBe("custom-tracker");
+ });
+
+ it("does not override explicit plugin name", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ plugin: "my-custom-name",
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ },
+ },
+ });
+
+ expect(config.projects.proj1.tracker?.plugin).toBe("my-custom-name");
+ });
+
+ it("handles local path without slashes correctly", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ path: "my-tracker",
+ },
+ },
+ },
+ });
+
+ // Should use the path as-is (not split by hyphens like npm packages)
+ expect(config.projects.proj1.tracker?.plugin).toBe("my-tracker");
+ });
+
+ it("preserves multi-word plugin names from scoped npm packages", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ tracker: {
+ package: "@acme/ao-plugin-tracker-jira-cloud",
+ },
+ },
+ },
+ });
+
+ // Should extract "jira-cloud" not just "cloud"
+ expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud");
+ });
+
+ it("preserves multi-word plugin names from unscoped npm packages", () => {
+ const config = validateConfig({
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ defaultBranch: "main",
+ scm: {
+ package: "ao-plugin-scm-azure-devops",
+ },
+ },
+ },
+ });
+
+ // Should extract "azure-devops" not just "devops"
+ expect(config.projects.proj1.scm?.plugin).toBe("azure-devops");
+ });
+});
diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts
index f07c7a59e..70af67407 100644
--- a/packages/core/src/__tests__/lifecycle-manager.test.ts
+++ b/packages/core/src/__tests__/lifecycle-manager.test.ts
@@ -8,6 +8,7 @@ import type {
SessionManager,
Agent,
ActivityState,
+ SessionStatus,
} from "../types.js";
import {
createTestEnvironment,
@@ -1329,6 +1330,137 @@ describe("reactions", () => {
});
});
+describe("pollAll terminal status accounting", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("treats all TERMINAL_STATUSES as inactive for all-complete", async () => {
+ const notifier = createMockNotifier();
+ const registryWithNotifier: PluginRegistry = {
+ ...mockRegistry,
+ get: vi.fn().mockImplementation((slot: string, name: string) => {
+ if (slot === "runtime") return plugins.runtime;
+ if (slot === "agent") return plugins.agent;
+ if (slot === "notifier" && name === "desktop") return notifier;
+ return null;
+ }),
+ };
+
+ // All sessions in various terminal states — should count as inactive
+ const terminalSessions = [
+ makeSession({ id: "s-1", status: "killed" as SessionStatus }),
+ makeSession({ id: "s-2", status: "merged" as SessionStatus }),
+ makeSession({ id: "s-3", status: "done" as SessionStatus }),
+ makeSession({ id: "s-4", status: "errored" as SessionStatus }),
+ makeSession({ id: "s-5", status: "terminated" as SessionStatus }),
+ makeSession({ id: "s-6", status: "cleanup" as SessionStatus }),
+ ];
+
+ vi.mocked(mockSessionManager.list).mockResolvedValue(terminalSessions);
+
+ // Route info-priority notifications to desktop so we can observe them
+ config.notificationRouting.info = ["desktop"];
+ config.reactions = {
+ "all-complete": { auto: true, action: "notify" },
+ };
+
+ const lm = createLifecycleManager({
+ config,
+ registry: registryWithNotifier,
+ sessionManager: mockSessionManager,
+ });
+
+ lm.start(60_000);
+ // Let the immediate pollAll() run
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(notifier.notify).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "reaction.triggered" }),
+ );
+
+ lm.stop();
+ });
+
+ it("does not fire all-complete when a session is in non-terminal status like done is missing", async () => {
+ const notifier = createMockNotifier();
+ const registryWithNotifier: PluginRegistry = {
+ ...mockRegistry,
+ get: vi.fn().mockImplementation((slot: string, name: string) => {
+ if (slot === "runtime") return plugins.runtime;
+ if (slot === "agent") return plugins.agent;
+ if (slot === "notifier" && name === "desktop") return notifier;
+ return null;
+ }),
+ };
+
+ // Mix of terminal and active sessions
+ const sessions = [
+ makeSession({ id: "s-1", status: "killed" as SessionStatus }),
+ makeSession({ id: "s-2", status: "working" as SessionStatus }),
+ ];
+
+ vi.mocked(mockSessionManager.list).mockResolvedValue(sessions);
+
+ config.reactions = {
+ "all-complete": { auto: true, action: "notify" },
+ };
+
+ const lm = createLifecycleManager({
+ config,
+ registry: registryWithNotifier,
+ sessionManager: mockSessionManager,
+ });
+
+ lm.start(60_000);
+ await vi.advanceTimersByTimeAsync(0);
+
+ // all-complete should NOT have fired — "working" is still active
+ const allCompleteNotifications = vi.mocked(notifier.notify).mock.calls.filter(
+ (call: unknown[]) => {
+ const event = call[0] as Record | undefined;
+ const data = event?.data as Record | undefined;
+ return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete";
+ },
+ );
+ expect(allCompleteNotifications).toHaveLength(0);
+
+ lm.stop();
+ });
+
+ it("skips polling sessions in terminal statuses like done or errored", async () => {
+ // Sessions in "done" / "errored" should not be polled
+ const sessions = [
+ makeSession({ id: "s-done", status: "done" as SessionStatus }),
+ makeSession({ id: "s-errored", status: "errored" as SessionStatus }),
+ ];
+
+ vi.mocked(mockSessionManager.list).mockResolvedValue(sessions);
+
+ // If these sessions were polled, determineStatus would call runtime.isAlive.
+ // Reset call count and verify it's not called.
+ vi.mocked(plugins.runtime.isAlive).mockClear();
+
+ const lm = createLifecycleManager({
+ config,
+ registry: mockRegistry,
+ sessionManager: mockSessionManager,
+ });
+
+ lm.start(60_000);
+ await vi.advanceTimersByTimeAsync(0);
+
+ // Terminal sessions should not be polled — runtime.isAlive should not be called
+ expect(plugins.runtime.isAlive).not.toHaveBeenCalled();
+
+ lm.stop();
+ });
+});
+
describe("getStates", () => {
it("returns copy of states map", async () => {
const lm = setupCheck("app-1", {
@@ -1345,3 +1477,301 @@ describe("getStates", () => {
expect(lm.getStates().get("app-1")).toBe("working");
});
});
+
+describe("rate limiting optimizations", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // PR with owner/repo that matches the test config's "org/my-app"
+ function makeMatchingPR() {
+ return makePR({ owner: "org", repo: "my-app" });
+ }
+
+ it("skips getMergeability() when batch enrichment has hasConflicts data", async () => {
+ config.reactions = {
+ "merge-conflicts": {
+ auto: true,
+ action: "send-to-agent",
+ message: "Resolve conflicts.",
+ },
+ };
+
+ const pr = makeMatchingPR();
+ const getMergeabilityMock = vi.fn();
+ const mockSCM = createMockSCM({
+ getMergeability: getMergeabilityMock,
+ getCISummary: vi.fn().mockResolvedValue("passing"),
+ enrichSessionsPRBatch: vi.fn().mockResolvedValue(
+ new Map([
+ [
+ `${pr.owner}/${pr.repo}#${pr.number}`,
+ {
+ state: "open" as const,
+ ciStatus: "passing" as const,
+ reviewDecision: "none" as const,
+ mergeable: false,
+ hasConflicts: true,
+ },
+ ],
+ ]),
+ ),
+ });
+
+ const registry = createMockRegistry({
+ runtime: plugins.runtime,
+ agent: plugins.agent,
+ scm: mockSCM,
+ });
+
+ const session = makeSession({ id: "s-1", status: "pr_open", pr });
+ vi.mocked(mockSessionManager.list).mockResolvedValue([session]);
+ vi.mocked(mockSessionManager.send).mockResolvedValue(undefined);
+
+ const lm = createLifecycleManager({ config, registry, sessionManager: mockSessionManager });
+ lm.start(60_000);
+ await vi.advanceTimersByTimeAsync(0);
+ lm.stop();
+
+ // getMergeability() should NOT be called — batch enrichment has the data
+ expect(getMergeabilityMock).not.toHaveBeenCalled();
+ // Conflict notification should have been sent
+ expect(mockSessionManager.send).toHaveBeenCalledWith("s-1", "Resolve conflicts.");
+ });
+
+ it("skips getCIChecks() when batch enrichment has ciChecks data", async () => {
+ config.reactions = {
+ "ci-failed": {
+ auto: true,
+ action: "send-to-agent",
+ message: "CI failing.",
+ retries: 3,
+ escalateAfter: 3,
+ },
+ };
+
+ const pr = makeMatchingPR();
+ const getCIChecksMock = vi.fn();
+ const mockSCM = createMockSCM({
+ getCIChecks: getCIChecksMock,
+ getCISummary: vi.fn().mockResolvedValue("failing"),
+ enrichSessionsPRBatch: vi.fn().mockResolvedValue(
+ new Map([
+ [
+ `${pr.owner}/${pr.repo}#${pr.number}`,
+ {
+ state: "open" as const,
+ ciStatus: "failing" as const,
+ reviewDecision: "none" as const,
+ mergeable: false,
+ hasConflicts: false,
+ ciChecks: [
+ { name: "lint", status: "failed" as const, conclusion: "FAILURE", url: "https://example.com/lint" },
+ { name: "test", status: "passed" as const, conclusion: "SUCCESS" },
+ ],
+ },
+ ],
+ ]),
+ ),
+ });
+
+ const registry = createMockRegistry({
+ runtime: plugins.runtime,
+ agent: plugins.agent,
+ scm: mockSCM,
+ });
+
+ // Start with pr_open state so that ci_failed transition happens on first poll
+ const session = makeSession({ id: "s-2", status: "pr_open", pr });
+ vi.mocked(mockSessionManager.list).mockResolvedValue([session]);
+ vi.mocked(mockSessionManager.send).mockResolvedValue(undefined);
+
+ const lm = createLifecycleManager({ config, registry, sessionManager: mockSessionManager });
+ lm.start(60_000);
+ // First poll: transitions to ci_failed, sends reaction message
+ await vi.advanceTimersByTimeAsync(0);
+
+ vi.mocked(mockSessionManager.send).mockClear();
+
+ // Second poll: dispatches detailed CI failure info
+ await vi.advanceTimersByTimeAsync(60_000);
+
+ // getCIChecks() should NOT be called — batch enrichment has ciChecks
+ expect(getCIChecksMock).not.toHaveBeenCalled();
+ // Detailed message with lint check name/URL should be sent
+ const calls = vi.mocked(mockSessionManager.send).mock.calls;
+ const sentMessages = calls.map((c) => c[1] as string);
+ const detailMessage = sentMessages.find((m) => m.includes("lint"));
+ expect(detailMessage).toBeDefined();
+ expect(detailMessage).toContain("https://example.com/lint");
+ // Passing check should not be included
+ expect(detailMessage).not.toContain("test");
+
+ lm.stop();
+ });
+
+ it("throttles review backlog API calls to at most once per 2 minutes", async () => {
+ config.reactions = {
+ "changes-requested": {
+ auto: true,
+ action: "send-to-agent",
+ message: "Handle review comments.",
+ },
+ };
+
+ const getPendingMock = vi.fn().mockResolvedValue([
+ {
+ id: "c1",
+ author: "reviewer",
+ body: "Please fix this",
+ path: "src/index.ts",
+ line: 10,
+ isResolved: false,
+ createdAt: new Date(),
+ url: "https://example.com/comment/1",
+ },
+ ]);
+ const getAutomatedMock = vi.fn().mockResolvedValue([]);
+ const mockSCM = createMockSCM({
+ getPendingComments: getPendingMock,
+ getAutomatedComments: getAutomatedMock,
+ });
+ const registry = createMockRegistry({
+ runtime: plugins.runtime,
+ agent: plugins.agent,
+ scm: mockSCM,
+ });
+
+ vi.mocked(mockSessionManager.send).mockResolvedValue(undefined);
+
+ const lm = setupCheck("app-1", {
+ session: makeSession({ status: "pr_open", pr: makePR() }),
+ registry,
+ });
+
+ // First check: API called, dispatch happens
+ await lm.check("app-1");
+ expect(getPendingMock).toHaveBeenCalledTimes(1);
+ vi.mocked(mockSessionManager.send).mockClear();
+ getPendingMock.mockClear();
+
+ // Second check immediately after: throttled — API NOT called
+ await lm.check("app-1");
+ expect(getPendingMock).not.toHaveBeenCalled();
+ expect(mockSessionManager.send).not.toHaveBeenCalled();
+
+ // Advance time past the 2-minute throttle window
+ await vi.advanceTimersByTimeAsync(2 * 60 * 1000 + 100);
+
+ // Third check: throttle expired — API called again
+ await lm.check("app-1");
+ expect(getPendingMock).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("summary pinning", () => {
+ it("pins first quality summary when pinnedSummary not set", async () => {
+ const session = makeSession({
+ status: "working",
+ agentInfo: {
+ summary: "Implementing authentication flow",
+ summaryIsFallback: false,
+ agentSessionId: "abc",
+ },
+ metadata: {},
+ });
+ const lm = setupCheck("app-1", { session });
+
+ await lm.check("app-1");
+
+ const meta = readMetadataRaw(env.sessionsDir, "app-1");
+ expect(meta!["pinnedSummary"]).toBe("Implementing authentication flow");
+ });
+
+ it("skips pinning when summaryIsFallback is true", async () => {
+ const session = makeSession({
+ status: "working",
+ agentInfo: {
+ summary: "You are working on issue #42...",
+ summaryIsFallback: true,
+ agentSessionId: "abc",
+ },
+ metadata: {},
+ });
+ const lm = setupCheck("app-1", { session });
+
+ await lm.check("app-1");
+
+ const meta = readMetadataRaw(env.sessionsDir, "app-1");
+ expect(meta!["pinnedSummary"]).toBeUndefined();
+ });
+
+ it("skips pinning when pinnedSummary already exists", async () => {
+ const session = makeSession({
+ status: "working",
+ agentInfo: {
+ summary: "New summary that should not overwrite",
+ summaryIsFallback: false,
+ agentSessionId: "abc",
+ },
+ metadata: { pinnedSummary: "Original pinned summary" },
+ });
+ const lm = setupCheck("app-1", {
+ session,
+ metaOverrides: { pinnedSummary: "Original pinned summary" },
+ });
+
+ await lm.check("app-1");
+
+ const meta = readMetadataRaw(env.sessionsDir, "app-1");
+ expect(meta!["pinnedSummary"]).toBe("Original pinned summary");
+ });
+
+ it("skips pinning when trimmed summary is shorter than 5 chars", async () => {
+ const session = makeSession({
+ status: "working",
+ agentInfo: {
+ summary: " Hi ",
+ summaryIsFallback: false,
+ agentSessionId: "abc",
+ },
+ metadata: {},
+ });
+ const lm = setupCheck("app-1", { session });
+
+ await lm.check("app-1");
+
+ const meta = readMetadataRaw(env.sessionsDir, "app-1");
+ expect(meta!["pinnedSummary"]).toBeUndefined();
+ });
+
+ it("does not throw when metadata write fails", async () => {
+ const session = makeSession({
+ status: "working",
+ agentInfo: {
+ summary: "Valid summary for pinning",
+ summaryIsFallback: false,
+ agentSessionId: "abc",
+ },
+ metadata: {},
+ });
+ // Use a config with invalid path to trigger write failure
+ const badConfig = {
+ ...config,
+ projects: {
+ "my-app": {
+ ...config.projects["my-app"],
+ path: "/nonexistent/path/that/does/not/exist",
+ },
+ },
+ };
+ const lm = setupCheck("app-1", { session, configOverride: badConfig });
+
+ // Should not throw — error is swallowed
+ await expect(lm.check("app-1")).resolves.not.toThrow();
+ });
+});
diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts
index 369713742..a03e167f1 100644
--- a/packages/core/src/__tests__/metadata.test.ts
+++ b/packages/core/src/__tests__/metadata.test.ts
@@ -96,6 +96,18 @@ describe("writeMetadata + readMetadata", () => {
expect(content).not.toContain("pr=");
expect(content).not.toContain("summary=");
});
+
+ it("serializes pinnedSummary field when present", () => {
+ writeMetadata(dataDir, "app-5", {
+ worktree: "/tmp/w",
+ branch: "feat/test",
+ status: "working",
+ pinnedSummary: "First quality summary",
+ });
+
+ const content = readFileSync(join(dataDir, "app-5"), "utf-8");
+ expect(content).toContain("pinnedSummary=First quality summary\n");
+ });
});
describe("readMetadataRaw", () => {
diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts
index 799c947fa..a297d0c78 100644
--- a/packages/core/src/__tests__/plugin-registry.test.ts
+++ b/packages/core/src/__tests__/plugin-registry.test.ts
@@ -262,6 +262,63 @@ describe("loadBuiltins", () => {
});
});
+ it("strips package loading metadata from notifier config", async () => {
+ const registry = createPluginRegistry();
+ const fakeWebhook = makePlugin("notifier", "webhook");
+ const cfg = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ notifiers: {
+ mywebhook: {
+ plugin: "webhook",
+ // package field is allowed for resolution but should be stripped:
+ package: "@composio/ao-plugin-notifier-webhook",
+ // These are plugin-specific fields that should be passed through:
+ url: "https://webhook.example.com/notify",
+ retries: 3,
+ },
+ },
+ });
+
+ await registry.loadBuiltins(cfg, async (pkg: string) => {
+ if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook;
+ throw new Error(`Not found: ${pkg}`);
+ });
+
+ // Loading metadata (package) should be stripped to prevent leakage
+ // Plugin-specific fields (url, retries) should be passed through
+ expect(fakeWebhook.create).toHaveBeenCalledWith({
+ url: "https://webhook.example.com/notify",
+ retries: 3,
+ configPath: "/test/config.yaml",
+ });
+ });
+
+ it("warns and skips when path is used alongside plugin name in notifier config", async () => {
+ const registry = createPluginRegistry();
+ const fakeWebhook = makePlugin("notifier", "webhook");
+ const cfg = makeOrchestratorConfig({
+ notifiers: {
+ mywebhook: {
+ plugin: "webhook",
+ path: "./some/path", // This triggers the collision check
+ },
+ },
+ });
+
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
+
+ await registry.loadBuiltins(cfg, async (pkg: string) => {
+ if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook;
+ return null;
+ });
+
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('"path" field conflicts with reserved'));
+ stderrSpy.mockRestore();
+
+ // Plugin should not be registered due to config error
+ expect(registry.get("notifier", "webhook")).toBeNull();
+ });
+
it("does not match notifier key when explicit plugin points to another notifier", async () => {
const registry = createPluginRegistry();
const fakeOpenClaw = makePlugin("notifier", "openclaw");
@@ -455,3 +512,424 @@ describe("loadFromConfig", () => {
expect(registry.get("agent", "goose")).toBeNull();
});
});
+
+describe("External plugin manifest validation", () => {
+ it("accepts matching manifest.name and expectedPluginName", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "jira", slot: "tracker" as const, version: "1.0.0", description: "Jira tracker" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test",
+ tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ },
+ _externalPluginEntries: [
+ {
+ source: "projects.proj1.tracker",
+ location: { kind: "project", projectId: "proj1", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ expectedPluginName: "jira",
+ },
+ ],
+ });
+
+ // Should not throw
+ await registry.loadFromConfig(config, importFn);
+ expect(registry.get("tracker", "jira")).not.toBeNull();
+ });
+
+ it("warns when manifest.name does not match expectedPluginName but still registers plugin", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "jira-enterprise", slot: "tracker" as const, version: "1.0.0", description: "Jira Enterprise" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test",
+ tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ },
+ _externalPluginEntries: [
+ {
+ source: "projects.proj1.tracker",
+ location: { kind: "project", projectId: "proj1", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ expectedPluginName: "jira",
+ },
+ ],
+ });
+
+ // Should warn about validation failure but still register the plugin
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
+ await registry.loadFromConfig(config, importFn);
+
+ // Plugin should still be registered under its manifest.name
+ expect(registry.get("tracker", "jira-enterprise")).not.toBeNull();
+
+ // Should have logged a validation warning
+ expect(stderrSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Config validation failed for projects.proj1.tracker"),
+ );
+ stderrSpy.mockRestore();
+ });
+
+ it("infers plugin name when expectedPluginName is not specified", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "jira", slot: "tracker" as const, version: "1.0.0", description: "Jira tracker" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test",
+ // Plugin field will be updated with manifest.name
+ tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ },
+ _externalPluginEntries: [
+ {
+ source: "projects.proj1.tracker",
+ location: { kind: "project", projectId: "proj1", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ // No expectedPluginName - should accept any manifest.name
+ },
+ ],
+ });
+
+ await registry.loadFromConfig(config, importFn);
+
+ // Plugin should be registered under manifest.name
+ expect(registry.get("tracker", "jira")).not.toBeNull();
+ // Config should be updated with actual manifest.name
+ expect(config.projects.proj1.tracker?.plugin).toBe("jira");
+ });
+
+ it("updates config with actual manifest.name for notifiers", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "ms-teams", slot: "notifier" as const, version: "1.0.0", description: "Teams notifier" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ { name: "teams", source: "npm", package: "@acme/ao-plugin-notifier-teams", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test",
+ },
+ },
+ notifiers: {
+ myteams: { plugin: "teams", package: "@acme/ao-plugin-notifier-teams" },
+ },
+ _externalPluginEntries: [
+ {
+ source: "notifiers.myteams",
+ location: { kind: "notifier", notifierId: "myteams" },
+ slot: "notifier",
+ package: "@acme/ao-plugin-notifier-teams",
+ // No expectedPluginName - will accept any manifest.name
+ },
+ ],
+ });
+
+ await registry.loadFromConfig(config, importFn);
+
+ // Config should be updated with actual manifest.name
+ expect(config.notifiers?.myteams?.plugin).toBe("ms-teams");
+ // Plugin should be registered under manifest.name
+ expect(registry.get("notifier", "ms-teams")).not.toBeNull();
+ });
+
+ it("passes notifier config to plugin even when manifest name differs from temp name", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "ms-teams", slot: "notifier" as const, version: "1.0.0", description: "Teams notifier" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ // Temp name is "teams" (from package name), but manifest.name is "ms-teams"
+ { name: "teams", source: "npm", package: "@acme/ao-plugin-notifier-teams", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test",
+ },
+ },
+ notifiers: {
+ myteams: {
+ plugin: "teams", // Temp name - will be updated to "ms-teams"
+ package: "@acme/ao-plugin-notifier-teams",
+ webhookUrl: "https://teams.webhook.url/abc123",
+ channel: "#alerts",
+ },
+ },
+ _externalPluginEntries: [
+ {
+ source: "notifiers.myteams",
+ location: { kind: "notifier", notifierId: "myteams" },
+ slot: "notifier",
+ package: "@acme/ao-plugin-notifier-teams",
+ // No expectedPluginName - config.plugin will be updated to manifest.name
+ },
+ ],
+ });
+
+ await registry.loadFromConfig(config, importFn);
+
+ // Config should be updated BEFORE extractPluginConfig is called
+ expect(config.notifiers?.myteams?.plugin).toBe("ms-teams");
+
+ // Plugin should receive its config (webhookUrl, channel) despite name mismatch
+ expect(mockPlugin.create).toHaveBeenCalledWith({
+ webhookUrl: "https://teams.webhook.url/abc123",
+ channel: "#alerts",
+ configPath: "/test/config.yaml",
+ });
+
+ // Plugin should be registered
+ expect(registry.get("notifier", "ms-teams")).not.toBeNull();
+ });
+
+ it("warns when plugin slot does not match config slot", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "jira", slot: "notifier" as const, version: "1.0.0", description: "Wrong slot!" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test",
+ repo: "org/test",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test",
+ tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ },
+ _externalPluginEntries: [
+ {
+ source: "projects.proj1.tracker",
+ location: { kind: "project", projectId: "proj1", configType: "tracker" },
+ slot: "tracker", // Expected tracker
+ package: "@acme/ao-plugin-tracker-jira",
+ },
+ ],
+ });
+
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
+ await registry.loadFromConfig(config, importFn);
+
+ expect(stderrSpy).toHaveBeenCalledWith(
+ expect.stringContaining("has slot \"notifier\" but was configured as \"tracker\""),
+ );
+ stderrSpy.mockRestore();
+ });
+
+ it("updates all projects sharing same external plugin with manifest.name", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "jira-cloud", slot: "tracker" as const, version: "1.0.0", description: "Jira Cloud" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test1",
+ repo: "org/test1",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test1",
+ tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ proj2: {
+ path: "/repos/test2",
+ repo: "org/test2",
+ name: "proj2",
+ defaultBranch: "main",
+ sessionPrefix: "test2",
+ // Same external plugin as proj1
+ tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ },
+ _externalPluginEntries: [
+ {
+ source: "projects.proj1.tracker",
+ location: { kind: "project", projectId: "proj1", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ // No expectedPluginName - will accept any manifest.name
+ },
+ {
+ source: "projects.proj2.tracker",
+ location: { kind: "project", projectId: "proj2", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ // No expectedPluginName - will accept any manifest.name
+ },
+ ],
+ });
+
+ await registry.loadFromConfig(config, importFn);
+
+ // Both projects should be updated with the actual manifest.name
+ expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud");
+ expect(config.projects.proj2.tracker?.plugin).toBe("jira-cloud");
+ // Plugin should be registered under manifest.name
+ expect(registry.get("tracker", "jira-cloud")).not.toBeNull();
+ });
+
+ it("registers plugin even when one project has misconfigured expectedPluginName", async () => {
+ const registry = createPluginRegistry();
+
+ const mockPlugin = {
+ manifest: { name: "jira-cloud", slot: "tracker" as const, version: "1.0.0", description: "Jira Cloud" },
+ create: vi.fn(() => ({})),
+ };
+
+ const importFn = vi.fn(async () => mockPlugin);
+
+ const config = makeOrchestratorConfig({
+ configPath: "/test/config.yaml",
+ plugins: [
+ { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true },
+ ],
+ projects: {
+ proj1: {
+ path: "/repos/test1",
+ repo: "org/test1",
+ name: "proj1",
+ defaultBranch: "main",
+ sessionPrefix: "test1",
+ tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ proj2: {
+ path: "/repos/test2",
+ repo: "org/test2",
+ name: "proj2",
+ defaultBranch: "main",
+ sessionPrefix: "test2",
+ // Same external plugin but with WRONG explicit plugin name
+ tracker: { plugin: "wrong-name", package: "@acme/ao-plugin-tracker-jira" },
+ },
+ },
+ _externalPluginEntries: [
+ {
+ source: "projects.proj1.tracker",
+ location: { kind: "project", projectId: "proj1", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ // No expectedPluginName - will accept any manifest.name
+ },
+ {
+ source: "projects.proj2.tracker",
+ location: { kind: "project", projectId: "proj2", configType: "tracker" },
+ slot: "tracker",
+ package: "@acme/ao-plugin-tracker-jira",
+ expectedPluginName: "wrong-name", // Mismatches manifest.name "jira-cloud"
+ },
+ ],
+ });
+
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
+ await registry.loadFromConfig(config, importFn);
+
+ // Plugin should STILL be registered despite proj2's misconfiguration
+ expect(registry.get("tracker", "jira-cloud")).not.toBeNull();
+
+ // proj1 should be updated correctly (no expectedPluginName = accepts any)
+ expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud");
+
+ // proj2's config should NOT be updated (validation failed)
+ expect(config.projects.proj2.tracker?.plugin).toBe("wrong-name");
+
+ // Should have logged a warning about proj2's validation failure
+ expect(stderrSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Config validation failed for projects.proj2.tracker"),
+ );
+
+ stderrSpy.mockRestore();
+ });
+});
diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts
index cd1a3f391..b46e9ec63 100644
--- a/packages/core/src/__tests__/session-manager/communication.test.ts
+++ b/packages/core/src/__tests__/session-manager/communication.test.ts
@@ -8,7 +8,6 @@ import { createSessionManager } from "../../session-manager.js";
import {
writeMetadata,
readMetadataRaw,
- updateMetadata,
} from "../../metadata.js";
import type {
OrchestratorConfig,
@@ -54,37 +53,6 @@ describe("send", () => {
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "Fix the CI failures");
});
- it("blocks send to worker sessions while the project is globally paused", async () => {
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")),
- });
- updateMetadata(sessionsDir, "app-orchestrator", {
- globalPauseUntil: new Date(Date.now() + 60_000).toISOString(),
- globalPauseReason: "Rate limit reached",
- globalPauseSource: "app-9",
- });
-
- writeMetadata(sessionsDir, "app-1", {
- worktree: "/tmp",
- branch: "main",
- status: "working",
- project: "my-app",
- runtimeHandle: JSON.stringify(makeHandle("rt-1")),
- });
-
- const sm = createSessionManager({ config, registry: mockRegistry });
-
- await expect(sm.send("app-1", "Fix the CI failures")).rejects.toThrow(
- "Project is paused due to model rate limit until",
- );
- expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
- });
-
it("restores a dead session before sending the message", async () => {
const wsPath = join(tmpDir, "ws-app-1");
mkdirSync(wsPath, { recursive: true });
@@ -305,7 +273,7 @@ describe("send", () => {
await sm.send("app-1", "confirm via updated timestamp");
const elapsedMs = Date.now() - startedAt;
- expect(elapsedMs).toBeLessThan(2_000);
+ expect(elapsedMs).toBeLessThan(5_000);
expect(readFileSync(listLogPath, "utf-8").trim().split("\n").length).toBeGreaterThanOrEqual(2);
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
makeHandle("rt-1"),
@@ -442,7 +410,7 @@ describe("remap", () => {
expect(mapped).toBe("ses_slow_discovery");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["opencodeSessionId"]).toBe("ses_slow_discovery");
- });
+ }, 20000);
it("throws when OpenCode session id mapping is missing", async () => {
const deleteLogPath = join(tmpDir, "opencode-delete-missing-remap.log");
diff --git a/packages/core/src/__tests__/session-manager/lifecycle.test.ts b/packages/core/src/__tests__/session-manager/lifecycle.test.ts
index 3e7cf94e3..7a24277db 100644
--- a/packages/core/src/__tests__/session-manager/lifecycle.test.ts
+++ b/packages/core/src/__tests__/session-manager/lifecycle.test.ts
@@ -225,7 +225,7 @@ describe("kill", () => {
await sm.kill("app-1", { purgeOpenCode: true });
expect(existsSync(deleteLogPath)).toBe(false);
- });
+ }, 15000);
});
describe("cleanup", () => {
diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts
index 66bb0fb7c..9b2e737d6 100644
--- a/packages/core/src/__tests__/session-manager/restore.test.ts
+++ b/packages/core/src/__tests__/session-manager/restore.test.ts
@@ -365,7 +365,7 @@ describe("restore", () => {
expect(restored.status).toBe("spawning");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["opencodeSessionId"]).toBe("ses_restore_discovered");
- });
+ }, 15000);
it("uses orchestratorModel when restoring orchestrator sessions", async () => {
const wsPath = join(tmpDir, "ws-app-orchestrator-restore");
diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts
index 07aa3b741..560386e1f 100644
--- a/packages/core/src/__tests__/session-manager/spawn.test.ts
+++ b/packages/core/src/__tests__/session-manager/spawn.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
+import { mkdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { createSessionManager } from "../../session-manager.js";
import { validateConfig } from "../../config.js";
@@ -7,9 +7,6 @@ import {
writeMetadata,
readMetadata,
readMetadataRaw,
- deleteMetadata,
- reserveSessionId,
- updateMetadata,
} from "../../metadata.js";
import type {
OrchestratorConfig,
@@ -18,7 +15,6 @@ import type {
Agent,
Workspace,
Tracker,
- RuntimeHandle,
} from "../../types.js";
import {
setupTestContext,
@@ -75,29 +71,6 @@ describe("spawn", () => {
expect(mockRuntime.create).toHaveBeenCalled();
});
- it("blocks spawn while the project is globally paused", async () => {
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")),
- });
- updateMetadata(sessionsDir, "app-orchestrator", {
- globalPauseUntil: new Date(Date.now() + 60_000).toISOString(),
- globalPauseReason: "Rate limit reached",
- globalPauseSource: "app-9",
- });
-
- const sm = createSessionManager({ config, registry: mockRegistry });
-
- await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow(
- "Project is paused due to model rate limit until",
- );
- expect(mockRuntime.create).not.toHaveBeenCalled();
- });
-
it("uses issue ID to derive branch name", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
@@ -995,26 +968,23 @@ describe("spawn", () => {
}, 20_000);
describe("spawnOrchestrator", () => {
- it("blocks orchestrator spawn while the project is globally paused", async () => {
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")),
- });
- updateMetadata(sessionsDir, "app-orchestrator", {
- globalPauseUntil: new Date(Date.now() + 60_000).toISOString(),
- globalPauseReason: "Rate limit reached",
- globalPauseSource: "app-9",
- });
-
- const sm = createSessionManager({ config, registry: mockRegistry });
+ it("throws when no workspace plugin is configured", async () => {
+ const registryNoWorkspace: PluginRegistry = {
+ ...mockRegistry,
+ get: vi.fn().mockImplementation((slot: string) => {
+ if (slot === "runtime") return mockRuntime;
+ if (slot === "agent") return mockAgent;
+ return null; // no workspace plugin
+ }),
+ };
+ const sm = createSessionManager({ config, registry: registryNoWorkspace });
await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow(
- "Project is paused due to model rate limit until",
+ "spawnOrchestrator requires a workspace plugin",
);
+
+ // Reserved session metadata should be cleaned up
+ expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull();
expect(mockRuntime.create).not.toHaveBeenCalled();
});
@@ -1023,12 +993,42 @@ describe("spawn", () => {
const session = await sm.spawnOrchestrator({ projectId: "my-app" });
- expect(session.id).toBe("app-orchestrator");
+ expect(session.id).toBe("app-orchestrator-1");
expect(session.status).toBe("working");
expect(session.projectId).toBe("my-app");
- expect(session.branch).toBe("main");
+ expect(session.branch).toBe("orchestrator/app-orchestrator-1");
expect(session.issueId).toBeNull();
- expect(session.workspacePath).toBe(join(tmpDir, "my-app"));
+ expect(session.workspacePath).toBe("/tmp/ws");
+ });
+
+ it("creates a worktree with an orchestrator branch", async () => {
+ const sm = createSessionManager({ config, registry: mockRegistry });
+
+ await sm.spawnOrchestrator({ projectId: "my-app" });
+
+ expect(mockWorkspace.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionId: "app-orchestrator-1",
+ branch: "orchestrator/app-orchestrator-1",
+ projectId: "my-app",
+ }),
+ );
+ });
+
+ it("uses the worktree path returned by the workspace plugin", async () => {
+ const worktreePath = join(tmpDir, "orchestrator-ws");
+ (mockWorkspace.create as ReturnType).mockResolvedValueOnce({
+ path: worktreePath,
+ branch: "orchestrator/app-orchestrator-1",
+ sessionId: "app-orchestrator-1",
+ projectId: "my-app",
+ });
+ const sm = createSessionManager({ config, registry: mockRegistry });
+
+ const session = await sm.spawnOrchestrator({ projectId: "my-app" });
+
+ expect(session.workspacePath).toBe(worktreePath);
+ expect(session.branch).toBe("orchestrator/app-orchestrator-1");
});
it("writes metadata with proper fields", async () => {
@@ -1036,23 +1036,113 @@ describe("spawn", () => {
await sm.spawnOrchestrator({ projectId: "my-app" });
- const meta = readMetadata(sessionsDir, "app-orchestrator");
+ const meta = readMetadata(sessionsDir, "app-orchestrator-1");
expect(meta).not.toBeNull();
expect(meta!.status).toBe("working");
expect(meta!.project).toBe("my-app");
- expect(meta!.worktree).toBe(join(tmpDir, "my-app"));
- expect(meta!.branch).toBe("main");
+ expect(meta!.branch).toBe("orchestrator/app-orchestrator-1");
expect(meta!.tmuxName).toBeDefined();
expect(meta!.runtimeHandle).toBeDefined();
});
+ it("writes metadata with worktree path and orchestrator role", async () => {
+ const sm = createSessionManager({ config, registry: mockRegistry });
+
+ await sm.spawnOrchestrator({ projectId: "my-app" });
+
+ const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1");
+ expect(meta?.["role"]).toBe("orchestrator");
+ expect(meta?.["branch"]).toBe("orchestrator/app-orchestrator-1");
+ expect(meta?.["status"]).toBe("working");
+ expect(meta?.["project"]).toBe("my-app");
+ });
+
+ it("increments the orchestrator counter for each new session", async () => {
+ const sm = createSessionManager({ config, registry: mockRegistry });
+
+ const s1 = await sm.spawnOrchestrator({ projectId: "my-app" });
+ const s2 = await sm.spawnOrchestrator({ projectId: "my-app" });
+
+ expect(s1.id).toBe("app-orchestrator-1");
+ expect(s2.id).toBe("app-orchestrator-2");
+ expect(mockWorkspace.create).toHaveBeenCalledTimes(2);
+ });
+
+ it("cleans up reserved metadata on workspace creation failure", async () => {
+ (mockWorkspace.create as ReturnType).mockRejectedValueOnce(
+ new Error("workspace creation failed"),
+ );
+ const sm = createSessionManager({ config, registry: mockRegistry });
+
+ await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow(
+ "workspace creation failed",
+ );
+
+ // Reserved session file should be cleaned up
+ expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull();
+ });
+
+ it("destroys the worktree and metadata when runtime creation fails", async () => {
+ const worktreePath = join(tmpDir, "orchestrator-ws-rt-fail");
+ (mockWorkspace.create as ReturnType).mockResolvedValueOnce({
+ path: worktreePath,
+ branch: "orchestrator/app-orchestrator-1",
+ sessionId: "app-orchestrator-1",
+ projectId: "my-app",
+ });
+ (mockRuntime.create as ReturnType).mockRejectedValueOnce(
+ new Error("runtime creation failed"),
+ );
+ const sm = createSessionManager({ config, registry: mockRegistry });
+
+ await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow(
+ "runtime creation failed",
+ );
+
+ expect(mockWorkspace.destroy).toHaveBeenCalledWith(worktreePath);
+ expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull();
+ });
+
+ it("destroys the worktree when post-launch setup fails", async () => {
+ const worktreePath = join(tmpDir, "orchestrator-ws-postlaunch-fail");
+ (mockWorkspace.create as ReturnType).mockResolvedValueOnce({
+ path: worktreePath,
+ branch: "orchestrator/app-orchestrator-1",
+ sessionId: "app-orchestrator-1",
+ projectId: "my-app",
+ });
+ const postLaunchError = new Error("post-launch setup failed");
+ const agentWithPostLaunch: typeof mockAgent = {
+ ...mockAgent,
+ postLaunchSetup: vi.fn().mockRejectedValueOnce(postLaunchError),
+ };
+ const registryWithPostLaunch: PluginRegistry = {
+ ...mockRegistry,
+ get: vi.fn().mockImplementation((slot: string) => {
+ if (slot === "runtime") return mockRuntime;
+ if (slot === "agent") return agentWithPostLaunch;
+ if (slot === "workspace") return mockWorkspace;
+ return null;
+ }),
+ };
+ const sm = createSessionManager({ config, registry: registryWithPostLaunch });
+
+ await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow(
+ "post-launch setup failed",
+ );
+
+ expect(mockRuntime.destroy).toHaveBeenCalled();
+ expect(mockWorkspace.destroy).toHaveBeenCalledWith(worktreePath);
+ expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull();
+ });
+
it("deletes previous OpenCode orchestrator sessions before starting", async () => {
const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator.log");
const mockBin = installMockOpencode(
tmpDir,
JSON.stringify([
- { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" },
- { id: "ses_new", title: "AO:app-orchestrator", updated: "2025-01-02T00:00:00.000Z" },
+ { id: "ses_old", title: "AO:app-orchestrator-1", updated: "2025-01-01T00:00:00.000Z" },
+ { id: "ses_new", title: "AO:app-orchestrator-1", updated: "2025-01-02T00:00:00.000Z" },
]),
deleteLogPath,
);
@@ -1094,14 +1184,14 @@ describe("spawn", () => {
expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith(
expect.objectContaining({
- sessionId: "app-orchestrator",
+ sessionId: "app-orchestrator-1",
projectConfig: expect.objectContaining({
agentConfig: expect.not.objectContaining({ opencodeSessionId: expect.any(String) }),
}),
}),
);
- const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
+ const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1");
expect(meta?.["agent"]).toBe("opencode");
expect(meta?.["opencodeSessionId"]).toBeUndefined();
});
@@ -1113,7 +1203,7 @@ describe("spawn", () => {
JSON.stringify([
{
id: "ses_discovered_orchestrator",
- title: "AO:app-orchestrator",
+ title: "AO:app-orchestrator-1",
updated: 1_772_777_000_000,
},
]),
@@ -1151,205 +1241,20 @@ describe("spawn", () => {
const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
await sm.spawnOrchestrator({ projectId: "my-app" });
- const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
+ const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1");
expect(meta?.["opencodeSessionId"]).toBe("ses_discovered_orchestrator");
});
- it("reuses an existing orchestrator session when strategy is reuse", async () => {
- const listLogPath = join(tmpDir, "opencode-list-orchestrator-reuse.log");
- const mockBin = join(tmpDir, "mock-bin-reuse-no-list");
- mkdirSync(mockBin, { recursive: true });
- const scriptPath = join(mockBin, "opencode");
- writeFileSync(
- scriptPath,
- [
- "#!/usr/bin/env bash",
- "set -euo pipefail",
- 'if [[ "$1" == "session" && "$2" == "list" ]]; then',
- ` printf '%s\\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'`,
- " printf '[]\\n'",
- " exit 0",
- "fi",
- "exit 0",
- "",
- ].join("\n"),
- "utf-8",
- );
- chmodSync(scriptPath, 0o755);
- process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
-
- const opencodeAgent: Agent = {
- ...mockAgent,
- name: "opencode",
- };
- const registryWithOpenCode: PluginRegistry = {
- ...mockRegistry,
- get: vi.fn().mockImplementation((slot: string) => {
- if (slot === "runtime") return mockRuntime;
- if (slot === "agent") return opencodeAgent;
- if (slot === "workspace") return mockWorkspace;
- return null;
- }),
- };
-
- const configWithReuse: OrchestratorConfig = {
- ...config,
- defaults: { ...config.defaults, agent: "opencode" },
- projects: {
- ...config.projects,
- "my-app": {
- ...config.projects["my-app"],
- agent: "opencode",
- orchestratorSessionStrategy: "reuse",
- },
- },
- };
-
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
- opencodeSessionId: "ses_existing",
- createdAt: new Date().toISOString(),
- });
-
- const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
- const session = await sm.spawnOrchestrator({ projectId: "my-app" });
-
- expect(session.id).toBe("app-orchestrator");
- expect(session.metadata["orchestratorSessionReused"]).toBe("true");
- expect(mockRuntime.create).not.toHaveBeenCalled();
- expect(mockRuntime.destroy).not.toHaveBeenCalled();
- expect(existsSync(listLogPath)).toBe(false);
- });
-
- it("destroys orphaned runtime when reuse strategy finds alive runtime but get returns null", async () => {
- const deleteLogPath = join(tmpDir, "opencode-delete-orphaned-runtime.log");
- const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
- process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
-
- const opencodeAgent: Agent = {
- ...mockAgent,
- name: "opencode",
- };
- const registryWithOpenCode: PluginRegistry = {
- ...mockRegistry,
- get: vi.fn().mockImplementation((slot: string) => {
- if (slot === "runtime") return mockRuntime;
- if (slot === "agent") return opencodeAgent;
- if (slot === "workspace") return mockWorkspace;
- return null;
- }),
- };
-
- const configWithReuse: OrchestratorConfig = {
- ...config,
- defaults: { ...config.defaults, agent: "opencode" },
- projects: {
- ...config.projects,
- "my-app": {
- ...config.projects["my-app"],
- agent: "opencode",
- orchestratorSessionStrategy: "reuse",
- },
- },
- };
-
- const orphanedHandle = makeHandle("rt-orphaned");
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(orphanedHandle),
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockImplementation(async (handle: RuntimeHandle) => {
- if (handle?.id === "rt-orphaned") {
- deleteMetadata(sessionsDir, "app-orchestrator");
- return true;
- }
- return false;
- });
-
- const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
- const session = await sm.spawnOrchestrator({ projectId: "my-app" });
-
- expect(session.id).toBe("app-orchestrator");
- expect(mockRuntime.destroy).toHaveBeenCalledWith(orphanedHandle);
- expect(mockRuntime.create).toHaveBeenCalled();
- });
-
- it("reuses mapped OpenCode session id when strategy is reuse and runtime is restarted", async () => {
- const opencodeAgent: Agent = {
- ...mockAgent,
- name: "opencode",
- };
- const registryWithOpenCode: PluginRegistry = {
- ...mockRegistry,
- get: vi.fn().mockImplementation((slot: string) => {
- if (slot === "runtime") return mockRuntime;
- if (slot === "agent") return opencodeAgent;
- if (slot === "workspace") return mockWorkspace;
- return null;
- }),
- };
-
- const configWithReuse: OrchestratorConfig = {
- ...config,
- defaults: { ...config.defaults, agent: "opencode" },
- projects: {
- ...config.projects,
- "my-app": {
- ...config.projects["my-app"],
- agent: "opencode",
- orchestratorSessionStrategy: "reuse",
- },
- },
- };
-
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
- opencodeSessionId: "ses_existing",
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
-
- const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
- await sm.spawnOrchestrator({ projectId: "my-app" });
-
- expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith(
- expect.objectContaining({
- projectConfig: expect.objectContaining({
- agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }),
- }),
- }),
- );
- const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
- expect(meta?.["opencodeSessionId"]).toBe("ses_existing");
- });
-
- it("reuses archived OpenCode mapping for orchestrator when active metadata has no mapping", async () => {
- const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-archived.log");
+ it("reuses mapped OpenCode session id when strategy is reuse and opencode lists it by title", async () => {
+ const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-restart.log");
const mockBin = installMockOpencode(
tmpDir,
JSON.stringify([
- null,
- { id: "ses_existing", title: "AO:app-orchestrator", updated: 1_772_777_000_000 },
+ {
+ id: "ses_existing",
+ title: "AO:app-orchestrator-1",
+ updated: 1_772_777_000_000,
+ },
]),
deleteLogPath,
);
@@ -1382,30 +1287,57 @@ describe("spawn", () => {
},
};
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
- opencodeSessionId: "ses_existing",
- createdAt: new Date().toISOString(),
- });
- deleteMetadata(sessionsDir, "app-orchestrator", true);
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
- createdAt: new Date().toISOString(),
- });
+ const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
+ await sm.spawnOrchestrator({ projectId: "my-app" });
- vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
+ expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ projectConfig: expect.objectContaining({
+ agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }),
+ }),
+ }),
+ );
+ const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1");
+ expect(meta?.["opencodeSessionId"]).toBe("ses_existing");
+ });
+
+ it("discovers OpenCode mapping by title when no archived mapping exists for new session id", async () => {
+ const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-title-fallback.log");
+ const mockBin = installMockOpencode(
+ tmpDir,
+ JSON.stringify([
+ { id: "ses_existing", title: "AO:app-orchestrator-1", updated: 1_772_777_000_000 },
+ ]),
+ deleteLogPath,
+ );
+ process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
+
+ const opencodeAgent: Agent = {
+ ...mockAgent,
+ name: "opencode",
+ };
+ const registryWithOpenCode: PluginRegistry = {
+ ...mockRegistry,
+ get: vi.fn().mockImplementation((slot: string) => {
+ if (slot === "runtime") return mockRuntime;
+ if (slot === "agent") return opencodeAgent;
+ if (slot === "workspace") return mockWorkspace;
+ return null;
+ }),
+ };
+
+ const configWithReuse: OrchestratorConfig = {
+ ...config,
+ defaults: { ...config.defaults, agent: "opencode" },
+ projects: {
+ ...config.projects,
+ "my-app": {
+ ...config.projects["my-app"],
+ agent: "opencode",
+ orchestratorSessionStrategy: "reuse",
+ },
+ },
+ };
const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
await sm.spawnOrchestrator({ projectId: "my-app" });
@@ -1424,8 +1356,7 @@ describe("spawn", () => {
const mockBin = installMockOpencode(
tmpDir,
JSON.stringify([
- null,
- { id: "ses_title_match", title: "AO:app-orchestrator", updated: 1_772_777_000_000 },
+ { id: "ses_title_match", title: "AO:app-orchestrator-1", updated: 1_772_777_000_000 },
]),
deleteLogPath,
);
@@ -1458,19 +1389,6 @@ describe("spawn", () => {
},
};
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
-
const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
await sm.spawnOrchestrator({ projectId: "my-app" });
@@ -1481,81 +1399,11 @@ describe("spawn", () => {
}),
}),
);
- const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
+ const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1");
expect(meta?.["opencodeSessionId"]).toBe("ses_title_match");
});
- it("starts fresh without deleting prior OpenCode sessions when strategy is ignore", async () => {
- const deleteLogPath = join(tmpDir, "opencode-delete-ignore.log");
- const mockBin = installMockOpencode(
- tmpDir,
- JSON.stringify([
- { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" },
- ]),
- deleteLogPath,
- );
- process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
-
- const opencodeAgent: Agent = {
- ...mockAgent,
- name: "opencode",
- };
- const registryWithOpenCode: PluginRegistry = {
- ...mockRegistry,
- get: vi.fn().mockImplementation((slot: string) => {
- if (slot === "runtime") return mockRuntime;
- if (slot === "agent") return opencodeAgent;
- if (slot === "workspace") return mockWorkspace;
- return null;
- }),
- };
-
- const configWithIgnoreNew: OrchestratorConfig = {
- ...config,
- defaults: { ...config.defaults, agent: "opencode" },
- projects: {
- ...config.projects,
- "my-app": {
- ...config.projects["my-app"],
- agent: "opencode",
- orchestratorSessionStrategy: "ignore",
- },
- },
- };
-
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockResolvedValueOnce(true);
-
- const sm = createSessionManager({
- config: configWithIgnoreNew,
- registry: registryWithOpenCode,
- });
- await sm.spawnOrchestrator({ projectId: "my-app" });
-
- expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("rt-existing"));
- expect(mockRuntime.create).toHaveBeenCalled();
- expect(existsSync(deleteLogPath)).toBe(false);
- });
-
- it("skips workspace creation", async () => {
- const sm = createSessionManager({ config, registry: mockRegistry });
-
- await sm.spawnOrchestrator({ projectId: "my-app" });
-
- expect(mockWorkspace.create).not.toHaveBeenCalled();
- });
-
- it("calls agent.setupWorkspaceHooks on project path", async () => {
+ it("calls agent.setupWorkspaceHooks on worktree path", async () => {
const agentWithHooks: Agent = {
...mockAgent,
setupWorkspaceHooks: vi.fn().mockResolvedValue(undefined),
@@ -1574,7 +1422,7 @@ describe("spawn", () => {
await sm.spawnOrchestrator({ projectId: "my-app" });
expect(agentWithHooks.setupWorkspaceHooks).toHaveBeenCalledWith(
- join(tmpDir, "my-app"),
+ "/tmp/ws",
expect.objectContaining({ dataDir: sessionsDir }),
);
});
@@ -1586,7 +1434,7 @@ describe("spawn", () => {
expect(mockRuntime.create).toHaveBeenCalledWith(
expect.objectContaining({
- workspacePath: join(tmpDir, "my-app"),
+ workspacePath: "/tmp/ws",
launchCommand: "mock-agent --start",
}),
);
@@ -1597,31 +1445,10 @@ describe("spawn", () => {
await sm.spawnOrchestrator({ projectId: "my-app" });
- const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
+ const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1");
expect(meta?.["orchestratorSessionReused"]).toBeUndefined();
});
- it("respawns the orchestrator when stale metadata exists but the runtime is dead", async () => {
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- project: "my-app",
- role: "orchestrator",
- runtimeHandle: JSON.stringify(makeHandle("rt-stale")),
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
-
- const sm = createSessionManager({ config, registry: mockRegistry });
- await sm.spawnOrchestrator({ projectId: "my-app" });
-
- expect(mockRuntime.create).toHaveBeenCalledTimes(1);
- const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
- expect(meta?.["runtimeHandle"]).toBe(JSON.stringify(makeHandle("rt-1")));
- });
-
it("uses orchestratorModel when configured", async () => {
const configWithOrchestratorModel: OrchestratorConfig = {
...config,
@@ -1720,7 +1547,7 @@ describe("spawn", () => {
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled();
- expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex");
+ expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")?.["agent"]).toBe("codex");
});
it("uses defaults orchestrator agent when project agent is not set", async () => {
@@ -1767,7 +1594,7 @@ describe("spawn", () => {
await sm.spawnOrchestrator({ projectId: "my-app" });
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
- expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex");
+ expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")?.["agent"]).toBe("codex");
});
it("keeps shared worker permissions when role-specific config only overrides model", async () => {
@@ -1869,8 +1696,8 @@ describe("spawn", () => {
// Should pass systemPromptFile (not inline systemPrompt) to avoid tmux truncation
expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith(
expect.objectContaining({
- sessionId: "app-orchestrator",
- systemPromptFile: expect.stringContaining("orchestrator-prompt.md"),
+ sessionId: "app-orchestrator-1",
+ systemPromptFile: expect.stringContaining("orchestrator-prompt-app-orchestrator-1.md"),
}),
);
@@ -1909,101 +1736,5 @@ describe("spawn", () => {
expect(session.runtimeHandle).toEqual(makeHandle("rt-1"));
});
- it("reuses existing orchestrator on reservation conflict when strategy is reuse", async () => {
- const opencodeAgent: Agent = {
- ...mockAgent,
- name: "opencode",
- };
- const registryWithOpenCode: PluginRegistry = {
- ...mockRegistry,
- get: vi.fn().mockImplementation((slot: string) => {
- if (slot === "runtime") return mockRuntime;
- if (slot === "agent") return opencodeAgent;
- if (slot === "workspace") return mockWorkspace;
- return null;
- }),
- };
-
- const configWithReuse: OrchestratorConfig = {
- ...config,
- defaults: { ...config.defaults, agent: "opencode" },
- projects: {
- ...config.projects,
- "my-app": {
- ...config.projects["my-app"],
- agent: "opencode",
- orchestratorSessionStrategy: "reuse",
- },
- },
- };
-
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- agent: "opencode",
- runtimeHandle: JSON.stringify(makeHandle("rt-concurrent")),
- opencodeSessionId: "ses_concurrent",
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockResolvedValue(true);
-
- const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
- const session = await sm.spawnOrchestrator({ projectId: "my-app" });
-
- expect(session.metadata["orchestratorSessionReused"]).toBe("true");
- expect(mockRuntime.create).not.toHaveBeenCalled();
- });
-
- it("recovers reservation conflict when existing session is not usable", async () => {
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "killed",
- role: "orchestrator",
- project: "my-app",
- runtimeHandle: JSON.stringify(makeHandle("rt-dead")),
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
-
- const sm = createSessionManager({ config, registry: mockRegistry });
- await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined();
- expect(mockRuntime.create).toHaveBeenCalledTimes(1);
- });
-
- it("creates only one runtime on reservation conflict", async () => {
- writeMetadata(sessionsDir, "app-orchestrator", {
- worktree: join(tmpDir, "my-app"),
- branch: "main",
- status: "working",
- role: "orchestrator",
- project: "my-app",
- runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
- createdAt: new Date().toISOString(),
- });
-
- vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
-
- const sm = createSessionManager({ config, registry: mockRegistry });
- await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined();
- expect(mockRuntime.create).toHaveBeenCalledTimes(1);
- });
-
- it("does not delete an in-progress reservation file without runtime metadata", async () => {
- expect(reserveSessionId(sessionsDir, "app-orchestrator")).toBe(true);
-
- const sm = createSessionManager({ config, registry: mockRegistry });
-
- await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow(
- "already exists but is not in a reusable state",
- );
- expect(mockRuntime.create).not.toHaveBeenCalled();
- expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toEqual({});
- });
});
});
diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts
index bf54e4b0c..911d36be0 100644
--- a/packages/core/src/__tests__/types.test.ts
+++ b/packages/core/src/__tests__/types.test.ts
@@ -4,19 +4,35 @@ import { isOrchestratorSession, isIssueNotFoundError } from "../types.js";
describe("isOrchestratorSession", () => {
it("detects orchestrators by explicit role metadata", () => {
expect(
- isOrchestratorSession({
- id: "app-control",
- metadata: { role: "orchestrator" },
- }),
+ isOrchestratorSession({ id: "app-control", metadata: { role: "orchestrator" } }, "app"),
).toBe(true);
});
it("falls back to orchestrator naming for legacy sessions", () => {
- expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} })).toBe(true);
+ expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} }, "app")).toBe(true);
});
- it("does not classify worker sessions as orchestrators", () => {
- expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } })).toBe(false);
+ it("detects numbered worktree orchestrators by prefix pattern", () => {
+ expect(isOrchestratorSession({ id: "app-orchestrator-1", metadata: {} }, "app")).toBe(true);
+ expect(isOrchestratorSession({ id: "app-orchestrator-42", metadata: {} }, "app")).toBe(true);
+ });
+
+ it("does not false-positive on worker sessions", () => {
+ expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } }, "app")).toBe(false);
+ });
+
+ it("does not false-positive when prefix ends with -orchestrator", () => {
+ // my-orchestrator-1 is a worker when prefix is "my-orchestrator"
+ expect(
+ isOrchestratorSession({ id: "my-orchestrator-1", metadata: {} }, "my-orchestrator"),
+ ).toBe(false);
+ // my-orchestrator-orchestrator-1 is the real worktree orchestrator
+ expect(
+ isOrchestratorSession(
+ { id: "my-orchestrator-orchestrator-1", metadata: {} },
+ "my-orchestrator",
+ ),
+ ).toBe(true);
});
});
diff --git a/packages/core/src/agent-selection.ts b/packages/core/src/agent-selection.ts
index de8a76d77..158cb280e 100644
--- a/packages/core/src/agent-selection.ts
+++ b/packages/core/src/agent-selection.ts
@@ -20,9 +20,10 @@ export interface ResolvedAgentSelection {
export function resolveSessionRole(
sessionId: string,
- metadata?: Record,
+ metadata: Record | undefined,
+ sessionPrefix: string,
): SessionRole {
- return isOrchestratorSession({ id: sessionId, metadata }) ? "orchestrator" : "worker";
+ return isOrchestratorSession({ id: sessionId, metadata }, sessionPrefix) ? "orchestrator" : "worker";
}
export function resolveAgentSelection(params: {
diff --git a/packages/core/src/config-generator.ts b/packages/core/src/config-generator.ts
index 250871d92..d0ad9c78a 100644
--- a/packages/core/src/config-generator.ts
+++ b/packages/core/src/config-generator.ts
@@ -243,7 +243,7 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record validatePluginConfigFields(value, ctx, "Tracker"));
const SCMConfigSchema = z
.object({
- plugin: z.string(),
+ plugin: z.string().optional(),
+ package: z.string().optional(),
+ path: z.string().optional(),
webhook: z
.object({
enabled: z.boolean().default(true),
@@ -82,13 +113,17 @@ const SCMConfigSchema = z
})
.optional(),
})
- .passthrough();
+ .passthrough()
+ .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "SCM"));
const NotifierConfigSchema = z
.object({
- plugin: z.string(),
+ plugin: z.string().optional(),
+ package: z.string().optional(),
+ path: z.string().optional(),
})
- .passthrough();
+ .passthrough()
+ .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "Notifier"));
const AgentPermissionSchema = z
.enum(["permissionless", "default", "auto-edit", "suggest", "skip"])
@@ -176,7 +211,7 @@ const DefaultPluginsSchema = z.object({
runtime: z.string().default("tmux"),
agent: z.string().default("claude-code"),
workspace: z.string().default("worktree"),
- notifiers: z.array(z.string()).default(["composio", "desktop"]),
+ notifiers: z.array(z.string()).default([]),
orchestrator: RoleAgentDefaultsSchema,
worker: RoleAgentDefaultsSchema,
});
@@ -215,14 +250,12 @@ const OrchestratorConfigSchema = z.object({
readyThresholdMs: z.number().nonnegative().default(300_000),
defaults: DefaultPluginsSchema.default({}),
plugins: z.array(InstalledPluginConfigSchema).default([]),
- projects: z.record(ProjectConfigSchema),
+ projects: z.record(
+ z.string().regex(/^[a-zA-Z0-9_-]+$/, "Project ID must match [a-zA-Z0-9_-]+ (no dots, slashes, or special characters)"),
+ ProjectConfigSchema,
+ ),
notifiers: z.record(NotifierConfigSchema).default({}),
- notificationRouting: z.record(z.array(z.string())).default({
- urgent: ["desktop", "composio"],
- action: ["desktop", "composio"],
- warning: ["composio"],
- info: ["composio"],
- }),
+ notificationRouting: z.record(z.array(z.string())).default({}),
reactions: z.record(ReactionConfigSchema).default({}),
});
@@ -253,6 +286,181 @@ function expandPaths(config: OrchestratorConfig): OrchestratorConfig {
return config;
}
+/**
+ * Generate a temporary plugin name from a package or path specifier.
+ * This name is used until the actual manifest.name is discovered during plugin loading.
+ * Format: extract the plugin name from the package/path, removing common prefixes.
+ * e.g., "@acme/ao-plugin-tracker-jira" -> "jira"
+ * e.g., "@acme/ao-plugin-tracker-jira-cloud" -> "jira-cloud"
+ * e.g., "./plugins/my-tracker" -> "my-tracker"
+ * e.g., "my-tracker" (local path without slashes) -> "my-tracker"
+ */
+function generateTempPluginName(pkg?: string, path?: string): string {
+ if (pkg) {
+ // Extract package name without scope: "@acme/ao-plugin-tracker-jira" -> "ao-plugin-tracker-jira"
+ const slashParts = pkg.split("/");
+ const packageName = slashParts[slashParts.length - 1] ?? pkg;
+
+ // Extract plugin name after ao-plugin-{slot}- prefix, preserving multi-word names like "jira-cloud"
+ const prefixMatch = packageName.match(/^ao-plugin-(?:runtime|agent|workspace|tracker|scm|notifier|terminal)-(.+)$/);
+ if (prefixMatch?.[1]) {
+ return prefixMatch[1];
+ }
+
+ // Non-standard package name (doesn't follow ao-plugin convention): use the full package name
+ // to avoid collisions. "plugin" from "custom-tracker-plugin" would collide with other packages
+ // that also end in "-plugin". The temp name is replaced with manifest.name after loading anyway.
+ return packageName;
+ }
+
+ // Handle local paths: use the basename
+ // ./plugins/my-tracker -> my-tracker
+ // my-tracker -> my-tracker (no slashes is still a valid path)
+ if (path) {
+ const segments = path.split("/").filter((s) => s && s !== "." && s !== "..");
+ return segments[segments.length - 1] ?? path;
+ }
+
+ return "unknown";
+}
+
+/**
+ * Helper to process a single external plugin config entry.
+ * Expands home paths, generates temp plugin name if needed, and returns the entry ref.
+ */
+function processExternalPluginConfig(
+ pluginConfig: { plugin?: string; package?: string; path?: string },
+ source: string,
+ location: ExternalPluginEntryRef["location"],
+ slot: ExternalPluginEntryRef["slot"],
+): ExternalPluginEntryRef | null {
+ if (!pluginConfig.package && !pluginConfig.path) return null;
+
+ // Expand home paths (~/...) for consistency with config.plugins
+ if (pluginConfig.path) {
+ pluginConfig.path = expandHome(pluginConfig.path);
+ }
+
+ // Track if user explicitly specified plugin name (for validation)
+ const userSpecifiedPlugin = pluginConfig.plugin;
+
+ // If plugin name not specified, generate a temporary one from package/path
+ if (!pluginConfig.plugin) {
+ pluginConfig.plugin = generateTempPluginName(pluginConfig.package, pluginConfig.path);
+ }
+
+ return {
+ source,
+ location,
+ slot,
+ package: pluginConfig.package,
+ path: pluginConfig.path,
+ expectedPluginName: userSpecifiedPlugin,
+ };
+}
+
+/**
+ * Collect external plugin configs from tracker, scm, and notifier inline configs.
+ * These will be auto-added to config.plugins for loading.
+ *
+ * Also sets a temporary plugin name on configs that only have package/path,
+ * so that resolvePlugins() can look up the plugin by name.
+ *
+ * IMPORTANT: Only sets expectedPluginName when user explicitly specified `plugin`.
+ * When plugin is auto-generated, expectedPluginName is left undefined so that
+ * any manifest.name is accepted and the config is updated with it.
+ */
+export function collectExternalPluginConfigs(config: OrchestratorConfig): ExternalPluginEntryRef[] {
+ const entries: ExternalPluginEntryRef[] = [];
+
+ // Collect from project tracker and scm configs
+ for (const [projectId, project] of Object.entries(config.projects)) {
+ if (project.tracker) {
+ const entry = processExternalPluginConfig(
+ project.tracker,
+ `projects.${projectId}.tracker`,
+ { kind: "project", projectId, configType: "tracker" },
+ "tracker",
+ );
+ if (entry) entries.push(entry);
+ }
+
+ if (project.scm) {
+ const entry = processExternalPluginConfig(
+ project.scm,
+ `projects.${projectId}.scm`,
+ { kind: "project", projectId, configType: "scm" },
+ "scm",
+ );
+ if (entry) entries.push(entry);
+ }
+ }
+
+ // Collect from global notifier configs
+ for (const [notifierId, notifierConfig] of Object.entries(config.notifiers ?? {})) {
+ if (notifierConfig) {
+ const entry = processExternalPluginConfig(
+ notifierConfig,
+ `notifiers.${notifierId}`,
+ { kind: "notifier", notifierId },
+ "notifier",
+ );
+ if (entry) entries.push(entry);
+ }
+ }
+
+ return entries;
+}
+
+/**
+ * Generate InstalledPluginConfig entries from external plugin entries.
+ * Merges with existing plugins, avoiding duplicates by package/path.
+ */
+function mergeExternalPlugins(
+ existingPlugins: OrchestratorConfig["plugins"],
+ externalEntries: ExternalPluginEntryRef[],
+): OrchestratorConfig["plugins"] {
+ const plugins = [...(existingPlugins ?? [])];
+ const seen = new Set();
+
+ // Track existing plugins by package/path
+ for (const plugin of plugins) {
+ if (plugin.package) seen.add(`package:${plugin.package}`);
+ if (plugin.path) seen.add(`path:${plugin.path}`);
+ }
+
+ // Add external entries that aren't already present, or enable if disabled
+ for (const entry of externalEntries) {
+ const key = entry.package ? `package:${entry.package}` : `path:${entry.path}`;
+ if (seen.has(key)) {
+ // If the existing plugin is disabled but there's an inline reference, enable it
+ const existingPlugin = plugins.find(
+ (p) =>
+ (entry.package && p.package === entry.package) ||
+ (entry.path && p.path === entry.path),
+ );
+ if (existingPlugin && existingPlugin.enabled === false) {
+ existingPlugin.enabled = true;
+ }
+ continue;
+ }
+ seen.add(key);
+
+ // Generate a temporary name - will be replaced with manifest.name during loading
+ const tempName = entry.expectedPluginName ?? generateTempPluginName(entry.package, entry.path);
+
+ plugins.push({
+ name: tempName,
+ source: entry.package ? "npm" : "local",
+ package: entry.package,
+ path: entry.path,
+ enabled: true,
+ });
+ }
+
+ return plugins;
+}
+
/** Apply defaults to project configs */
function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig {
for (const [id, project] of Object.entries(config.projects)) {
@@ -544,6 +752,15 @@ export function validateConfig(raw: unknown): OrchestratorConfig {
config = applyProjectDefaults(config);
config = applyDefaultReactions(config);
+ // Collect external plugin configs from inline tracker/scm/notifier configs
+ // and merge them into config.plugins for loading
+ const externalPluginEntries = collectExternalPluginConfigs(config);
+ if (externalPluginEntries.length > 0) {
+ config.plugins = mergeExternalPlugins(config.plugins, externalPluginEntries);
+ // Store entries for manifest validation during plugin loading
+ config._externalPluginEntries = externalPluginEntries;
+ }
+
// Validate project uniqueness and prefix collisions
validateProjectUniqueness(config);
diff --git a/packages/core/src/global-pause.ts b/packages/core/src/global-pause.ts
deleted file mode 100644
index e8baed66e..000000000
--- a/packages/core/src/global-pause.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const GLOBAL_PAUSE_UNTIL_KEY = "globalPauseUntil";
-export const GLOBAL_PAUSE_REASON_KEY = "globalPauseReason";
-export const GLOBAL_PAUSE_SOURCE_KEY = "globalPauseSource";
-
-export function parsePauseUntil(raw: string | undefined): Date | null {
- if (!raw) return null;
- const parsed = new Date(raw);
- if (Number.isNaN(parsed.getTime())) return null;
- return parsed;
-}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ef028390a..9bd10086c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -84,15 +84,6 @@ export type {
export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
-
-// Global pause constants and utilities
-export {
- GLOBAL_PAUSE_UNTIL_KEY,
- GLOBAL_PAUSE_REASON_KEY,
- GLOBAL_PAUSE_SOURCE_KEY,
- parsePauseUntil,
-} from "./global-pause.js";
-
// Shared utilities
export {
shellEscape,
diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts
index 678619b43..86323a169 100644
--- a/packages/core/src/lifecycle-manager.ts
+++ b/packages/core/src/lifecycle-manager.ts
@@ -15,6 +15,7 @@ import {
SESSION_STATUS,
PR_STATE,
CI_STATUS,
+ TERMINAL_STATUSES,
type LifecycleManager,
type SessionManager,
type SessionId,
@@ -207,6 +208,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
*/
const prEnrichmentCache = new Map();
+ /**
+ * Per-session timestamp of last review backlog API check.
+ * Used to throttle getPendingComments/getAutomatedComments to at most once per 2 minutes.
+ * In-memory only — resets on restart (acceptable since it's a rate-limit hint, not state).
+ */
+ const lastReviewBacklogCheckAt = new Map();
+
+ /** Throttle interval for review backlog API calls (2 minutes). */
+ const REVIEW_BACKLOG_THROTTLE_MS = 2 * 60 * 1000;
+
/**
* Populate the PR enrichment cache using batch GraphQL queries.
* This is called once per poll cycle to fetch data for all PRs efficiently.
@@ -235,7 +246,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const [owner, repo] = p.repo.split("/");
return owner === pr.owner && repo === pr.repo;
});
- if (!project?.scm) continue;
+ if (!project?.scm?.plugin) continue;
const pluginKey = project.scm.plugin;
if (!prsByPlugin.has(pluginKey)) {
@@ -344,13 +355,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
if (!project) return session.status;
const agentName = resolveAgentSelection({
- role: resolveSessionRole(session.id, session.metadata),
+ role: resolveSessionRole(session.id, session.metadata, project.sessionPrefix),
project,
defaults: config.defaults,
persistedAgent: session.metadata["agent"],
}).agentName;
const agent = registry.get("agent", agentName);
- const scm = project.scm ? registry.get("scm", project.scm.plugin) : null;
+ const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
// Track activity state across steps so stuck detection can run after PR checks
let detectedIdleTimestamp: Date | null = null;
@@ -693,7 +704,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return reactionConfig ? (reactionConfig as ReactionConfig) : null;
}
- function updateSessionMetadata(session: Session, updates: Partial>): void {
+ function updateSessionMetadata(
+ session: Session,
+ updates: Partial>,
+ ): void {
const project = config.projects[session.projectId];
if (!project) return;
@@ -726,15 +740,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const project = config.projects[session.projectId];
if (!project || !session.pr) return;
- const scm = project.scm ? registry.get("scm", project.scm.plugin) : null;
+ const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
if (!scm) return;
const humanReactionKey = "changes-requested";
const automatedReactionKey = "bugbot-comments";
- if (newStatus === "merged" || newStatus === "killed") {
+ if (TERMINAL_STATUSES.has(newStatus)) {
clearReactionTracker(session.id, humanReactionKey);
clearReactionTracker(session.id, automatedReactionKey);
+ lastReviewBacklogCheckAt.delete(session.id);
updateSessionMetadata(session, {
lastPendingReviewFingerprint: "",
lastPendingReviewDispatchHash: "",
@@ -746,6 +761,27 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return;
}
+ // Throttle review backlog API calls to at most once per 2 minutes.
+ // Comments don't change faster than this in practice, and the SCM calls
+ // (getPendingComments + getAutomatedComments) consume API quota on every poll.
+ //
+ // Exception: bypass throttle when a transition reaction just fired for a
+ // review reaction key. The transitionReaction branch records
+ // lastPendingReviewDispatchHash, which requires the current fingerprint from
+ // the API. If we throttle here, that metadata never gets written and the
+ // next unthrottled poll sees a "new" fingerprint, clears the reaction tracker,
+ // and fires a duplicate dispatch.
+ const hasRelevantTransition =
+ transitionReaction?.key === humanReactionKey ||
+ transitionReaction?.key === automatedReactionKey;
+ if (!hasRelevantTransition) {
+ const lastCheckAt = lastReviewBacklogCheckAt.get(session.id) ?? 0;
+ if (Date.now() - lastCheckAt < REVIEW_BACKLOG_THROTTLE_MS) {
+ return;
+ }
+ }
+ lastReviewBacklogCheckAt.set(session.id, Date.now());
+
const [pendingResult, automatedResult] = await Promise.allSettled([
scm.getPendingComments(session.pr),
scm.getAutomatedComments(session.pr),
@@ -826,9 +862,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
// --- Automated (bot) review comments ---
if (automatedComments !== null) {
- const automatedFingerprint = makeFingerprint(automatedComments.map((comment) => comment.id));
+ const automatedFingerprint = makeFingerprint(
+ automatedComments.map((comment) => comment.id),
+ );
const lastAutomatedFingerprint = session.metadata["lastAutomatedReviewFingerprint"] ?? "";
- const lastAutomatedDispatchHash = session.metadata["lastAutomatedReviewDispatchHash"] ?? "";
+ const lastAutomatedDispatchHash =
+ session.metadata["lastAutomatedReviewDispatchHash"] ?? "";
if (automatedFingerprint !== lastAutomatedFingerprint) {
clearReactionTracker(session.id, automatedReactionKey);
@@ -903,7 +942,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const project = config.projects[session.projectId];
if (!project || !session.pr) return;
- const scm = project.scm ? registry.get("scm", project.scm.plugin) : null;
+ const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
if (!scm) return;
const ciReactionKey = "ci-failed";
@@ -934,13 +973,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return;
}
- // Fetch individual CI checks for failure details
+ // Fetch individual CI checks for failure details.
+ // Use batch enrichment data when available to avoid an extra REST call;
+ // fall back to getCIChecks() when the batch didn't run this cycle.
+ const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
+ const cachedEnrichment = prEnrichmentCache.get(prKey);
+
let checks: CICheck[];
- try {
- checks = await scm.getCIChecks(session.pr);
- } catch {
- // Failed to fetch checks — skip this cycle
- return;
+ if (cachedEnrichment?.ciChecks !== undefined) {
+ checks = cachedEnrichment.ciChecks;
+ } else {
+ try {
+ checks = await scm.getCIChecks(session.pr);
+ } catch {
+ // Failed to fetch checks — skip this cycle
+ return;
+ }
}
const failedChecks = checks.filter(
@@ -1025,7 +1073,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const project = config.projects[session.projectId];
if (!project || !session.pr) return;
- const scm = project.scm ? registry.get("scm", project.scm.plugin) : null;
+ const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
if (!scm) return;
const conflictReactionKey = "merge-conflicts";
@@ -1051,14 +1099,19 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return;
}
- // Check for conflicts using cached enrichment data or fallback to individual call
+ // Check for conflicts using cached enrichment data or fallback to individual call.
+ // When batch enrichment ran (cachedData is present), use its hasConflicts value
+ // to avoid 3 redundant REST calls from getMergeability() — the batch already
+ // fetched the mergeable/mergeStateStatus fields via GraphQL.
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
const cachedData = prEnrichmentCache.get(prKey);
let hasConflicts: boolean;
- if (cachedData && cachedData.hasConflicts !== undefined) {
- hasConflicts = cachedData.hasConflicts;
+ if (cachedData) {
+ // Batch ran — trust its data (undefined means CONFLICTING wasn't set → no conflicts)
+ hasConflicts = cachedData.hasConflicts ?? false;
} else {
+ // Batch didn't run this cycle — fall back to individual API call
try {
const mergeReadiness = await scm.getMergeability(session.pr);
hasConflicts = !mergeReadiness.noConflicts;
@@ -1155,7 +1208,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
});
// Reset allCompleteEmitted when any session becomes active again
- if (newStatus !== "merged" && newStatus !== "killed") {
+ if (!TERMINAL_STATUSES.has(newStatus)) {
allCompleteEmitted = false;
}
@@ -1215,6 +1268,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
states.set(session.id, newStatus);
}
+ // Pin first quality summary for title stability
+ if (
+ session.agentInfo?.summary &&
+ !session.agentInfo.summaryIsFallback &&
+ !session.metadata["pinnedSummary"]
+ ) {
+ const trimmed = session.agentInfo.summary.replace(/[\n\r]/g, " ").trim();
+ if (trimmed.length >= 5) {
+ try {
+ updateSessionMetadata(session, { pinnedSummary: trimmed });
+ } catch {
+ // Non-critical: title just won't be pinned this cycle
+ }
+ }
+ }
+
await Promise.allSettled([
maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction),
maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction),
@@ -1237,7 +1306,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
// (e.g., list() detected a dead runtime and marked it "killed" — we need to
// process that transition even though the new status is terminal)
const sessionsToCheck = sessions.filter((s) => {
- if (s.status !== "merged" && s.status !== "killed") return true;
+ if (!TERMINAL_STATUSES.has(s.status)) return true;
const tracked = states.get(s.id);
return tracked !== undefined && tracked !== s.status;
});
@@ -1249,8 +1318,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
// Poll all sessions concurrently
await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s)));
- // Prune stale entries from states and reactionTrackers for sessions
- // that no longer appear in the session list (e.g., after kill/cleanup)
+ // Prune stale entries from states, reactionTrackers, and lastReviewBacklogCheckAt
+ // for sessions that no longer appear in the session list (e.g., after kill/cleanup)
const currentSessionIds = new Set(sessions.map((s) => s.id));
for (const trackedId of states.keys()) {
if (!currentSessionIds.has(trackedId)) {
@@ -1263,9 +1332,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
reactionTrackers.delete(trackerKey);
}
}
+ for (const sessionId of lastReviewBacklogCheckAt.keys()) {
+ if (!currentSessionIds.has(sessionId)) {
+ lastReviewBacklogCheckAt.delete(sessionId);
+ }
+ }
// Check if all sessions are complete (trigger reaction only once)
- const activeSessions = sessions.filter((s) => s.status !== "merged" && s.status !== "killed");
+ const activeSessions = sessions.filter((s) => !TERMINAL_STATUSES.has(s.status));
if (sessions.length > 0 && activeSessions.length === 0 && !allCompleteEmitted) {
allCompleteEmitted = true;
diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts
index 7010e3f88..de5bda0b1 100644
--- a/packages/core/src/metadata.ts
+++ b/packages/core/src/metadata.ts
@@ -93,6 +93,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
? Number(raw["directTerminalWsPort"])
: undefined,
opencodeSessionId: raw["opencodeSessionId"],
+ pinnedSummary: raw["pinnedSummary"],
};
}
@@ -142,6 +143,7 @@ export function writeMetadata(
if (metadata.directTerminalWsPort !== undefined)
data["directTerminalWsPort"] = String(metadata.directTerminalWsPort);
if (metadata.opencodeSessionId) data["opencodeSessionId"] = metadata.opencodeSessionId;
+ if (metadata.pinnedSummary) data["pinnedSummary"] = metadata.pinnedSummary;
atomicWriteFileSync(path, serializeMetadata(data));
}
diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts
index d807d956d..e05e395ff 100644
--- a/packages/core/src/plugin-registry.ts
+++ b/packages/core/src/plugin-registry.ts
@@ -11,6 +11,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type {
+ ExternalPluginEntryRef,
InstalledPluginConfig,
PluginSlot,
PluginManifest,
@@ -60,30 +61,178 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> =
{ slot: "terminal", name: "web", pkg: "@composio/ao-plugin-terminal-web" },
];
-/** Extract plugin-specific config from orchestrator config */
function extractPluginConfig(
slot: PluginSlot,
name: string,
config: OrchestratorConfig,
): Record | undefined {
- // Notifiers are configured under config.notifiers..
- // Match by key (e.g. "openclaw") or explicit plugin field.
+ // 1. Handle Notifier Slot
if (slot === "notifier") {
- for (const [notifierName, notifierConfig] of Object.entries(config.notifiers ?? {})) {
+ for (const [notifierId, notifierConfig] of Object.entries(config.notifiers ?? {})) {
if (!notifierConfig || typeof notifierConfig !== "object") continue;
const configuredPlugin = (notifierConfig as Record)["plugin"];
const hasExplicitPlugin = typeof configuredPlugin === "string" && configuredPlugin.length > 0;
- const matches = hasExplicitPlugin ? configuredPlugin === name : notifierName === name;
+ const matches = hasExplicitPlugin ? configuredPlugin === name : notifierId === name;
+
if (matches) {
- const { plugin: _plugin, ...rest } = notifierConfig as Record;
- return config.configPath ? { ...rest, configPath: config.configPath } : rest;
+ return prepareConfig(slot, name, notifierId, notifierConfig, config.configPath);
}
}
}
+ // 2. Handle Tracker and SCM Slots (Project-level)
+ // Tracker and SCM plugins are typically stateless singletons that receive
+ // project-specific config per-call (via ProjectConfig argument), not at create() time.
+ // This applies to BOTH built-in and external plugins to avoid order-dependent
+ // behavior when multiple projects share the same plugin but have different configs.
+ // Return undefined so plugins are initialized without project-specific config.
+ if (slot === "tracker" || slot === "scm") {
+ return undefined;
+ }
+
return undefined;
}
+/**
+ * Internal helper to validate and strip loading metadata from a plugin configuration.
+ * Reserved fields (plugin, package, path) are used for plugin resolution and stripped.
+ */
+function prepareConfig(
+ slot: string,
+ name: string,
+ sourceId: string,
+ rawConfig: Record,
+ configPath?: string,
+): Record {
+ // Explicitly check for reserved fields to prevent silent stripping/collision.
+ // 'path' is reserved for local resolution; 'package' is reserved for npm resolution.
+ if ("package" in rawConfig && "path" in rawConfig) {
+ throw new Error(
+ `In ${slot} "${sourceId}": both "package" and "path" are specified. ` +
+ `Use "package" for npm plugins or "path" for local plugins, not both.`,
+ );
+ }
+
+ // If loading via built-in name or npm package, having a 'path' field is ambiguous:
+ // it could be a local plugin path (for loading) or a plugin config value.
+ // We reject this to avoid silently stripping a config value the user intended to pass.
+ const isBuiltin = BUILTIN_PLUGINS.some((b) => b.slot === slot && b.name === name);
+ if ((rawConfig.package || isBuiltin) && "path" in rawConfig) {
+ const loadingMethod = rawConfig.package ? `npm package "${rawConfig.package}"` : `built-in plugin "${name}"`;
+ throw new Error(
+ `In ${slot} "${sourceId}": "path" field conflicts with reserved plugin loading field. ` +
+ `You're loading via ${loadingMethod}, but also have a "path" field which would be stripped. ` +
+ `Rename your configuration field to something else (e.g., "apiPath", "webhookPath").`,
+ );
+ }
+
+ // Strip loading metadata fields (plugin, package, path) from config passed to plugin.
+ const { plugin: _plugin, package: _package, path: _path, ...rest } = rawConfig;
+ return configPath ? { ...rest, configPath } : rest;
+}
+
+/**
+ * Build an index of external plugin entries by package/path for O(1) lookups.
+ * Multiple entries can share the same package/path (e.g., multiple projects using same plugin).
+ */
+function buildExternalPluginIndex(
+ externalEntries: ExternalPluginEntryRef[] | undefined,
+): Map {
+ const index = new Map();
+ if (!externalEntries) return index;
+
+ for (const entry of externalEntries) {
+ const key = entry.package ? `package:${entry.package}` : `path:${entry.path}`;
+ const existing = index.get(key);
+ if (existing) {
+ existing.push(entry);
+ } else {
+ index.set(key, [entry]);
+ }
+ }
+
+ return index;
+}
+
+/**
+ * Find ALL external plugin entries that match a given plugin config.
+ * Used for manifest.name validation when loading inline tracker/scm/notifier plugins.
+ *
+ * Returns all matching entries because multiple projects may share the same
+ * external plugin (same package/path), and all their configs need to be updated
+ * with the actual manifest.name.
+ */
+function findAllExternalPluginEntries(
+ plugin: InstalledPluginConfig,
+ externalIndex: Map,
+): ExternalPluginEntryRef[] {
+ if (plugin.package) {
+ return externalIndex.get(`package:${plugin.package}`) ?? [];
+ }
+ if (plugin.path) {
+ return externalIndex.get(`path:${plugin.path}`) ?? [];
+ }
+ return [];
+}
+
+/**
+ * Validate that a plugin's manifest.name matches the expected name (if specified).
+ * Throws an error if there's a mismatch.
+ */
+function validateManifestName(
+ manifest: PluginManifest,
+ entry: ExternalPluginEntryRef,
+ specifier: string,
+): void {
+ // If the user specified an explicit plugin name, validate it matches the manifest
+ if (entry.expectedPluginName && entry.expectedPluginName !== manifest.name) {
+ const specifierType = entry.package ? "package" : "path";
+ throw new Error(
+ `Plugin manifest.name mismatch at ${entry.source}: ` +
+ `expected "${entry.expectedPluginName}" but ${specifierType} "${specifier}" has manifest.name "${manifest.name}". ` +
+ `Either update the 'plugin' field to match the actual manifest.name, or remove it to auto-infer.`,
+ );
+ }
+}
+
+/**
+ * Update the config with the actual plugin name after loading an external plugin.
+ * This ensures resolvePlugins() can look up the plugin by its manifest.name.
+ *
+ * Uses structured location data to avoid ambiguity from parsing dotted strings
+ * (project/notifier keys can legally contain dots).
+ */
+function updateConfigWithManifestName(
+ manifest: PluginManifest,
+ entry: ExternalPluginEntryRef,
+ config: OrchestratorConfig,
+): void {
+ const { location, slot, source } = entry;
+
+ // Use structured location to update the config
+ if (location.kind === "project") {
+ const { projectId, configType } = location;
+ const project = config.projects[projectId];
+ if (project?.[configType]) {
+ project[configType]!.plugin = manifest.name;
+ }
+ } else if (location.kind === "notifier") {
+ const { notifierId } = location;
+ const notifierConfig = config.notifiers[notifierId];
+ if (notifierConfig) {
+ notifierConfig.plugin = manifest.name;
+ }
+ }
+
+ // Also validate slot matches
+ if (manifest.slot !== slot) {
+ process.stderr.write(
+ `[plugin-registry] Plugin at ${source} has slot "${manifest.slot}" but was configured as "${slot}". ` +
+ `The plugin will be registered under its declared slot "${manifest.slot}".\n`,
+ );
+ }
+}
+
export function isPluginModule(value: unknown): value is PluginModule {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial;
@@ -239,16 +388,25 @@ export function createPluginRegistry(): PluginRegistry {
): Promise {
const doImport = importFn ?? ((pkg: string) => import(pkg));
for (const builtin of BUILTIN_PLUGINS) {
+ let mod;
try {
- const mod = normalizeImportedPluginModule(await doImport(builtin.pkg));
- if (mod) {
+ mod = normalizeImportedPluginModule(await doImport(builtin.pkg));
+ } catch {
+ // Plugin not installed — that's fine, only load what's available
+ continue;
+ }
+
+ if (mod) {
+ try {
const pluginConfig = orchestratorConfig
? extractPluginConfig(builtin.slot, builtin.name, orchestratorConfig)
: undefined;
this.register(mod, pluginConfig);
+ } catch (error) {
+ process.stderr.write(
+ `[plugin-registry] Failed to load built-in plugin "${builtin.name}": ${error}\n`,
+ );
}
- } catch {
- // Plugin not installed — that's fine, only load what's available
}
}
},
@@ -261,13 +419,15 @@ export function createPluginRegistry(): PluginRegistry {
await this.loadBuiltins(config, importFn);
const doImport = importFn ?? ((pkg: string) => import(pkg));
+ // Build index once for O(1) lookups when matching plugins to external entries
+ const externalIndex = buildExternalPluginIndex(config._externalPluginEntries);
for (const plugin of config.plugins ?? []) {
if (plugin.enabled === false) continue;
const specifier = resolvePluginSpecifier(plugin, config);
if (!specifier) {
- console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`);
+ process.stderr.write(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})\n`);
continue;
}
@@ -275,10 +435,34 @@ export function createPluginRegistry(): PluginRegistry {
const mod = normalizeImportedPluginModule(await doImport(specifier));
if (!mod) continue;
+ // Check if this plugin was auto-added from inline tracker/scm/notifier config.
+ // Multiple projects may share the same external plugin, so find ALL matching entries.
+ // We validate and update configs FIRST, before extracting plugin config, because
+ // extractPluginConfig looks up by manifest.name which may differ from the temp name.
+ const matchingEntries = findAllExternalPluginEntries(plugin, externalIndex);
+ for (const externalEntry of matchingEntries) {
+ try {
+ // Validate manifest.name matches expectedPluginName (if specified)
+ validateManifestName(mod.manifest, externalEntry, specifier);
+ // Update the config with the actual manifest.name
+ updateConfigWithManifestName(mod.manifest, externalEntry, config);
+ } catch (validationError) {
+ // Log validation errors but don't abort - other projects can still use the plugin.
+ // The misconfigured project will fail later when it tries to use the plugin
+ // with the wrong name, giving a clearer error at point of use.
+ process.stderr.write(
+ `[plugin-registry] Config validation failed for ${externalEntry.source}: ${validationError}\n`,
+ );
+ }
+ }
+
+ // Extract plugin config AFTER updating configs with manifest.name.
+ // This ensures extractPluginConfig can find the config by manifest.name
+ // (e.g., manifest "ms-teams" after config was updated from temp "teams").
const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config);
this.register(mod, pluginConfig);
} catch (error) {
- console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error);
+ process.stderr.write(`[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`);
}
}
},
diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts
index 3a896e06f..410e0afbe 100644
--- a/packages/core/src/recovery/validator.ts
+++ b/packages/core/src/recovery/validator.ts
@@ -31,7 +31,7 @@ export async function validateSession(
const runtimeName = project.runtime ?? config.defaults.runtime;
const agentName = resolveAgentSelection({
- role: resolveSessionRole(sessionId, rawMetadata),
+ role: resolveSessionRole(sessionId, rawMetadata, project.sessionPrefix),
project,
defaults: config.defaults,
persistedAgent: rawMetadata["agent"],
diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts
index d23333cc4..7284baba7 100644
--- a/packages/core/src/session-manager.ts
+++ b/packages/core/src/session-manager.ts
@@ -11,7 +11,7 @@
* Reference: scripts/claude-ao-session, scripts/send-to-session
*/
-import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
+import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync, utimesSync, unlinkSync } from "node:fs";
import { execFile } from "node:child_process";
import { basename, join, resolve } from "node:path";
import { homedir } from "node:os";
@@ -41,7 +41,6 @@ import {
type PluginRegistry,
type RuntimeHandle,
type Issue,
- isOrchestratorSession,
PR_STATE,
} from "./types.js";
import {
@@ -60,17 +59,10 @@ import {
getWorktreesDir,
getProjectBaseDir,
generateTmuxName,
- generateConfigHash,
validateAndStoreOrigin,
} from "./paths.js";
import { asValidOpenCodeSessionId } from "./opencode-session-id.js";
import { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
-import {
- GLOBAL_PAUSE_REASON_KEY,
- GLOBAL_PAUSE_SOURCE_KEY,
- GLOBAL_PAUSE_UNTIL_KEY,
- parsePauseUntil,
-} from "./global-pause.js";
import { sessionFromMetadata } from "./utils/session-from-metadata.js";
import { safeJsonParse } from "./utils/validation.js";
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
@@ -286,27 +278,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return getSessionsDir(config.configPath, project.path);
}
- function getProjectPause(project: ProjectConfig): {
- until: Date;
- reason: string;
- sourceSessionId: string;
- } | null {
- const sessionsDir = getProjectSessionsDir(project);
- const orchestratorId = `${project.sessionPrefix}-orchestrator`;
- const orchestratorRaw = readMetadataRaw(sessionsDir, orchestratorId);
- if (!orchestratorRaw) return null;
-
- const until = parsePauseUntil(orchestratorRaw[GLOBAL_PAUSE_UNTIL_KEY]);
- if (!until) return null;
- if (until.getTime() <= Date.now()) return null;
-
- return {
- until,
- reason: orchestratorRaw[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
- sourceSessionId: orchestratorRaw[GLOBAL_PAUSE_SOURCE_KEY] ?? "unknown",
- };
- }
-
function normalizePath(path: string): string {
return resolve(path).replace(/\/$/, "");
}
@@ -358,9 +329,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
function isOrchestratorSessionRecord(
sessionId: string,
raw: Record | null | undefined,
+ sessionPrefix?: string,
): boolean {
if (!raw) return false;
- return raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator");
+ if (raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator")) return true;
+ // Check the -orchestrator-N pattern only when the prefix is known so the
+ // regex is anchored to the project prefix, preventing false-positives when
+ // the user-configured sessionPrefix itself ends with "-orchestrator".
+ if (sessionPrefix) {
+ return new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId);
+ }
+ return false;
}
function isCleanupProtectedSession(
@@ -368,11 +347,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
sessionId: string,
metadata?: Record | null,
): boolean {
- const canonicalOrchestratorId = `${project.sessionPrefix}-orchestrator`;
- return (
- sessionId === canonicalOrchestratorId ||
- isOrchestratorSession({ id: sessionId, metadata: metadata ?? undefined })
- );
+ return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix);
}
function applyMetadataUpdatesToRaw(
@@ -422,9 +397,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
function repairSingleSessionMetadataOnRead(
sessionsDir: string,
record: ActiveSessionRecord,
+ sessionPrefix?: string,
): ActiveSessionRecord {
const repaired = { ...record, raw: { ...record.raw } };
- if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw)) {
+ if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw, sessionPrefix)) {
return repaired;
}
@@ -464,13 +440,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
function repairSessionMetadataOnRead(
sessionsDir: string,
records: ActiveSessionRecord[],
+ sessionPrefix?: string,
): ActiveSessionRecord[] {
const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } }));
const duplicatePRAttachments = new Map();
for (const record of repaired) {
- if (isOrchestratorSessionRecord(record.sessionName, record.raw)) {
- record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record).raw;
+ if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) {
+ record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw;
continue;
}
@@ -531,7 +508,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return [{ sessionName, raw, modifiedAt } satisfies ActiveSessionRecord];
});
- return repairSessionMetadataOnRead(sessionsDir, records);
+ return repairSessionMetadataOnRead(sessionsDir, records, project.sessionPrefix);
}
function markArchivedOpenCodeCleanup(sessionsDir: string, sessionId: SessionId): void {
@@ -702,6 +679,66 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
);
}
+ /**
+ * Reserve a unique orchestrator identity ({prefix}-orchestrator-N) for a worktree-based orchestrator.
+ * Unlike worker sessions, orchestrator IDs are assigned locally without remote branch checks.
+ */
+ function reserveNextOrchestratorIdentity(
+ project: ProjectConfig,
+ sessionsDir: string,
+ ): { num: number; sessionId: string; tmuxName: string | undefined } {
+ const orchestratorPrefix = `${project.sessionPrefix}-orchestrator`;
+ const usedNumbers = new Set();
+
+ const orchestratorPattern = new RegExp(`^${escapeRegex(orchestratorPrefix)}-(\\d+)$`);
+ for (const sessionName of [
+ ...listMetadata(sessionsDir),
+ ...listArchivedSessionIds(sessionsDir),
+ ]) {
+ const match = sessionName.match(orchestratorPattern);
+ if (match) {
+ const parsed = Number.parseInt(match[1], 10);
+ if (!Number.isNaN(parsed)) usedNumbers.add(parsed);
+ }
+ }
+
+ // Build worker-ID patterns for all other projects. If another project has
+ // sessionPrefix === orchestratorPrefix (e.g. project B has prefix "app-orchestrator"),
+ // then its workers are named "app-orchestrator-1", "app-orchestrator-2", etc. — which
+ // would collide with our orchestrator IDs. Detect this impossible configuration early.
+ for (const [otherProjectId, otherProject] of Object.entries(config.projects)) {
+ const otherPrefix = otherProject.sessionPrefix ?? otherProjectId;
+ if (otherPrefix === project.sessionPrefix) continue;
+ if (otherPrefix === orchestratorPrefix) {
+ // Another project's workers are "{otherPrefix}-\d+" which equals "{orchestratorPrefix}-\d+".
+ // Every candidate ID we would generate collides — fail immediately with a clear message.
+ throw new Error(
+ `Cannot spawn orchestrator for project "${project.sessionPrefix}": the orchestrator ID prefix "${orchestratorPrefix}" ` +
+ `conflicts with the session prefix of project "${otherProjectId}" ("${otherPrefix}"). ` +
+ `Rename one of the project sessionPrefix values to avoid this overlap.`,
+ );
+ }
+ }
+
+ let num = 1;
+ for (let attempts = 0; attempts < 10_000; attempts++) {
+ if (!usedNumbers.has(num)) {
+ const sessionId = `${orchestratorPrefix}-${num}`;
+ const tmuxName = config.configPath
+ ? generateTmuxName(config.configPath, orchestratorPrefix, num)
+ : undefined;
+ if (reserveSessionId(sessionsDir, sessionId)) {
+ return { num, sessionId, tmuxName };
+ }
+ }
+ num += 1;
+ }
+
+ throw new Error(
+ `Failed to reserve orchestrator session ID after 10000 attempts (prefix: ${orchestratorPrefix})`,
+ );
+ }
+
/** Resolve which plugins to use for a project. */
function resolvePlugins(project: ProjectConfig, agentName?: string) {
const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime);
@@ -710,10 +747,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
"workspace",
project.workspace ?? config.defaults.workspace,
);
- const tracker = project.tracker
- ? registry.get("tracker", project.tracker.plugin)
- : null;
- const scm = project.scm ? registry.get("scm", project.scm.plugin) : null;
+ // After config validation, plugin is always set if tracker/scm exists
+ // (either from user config or auto-generated from package/path)
+ const tracker =
+ project.tracker?.plugin ? registry.get("tracker", project.tracker.plugin) : null;
+ const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
return { runtime, agent, workspace, tracker, scm };
}
@@ -724,7 +762,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
metadata: Record,
) {
return resolveAgentSelection({
- role: resolveSessionRole(sessionId, metadata),
+ role: resolveSessionRole(sessionId, metadata, project.sessionPrefix),
project,
defaults: config.defaults,
persistedAgent: metadata["agent"],
@@ -765,11 +803,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
modifiedAt = undefined;
}
- const repaired = repairSingleSessionMetadataOnRead(sessionsDir, {
- sessionName: sessionId,
- raw,
- modifiedAt,
- });
+ const repaired = repairSingleSessionMetadataOnRead(
+ sessionsDir,
+ { sessionName: sessionId, raw, modifiedAt },
+ project.sessionPrefix,
+ );
return { raw: repaired.raw, sessionsDir, project, projectId };
}
@@ -896,13 +934,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
throw new Error(`Unknown project: ${spawnConfig.projectId}`);
}
- const pause = getProjectPause(project);
- if (pause) {
- throw new Error(
- `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`,
- );
- }
-
const selection = resolveAgentSelection({
role: "worker",
project,
@@ -1227,13 +1258,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
}
- const pause = getProjectPause(project);
- if (pause) {
- throw new Error(
- `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`,
- );
- }
-
const selection = resolveAgentSelection({
role: "orchestrator",
project,
@@ -1247,29 +1271,88 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
throw new Error(`Agent plugin '${selection.agentName}' not found`);
}
- const sessionId = `${project.sessionPrefix}-orchestrator`;
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
project.orchestratorSessionStrategy,
);
- // Generate tmux name if using new architecture
- let tmuxName: string | undefined;
- if (config.configPath) {
- const hash = generateConfigHash(config.configPath);
- tmuxName = `${hash}-${sessionId}`;
- }
-
// Get the sessions directory for this project
const sessionsDir = getProjectSessionsDir(project);
- // Validate and store .origin file
+ // Validate and store .origin file before reserving any identity so that
+ // a validation failure does not leave an orphaned metadata entry.
if (config.configPath) {
validateAndStoreOrigin(config.configPath, project.path);
}
+ // Reserve a new unique orchestrator identity (e.g. {prefix}-orchestrator-1, -2, …).
+ // Each spawnOrchestrator call gets its own numbered session and isolated worktree.
+ const identity = reserveNextOrchestratorIdentity(project, sessionsDir);
+ const sessionId = identity.sessionId;
+ const tmuxName = identity.tmuxName;
+
+ // Each orchestrator gets an isolated worktree on its own branch.
+ const branch = `orchestrator/${sessionId}`;
+
+ if (!plugins.workspace) {
+ try {
+ deleteMetadata(sessionsDir, sessionId, false);
+ } catch {
+ /* best effort */
+ }
+ throw new Error(
+ `spawnOrchestrator requires a workspace plugin but none is configured for project '${orchestratorConfig.projectId}'`,
+ );
+ }
+
+ let workspacePath: string;
+ try {
+ const wsInfo = await plugins.workspace.create({
+ projectId: orchestratorConfig.projectId,
+ project,
+ sessionId,
+ branch,
+ });
+ workspacePath = wsInfo.path;
+ } catch (err) {
+ try {
+ deleteMetadata(sessionsDir, sessionId, false);
+ } catch {
+ /* best effort */
+ }
+ throw err;
+ }
+
+ // Helper: undo worktree + metadata if anything between workspace creation
+ // and a fully-written metadata record fails.
+ const cleanupWorktreeAndMetadata = async (promptFile?: string): Promise => {
+ try {
+ // plugins.workspace is guaranteed non-null here: we threw above if it was null
+ await plugins.workspace!.destroy(workspacePath);
+ } catch {
+ /* best effort */
+ }
+ try {
+ deleteMetadata(sessionsDir, sessionId, false);
+ } catch {
+ /* best effort */
+ }
+ if (promptFile) {
+ try {
+ unlinkSync(promptFile);
+ } catch {
+ /* best effort */
+ }
+ }
+ };
+
// Setup agent hooks for automatic metadata updates
- if (plugins.agent.setupWorkspaceHooks) {
- await plugins.agent.setupWorkspaceHooks(project.path, { dataDir: sessionsDir });
+ try {
+ if (plugins.agent.setupWorkspaceHooks) {
+ await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
+ }
+ } catch (err) {
+ await cleanupWorktreeAndMetadata();
+ throw err;
}
// Write system prompt to a file to avoid shell/tmux truncation.
@@ -1277,93 +1360,38 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// via tmux send-keys or paste-buffer. File-based approach is reliable.
let systemPromptFile: string | undefined;
if (orchestratorConfig.systemPrompt) {
- const baseDir = getProjectBaseDir(config.configPath, project.path);
- mkdirSync(baseDir, { recursive: true });
- systemPromptFile = join(baseDir, "orchestrator-prompt.md");
- writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8");
- }
-
- const existingRaw = readMetadataRaw(sessionsDir, sessionId);
- const existingOrchestrator = existingRaw?.["runtimeHandle"]
- ? metadataToSession(sessionId, existingRaw, orchestratorConfig.projectId)
- : null;
- if (existingOrchestrator?.runtimeHandle) {
- const existingAlive = await plugins.runtime
- .isAlive(existingOrchestrator.runtimeHandle)
- .catch(() => false);
- if (existingAlive && orchestratorSessionStrategy === "reuse") {
- const persistedRaw = readMetadataRaw(sessionsDir, sessionId);
- if (persistedRaw?.["runtimeHandle"]) {
- const persisted = metadataToSession(
- sessionId,
- persistedRaw,
- orchestratorConfig.projectId,
- );
- persisted.metadata["orchestratorSessionReused"] = "true";
- return persisted;
- }
- await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined);
- deleteMetadata(sessionsDir, sessionId, false);
- }
- if (existingAlive && orchestratorSessionStrategy !== "reuse") {
- await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined);
- // Destroy runtime and delete metadata without archive for ignore strategy
- deleteMetadata(sessionsDir, sessionId, false);
- }
- // For dead runtime, delete metadata so reserveSessionId can succeed:
- // - With reuse strategy + opencode: archive to preserve opencodeSessionId for reuse lookup
- // - With non-reuse strategy: delete without archive to respawn fresh
- if (!existingAlive) {
- deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse");
+ try {
+ const baseDir = getProjectBaseDir(config.configPath, project.path);
+ mkdirSync(baseDir, { recursive: true });
+ systemPromptFile = join(baseDir, `orchestrator-prompt-${sessionId}.md`);
+ writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8");
+ } catch (err) {
+ await cleanupWorktreeAndMetadata(systemPromptFile);
+ throw err;
}
}
- // Atomically reserve the session ID before creating any resources.
- // This prevents race conditions where concurrent spawnOrchestrator calls
- // both see no existing session and proceed to create duplicate runtimes.
- let reserved = reserveSessionId(sessionsDir, sessionId);
- if (!reserved) {
- // Reservation failed - another process reserved it first.
- // Check if the session now exists and is alive.
- const concurrentRaw = readMetadataRaw(sessionsDir, sessionId);
- const concurrentSession = concurrentRaw?.["runtimeHandle"]
- ? metadataToSession(sessionId, concurrentRaw, orchestratorConfig.projectId)
- : null;
- if (concurrentSession?.runtimeHandle) {
- const concurrentAlive = await plugins.runtime
- .isAlive(concurrentSession.runtimeHandle)
- .catch(() => false);
- if (concurrentAlive && orchestratorSessionStrategy === "reuse") {
- concurrentSession.metadata["orchestratorSessionReused"] = "true";
- return concurrentSession;
- }
- if (!concurrentAlive) {
- deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse");
- reserved = reserveSessionId(sessionsDir, sessionId);
- }
- } else {
- reserved = reserveSessionId(sessionsDir, sessionId);
+ let reusableOpenCodeSessionId: string | undefined;
+ try {
+ reusableOpenCodeSessionId =
+ plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse"
+ ? await resolveOpenCodeSessionReuse({
+ sessionsDir,
+ criteria: { sessionId },
+ strategy: "reuse",
+ })
+ : undefined;
+ if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") {
+ await resolveOpenCodeSessionReuse({
+ sessionsDir,
+ criteria: { sessionId },
+ strategy: "delete",
+ includeTitleDiscoveryForSessionId: true,
+ });
}
- if (!reserved) {
- throw new Error(`Session ${sessionId} already exists but is not in a reusable state`);
- }
- }
-
- const reusableOpenCodeSessionId =
- plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse"
- ? await resolveOpenCodeSessionReuse({
- sessionsDir,
- criteria: { sessionId },
- strategy: "reuse",
- })
- : undefined;
- if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") {
- await resolveOpenCodeSessionReuse({
- sessionsDir,
- criteria: { sessionId },
- strategy: "delete",
- includeTitleDiscoveryForSessionId: true,
- });
+ } catch (err) {
+ await cleanupWorktreeAndMetadata(systemPromptFile);
+ throw err;
}
// Get agent launch config — uses systemPromptFile, no issue/tracker interaction.
@@ -1387,22 +1415,29 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
const environment = plugins.agent.getEnvironment(agentLaunchConfig);
- const handle = await plugins.runtime.create({
- sessionId: tmuxName ?? sessionId,
- workspacePath: project.path,
- launchCommand,
- environment: {
- ...environment,
- AO_SESSION: sessionId,
- AO_DATA_DIR: sessionsDir,
- AO_SESSION_NAME: sessionId,
- ...(tmuxName && { AO_TMUX_NAME: tmuxName }),
- AO_CALLER_TYPE: "orchestrator",
- AO_PROJECT_ID: orchestratorConfig.projectId,
- AO_CONFIG_PATH: config.configPath,
- ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }),
- },
- });
+ // Create runtime — clean up worktree and metadata on failure
+ let handle: RuntimeHandle;
+ try {
+ handle = await plugins.runtime.create({
+ sessionId: tmuxName ?? sessionId,
+ workspacePath,
+ launchCommand,
+ environment: {
+ ...environment,
+ AO_SESSION: sessionId,
+ AO_DATA_DIR: sessionsDir,
+ AO_SESSION_NAME: sessionId,
+ ...(tmuxName && { AO_TMUX_NAME: tmuxName }),
+ AO_CALLER_TYPE: "orchestrator",
+ AO_PROJECT_ID: orchestratorConfig.projectId,
+ AO_CONFIG_PATH: config.configPath,
+ ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }),
+ },
+ });
+ } catch (err) {
+ await cleanupWorktreeAndMetadata(systemPromptFile);
+ throw err;
+ }
// Write metadata and run post-launch setup
const session: Session = {
@@ -1410,10 +1445,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
projectId: orchestratorConfig.projectId,
status: "working",
activity: "active",
- branch: project.defaultBranch,
+ branch,
issueId: null,
pr: null,
- workspacePath: project.path,
+ workspacePath,
runtimeHandle: handle,
agentInfo: null,
createdAt: new Date(),
@@ -1425,8 +1460,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
try {
writeMetadata(sessionsDir, sessionId, {
- worktree: project.path,
- branch: project.defaultBranch,
+ worktree: workspacePath,
+ branch,
status: "working",
role: "orchestrator",
tmuxName,
@@ -1465,11 +1500,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
} catch {
/* best effort */
}
- try {
- deleteMetadata(sessionsDir, sessionId, false);
- } catch {
- /* best effort */
- }
+ await cleanupWorktreeAndMetadata(systemPromptFile);
throw err;
}
@@ -1560,11 +1591,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// If stat fails, timestamps will fall back to current time
}
- const repaired = repairSingleSessionMetadataOnRead(sessionsDir, {
- sessionName: sessionId,
- raw,
- modifiedAt,
- });
+ const repaired = repairSingleSessionMetadataOnRead(
+ sessionsDir,
+ { sessionName: sessionId, raw, modifiedAt },
+ project.sessionPrefix,
+ );
const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt);
@@ -1814,13 +1845,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
async function send(sessionId: SessionId, message: string): Promise {
const { raw, sessionsDir, project } = requireSessionRecord(sessionId);
- const pause = getProjectPause(project);
- const orchestratorId = `${project.sessionPrefix}-orchestrator`;
- if (pause && sessionId !== orchestratorId) {
- throw new Error(
- `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`,
- );
- }
const selection = resolveSelectionForSession(project, sessionId, raw);
const selectedAgent = selection.agentName;
@@ -2108,7 +2132,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (!reference) throw new Error("PR reference is required");
const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId);
- if (isOrchestratorSessionRecord(sessionId, raw)) {
+ if (isOrchestratorSessionRecord(sessionId, raw, project.sessionPrefix)) {
throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`);
}
@@ -2135,7 +2159,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
);
for (const { sessionName, raw: otherRaw } of activeRecords) {
- if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw)) continue;
+ if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw, project.sessionPrefix)) continue;
const samePr = otherRaw["pr"] === pr.url;
const sameBranch =
@@ -2309,6 +2333,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
createdAt: raw["createdAt"],
runtimeHandle: raw["runtimeHandle"],
opencodeSessionId: raw["opencodeSessionId"],
+ pinnedSummary: raw["pinnedSummary"],
});
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index a5c5f804a..463dc02ea 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -189,11 +189,37 @@ export interface Session {
metadata: Record;
}
-export function isOrchestratorSession(session: {
- id: SessionId;
- metadata?: Record;
-}): boolean {
- return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
+export function isOrchestratorSession(
+ session: { id: SessionId; metadata?: Record },
+ sessionPrefix?: string,
+ allSessionPrefixes?: string[],
+): boolean {
+ if (session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) {
+ return true;
+ }
+ if (!sessionPrefix) {
+ return false;
+ }
+ const escaped = sessionPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ if (!new RegExp(`^${escaped}-orchestrator-\\d+$`).test(session.id)) {
+ return false;
+ }
+ // Guard against cross-project false positives: if the session ID is a plain
+ // numbered worker for any other known prefix (e.g. prefix "app-orchestrator"
+ // matches "app-orchestrator-1" as a worker), it is not an orchestrator.
+ if (allSessionPrefixes) {
+ for (const prefix of allSessionPrefixes) {
+ if (prefix === sessionPrefix) continue;
+ if (
+ new RegExp(
+ `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`,
+ ).test(session.id)
+ ) {
+ return false;
+ }
+ }
+ }
+ return true;
}
/** Config for creating a new session */
@@ -790,6 +816,8 @@ export interface PREnrichmentData {
isBehind?: boolean;
/** List of blockers preventing merge */
blockers?: string[];
+ /** Individual CI check results (populated from batch enrichment when available) */
+ ciChecks?: CICheck[];
}
/**
@@ -1013,6 +1041,43 @@ export interface OrchestratorConfig {
/** Default reaction configs */
reactions: Record;
+
+ /**
+ * Internal: External plugin entries collected from inline tracker/scm/notifier configs.
+ * Used by plugin-registry for manifest validation. Set automatically during config validation.
+ */
+ _externalPluginEntries?: ExternalPluginEntryRef[];
+}
+
+/**
+ * Structured location of an external plugin config.
+ * Used to update config with manifest.name after loading (avoids parsing dotted strings).
+ */
+export type ExternalPluginLocation =
+ | { kind: "project"; projectId: string; configType: "tracker" | "scm" }
+ | { kind: "notifier"; notifierId: string };
+
+/**
+ * Reference to an external plugin config (from inline tracker/scm/notifier configs).
+ * Used for manifest.name validation during plugin loading.
+ */
+export interface ExternalPluginEntryRef {
+ /** Where this config came from (for error messages) */
+ source: string;
+ /** Structured location for updating config (avoids parsing source string) */
+ location: ExternalPluginLocation;
+ /** The slot this plugin fills */
+ slot: "tracker" | "scm" | "notifier";
+ /** npm package name (if specified) */
+ package?: string;
+ /** Local path (if specified) */
+ path?: string;
+ /**
+ * Expected plugin name (manifest.name).
+ * Only set when user explicitly specified `plugin` field.
+ * When undefined, any manifest.name is accepted and config is updated with it.
+ */
+ expectedPluginName?: string;
}
export interface DefaultPlugins {
@@ -1135,13 +1200,41 @@ export interface ProjectConfig {
}
export interface TrackerConfig {
- plugin: string;
+ /**
+ * Plugin name (manifest.name). Required when using built-in plugins.
+ * Optional when `package` or `path` is specified (will be inferred from manifest).
+ * When both plugin and package/path are specified, manifest.name must match plugin.
+ *
+ * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated.
+ * Either from user input, inferred from repo (github/gitlab), or auto-generated from
+ * package/path via generateTempPluginName(). The optional typing exists for raw config
+ * input before validation. Downstream code can safely assume non-null after validation.
+ */
+ plugin?: string;
+ /** npm package name for external plugins (e.g. "@acme/ao-plugin-tracker-jira") */
+ package?: string;
+ /** Local filesystem path for external plugins (relative to config file or absolute) */
+ path?: string;
/** Plugin-specific config (e.g. teamId for Linear) */
[key: string]: unknown;
}
export interface SCMConfig {
- plugin: string;
+ /**
+ * Plugin name (manifest.name). Required when using built-in plugins.
+ * Optional when `package` or `path` is specified (will be inferred from manifest).
+ * When both plugin and package/path are specified, manifest.name must match plugin.
+ *
+ * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated.
+ * Either from user input, inferred from repo (github/gitlab), or auto-generated from
+ * package/path via generateTempPluginName(). The optional typing exists for raw config
+ * input before validation. Downstream code can safely assume non-null after validation.
+ */
+ plugin?: string;
+ /** npm package name for external plugins (e.g. "@acme/ao-plugin-scm-bitbucket") */
+ package?: string;
+ /** Local filesystem path for external plugins (relative to config file or absolute) */
+ path?: string;
webhook?: SCMWebhookConfig;
[key: string]: unknown;
}
@@ -1157,7 +1250,21 @@ export interface SCMWebhookConfig {
}
export interface NotifierConfig {
- plugin: string;
+ /**
+ * Plugin name (manifest.name). Required when using built-in plugins.
+ * Optional when `package` or `path` is specified (will be inferred from manifest).
+ * When both plugin and package/path are specified, manifest.name must match plugin.
+ *
+ * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated.
+ * Either from user input or auto-generated from package/path via generateTempPluginName().
+ * The optional typing exists for raw config input before validation.
+ * Downstream code can safely assume non-null after validation.
+ */
+ plugin?: string;
+ /** npm package name for external plugins (e.g. "@acme/ao-plugin-notifier-teams") */
+ package?: string;
+ /** Local filesystem path for external plugins (relative to config file or absolute) */
+ path?: string;
[key: string]: unknown;
}
@@ -1281,6 +1388,7 @@ export interface SessionMetadata {
terminalWsPort?: number;
directTerminalWsPort?: number;
opencodeSessionId?: string;
+ pinnedSummary?: string; // First quality summary, pinned for display stability
}
// =============================================================================
diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts
index b18563b6b..5abedf842 100644
--- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts
+++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts
@@ -136,19 +136,23 @@ describe("Claude Code Activity Detection", () => {
// Fallback cases (no JSONL data available)
// -----------------------------------------------------------------------
- it("returns null when no session file exists yet", async () => {
- // projectDir exists but is empty — no .jsonl files
- expect(await agent.getActivityState(makeSession())).toBeNull();
+ it("returns 'idle' when no session file exists yet", async () => {
+ // projectDir exists but is empty — no .jsonl files yet (freshly spawned session)
+ const session = makeSession();
+ const result = await agent.getActivityState(session);
+ expect(result?.state).toBe("idle");
+ // timestamp must be session.createdAt so stuck-detection can fire eventually
+ expect(result?.timestamp).toBe(session.createdAt);
});
it("returns null when no workspacePath", async () => {
expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull();
});
- it("returns null when project directory does not exist", async () => {
- // Point to a workspace whose project dir doesn't exist
+ it("returns 'idle' when project directory does not exist", async () => {
+ // Process is running but no Claude project dir yet — treat as idle
const badPath = join(fakeHome, "nonexistent-workspace");
- expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull();
+ expect((await agent.getActivityState(makeSession({ workspacePath: badPath })))?.state).toBe("idle");
});
// -----------------------------------------------------------------------
@@ -297,8 +301,8 @@ describe("Claude Code Activity Detection", () => {
it("ignores agent- prefixed JSONL files", async () => {
writeJsonl([{ type: "user" }], 0, "agent-toolkit.jsonl");
- // No real session file → returns null (cannot determine activity)
- expect(await agent.getActivityState(makeSession())).toBeNull();
+ // No real session file → process is running, treat as idle
+ expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
});
it("reads last entry from multi-entry JSONL (not first)", async () => {
diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts
index 4f141415d..c10ed3b9f 100644
--- a/packages/plugins/agent-claude-code/src/index.ts
+++ b/packages/plugins/agent-claude-code/src/index.ts
@@ -733,8 +733,9 @@ function createClaudeCodeAgent(): Agent {
const sessionFile = await findLatestSessionFile(projectDir);
if (!sessionFile) {
- // No session file found — cannot determine activity
- return null;
+ // No session file yet — process is running but no conversation started.
+ // Treat as idle (waiting for first task).
+ return { state: "idle", timestamp: session.createdAt };
}
const entry = await readLastJsonlEntry(sessionFile);
diff --git a/packages/plugins/scm-github/src/graphql-batch.ts b/packages/plugins/scm-github/src/graphql-batch.ts
index 783d207a0..f877e3d58 100644
--- a/packages/plugins/scm-github/src/graphql-batch.ts
+++ b/packages/plugins/scm-github/src/graphql-batch.ts
@@ -9,6 +9,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
BatchObserver,
+ CICheck,
CIStatus,
PREnrichmentData,
PRInfo,
@@ -466,6 +467,24 @@ const PR_FIELDS = `
commit {
statusCheckRollup {
state
+ contexts(first: 20) {
+ nodes {
+ ... on CheckRun {
+ name
+ status
+ conclusion
+ detailsUrl
+ }
+ ... on StatusContext {
+ context
+ state
+ targetUrl
+ }
+ }
+ pageInfo {
+ hasNextPage
+ }
+ }
}
}
}
@@ -574,12 +593,108 @@ async function executeBatchQuery(
return (result.data ?? {}) as Record;
}
+/**
+ * Parse individual CI check contexts from statusCheckRollup.contexts.nodes.
+ * Handles both CheckRun (GitHub Actions) and StatusContext (legacy status checks).
+ */
+function parseCheckContexts(contexts: unknown): CICheck[] {
+ if (!contexts || typeof contexts !== "object") return [];
+
+ const nodes = (contexts as Record)["nodes"];
+ if (!Array.isArray(nodes)) return [];
+
+ const checks: CICheck[] = [];
+ for (const node of nodes) {
+ if (!node || typeof node !== "object") continue;
+ const n = node as Record;
+
+ // CheckRun node (GitHub Actions)
+ if (typeof n["name"] === "string" && typeof n["status"] === "string") {
+ const rawStatus = (n["status"] as string).toUpperCase();
+ // Uppercase conclusion to match REST getCIChecks/getCIChecksFromStatusRollup format
+ // so fingerprints are consistent regardless of which data source is used.
+ const rawConclusion =
+ typeof n["conclusion"] === "string" ? (n["conclusion"] as string).toUpperCase() : null;
+
+ let status: CICheck["status"];
+ if (rawStatus === "COMPLETED") {
+ if (rawConclusion === "SUCCESS") {
+ status = "passed";
+ } else if (
+ rawConclusion === "SKIPPED" ||
+ rawConclusion === "NEUTRAL" ||
+ rawConclusion === "STALE" ||
+ rawConclusion === "NOT_REQUIRED" ||
+ rawConclusion === "NONE"
+ ) {
+ // Mirror mapRawCheckStateToStatus() in the REST path: all non-failure
+ // terminal conclusions that are not SUCCESS map to "skipped".
+ status = "skipped";
+ } else if (
+ rawConclusion === "FAILURE" ||
+ rawConclusion === "TIMED_OUT" ||
+ rawConclusion === "CANCELLED" ||
+ rawConclusion === "ACTION_REQUIRED" ||
+ rawConclusion === "ERROR"
+ ) {
+ // Explicit failure conclusions — mirrors the failure list in mapRawCheckStateToStatus()
+ status = "failed";
+ } else {
+ // STARTUP_FAILURE and any other unrecognized conclusion → "skipped",
+ // matching mapRawCheckStateToStatus()'s default return "skipped" in the REST path.
+ status = "skipped";
+ }
+ } else if (rawStatus === "IN_PROGRESS") {
+ // Only IN_PROGRESS maps to "running" — matches mapRawCheckStateToStatus() in REST path
+ status = "running";
+ } else {
+ // QUEUED, WAITING, and any other non-COMPLETED status → "pending"
+ // (REST path maps QUEUED/WAITING to "pending", not "running")
+ status = "pending";
+ }
+
+ checks.push({
+ name: n["name"] as string,
+ status,
+ // Store the uppercased conclusion to match REST format
+ conclusion: rawConclusion ?? undefined,
+ url: typeof n["detailsUrl"] === "string" ? (n["detailsUrl"] as string) : undefined,
+ });
+ continue;
+ }
+
+ // StatusContext node (legacy commit statuses)
+ if (typeof n["context"] === "string" && typeof n["state"] === "string") {
+ const rawState = (n["state"] as string).toUpperCase();
+ let status: CICheck["status"];
+ if (rawState === "SUCCESS") {
+ status = "passed";
+ } else if (rawState === "FAILURE" || rawState === "ERROR") {
+ status = "failed";
+ } else {
+ status = "pending";
+ }
+
+ // Set conclusion to match the REST getCIChecksFromStatusRollup format
+ // (which sets conclusion = rawState.toUpperCase()) so fingerprints are
+ // consistent regardless of which data source is used.
+ checks.push({
+ name: n["context"] as string,
+ status,
+ conclusion: rawState,
+ url: typeof n["targetUrl"] === "string" ? (n["targetUrl"] as string) : undefined,
+ });
+ }
+ }
+
+ return checks;
+}
+
/**
* Parse raw CI state from status check rollup.
*
- * Uses only the top-level aggregate state, which is much cheaper
- * than fetching individual check contexts. The top-level state
- * provides the same semantic information (passing/failing/pending).
+ * Uses only the top-level aggregate state to determine overall CI status.
+ * Individual check details are parsed separately via parseCheckContexts().
*/
function parseCIState(
statusCheckRollup: unknown,
@@ -679,13 +794,30 @@ function extractPREnrichment(
// Extract review decision
const reviewDecision = parseReviewDecision(pr["reviewDecision"]);
- // Extract CI status from commits
+ // Extract CI status and individual checks from commits
const commits = pr["commits"] as
- | { nodes?: Array<{ commit?: { statusCheckRollup?: unknown } }> }
+ | { nodes?: Array<{ commit?: { statusCheckRollup?: Record } }> }
| undefined;
- const ciStatus = commits?.nodes?.[0]?.commit?.statusCheckRollup
- ? parseCIState(commits.nodes[0].commit.statusCheckRollup)
- : "none";
+ const statusCheckRollup = commits?.nodes?.[0]?.commit?.statusCheckRollup;
+ const ciStatus = statusCheckRollup ? parseCIState(statusCheckRollup) : "none";
+
+ // Only include ciChecks when the list is complete (no truncation).
+ // contexts(first: 20) silently truncates PRs with >20 checks — when truncated,
+ // the failing check may be missing, so we set ciChecks to undefined to force
+ // the getCIChecks() REST fallback in maybeDispatchCIFailureDetails.
+ const contextsField = statusCheckRollup?.["contexts"] as
+ | Record
+ | undefined;
+ const pageInfo = contextsField?.["pageInfo"];
+ const contextsHasNextPage =
+ pageInfo !== null &&
+ pageInfo !== undefined &&
+ typeof pageInfo === "object" &&
+ (pageInfo as Record)["hasNextPage"] === true;
+ const ciChecks =
+ contextsField && !contextsHasNextPage
+ ? parseCheckContexts(contextsField)
+ : undefined;
// Build blockers list
const blockers: string[] = [];
@@ -722,6 +854,7 @@ function extractPREnrichment(
hasConflicts,
isBehind,
blockers,
+ ...(ciChecks !== undefined ? { ciChecks } : {}),
};
return { data, headSha };
diff --git a/packages/plugins/scm-github/test/graphql-batch.test.ts b/packages/plugins/scm-github/test/graphql-batch.test.ts
index a25417087..cdab3eea2 100644
--- a/packages/plugins/scm-github/test/graphql-batch.test.ts
+++ b/packages/plugins/scm-github/test/graphql-batch.test.ts
@@ -1172,3 +1172,576 @@ describe("shouldRefreshPREnrichment - ETag Guard Strategy", () => {
});
});
});
+
+describe("extractPREnrichment ciChecks", () => {
+ it("parses CheckRun contexts into ciChecks", () => {
+ const pullRequest = {
+ title: "Fix CI",
+ state: "OPEN",
+ additions: 10,
+ deletions: 5,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "FAILURE",
+ contexts: {
+ nodes: [
+ {
+ name: "lint",
+ status: "COMPLETED",
+ conclusion: "FAILURE",
+ detailsUrl: "https://github.com/org/repo/actions/runs/123",
+ },
+ {
+ name: "test",
+ status: "COMPLETED",
+ conclusion: "SUCCESS",
+ detailsUrl: "https://github.com/org/repo/actions/runs/124",
+ },
+ {
+ name: "typecheck",
+ status: "IN_PROGRESS",
+ conclusion: null,
+ detailsUrl: "https://github.com/org/repo/actions/runs/125",
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const ciChecks = extracted?.data.ciChecks;
+
+ expect(ciChecks).toBeDefined();
+ expect(ciChecks).toHaveLength(3);
+
+ const lint = ciChecks?.find((c) => c.name === "lint");
+ expect(lint?.status).toBe("failed");
+ expect(lint?.conclusion).toBe("FAILURE");
+ expect(lint?.url).toBe("https://github.com/org/repo/actions/runs/123");
+
+ const test = ciChecks?.find((c) => c.name === "test");
+ expect(test?.status).toBe("passed");
+ expect(test?.conclusion).toBe("SUCCESS");
+
+ const typecheck = ciChecks?.find((c) => c.name === "typecheck");
+ expect(typecheck?.status).toBe("running");
+ });
+
+ it("maps QUEUED and WAITING to pending, not running (matches REST mapRawCheckStateToStatus)", () => {
+ const pullRequest = {
+ title: "Queued checks",
+ state: "OPEN",
+ additions: 1,
+ deletions: 0,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "PENDING",
+ contexts: {
+ nodes: [
+ { name: "queued-check", status: "QUEUED", conclusion: null, detailsUrl: null },
+ { name: "waiting-check", status: "WAITING", conclusion: null, detailsUrl: null },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const ciChecks = extracted?.data.ciChecks;
+ expect(ciChecks?.find((c) => c.name === "queued-check")?.status).toBe("pending");
+ expect(ciChecks?.find((c) => c.name === "waiting-check")?.status).toBe("pending");
+ });
+
+ it("parses StatusContext nodes into ciChecks", () => {
+ const pullRequest = {
+ title: "Legacy CI",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "FAILURE",
+ contexts: {
+ nodes: [
+ {
+ context: "ci/build",
+ state: "FAILURE",
+ targetUrl: "https://ci.example.com/build/1",
+ },
+ {
+ context: "ci/test",
+ state: "SUCCESS",
+ targetUrl: "https://ci.example.com/test/1",
+ },
+ {
+ context: "ci/deploy",
+ state: "PENDING",
+ targetUrl: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const ciChecks = extracted?.data.ciChecks;
+
+ expect(ciChecks).toBeDefined();
+ expect(ciChecks).toHaveLength(3);
+
+ const build = ciChecks?.find((c) => c.name === "ci/build");
+ expect(build?.status).toBe("failed");
+ expect(build?.conclusion).toBe("FAILURE"); // matches REST getCIChecksFromStatusRollup format
+ expect(build?.url).toBe("https://ci.example.com/build/1");
+
+ const test = ciChecks?.find((c) => c.name === "ci/test");
+ expect(test?.status).toBe("passed");
+ expect(test?.conclusion).toBe("SUCCESS");
+
+ const deploy = ciChecks?.find((c) => c.name === "ci/deploy");
+ expect(deploy?.status).toBe("pending");
+ expect(deploy?.conclusion).toBe("PENDING");
+ expect(deploy?.url).toBeUndefined();
+ });
+
+ it("returns empty array when contexts nodes is empty", () => {
+ const pullRequest = {
+ title: "Clean PR",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "SUCCESS",
+ contexts: { nodes: [] },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ expect(extracted?.data.ciChecks).toEqual([]);
+ });
+
+ it("returns undefined ciChecks when statusCheckRollup has no contexts field", () => {
+ const pullRequest = {
+ title: "No contexts",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "SUCCESS",
+ // No contexts field — older API response
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ expect(extracted?.data.ciChecks).toBeUndefined();
+ });
+
+ it("maps COMPLETED+SKIPPED conclusion to skipped status", () => {
+ const pullRequest = {
+ title: "Skipped check",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "SUCCESS",
+ contexts: {
+ nodes: [
+ {
+ name: "optional-check",
+ status: "COMPLETED",
+ conclusion: "SKIPPED",
+ detailsUrl: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const check = extracted?.data.ciChecks?.[0];
+ expect(check?.status).toBe("skipped");
+ expect(check?.conclusion).toBe("SKIPPED"); // uppercased to match REST format
+ });
+
+ it("maps COMPLETED+NEUTRAL conclusion to skipped (matches REST mapRawCheckStateToStatus)", () => {
+ const pullRequest = {
+ title: "Neutral check",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "SUCCESS",
+ contexts: {
+ nodes: [
+ {
+ name: "optional-check",
+ status: "COMPLETED",
+ conclusion: "NEUTRAL",
+ detailsUrl: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const check = extracted?.data.ciChecks?.[0];
+ // NEUTRAL maps to "skipped" in REST mapRawCheckStateToStatus — must match
+ expect(check?.status).toBe("skipped");
+ expect(check?.conclusion).toBe("NEUTRAL");
+ });
+
+ it("uppercases CheckRun conclusion to match REST getCIChecks format", () => {
+ const pullRequest = {
+ title: "Mixed case conclusion",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "FAILURE",
+ contexts: {
+ nodes: [
+ {
+ name: "lint",
+ status: "COMPLETED",
+ conclusion: "failure", // lowercase from API
+ detailsUrl: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const check = extracted?.data.ciChecks?.[0];
+ expect(check?.status).toBe("failed");
+ expect(check?.conclusion).toBe("FAILURE"); // must be uppercased
+ });
+
+ it("maps STALE/NOT_REQUIRED/NONE conclusions to skipped (matches REST mapRawCheckStateToStatus)", () => {
+ const makeContextsWithConclusion = (conclusion: string) => ({
+ title: "Check",
+ state: "OPEN",
+ additions: 1,
+ deletions: 0,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "SUCCESS",
+ contexts: {
+ nodes: [{ name: "check", status: "COMPLETED", conclusion, detailsUrl: null }],
+ },
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ for (const conclusion of ["STALE", "NOT_REQUIRED", "NONE"]) {
+ const extracted = extractPREnrichment(makeContextsWithConclusion(conclusion));
+ const check = extracted?.data.ciChecks?.[0];
+ expect(check?.status, `${conclusion} should map to skipped`).toBe("skipped");
+ }
+ });
+
+ it("returns undefined ciChecks when contexts list is truncated (hasNextPage=true)", () => {
+ const pullRequest = {
+ title: "Many CI checks",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "FAILURE",
+ contexts: {
+ nodes: [
+ { name: "check-1", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: null },
+ // ... 19 more checks truncated
+ ],
+ pageInfo: { hasNextPage: true }, // list was truncated!
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ // ciChecks should be undefined when truncated — forces getCIChecks() REST fallback
+ expect(extracted?.data.ciChecks).toBeUndefined();
+ });
+
+ it("returns ciChecks when contexts list is complete (hasNextPage=false)", () => {
+ const pullRequest = {
+ title: "Few CI checks",
+ state: "OPEN",
+ additions: 5,
+ deletions: 2,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "FAILURE",
+ contexts: {
+ nodes: [
+ { name: "lint", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: "https://ci.example.com/lint" },
+ ],
+ pageInfo: { hasNextPage: false }, // complete list
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ expect(extracted?.data.ciChecks).toBeDefined();
+ expect(extracted?.data.ciChecks).toHaveLength(1);
+ expect(extracted?.data.ciChecks?.[0]?.name).toBe("lint");
+ });
+
+ it("maps COMPLETED with null conclusion to skipped (matches REST mapRawCheckStateToStatus empty-string branch)", () => {
+ // REST path: mapRawCheckStateToStatus(undefined) → state="" → "skipped"
+ // GraphQL path must match: COMPLETED + null conclusion → "skipped", not "passed"
+ const pullRequest = {
+ title: "Null conclusion check",
+ state: "OPEN",
+ additions: 1,
+ deletions: 0,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "SUCCESS",
+ contexts: {
+ nodes: [
+ {
+ name: "some-check",
+ status: "COMPLETED",
+ conclusion: null, // null conclusion — shouldn't map to "passed"
+ detailsUrl: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const check = extracted?.data.ciChecks?.[0];
+ expect(check?.status).toBe("skipped");
+ });
+
+ it("maps COMPLETED+STARTUP_FAILURE to skipped (matches REST mapRawCheckStateToStatus default fallback)", () => {
+ const pullRequest = {
+ title: "Startup failure check",
+ state: "OPEN",
+ additions: 1,
+ deletions: 0,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "FAILURE",
+ contexts: {
+ nodes: [
+ {
+ name: "infra-check",
+ status: "COMPLETED",
+ conclusion: "STARTUP_FAILURE",
+ detailsUrl: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ const extracted = extractPREnrichment(pullRequest);
+ const check = extracted?.data.ciChecks?.[0];
+ // STARTUP_FAILURE is not in the explicit failure list → falls through to "skipped"
+ // matching mapRawCheckStateToStatus()'s default return "skipped"
+ expect(check?.status).toBe("skipped");
+ expect(check?.conclusion).toBe("STARTUP_FAILURE");
+ });
+
+ it("handles null pageInfo safely without TypeError (typeof null === 'object' quirk)", () => {
+ const pullRequest = {
+ title: "Null pageInfo",
+ state: "OPEN",
+ additions: 1,
+ deletions: 0,
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: "NONE",
+ reviews: { nodes: [] },
+ commits: {
+ nodes: [
+ {
+ commit: {
+ statusCheckRollup: {
+ state: "SUCCESS",
+ contexts: {
+ nodes: [
+ { name: "lint", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: null },
+ ],
+ pageInfo: null, // null pageInfo — older API responses may omit this
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ // Must not throw TypeError: Cannot read properties of null
+ expect(() => extractPREnrichment(pullRequest)).not.toThrow();
+ const extracted = extractPREnrichment(pullRequest);
+ // null pageInfo is treated as "not truncated" → ciChecks should be defined
+ expect(extracted?.data.ciChecks).toBeDefined();
+ expect(extracted?.data.ciChecks).toHaveLength(1);
+ });
+});
diff --git a/packages/web/next.config.js b/packages/web/next.config.js
index 654d91bc4..64baaa9ed 100644
--- a/packages/web/next.config.js
+++ b/packages/web/next.config.js
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
+ serverExternalPackages: ["@composio/core"],
transpilePackages: [
"@composio/ao-core",
"@composio/ao-plugin-agent-claude-code",
@@ -23,4 +24,11 @@ const nextConfig = {
},
};
-export default nextConfig;
+// Only load bundle analyzer when ANALYZE=true (dev-only dependency)
+let config = nextConfig;
+if (process.env.ANALYZE === "true") {
+ const { default: bundleAnalyzer } = await import("@next/bundle-analyzer");
+ config = bundleAnalyzer({ enabled: true })(nextConfig);
+}
+
+export default config;
diff --git a/packages/web/package.json b/packages/web/package.json
index 962df764c..a1e24ed76 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -19,6 +19,7 @@
"build": "next build && tsc -p tsconfig.server.json",
"start": "next start",
"start:all": "node dist-server/start-all.js",
+ "dev:optimized": "next build && tsc -p tsconfig.server.json && node dist-server/start-all.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
@@ -41,6 +42,7 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "server-only": "^0.0.1",
"ws": "^8.19.0",
"xterm": "^5.3.0"
},
@@ -48,6 +50,7 @@
"node-pty": "^1.1.0"
},
"devDependencies": {
+ "@next/bundle-analyzer": "^15.1.0",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
@@ -55,6 +58,7 @@
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.0",
+ "@vitest/coverage-v8": "^2.1.0",
"concurrently": "^9.2.1",
"jsdom": "^25.0.0",
"node-gyp": "^12.2.0",
@@ -62,7 +66,6 @@
"tailwindcss": "^4.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
- "@vitest/coverage-v8": "^2.1.0",
"vitest": "^2.1.0"
}
}
diff --git a/packages/web/screenshots/dashboard.png b/packages/web/screenshots/dashboard.png
index bd310cf3c..4a3113879 100644
Binary files a/packages/web/screenshots/dashboard.png and b/packages/web/screenshots/dashboard.png differ
diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts
index 9aba8ff87..70d5b5e84 100644
--- a/packages/web/src/__tests__/api-routes.test.ts
+++ b/packages/web/src/__tests__/api-routes.test.ts
@@ -195,7 +195,7 @@ vi.mock("@/lib/services", () => ({
// ── Import routes after mocking ───────────────────────────────────────
import { GET as sessionsGET } from "@/app/api/sessions/route";
-import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route";
+import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route";
import { POST as spawnPOST } from "@/app/api/spawn/route";
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route";
@@ -318,81 +318,6 @@ describe("API Routes", () => {
expect(mockSessionManager.list).toHaveBeenCalledWith("docs-app");
});
- it("keeps global pause sourced from all projects even for project-scoped requests", async () => {
- const pausedUntil = new Date(Date.now() + 60_000).toISOString();
- const pausedSessions = [
- makeSession({
- id: "docs-orchestrator",
- projectId: "docs-app",
- metadata: {
- role: "orchestrator",
- globalPauseUntil: pausedUntil,
- globalPauseReason: "Rate limit hit",
- globalPauseSource: "docs-orchestrator",
- },
- }),
- makeSession({ id: "docs-1", projectId: "docs-app", status: "working", activity: "active" }),
- makeSession({
- id: "backend-3",
- projectId: "my-app",
- status: "working",
- activity: "active",
- }),
- ];
- (mockSessionManager.list as ReturnType).mockImplementation(
- async (projectId?: string) =>
- projectId
- ? pausedSessions.filter((session) => session.projectId === projectId)
- : pausedSessions,
- );
-
- const res = await sessionsGET(
- makeRequest("http://localhost:3000/api/sessions?project=my-app"),
- );
- expect(res.status).toBe(200);
- const data = await res.json();
-
- expect(data.globalPause).toMatchObject({
- pausedUntil,
- reason: "Rate limit hit",
- sourceSessionId: "docs-orchestrator",
- });
- expect(mockSessionManager.list).toHaveBeenNthCalledWith(1, "my-app");
- expect(mockSessionManager.list).toHaveBeenNthCalledWith(2);
- });
-
- it("finds active global pause even when a metadata-role orchestrator appears first", async () => {
- const pausedUntil = new Date(Date.now() + 60_000).toISOString();
- const sessions = [
- makeSession({
- id: "control-session",
- projectId: "docs-app",
- metadata: { role: "orchestrator" },
- }),
- makeSession({
- id: "docs-orchestrator",
- projectId: "docs-app",
- metadata: {
- role: "orchestrator",
- globalPauseUntil: pausedUntil,
- globalPauseReason: "Rate limit hit",
- globalPauseSource: "docs-orchestrator",
- },
- }),
- ];
- (mockSessionManager.list as ReturnType).mockResolvedValueOnce(sessions);
-
- const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions"));
- expect(res.status).toBe(200);
- const data = await res.json();
-
- expect(data.globalPause).toMatchObject({
- pausedUntil,
- reason: "Rate limit hit",
- sourceSessionId: "docs-orchestrator",
- });
- });
-
it("enriches all PRs concurrently, not sequentially", async () => {
vi.useFakeTimers();
@@ -687,6 +612,52 @@ describe("API Routes", () => {
});
});
+ describe("GET /api/orchestrators", () => {
+ it("returns orchestrators for a project", async () => {
+ const orchestrator = makeSession({
+ id: "my-app-orchestrator",
+ projectId: "my-app",
+ metadata: { role: "orchestrator" },
+ });
+ (mockSessionManager.list as ReturnType).mockResolvedValueOnce([orchestrator]);
+
+ const res = await orchestratorsGET(
+ makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
+ );
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.orchestrators).toHaveLength(1);
+ expect(data.orchestrators[0].id).toBe("my-app-orchestrator");
+ expect(data.projectName).toBe("My App");
+ });
+
+ it("returns 400 when project parameter is missing", async () => {
+ const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators"));
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toMatch(/Missing project query parameter/);
+ });
+
+ it("returns 404 for unknown project", async () => {
+ const res = await orchestratorsGET(
+ makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"),
+ );
+ expect(res.status).toBe(404);
+ const data = await res.json();
+ expect(data.error).toMatch(/Unknown project/);
+ });
+
+ it("returns 500 when list fails", async () => {
+ (mockSessionManager.list as ReturnType).mockRejectedValueOnce(new Error("boom"));
+ const res = await orchestratorsGET(
+ makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
+ );
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.error).toBe("boom");
+ });
+ });
+
// ── POST /api/sessions/:id/send ────────────────────────────────────
describe("POST /api/sessions/:id/send", () => {
diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx
index e747cf6d1..49fd3cad6 100644
--- a/packages/web/src/__tests__/components.test.tsx
+++ b/packages/web/src/__tests__/components.test.tsx
@@ -184,8 +184,8 @@ describe("PRStatus", () => {
// ── SessionCard ──────────────────────────────────────────────────────
describe("SessionCard", () => {
- it("renders session id and summary", () => {
- const session = makeSession({ id: "backend-1", summary: "Fixing auth" });
+ it("renders summary when no PR, issue title, or branch title exists", () => {
+ const session = makeSession({ id: "backend-1", summary: "Fixing auth", branch: null });
render();
expect(screen.getByText("backend-1")).toBeInTheDocument();
expect(screen.getByText("Fixing auth")).toBeInTheDocument();
@@ -607,3 +607,46 @@ describe("AttentionZone", () => {
expect(onRestore).toHaveBeenCalledWith("s1");
});
});
+
+// ── Unenriched PR shimmer ─────────────────────────────────────────────
+
+describe("Unenriched PR shimmer", () => {
+ it("SessionCard shows shimmer for unenriched PR size", () => {
+ const pr = makePR({ enriched: false });
+ const session = makeSession({ pr });
+ const { container } = render();
+ const shimmers = container.querySelectorAll(".animate-pulse");
+ expect(shimmers.length).toBeGreaterThan(0);
+ });
+
+ it("SessionCard shows actual size for enriched PR", () => {
+ const pr = makePR({ enriched: true, additions: 50, deletions: 10 });
+ const session = makeSession({ pr });
+ render();
+ expect(screen.getByText("+50 -10 S")).toBeInTheDocument();
+ });
+
+ it("SessionCard suppresses alerts for unenriched PR", () => {
+ const pr = makePR({
+ enriched: false,
+ ciStatus: "failing",
+ ciChecks: [{ name: "build", status: "failed" }],
+ });
+ const session = makeSession({ pr });
+ const { container } = render();
+ expect(container.querySelector(".session-card__alert-pill")).toBeNull();
+ });
+
+ it("PRStatus shows shimmer for unenriched PR", () => {
+ const pr = makePR({ enriched: false });
+ const { container } = render();
+ const shimmers = container.querySelectorAll(".animate-pulse");
+ expect(shimmers.length).toBeGreaterThan(0);
+ });
+
+ it("PRStatus shows actual data for enriched PR", () => {
+ const pr = makePR({ enriched: true, additions: 50, deletions: 10 });
+ render();
+ expect(screen.getByText("+50 -10 S")).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/__tests__/helpers.ts b/packages/web/src/__tests__/helpers.ts
index 579c2f895..275cbb79e 100644
--- a/packages/web/src/__tests__/helpers.ts
+++ b/packages/web/src/__tests__/helpers.ts
@@ -50,6 +50,7 @@ export function makePR(overrides: Partial = {}): DashboardPR {
},
unresolvedThreads: 0,
unresolvedComments: [],
+ enriched: true,
...overrides,
};
}
diff --git a/packages/web/src/__tests__/orchestrators.test.tsx b/packages/web/src/__tests__/orchestrators.test.tsx
new file mode 100644
index 000000000..eb83c4c42
--- /dev/null
+++ b/packages/web/src/__tests__/orchestrators.test.tsx
@@ -0,0 +1,102 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import OrchestratorsRoute from "@/app/orchestrators/page";
+import { getServices } from "@/lib/services";
+import { getAllProjects } from "@/lib/project-name";
+
+// ── Mocks ─────────────────────────────────────────────────────────────
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
+vi.mock("next/link", () => ({
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/lib/services", () => ({
+ getServices: vi.fn(),
+}));
+
+vi.mock("@/lib/project-name", () => ({
+ getAllProjects: vi.fn(),
+}));
+
+global.fetch = vi.fn();
+
+// ── Tests ─────────────────────────────────────────────────────────────
+
+describe("Orchestrators Page (OrchestratorsRoute)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the page with searchParams and listed orchestrators", async () => {
+ const mockSessionManager = {
+ list: vi.fn().mockResolvedValue([
+ {
+ id: "app-orchestrator",
+ projectId: "my-app",
+ status: "working",
+ activity: "active",
+ createdAt: new Date(),
+ lastActivityAt: new Date(),
+ },
+ ]),
+ };
+
+ (getServices as any).mockResolvedValue({
+ config: {
+ projects: {
+ "my-app": { name: "My App", sessionPrefix: "app" },
+ },
+ },
+ sessionManager: mockSessionManager,
+ });
+
+ (getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]);
+
+ const searchParams = Promise.resolve({ project: "my-app" });
+ const jsx = await OrchestratorsRoute({ searchParams });
+ render(jsx);
+
+ expect(screen.getByText("My App")).toBeInTheDocument();
+ expect(screen.getByText("app-orchestrator")).toBeInTheDocument();
+ });
+
+ it("shows error when project is missing in searchParams", async () => {
+ const searchParams = Promise.resolve({});
+ const jsx = await OrchestratorsRoute({ searchParams });
+ render(jsx);
+
+ expect(screen.getByText("Missing Project")).toBeInTheDocument();
+ });
+
+ it("shows error when project is not found in config", async () => {
+ (getServices as any).mockResolvedValue({
+ config: { projects: {} },
+ sessionManager: { list: vi.fn() },
+ });
+ (getAllProjects as any).mockReturnValue([]);
+
+ const searchParams = Promise.resolve({ project: "ghost" });
+ const jsx = await OrchestratorsRoute({ searchParams });
+ render(jsx);
+
+ expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument();
+ });
+
+ it("handles service errors gracefully", async () => {
+ (getServices as any).mockRejectedValue(new Error("Database down"));
+
+ const searchParams = Promise.resolve({ project: "my-app" });
+ const jsx = await OrchestratorsRoute({ searchParams });
+ render(jsx);
+
+ expect(screen.getByText("Database down")).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/__tests__/server-only-mock.ts b/packages/web/src/__tests__/server-only-mock.ts
new file mode 100644
index 000000000..f83932659
--- /dev/null
+++ b/packages/web/src/__tests__/server-only-mock.ts
@@ -0,0 +1,3 @@
+// Stub for "server-only" package in vitest — the real module throws when
+// imported outside a React Server Component context.
+export {};
diff --git a/packages/web/src/app/api/issues/route.ts b/packages/web/src/app/api/issues/route.ts
index 892d3f6c8..3212afd34 100644
--- a/packages/web/src/app/api/issues/route.ts
+++ b/packages/web/src/app/api/issues/route.ts
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
for (const [projectId, project] of Object.entries(config.projects)) {
if (projectFilter && projectId !== projectFilter) continue;
- if (!project.tracker) continue;
+ if (!project.tracker?.plugin) continue;
const tracker = registry.get("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
}
const project = config.projects[projectId];
- if (!project.tracker) {
+ if (!project.tracker?.plugin) {
return NextResponse.json({ error: "No tracker configured for this project" }, { status: 422 });
}
diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts
index 64f5d6901..d3c0b2359 100644
--- a/packages/web/src/app/api/orchestrators/route.ts
+++ b/packages/web/src/app/api/orchestrators/route.ts
@@ -1,7 +1,48 @@
import { type NextRequest, NextResponse } from "next/server";
-import { generateOrchestratorPrompt } from "@composio/ao-core";
+import { generateOrchestratorPrompt, generateSessionPrefix } from "@composio/ao-core";
import { getServices } from "@/lib/services";
import { validateIdentifier, validateConfiguredProject } from "@/lib/validation";
+import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils";
+
+/**
+ * GET /api/orchestrators?project=
+ * List existing orchestrator sessions for a project.
+ */
+export async function GET(request: NextRequest) {
+ const projectId = request.nextUrl.searchParams.get("project");
+
+ if (!projectId) {
+ return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 });
+ }
+
+ const projectErr = validateIdentifier(projectId, "projectId");
+ if (projectErr) {
+ return NextResponse.json({ error: projectErr }, { status: 400 });
+ }
+
+ try {
+ const { config, sessionManager } = await getServices();
+ const configProjectErr = validateConfiguredProject(config.projects, projectId);
+ if (configProjectErr) {
+ return NextResponse.json({ error: configProjectErr }, { status: 404 });
+ }
+ const project = config.projects[projectId];
+ const sessionPrefix = project.sessionPrefix ?? projectId;
+
+ const allSessions = await sessionManager.list(projectId);
+ const allSessionPrefixes = Object.entries(config.projects).map(
+ ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
+ );
+ const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes);
+
+ return NextResponse.json({ orchestrators, projectName: project.name });
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : "Failed to list orchestrators" },
+ { status: 500 },
+ );
+ }
+}
export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => null)) as Record | null;
@@ -17,9 +58,9 @@ export async function POST(request: NextRequest) {
try {
const { config, sessionManager } = await getServices();
const projectId = body.projectId as string;
- const projectErr = validateConfiguredProject(config.projects, projectId);
- if (projectErr) {
- return NextResponse.json({ error: projectErr }, { status: 404 });
+ const configProjectErr = validateConfiguredProject(config.projects, projectId);
+ if (configProjectErr) {
+ return NextResponse.json({ error: configProjectErr }, { status: 404 });
}
const project = config.projects[projectId];
diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts
index 3485e7231..0156a105a 100644
--- a/packages/web/src/app/api/sessions/route.ts
+++ b/packages/web/src/app/api/sessions/route.ts
@@ -9,28 +9,13 @@ import {
listDashboardOrchestrators,
} from "@/lib/serialize";
import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability";
-import { resolveGlobalPause } from "@/lib/global-pause";
import { filterProjectSessions } from "@/lib/project-utils";
+import { settlesWithin } from "@/lib/async-utils";
const METADATA_ENRICH_TIMEOUT_MS = 3_000;
const PR_ENRICH_TIMEOUT_MS = 4_000;
const PER_PR_ENRICH_TIMEOUT_MS = 1_500;
-async function settlesWithin(promise: Promise, timeoutMs: number): Promise {
- let timeoutId: ReturnType | null = null;
- const timeoutPromise = new Promise((resolve) => {
- timeoutId = setTimeout(() => resolve(false), timeoutMs);
- });
-
- try {
- return await Promise.race([promise.then(() => true).catch(() => true), timeoutPromise]);
- } finally {
- if (timeoutId) {
- clearTimeout(timeoutId);
- }
- }
-}
-
export async function GET(request: Request) {
const correlationId = getCorrelationId(request);
const startedAt = Date.now();
@@ -73,9 +58,17 @@ export async function GET(request: Request) {
);
}
- const allSessions = requestedProjectId ? await sessionManager.list() : coreSessions;
-
- let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session));
+ const allSessionPrefixes = Object.entries(config.projects).map(
+ ([projectId, p]) => p.sessionPrefix ?? projectId,
+ );
+ let workerSessions = visibleSessions.filter(
+ (session) =>
+ !isOrchestratorSession(
+ session,
+ config.projects[session.projectId]?.sessionPrefix ?? session.projectId,
+ allSessionPrefixes,
+ ),
+ );
// Convert to dashboard format
let dashboardSessions = workerSessions.map(sessionToDashboard);
@@ -134,7 +127,6 @@ export async function GET(request: Request) {
stats: computeStats(dashboardSessions),
orchestratorId,
orchestrators,
- globalPause: resolveGlobalPause(allSessions),
},
{ status: 200 },
correlationId,
diff --git a/packages/web/src/app/api/verify/route.ts b/packages/web/src/app/api/verify/route.ts
index f0c00e847..44c0ef627 100644
--- a/packages/web/src/app/api/verify/route.ts
+++ b/packages/web/src/app/api/verify/route.ts
@@ -64,7 +64,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: projectErr }, { status: 404 });
}
const project = config.projects[projectId];
- if (!project.tracker) {
+ if (!project.tracker?.plugin) {
return NextResponse.json({ error: `Project ${projectId} has no tracker` }, { status: 422 });
}
diff --git a/packages/web/src/app/dev/terminal-test/page.tsx b/packages/web/src/app/dev/terminal-test/page.tsx
index 8323ae23e..da56936aa 100644
--- a/packages/web/src/app/dev/terminal-test/page.tsx
+++ b/packages/web/src/app/dev/terminal-test/page.tsx
@@ -1,10 +1,22 @@
"use client";
-import { DirectTerminal } from "@/components/DirectTerminal";
-import { Terminal } from "@/components/Terminal";
+import nextDynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { useState, useEffect, Suspense } from "react";
+const DirectTerminal = nextDynamic(
+ () =>
+ import("@/components/DirectTerminal").then((m) => ({
+ default: m.DirectTerminal,
+ })),
+ { ssr: false },
+);
+
+const Terminal = nextDynamic(
+ () => import("@/components/Terminal").then((m) => ({ default: m.Terminal })),
+ { ssr: false },
+);
+
// Force dynamic rendering (required for useSearchParams)
export const dynamic = "force-dynamic";
diff --git a/packages/web/src/app/error.test.tsx b/packages/web/src/app/error.test.tsx
new file mode 100644
index 000000000..d28115410
--- /dev/null
+++ b/packages/web/src/app/error.test.tsx
@@ -0,0 +1,42 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const refresh = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ refresh }),
+}));
+
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+}));
+
+import ErrorPage from "./error";
+
+describe("Route error boundary", () => {
+ beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ it("retries with reset and router refresh", () => {
+ const reset = vi.fn();
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders a dashboard escape hatch", () => {
+ render();
+
+ expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/");
+ });
+});
diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx
new file mode 100644
index 000000000..ba96be99a
--- /dev/null
+++ b/packages/web/src/app/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ const router = useRouter();
+
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+ {
+ reset();
+ router.refresh();
+ },
+ }}
+ secondaryAction={{ label: "Back to dashboard", href: "/" }}
+ error={error}
+ compact
+ chrome="card"
+ />
+ );
+}
diff --git a/packages/web/src/app/global-error.test.tsx b/packages/web/src/app/global-error.test.tsx
new file mode 100644
index 000000000..461d1c744
--- /dev/null
+++ b/packages/web/src/app/global-error.test.tsx
@@ -0,0 +1,72 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const errorDisplaySpy = vi.fn();
+
+vi.mock("@/components/ErrorDisplay", () => ({
+ ErrorDisplay: (props: {
+ title: string;
+ message: string;
+ primaryAction?: { label: string; onClick?: () => void };
+ secondaryAction?: { label: string; onClick?: () => void };
+ }) => {
+ errorDisplaySpy(props);
+ return (
+
+
{props.title}
+
{props.message}
+ {props.primaryAction ? (
+
+ ) : null}
+ {props.secondaryAction ? (
+
+ ) : null}
+
+ );
+ },
+}));
+
+import GlobalError from "./global-error";
+
+describe("Global error boundary", () => {
+ beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ errorDisplaySpy.mockClear();
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { reload: vi.fn() },
+ });
+ });
+
+ it("calls reset when retrying", () => {
+ const reset = vi.fn();
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(reset).toHaveBeenCalledTimes(1);
+ });
+
+ it("reloads the page on demand", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Reload page" }));
+ expect(window.location.reload).toHaveBeenCalledTimes(1);
+ });
+
+ it("passes the expected shell copy to ErrorDisplay", () => {
+ render();
+
+ expect(errorDisplaySpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Something broke at the app shell",
+ message:
+ "The dashboard could not recover from this error at the layout level. Try again first, then reload the page if it still fails.",
+ }),
+ );
+ });
+});
diff --git a/packages/web/src/app/global-error.tsx b/packages/web/src/app/global-error.tsx
new file mode 100644
index 000000000..fa1784e5f
--- /dev/null
+++ b/packages/web/src/app/global-error.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { useEffect } from "react";
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+ window.location.reload() }}
+ error={error}
+ />
+
+
+ );
+}
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index 113d6251a..9e57b0d2a 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -2000,6 +2000,68 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-radius: 2px;
}
+/* ── Done / Terminated collapsible bar ────────────────────────────────── */
+
+.done-bar__toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 0;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ transition: color 0.15s ease;
+}
+
+.done-bar__toggle:hover {
+ color: var(--color-text-secondary);
+}
+
+.done-bar__chevron {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ transition: transform 0.15s ease;
+}
+
+.done-bar__chevron--open {
+ transform: rotate(90deg);
+}
+
+.done-bar__count {
+ flex-shrink: 0;
+ min-width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 700;
+ border-radius: 999px;
+ background: var(--color-border-subtle);
+ color: var(--color-text-muted);
+ padding: 0 5px;
+}
+
+.done-bar__rule {
+ flex: 1;
+ height: 1px;
+ background: var(--color-border-subtle);
+}
+
+.done-bar__cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 8px;
+ margin-top: 10px;
+}
+
/* ── Session card zone glow (hover) ──────────────────────────────────── */
.kanban-column__empty {
diff --git a/packages/web/src/app/loading.test.tsx b/packages/web/src/app/loading.test.tsx
new file mode 100644
index 000000000..ef0dff5ba
--- /dev/null
+++ b/packages/web/src/app/loading.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import Loading from "./loading";
+
+describe("Loading (global)", () => {
+ it("renders the loading text", () => {
+ render();
+ expect(screen.getByText("Loading…")).toBeInTheDocument();
+ });
+
+ it("renders a spinning indicator", () => {
+ const { container } = render();
+ const spinner = container.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+ expect(spinner?.tagName.toLowerCase()).toBe("svg");
+ });
+});
diff --git a/packages/web/src/app/loading.tsx b/packages/web/src/app/loading.tsx
new file mode 100644
index 000000000..77f71eb27
--- /dev/null
+++ b/packages/web/src/app/loading.tsx
@@ -0,0 +1,20 @@
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/packages/web/src/app/not-found.test.tsx b/packages/web/src/app/not-found.test.tsx
new file mode 100644
index 000000000..c2678acbc
--- /dev/null
+++ b/packages/web/src/app/not-found.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+}));
+
+import NotFound from "./not-found";
+
+describe("NotFound (global)", () => {
+ it("renders the page-not-found message", () => {
+ render();
+ expect(screen.getByText("Page not found")).toBeInTheDocument();
+ });
+
+ it("renders descriptive copy", () => {
+ render();
+ expect(
+ screen.getByText(
+ "This route does not exist in the dashboard. Return to the main view to pick an active project or session.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a link back to the dashboard", () => {
+ render();
+ const link = screen.getByText("Back to dashboard");
+ expect(link).toBeInTheDocument();
+ expect(link.closest("a")).toHaveAttribute("href", "/");
+ });
+
+ it("renders the not-found icon", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx
new file mode 100644
index 000000000..8b817324d
--- /dev/null
+++ b/packages/web/src/app/not-found.tsx
@@ -0,0 +1,12 @@
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+
+export default function NotFound() {
+ return (
+
+ );
+}
diff --git a/packages/web/src/app/orchestrators/page.tsx b/packages/web/src/app/orchestrators/page.tsx
new file mode 100644
index 000000000..ba8b47d3f
--- /dev/null
+++ b/packages/web/src/app/orchestrators/page.tsx
@@ -0,0 +1,78 @@
+import type { Metadata } from "next";
+import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector";
+import { getServices } from "@/lib/services";
+import { getAllProjects } from "@/lib/project-name";
+import { generateSessionPrefix } from "@composio/ao-core";
+import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata(props: {
+ searchParams: Promise<{ project?: string }>;
+}): Promise {
+ const searchParams = await props.searchParams;
+ const projectId = searchParams.project;
+ let projectName = "Orchestrator";
+ if (projectId) {
+ const projects = getAllProjects();
+ const project = projects.find((p) => p.id === projectId);
+ if (project) {
+ projectName = project.name;
+ }
+ }
+ return { title: { absolute: `ao | ${projectName} - Select Orchestrator` } };
+}
+
+export default async function OrchestratorsRoute(props: {
+ searchParams: Promise<{ project?: string }>;
+}) {
+ const searchParams = await props.searchParams;
+ const projectId = searchParams.project;
+
+ if (!projectId) {
+ return (
+
+
+
+ Missing Project
+
+
+ No project specified. Please provide a project parameter.
+
+
+
+ );
+ }
+
+ let orchestrators: Orchestrator[] = [];
+ let projectName = projectId;
+ let error: string | null = null;
+
+ try {
+ const { config, sessionManager } = await getServices();
+ const project = config.projects[projectId];
+
+ if (!project) {
+ error = `Project "${projectId}" not found`;
+ } else {
+ projectName = project.name;
+ const sessionPrefix = project.sessionPrefix ?? projectId;
+ const allSessions = await sessionManager.list(projectId);
+ const allSessionPrefixes = Object.entries(config.projects).map(
+ ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
+ );
+ orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes);
+ }
+ } catch (err) {
+ error = err instanceof Error ? err.message : "Failed to load orchestrators";
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx
index f791b063c..27a7149d0 100644
--- a/packages/web/src/app/page.tsx
+++ b/packages/web/src/app/page.tsx
@@ -28,7 +28,6 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
projectId={pageData.selectedProjectId}
projectName={pageData.projectName}
projects={pageData.projects}
- initialGlobalPause={pageData.globalPause}
orchestrators={pageData.orchestrators}
/>
);
diff --git a/packages/web/src/app/sessions/[id]/error.test.tsx b/packages/web/src/app/sessions/[id]/error.test.tsx
new file mode 100644
index 000000000..253fa11c2
--- /dev/null
+++ b/packages/web/src/app/sessions/[id]/error.test.tsx
@@ -0,0 +1,46 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const refresh = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ refresh }),
+}));
+
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+}));
+
+import SessionError from "./error";
+
+describe("Session error boundary", () => {
+ beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ it("retries with reset and router refresh", () => {
+ const reset = vi.fn();
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows a session-specific message", () => {
+ render();
+
+ expect(
+ screen.getByText(
+ "The session request failed before the dashboard got a response. Check the server connection and try again.",
+ ),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/app/sessions/[id]/error.tsx b/packages/web/src/app/sessions/[id]/error.tsx
new file mode 100644
index 000000000..f9a0011c1
--- /dev/null
+++ b/packages/web/src/app/sessions/[id]/error.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+
+function getSessionErrorMessage(error: Error): string {
+ const normalized = error.message.toLowerCase();
+ if (normalized.includes("network")) {
+ return "The session request failed before the dashboard got a response. Check the server connection and try again.";
+ }
+ if (normalized.includes("403")) {
+ return "The dashboard could not access this session. Permissions or auth may have changed.";
+ }
+ if (normalized.includes("404")) {
+ return "This session is no longer available. It may have been removed while the page was open.";
+ }
+ if (normalized.includes("500")) {
+ return "The server returned an internal error while loading this session. Try re-fetching the session data.";
+ }
+ return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
+}
+
+export default function SessionError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ const router = useRouter();
+
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+ {
+ reset();
+ router.refresh();
+ },
+ }}
+ secondaryAction={{ label: "Back to dashboard", href: "/" }}
+ error={error}
+ compact
+ chrome="card"
+ />
+ );
+}
diff --git a/packages/web/src/app/sessions/[id]/loading.test.tsx b/packages/web/src/app/sessions/[id]/loading.test.tsx
new file mode 100644
index 000000000..da87ae0f6
--- /dev/null
+++ b/packages/web/src/app/sessions/[id]/loading.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import SessionLoading from "./loading";
+
+describe("SessionLoading", () => {
+ it("renders the session loading text", () => {
+ render();
+ expect(screen.getByText("Loading session…")).toBeInTheDocument();
+ });
+
+ it("renders a spinning indicator", () => {
+ const { container } = render();
+ const spinner = container.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+ expect(spinner?.tagName.toLowerCase()).toBe("svg");
+ });
+});
diff --git a/packages/web/src/app/sessions/[id]/loading.tsx b/packages/web/src/app/sessions/[id]/loading.tsx
new file mode 100644
index 000000000..d81ee937a
--- /dev/null
+++ b/packages/web/src/app/sessions/[id]/loading.tsx
@@ -0,0 +1,20 @@
+export default function SessionLoading() {
+ return (
+
+
+
+
+ Loading session…
+
+
+
+ );
+}
diff --git a/packages/web/src/app/sessions/[id]/not-found.test.tsx b/packages/web/src/app/sessions/[id]/not-found.test.tsx
new file mode 100644
index 000000000..614ec96de
--- /dev/null
+++ b/packages/web/src/app/sessions/[id]/not-found.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+}));
+
+import SessionNotFound from "./not-found";
+
+describe("SessionNotFound", () => {
+ it("renders the session-not-found message", () => {
+ render();
+ expect(screen.getByText("Session not found")).toBeInTheDocument();
+ });
+
+ it("renders descriptive subtext", () => {
+ render();
+ expect(
+ screen.getByText(
+ "The session you’re looking for does not exist anymore, or the link is stale.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a link back to the dashboard", () => {
+ render();
+ const link = screen.getByText("Back to dashboard");
+ expect(link).toBeInTheDocument();
+ expect(link.closest("a")).toHaveAttribute("href", "/");
+ });
+
+ it("renders the terminal icon", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/app/sessions/[id]/not-found.tsx b/packages/web/src/app/sessions/[id]/not-found.tsx
new file mode 100644
index 000000000..4fb654b60
--- /dev/null
+++ b/packages/web/src/app/sessions/[id]/not-found.tsx
@@ -0,0 +1,14 @@
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+
+export default function SessionNotFound() {
+ return (
+
+ );
+}
diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx
index 45eb15790..3b2243e5a 100644
--- a/packages/web/src/app/sessions/[id]/page.test.tsx
+++ b/packages/web/src/app/sessions/[id]/page.test.tsx
@@ -1,11 +1,17 @@
-import { act, render } from "@testing-library/react";
+import React, { type ReactNode } from "react";
+import { act, render, screen } from "@testing-library/react";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import type { DashboardSession } from "@/lib/types";
const sessionDetailSpy = vi.fn();
+const notFoundError = new Error("NEXT_NOT_FOUND");
+const notFoundSpy = vi.fn(() => {
+ throw notFoundError;
+});
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "worker-1" }),
+ notFound: notFoundSpy,
}));
vi.mock("@/components/SessionDetail", () => ({
@@ -40,10 +46,32 @@ async function flushAsyncWork(): Promise {
});
}
+class TestErrorBoundary extends React.Component<
+ { children: ReactNode },
+ { error: Error | null }
+> {
+ constructor(props: { children: ReactNode }) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ render() {
+ if (this.state.error) {
+ return {this.state.error.message}
;
+ }
+ return this.props.children;
+ }
+}
+
describe("SessionPage project polling", () => {
beforeEach(() => {
vi.useFakeTimers();
sessionDetailSpy.mockClear();
+ vi.spyOn(console, "error").mockImplementation(() => {});
const eventSourceMock = {
addEventListener: vi.fn(),
@@ -59,6 +87,7 @@ describe("SessionPage project polling", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
+ notFoundSpy.mockReset();
});
it("resolves orchestrator nav once for non-orchestrator pages and skips repeated project polling", async () => {
@@ -125,4 +154,70 @@ describe("SessionPage project polling", () => {
),
).toHaveLength(1);
});
+
+ it("routes 404 responses through notFound()", async () => {
+ global.fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/projects") {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ projects: [] }),
+ } as Response;
+ }
+
+ if (url === "/api/sessions/worker-1") {
+ return {
+ ok: false,
+ status: 404,
+ json: async () => ({}),
+ } as Response;
+ }
+
+ throw new Error(`Unexpected fetch: ${url}`);
+ }) as typeof fetch;
+
+ const { default: SessionPage } = await import("./page");
+
+ render();
+ await flushAsyncWork();
+
+ expect(notFoundSpy).toHaveBeenCalled();
+ expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument();
+ });
+
+ it("throws non-404 session fetch failures to the route error boundary", async () => {
+ global.fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/projects") {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ projects: [] }),
+ } as Response;
+ }
+
+ if (url === "/api/sessions/worker-1") {
+ return {
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ } as Response;
+ }
+
+ throw new Error(`Unexpected fetch: ${url}`);
+ }) as typeof fetch;
+
+ const { default: SessionPage } = await import("./page");
+
+ render(
+
+
+ ,
+ );
+
+ await flushAsyncWork();
+
+ expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500");
+ });
});
diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx
index fc8c33b00..ce1380472 100644
--- a/packages/web/src/app/sessions/[id]/page.tsx
+++ b/packages/web/src/app/sessions/[id]/page.tsx
@@ -1,11 +1,13 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
-import { useParams } from "next/navigation";
+import { notFound, useParams } from "next/navigation";
import { isOrchestratorSession } from "@composio/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { type DashboardSession, type ActivityState, getAttentionLevel, type AttentionLevel } from "@/lib/types";
import { activityIcon } from "@/lib/activity-icons";
+import type { ProjectInfo } from "@/lib/project-name";
+import { getSessionTitle } from "@/lib/format";
import { useSSESessionActivity } from "@/hooks/useSSESessionActivity";
function truncate(s: string, max: number): string {
@@ -15,23 +17,21 @@ function truncate(s: string, max: number): string {
/** Build a descriptive tab title from session data. */
function buildSessionTitle(
session: DashboardSession,
+ prefixByProject: Map,
activityOverride?: ActivityState | null,
): string {
const id = session.id;
const activity = activityOverride !== undefined ? activityOverride : session.activity;
const emoji = activity ? (activityIcon[activity] ?? "") : "";
- const isOrchestrator = isOrchestratorSession(session);
+ const allPrefixes = [...prefixByProject.values()];
+ const isOrchestrator = isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes);
let detail: string;
if (isOrchestrator) {
detail = "Orchestrator Terminal";
- } else if (session.pr) {
- detail = `#${session.pr.number} ${truncate(session.pr.branch, 30)}`;
- } else if (session.branch) {
- detail = truncate(session.branch, 30);
} else {
- detail = "Session Detail";
+ detail = truncate(getSessionTitle(session), 40);
}
return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`;
@@ -60,12 +60,38 @@ export default function SessionPage() {
const [zoneCounts, setZoneCounts] = useState(null);
const [projectOrchestratorId, setProjectOrchestratorId] = useState(undefined);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [routeError, setRouteError] = useState(null);
+ const [sessionMissing, setSessionMissing] = useState(false);
+ const [prefixByProject, setPrefixByProject] = useState
)}
@@ -474,11 +490,13 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
#{pr.number}
)}
- {pr && !rateLimited && (
+ {pr && !rateLimited && (prUnenriched ? (
+
+ ) : (
+{pr.additions} -{pr.deletions} {getSizeLabel(pr.additions, pr.deletions)}
- )}
+ ))}
{secondaryText && (
@@ -725,6 +743,7 @@ function getAlerts(session: DashboardSession): Alert[] {
const pr = session.pr;
if (!pr || pr.state !== "open") return [];
if (isPRRateLimited(pr)) return [];
+ if (isPRUnenriched(pr)) return [];
const meta = session.metadata;
const alerts: Alert[] = [];
diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx
index 567b73d12..debc055cb 100644
--- a/packages/web/src/components/SessionDetail.tsx
+++ b/packages/web/src/components/SessionDetail.tsx
@@ -6,10 +6,21 @@ import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types";
import { CI_STATUS } from "@composio/ao-core/types";
import { cn } from "@/lib/cn";
+import dynamic from "next/dynamic";
+import { getSessionTitle } from "@/lib/format";
import { CICheckList } from "./CIBadge";
-import { DirectTerminal } from "./DirectTerminal";
import { MobileBottomNav } from "./MobileBottomNav";
+const DirectTerminal = dynamic(
+ () => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+
interface OrchestratorZones {
merge: number;
respond: number;
@@ -37,10 +48,6 @@ const activityMeta: Record = {
exited: { label: "Exited", color: "var(--color-status-error)" },
};
-function getSessionHeadline(session: DashboardSession): string {
- return session.issueTitle ?? session.summary ?? session.id;
-}
-
function cleanBugbotComment(body: string): { title: string; description: string } {
const isBugbot = body.includes("") || body.includes("### ");
if (isBugbot) {
@@ -329,7 +336,7 @@ export function SessionDetail({
label: session.activity ?? "unknown",
color: "var(--color-text-muted)",
};
- const headline = getSessionHeadline(session);
+ const headline = getSessionTitle(session);
const accentColor = "var(--color-accent)";
const terminalVariant = isOrchestrator ? "orchestrator" : "agent";
diff --git a/packages/web/src/components/Skeleton.tsx b/packages/web/src/components/Skeleton.tsx
index d61c430a5..2d2c30f93 100644
--- a/packages/web/src/components/Skeleton.tsx
+++ b/packages/web/src/components/Skeleton.tsx
@@ -7,7 +7,6 @@ interface EmptyStateProps {
export function EmptyState({
message,
}: EmptyStateProps) {
- const isDefault = !message;
return (
{/* Terminal icon */}
@@ -22,16 +21,7 @@ export function EmptyState({
- {isDefault ? (
- <>
- No sessions running. Start one with{" "}
-
- ao start
-
- >
- ) : (
- message
- )}
+ {message ?? "No active sessions"}
);
diff --git a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx
new file mode 100644
index 000000000..1bfd2bcb7
--- /dev/null
+++ b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx
@@ -0,0 +1,62 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Dashboard } from "../Dashboard";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
+ usePathname: () => "/",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+beforeEach(() => {
+ const eventSourceMock = {
+ onmessage: null,
+ onerror: null,
+ close: vi.fn(),
+ };
+ const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource);
+ global.EventSource = Object.assign(eventSourceConstructor, {
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSED: 2,
+ }) as unknown as typeof EventSource;
+ global.fetch = vi.fn();
+});
+
+const DONE_SESSION = {
+ id: "done-1",
+ projectId: "proj",
+ status: "merged" as const,
+ activity: "exited" as const,
+ branch: "feat/done",
+ issueId: null,
+ issueUrl: null,
+ issueLabel: null,
+ issueTitle: null,
+ summary: "Finished task",
+ summaryIsFallback: false,
+ createdAt: new Date().toISOString(),
+ lastActivityAt: new Date().toISOString(),
+ pr: null,
+ metadata: {},
+};
+
+describe("Dashboard done bar", () => {
+ it("shows the done bar when done sessions exist", () => {
+ render();
+ expect(screen.getByText("Done / Terminated")).toBeInTheDocument();
+ });
+
+ it("expands to show session cards when clicked", () => {
+ const { container } = render();
+ const toggle = screen.getByText("Done / Terminated").closest("button")!;
+ expect(container.querySelector(".done-bar__cards")).toBeNull();
+ fireEvent.click(toggle);
+ expect(container.querySelector(".done-bar__cards")).toBeInTheDocument();
+ });
+
+ it("does not show empty state when only done sessions exist", () => {
+ render();
+ expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx
index 6eddff75c..2e05c66a1 100644
--- a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx
+++ b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx
@@ -26,7 +26,7 @@ beforeEach(() => {
describe("Dashboard empty state", () => {
it("shows empty state when there are no sessions (single-project view)", () => {
render();
- expect(screen.getByText(/No sessions running/i)).toBeInTheDocument();
+ expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
});
it("does not show empty state when sessions exist", () => {
@@ -53,6 +53,6 @@ describe("Dashboard empty state", () => {
]}
/>,
);
- expect(queryByText(/No sessions running/i)).not.toBeInTheDocument();
+ expect(queryByText(/No active sessions/i)).not.toBeInTheDocument();
});
});
diff --git a/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx b/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx
deleted file mode 100644
index 5b46489ea..000000000
--- a/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
-import { Dashboard } from "@/components/Dashboard";
-import type { GlobalPauseState } from "@/lib/types";
-import { makeSession } from "@/__tests__/helpers";
-
-vi.mock("next/navigation", () => ({
- useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
- usePathname: () => "/",
- useSearchParams: () => new URLSearchParams(),
-}));
-
-describe("Dashboard globalPause banner", () => {
- let eventSourceMock: {
- onmessage: ((event: MessageEvent) => void) | null;
- onerror: (() => void) | null;
- close: () => void;
- };
-
- const makeGlobalPause = (overrides: Partial = {}): GlobalPauseState => ({
- pausedUntil: new Date(Date.now() + 3600000).toISOString(),
- reason: "Model rate limit reached",
- sourceSessionId: "session-1",
- ...overrides,
- });
-
- beforeEach(() => {
- eventSourceMock = {
- onmessage: null,
- onerror: null,
- close: vi.fn(),
- };
- global.EventSource = vi.fn(() => eventSourceMock as unknown as EventSource);
- global.fetch = vi.fn();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it("shows banner when initialGlobalPause is set", () => {
- const sessions = [makeSession()];
- const globalPause = makeGlobalPause();
-
- render();
-
- expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument();
- expect(screen.getByText(/Model rate limit reached/)).toBeInTheDocument();
- });
-
- it("hides banner when initialGlobalPause is null", () => {
- const sessions = [makeSession()];
-
- render();
-
- expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
- });
-
- it("shows banner with custom reason from any provider", () => {
- const sessions = [makeSession()];
- const globalPause = makeGlobalPause({ reason: "Custom provider limit exceeded" });
-
- render();
-
- expect(screen.getByText(/Custom provider limit exceeded/)).toBeInTheDocument();
- });
-
- it("shows the automatic resume time", () => {
- const sessions = [makeSession()];
- const globalPause = makeGlobalPause({ pausedUntil: "2026-03-10T12:30:00.000Z" });
-
- render();
-
- expect(screen.getByText(/Resume after/)).toBeInTheDocument();
- });
-
- it("displays source session ID when provided", () => {
- const sessions = [makeSession()];
- const globalPause = makeGlobalPause({ sourceSessionId: "my-worker-42" });
-
- render();
-
- expect(screen.getByText(/Source: my-worker-42/)).toBeInTheDocument();
- });
-
- it("banner appears from state update via SSE (provider-agnostic)", async () => {
- const sessions = [makeSession()];
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions: [...sessions, makeSession({ id: "session-new" })],
- globalPause: makeGlobalPause({ reason: "Rate limit from any agent" }),
- }),
- } as Response);
-
- render();
-
- expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
-
- await waitFor(() => expect(eventSourceMock.onmessage).not.toBeNull());
-
- await act(async () => {
- eventSourceMock.onmessage!({
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument();
- expect(screen.getByText(/Rate limit from any agent/)).toBeInTheDocument();
- });
- });
-
- it("banner disappears from state update via SSE (pause expires)", async () => {
- const sessions = [makeSession()];
- const globalPause = makeGlobalPause();
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions: [...sessions, makeSession({ id: "session-new" })],
- globalPause: null,
- }),
- } as Response);
-
- render();
-
- expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument();
-
- await waitFor(() => expect(eventSourceMock.onmessage).not.toBeNull());
-
- await act(async () => {
- eventSourceMock.onmessage!({
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
- });
- });
-
- it("shows a new pause after dismissing a previous one", () => {
- const sessions = [makeSession()];
- const firstPause = makeGlobalPause({
- reason: "First pause",
- pausedUntil: "2026-03-10T12:00:00.000Z",
- });
- const secondPause = makeGlobalPause({
- reason: "Second pause",
- pausedUntil: "2026-03-10T13:00:00.000Z",
- });
-
- const { rerender } = render(
- ,
- );
-
- expect(screen.getByText(/First pause/)).toBeInTheDocument();
-
- const dismissButtons = screen.getAllByLabelText("Dismiss");
- act(() => {
- fireEvent.click(dismissButtons[0]);
- });
- expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
-
- rerender();
-
- expect(screen.getByText(/Second pause/)).toBeInTheDocument();
- });
-});
diff --git a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx
index 104915659..2faff2fa6 100644
--- a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx
+++ b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx
@@ -55,6 +55,7 @@ describe("Dashboard mobile layout", () => {
makeSession({
id: `needs-input-${index + 1}`,
summary: `Need approval ${index + 1}`,
+ branch: null,
status: "needs_input",
activity: "waiting_input",
}),
@@ -83,18 +84,18 @@ describe("Dashboard mobile layout", () => {
render();
- expect(screen.getByRole("link", { name: /go to need approval to proceed/i })).toHaveAttribute(
+ expect(screen.getByRole("link", { name: /go to mobile density/i })).toHaveAttribute(
"href",
"/sessions/respond-1",
);
await act(async () => {
- fireEvent.click(screen.getByRole("button", { name: /open need approval to proceed/i }));
+ fireEvent.click(screen.getByRole("button", { name: /open mobile density/i }));
});
expect(screen.getByRole("link", { name: "Open session" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument();
- expect(screen.getAllByText("Need approval to proceed").length).toBeGreaterThan(1);
+ expect(screen.getAllByText("Mobile Density").length).toBeGreaterThan(1);
expect(screen.getAllByText("respond").length).toBeGreaterThan(0);
expect(screen.getAllByText("needs input").length).toBeGreaterThan(0);
expect(screen.getByText("waiting input")).toBeInTheDocument();
@@ -116,7 +117,7 @@ describe("Dashboard mobile layout", () => {
const { rerender } = render();
await act(async () => {
- fireEvent.click(screen.getByRole("button", { name: /open need approval to proceed/i }));
+ fireEvent.click(screen.getByRole("button", { name: /open mobile density/i }));
});
expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument();
@@ -219,12 +220,14 @@ describe("Dashboard mobile layout", () => {
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
+ branch: null,
}),
makeSession({
id: "working-1",
status: "running",
activity: "active",
summary: "Implement dashboard filters",
+ branch: null,
}),
]}
/>,
@@ -245,6 +248,7 @@ describe("Dashboard mobile layout", () => {
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
+ branch: null,
}),
]}
/>,
@@ -264,12 +268,14 @@ describe("Dashboard mobile layout", () => {
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
+ branch: null,
}),
makeSession({
id: "working-1",
status: "running",
activity: "active",
summary: "Implement dashboard filters",
+ branch: null,
}),
]}
/>,
@@ -289,6 +295,7 @@ describe("Dashboard mobile layout", () => {
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
+ branch: null,
lastActivityAt: new Date(Date.now() + 1_000).toISOString(),
}),
makeSession({
@@ -296,6 +303,7 @@ describe("Dashboard mobile layout", () => {
status: "running",
activity: "active",
summary: "Implement dashboard filters",
+ branch: null,
lastActivityAt: new Date(Date.now() + 2_000).toISOString(),
}),
]}
diff --git a/packages/web/src/components/__tests__/ErrorDisplay.test.tsx b/packages/web/src/components/__tests__/ErrorDisplay.test.tsx
new file mode 100644
index 000000000..1c86bff0e
--- /dev/null
+++ b/packages/web/src/components/__tests__/ErrorDisplay.test.tsx
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+}));
+
+import { ErrorDisplay } from "../ErrorDisplay";
+
+describe("ErrorDisplay", () => {
+ it("renders title, message, and actions", () => {
+ const onRetry = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Failed to load session")).toBeInTheDocument();
+ expect(screen.getByText("Try again.")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+
+ expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/");
+ });
+
+ it("renders technical details when provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Technical details")).toBeInTheDocument();
+ expect(screen.getByText(/digest: abc123/)).toBeInTheDocument();
+ expect(screen.getByText("HTTP 500")).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx b/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx
new file mode 100644
index 000000000..986de386d
--- /dev/null
+++ b/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx
@@ -0,0 +1,272 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { OrchestratorSelector } from "../OrchestratorSelector";
+
+const mockPush = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockPush }),
+}));
+
+const mockOrchestrators = [
+ {
+ id: "app-orchestrator-1",
+ projectId: "my-project",
+ projectName: "My Project",
+ status: "working",
+ activity: "active",
+ createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
+ lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago
+ },
+ {
+ id: "app-orchestrator-2",
+ projectId: "my-project",
+ projectName: "My Project",
+ status: "spawning",
+ activity: null,
+ createdAt: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
+ lastActivityAt: null,
+ },
+];
+
+const defaultProps = {
+ orchestrators: mockOrchestrators,
+ projectId: "my-project",
+ projectName: "My Project",
+ error: null,
+};
+
+describe("OrchestratorSelector", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockPush.mockClear();
+ global.fetch = vi.fn();
+ });
+
+ it("renders orchestrator list", () => {
+ render();
+
+ expect(screen.getByText("app-orchestrator-1")).toBeInTheDocument();
+ expect(screen.getByText("app-orchestrator-2")).toBeInTheDocument();
+ });
+
+ it("displays project name in header", () => {
+ render();
+
+ expect(screen.getByText("My Project")).toBeInTheDocument();
+ expect(screen.getByText("Select an orchestrator")).toBeInTheDocument();
+ });
+
+ it("shows count of existing sessions", () => {
+ render();
+
+ expect(screen.getByText(/existing orchestrator sessions/)).toBeInTheDocument();
+ // The count "2" appears in multiple places, so we check the full info banner text
+ expect(screen.getByText(/Found/)).toBeInTheDocument();
+ });
+
+ it("shows error state", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Error")).toBeInTheDocument();
+ expect(screen.getByText("Project not found")).toBeInTheDocument();
+ });
+
+ it("shows start new orchestrator button", () => {
+ render();
+
+ expect(screen.getByRole("button", { name: /start new orchestrator/i })).toBeInTheDocument();
+ });
+
+ it("spawns new orchestrator on button click and navigates", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ orchestrator: { id: "app-orchestrator-3" },
+ }),
+ });
+ global.fetch = mockFetch;
+
+ render();
+
+ const button = screen.getByRole("button", { name: /start new orchestrator/i });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith("/api/orchestrators", expect.any(Object));
+ });
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith("/sessions/app-orchestrator-3");
+ });
+ });
+
+ it("shows loading state while spawning", async () => {
+ const mockFetch = vi.fn().mockImplementation(
+ () => new Promise(() => {}), // Never resolves
+ );
+ global.fetch = mockFetch;
+
+ render();
+
+ const button = screen.getByRole("button", { name: /start new orchestrator/i });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByText(/creating new orchestrator/i)).toBeInTheDocument();
+ });
+ });
+
+ it("shows error when spawn fails", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: false,
+ json: () => Promise.resolve({ error: "Failed to spawn" }),
+ });
+ global.fetch = mockFetch;
+
+ render();
+
+ const button = screen.getByRole("button", { name: /start new orchestrator/i });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to spawn")).toBeInTheDocument();
+ });
+ });
+
+ it("links to orchestrator session page", () => {
+ render();
+
+ const link = screen.getByRole("link", { name: /app-orchestrator-1/i });
+ expect(link).toHaveAttribute("href", "/sessions/app-orchestrator-1");
+ });
+
+ it("displays status and activity for each orchestrator", () => {
+ render();
+
+ expect(screen.getByText("working")).toBeInTheDocument();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ expect(screen.getByText("spawning")).toBeInTheDocument();
+ });
+
+ it("covers relative time for days and status colors/labels", () => {
+ const wideOrchestrators = [
+ {
+ id: "orch-2",
+ projectId: "my-project",
+ projectName: "My Project",
+ status: "ci_failed",
+ activity: "waiting_input",
+ createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago
+ lastActivityAt: null,
+ },
+ {
+ id: "orch-3",
+ projectId: "my-project",
+ projectName: "My Project",
+ status: "killed",
+ activity: "ready",
+ createdAt: new Date(Date.now() - 1000).toISOString(), // Just now
+ lastActivityAt: null,
+ },
+ {
+ id: "orch-4",
+ projectId: "my-project",
+ projectName: "My Project",
+ status: "unknown",
+ activity: "blocked",
+ createdAt: new Date().toISOString(),
+ lastActivityAt: null,
+ },
+ {
+ id: "orch-5",
+ projectId: "my-project",
+ projectName: "My Project",
+ status: "mergeable",
+ activity: "exited",
+ createdAt: new Date().toISOString(),
+ lastActivityAt: null,
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/2d ago/)).toBeInTheDocument();
+ expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0);
+ expect(screen.getByText(/Waiting/)).toBeInTheDocument();
+ expect(screen.getByText(/Ready/)).toBeInTheDocument();
+ expect(screen.getByText(/Blocked/)).toBeInTheDocument();
+ expect(screen.getByText(/Exited/)).toBeInTheDocument();
+ expect(screen.getByText(/ci failed/i)).toBeInTheDocument();
+ });
+
+ describe("formatRelativeTime edge cases", () => {
+ it("shows Unknown for invalid date strings", () => {
+ const orchestratorsWithInvalidDate = [
+ {
+ ...mockOrchestrators[0],
+ createdAt: "not-a-valid-date",
+ lastActivityAt: null,
+ },
+ ];
+ render(
+ ,
+ );
+
+ // The "Created Unknown" text should appear for invalid dates
+ expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
+ });
+
+ it("shows Just now for future timestamps", () => {
+ const futureDate = new Date(Date.now() + 60000).toISOString(); // 1 minute in future
+ const orchestratorsWithFutureDate = [
+ {
+ ...mockOrchestrators[0],
+ createdAt: futureDate,
+ lastActivityAt: null,
+ },
+ ];
+ render(
+ ,
+ );
+
+ // Future timestamps should show "Just now" instead of negative values
+ expect(screen.getByText(/Created Just now/)).toBeInTheDocument();
+ });
+
+ it("shows Unknown for null dates", () => {
+ const orchestratorsWithNullDate = [
+ {
+ ...mockOrchestrators[0],
+ createdAt: null,
+ lastActivityAt: null,
+ },
+ ];
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/web/src/components/__tests__/PRStatus.coverage.test.tsx b/packages/web/src/components/__tests__/PRStatus.coverage.test.tsx
new file mode 100644
index 000000000..4e2a2366e
--- /dev/null
+++ b/packages/web/src/components/__tests__/PRStatus.coverage.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { PRCard } from "../PRStatus";
+import { makePR } from "../../__tests__/helpers";
+
+describe("PRCard diff coverage", () => {
+ it("shows fallback review and CI labels for unenriched PRs", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.getByRole("link", { name: /#635 hydrate pr details later/i })).toBeTruthy();
+ expect(screen.queryByText("approved")).toBeNull();
+ expect(screen.queryByText("CI passing")).toBeNull();
+ expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThanOrEqual(3);
+ });
+});
diff --git a/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx
new file mode 100644
index 000000000..8f08c78de
--- /dev/null
+++ b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx
@@ -0,0 +1,89 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { SessionCard } from "../SessionCard";
+import { makePR, makeSession } from "../../__tests__/helpers";
+
+describe("SessionCard diff coverage", () => {
+ it("shows the done-card size shimmer for terminal sessions with unenriched PRs", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
+ });
+
+ it("does not show placeholder PR metrics in the done-card detail panel before enrichment", () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText("Cold-cache terminal PR"));
+
+ expect(screen.getByText("PR details loading...")).not.toBeNull();
+ expect(screen.queryByText("mergeable: no")).toBeNull();
+ expect(screen.queryByText("review: none")).toBeNull();
+ });
+
+ it("shows enriched PR metrics in the done-card detail panel when data is available", () => {
+ render(
+ ,
+ );
+
+ // Click to expand the done card
+ fireEvent.click(screen.getByText("fix: auth token refresh"));
+
+ // Enriched PR detail lines 361-377 should render
+ expect(screen.getByText("mergeable: yes")).not.toBeNull();
+ expect(screen.getByText("review: approved")).not.toBeNull();
+ // +42 appears in both meta chips and expanded detail
+ expect(screen.getAllByText("+42").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("-7").length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx
index 4875f9841..db732c779 100644
--- a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx
+++ b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx
@@ -40,6 +40,7 @@ describe("SessionDetail mobile navbar", () => {
projectId: "my-app",
metadata: { role: "orchestrator" },
summary: "Orchestrator session title",
+ branch: null,
});
render(
@@ -123,7 +124,7 @@ describe("SessionDetail mobile navbar", () => {
/>,
);
- expect(screen.getByText("Compact mobile header")).toBeInTheDocument();
+ expect(screen.getByText("Compact header polish")).toBeInTheDocument();
expect(screen.getByText("feat/compact-header")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "PR #77" })).toHaveClass(
"session-detail-link-pill--link",
@@ -133,6 +134,24 @@ describe("SessionDetail mobile navbar", () => {
);
});
+ it("prefers issue title over changing summary text", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Fix stable session titles")).toBeInTheDocument();
+ expect(screen.queryByText("Responding to latest review comment")).not.toBeInTheDocument();
+ });
+
it("preserves CI and unresolved review comment detail on mobile session pages", () => {
render(
{
- let eventSourceMock: {
- onmessage: ((event: MessageEvent) => void) | null;
- onopen: (() => void) | null;
- onerror: (() => void) | null;
- readyState: number;
- close: () => void;
- };
- let eventSourceInstances: (typeof eventSourceMock)[];
-
- beforeEach(() => {
- eventSourceInstances = [];
- const eventSourceConstructor = vi.fn(() => {
- const instance = {
- onmessage: null as ((event: MessageEvent) => void) | null,
- onopen: null as (() => void) | null,
- onerror: null as (() => void) | null,
- readyState: 0,
- close: vi.fn(),
- };
- eventSourceInstances.push(instance);
- eventSourceMock = instance;
- return instance as unknown as EventSource;
- });
- global.EventSource = Object.assign(eventSourceConstructor, {
- CONNECTING: 0,
- OPEN: 1,
- CLOSED: 2,
- }) as unknown as typeof EventSource;
- global.fetch = vi.fn();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- const makeSessions = (count: number): DashboardSession[] =>
- Array.from({ length: count }, (_, i) => makeSession({ id: `session-${i}` }));
-
- const makeGlobalPause = (overrides: Partial = {}): GlobalPauseState => ({
- pausedUntil: new Date(Date.now() + 3600000).toISOString(),
- reason: "Model rate limit reached",
- sourceSessionId: "session-1",
- ...overrides,
- });
-
- describe("initial state", () => {
- it("returns initial sessions and globalPause", () => {
- const sessions = makeSessions(2);
- const globalPause = makeGlobalPause();
-
- const { result } = renderHook(() => useSessionEvents(sessions, globalPause));
-
- expect(result.current.sessions).toEqual(sessions);
- expect(result.current.globalPause).toEqual(globalPause);
- });
-
- it("accepts null globalPause", () => {
- const sessions = makeSessions(1);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- expect(result.current.sessions).toEqual(sessions);
- expect(result.current.globalPause).toBeNull();
- });
- });
-
- describe("globalPause state updates from /api/sessions", () => {
- it("updates globalPause when membership changes and /api/sessions returns new pause state", async () => {
- const initialSessions = makeSessions(2);
- const initialPause: GlobalPauseState = makeGlobalPause({ reason: "Initial pause" });
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions: [...initialSessions, makeSession({ id: "session-new" })],
- globalPause: makeGlobalPause({ reason: "Updated pause from different provider" }),
- }),
- } as unknown as Response);
-
- const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause));
-
- expect(result.current.globalPause?.reason).toBe("Initial pause");
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-1",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(result.current.globalPause?.reason).toBe("Updated pause from different provider");
- });
- });
-
- it("clears globalPause when /api/sessions returns null pause", async () => {
- const initialSessions = makeSessions(2);
- const initialPause = makeGlobalPause();
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions: [...initialSessions, makeSession({ id: "session-new" })],
- globalPause: null,
- }),
- } as unknown as Response);
-
- const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause));
-
- expect(result.current.globalPause).not.toBeNull();
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-1",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(result.current.globalPause).toBeNull();
- });
- });
-
- it("sets globalPause when initially null and /api/sessions returns pause", async () => {
- const initialSessions = makeSessions(2);
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions: [...initialSessions, makeSession({ id: "session-new" })],
- globalPause: makeGlobalPause({ reason: "New rate limit detected" }),
- }),
- } as Response);
-
- const { result } = renderHook(() => useSessionEvents(initialSessions, null));
-
- expect(result.current.globalPause).toBeNull();
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-1",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(result.current.globalPause?.reason).toBe("New rate limit detected");
- });
- });
-
- it("refreshes globalPause on stale same-membership snapshots without waiting for membership churn", async () => {
- vi.useFakeTimers();
- const initialSessions = makeSessions(2);
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions: initialSessions,
- globalPause: makeGlobalPause({ reason: "Pause updated during steady-state SSE" }),
- }),
- } as Response);
-
- const { result } = renderHook(() => useSessionEvents(initialSessions, null));
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(15000);
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: initialSessions.map((session) => ({
- id: session.id,
- status: session.status,
- activity: session.activity,
- lastActivityAt: session.lastActivityAt,
- })),
- }),
- } as MessageEvent);
- await vi.advanceTimersByTimeAsync(120);
- });
-
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) });
- expect(result.current.globalPause?.reason).toBe("Pause updated during steady-state SSE");
- });
- });
-
- describe("provider-agnostic behavior", () => {
- it("handles globalPause from Claude Code agent without provider-specific logic", async () => {
- const sessions = makeSessions(1);
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions,
- globalPause: {
- pausedUntil: new Date(Date.now() + 7200000).toISOString(),
- reason: "usage limit reached for 2 hours",
- sourceSessionId: "claude-session-1",
- },
- }),
- } as Response);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(result.current.globalPause?.reason).toBe("usage limit reached for 2 hours");
- expect(result.current.globalPause?.sourceSessionId).toBe("claude-session-1");
- });
- });
-
- it("handles globalPause from OpenCode agent without provider-specific logic", async () => {
- const sessions = makeSessions(1);
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions,
- globalPause: {
- pausedUntil: new Date(Date.now() + 1800000).toISOString(),
- reason: "Model capacity exceeded",
- sourceSessionId: "opencode-session-42",
- },
- }),
- } as Response);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(result.current.globalPause?.reason).toBe("Model capacity exceeded");
- expect(result.current.globalPause?.sourceSessionId).toBe("opencode-session-42");
- });
- });
-
- it("handles globalPause from Codex agent without provider-specific logic", async () => {
- const sessions = makeSessions(1);
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- sessions,
- globalPause: {
- pausedUntil: new Date(Date.now() + 3600000).toISOString(),
- reason: "API quota exhausted",
- sourceSessionId: "codex-worker-99",
- },
- }),
- } as Response);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(result.current.globalPause?.reason).toBe("API quota exhausted");
- expect(result.current.globalPause?.sourceSessionId).toBe("codex-worker-99");
- });
- });
- });
-
- describe("session state updates", () => {
- it("falls back to disconnected after a prolonged EventSource outage", async () => {
- vi.useFakeTimers();
- const sessions = makeSessions(1);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- expect(result.current.connectionStatus).toBe("connected");
-
- await act(async () => {
- eventSourceMock.readyState = EventSource.CONNECTING;
- eventSourceMock.onerror?.();
- });
-
- expect(result.current.connectionStatus).toBe("reconnecting");
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(4000);
- });
-
- expect(result.current.connectionStatus).toBe("disconnected");
- });
-
- it("clears the disconnect timer when EventSource reconnects", async () => {
- vi.useFakeTimers();
- const sessions = makeSessions(1);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- await act(async () => {
- eventSourceMock.readyState = EventSource.CONNECTING;
- eventSourceMock.onerror?.();
- await vi.advanceTimersByTimeAsync(2000);
- eventSourceMock.readyState = EventSource.OPEN;
- eventSourceMock.onopen?.();
- await vi.advanceTimersByTimeAsync(3000);
- });
-
- expect(result.current.connectionStatus).toBe("connected");
- });
-
- it("applies snapshot patches to sessions", async () => {
- const sessions = makeSessions(2);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "pr_open",
- activity: "idle",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- expect(result.current.sessions[0].status).toBe("pr_open");
- expect(result.current.sessions[0].activity).toBe("idle");
- expect(result.current.sessions[1].status).toBe("working");
- });
-
- it("preserves untouched session references across snapshot patches", async () => {
- const sessions = makeSessions(2);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
- const untouchedSession = result.current.sessions[1];
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "pr_open",
- activity: "idle",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- expect(result.current.sessions[0]).not.toBe(sessions[0]);
- expect(result.current.sessions[1]).toBe(untouchedSession);
- });
-
- it("coalesces bursty membership changes into a single refresh fetch", async () => {
- vi.useFakeTimers();
- const initialSessions = makeSessions(1);
-
- vi.mocked(fetch).mockResolvedValue({
- ok: true,
- json: async () => ({
- sessions: [
- ...initialSessions,
- makeSession({ id: "session-1" }),
- makeSession({ id: "session-2" }),
- makeSession({ id: "session-3" }),
- ],
- globalPause: null,
- }),
- } as Response);
-
- renderHook(() => useSessionEvents(initialSessions, null));
-
- await act(async () => {
- for (const sessionId of ["session-1", "session-2", "session-3"]) {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: sessionId,
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- }
-
- await vi.advanceTimersByTimeAsync(120);
- await Promise.resolve();
- });
-
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) });
- });
-
- it("ignores stale refresh completions after project changes", async () => {
- vi.useFakeTimers();
- const alphaSessions = [makeSession({ id: "alpha-0", projectId: "alpha" })];
- const betaSessions = [makeSession({ id: "beta-0", projectId: "beta" })];
- let resolveAlphaFetch: ((value: Response) => void) | null = null;
-
- vi.mocked(fetch).mockImplementation((input) => {
- const url = typeof input === "string" ? input : input.toString();
- if (url.includes("project=alpha")) {
- return new Promise((resolve) => {
- resolveAlphaFetch = resolve;
- });
- }
-
- return Promise.resolve({
- ok: true,
- json: async () => ({
- sessions: [...betaSessions, makeSession({ id: "beta-1", projectId: "beta" })],
- globalPause: null,
- }),
- } as unknown as Response);
- });
-
- const { result, rerender } = renderHook(
- ({ sessions, project }) => useSessionEvents(sessions, null, project),
- {
- initialProps: { sessions: alphaSessions, project: "alpha" },
- },
- );
-
- await act(async () => {
- eventSourceInstances[0].onmessage?.({
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "alpha-0",
- status: "working",
- activity: "active",
- lastActivityAt: alphaSessions[0].lastActivityAt,
- },
- {
- id: "alpha-1",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- await vi.advanceTimersByTimeAsync(120);
- });
-
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenNthCalledWith(1, "/api/sessions?project=alpha", {
- signal: expect.any(AbortSignal),
- });
-
- rerender({ sessions: betaSessions, project: "beta" });
-
- await act(async () => {
- resolveAlphaFetch?.({
- ok: true,
- json: async () => ({
- sessions: [...alphaSessions, makeSession({ id: "alpha-1", projectId: "alpha" })],
- globalPause: null,
- }),
- } as unknown as Response);
- await Promise.resolve();
- });
-
- expect(result.current.sessions).toEqual(betaSessions);
-
- await act(async () => {
- eventSourceInstances[1].onmessage?.({
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "beta-0",
- status: "working",
- activity: "active",
- lastActivityAt: betaSessions[0].lastActivityAt,
- },
- {
- id: "beta-1",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- await vi.advanceTimersByTimeAsync(120);
- });
-
- expect(fetch).toHaveBeenCalledTimes(2);
- expect(fetch).toHaveBeenNthCalledWith(2, "/api/sessions?project=beta", {
- signal: expect.any(AbortSignal),
- });
- });
-
- it("swallows refresh fetch JSON failures without resetting sessions", async () => {
- const sessions = makeSessions(1);
-
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => {
- throw new Error("bad json");
- },
- } as unknown as Response);
-
- const { result } = renderHook(() => useSessionEvents(sessions, null));
-
- await act(async () => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- {
- id: "session-0",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- {
- id: "session-new",
- status: "working",
- activity: "active",
- lastActivityAt: new Date().toISOString(),
- },
- ],
- }),
- } as MessageEvent);
- });
-
- await waitFor(() => {
- expect(fetch).toHaveBeenCalledWith("/api/sessions", {
- signal: expect.any(AbortSignal),
- });
- });
-
- expect(result.current.sessions).toHaveLength(1);
- expect(result.current.sessions[0].id).toBe("session-0");
- });
- });
-
- describe("sseAttentionLevels", () => {
- it("starts with empty attention levels when no initial levels provided", () => {
- const sessions = makeSessions(2);
- const { result } = renderHook(() => useSessionEvents(sessions));
- expect(result.current.sseAttentionLevels).toEqual({});
- });
-
- it("seeds attention levels from initialAttentionLevels parameter", () => {
- const sessions = makeSessions(2);
- const initialLevels = { "session-0": "working" as const, "session-1": "respond" as const };
- const { result } = renderHook(() => useSessionEvents(sessions, null, undefined, initialLevels));
- expect(result.current.sseAttentionLevels).toEqual({
- "session-0": "working",
- "session-1": "respond",
- });
- });
-
- it("populates attention levels from SSE snapshot", () => {
- const sessions = makeSessions(2);
- const { result } = renderHook(() => useSessionEvents(sessions));
-
- act(() => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
- { id: "session-1", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() },
- ],
- }),
- } as MessageEvent);
- });
-
- expect(result.current.sseAttentionLevels).toEqual({
- "session-0": "working",
- "session-1": "respond",
- });
- });
-
- it("updates attention levels when they change", () => {
- const sessions = makeSessions(1);
- const { result } = renderHook(() => useSessionEvents(sessions));
-
- act(() => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
- ],
- }),
- } as MessageEvent);
- });
-
- expect(result.current.sseAttentionLevels["session-0"]).toBe("working");
-
- act(() => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- { id: "session-0", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() },
- ],
- }),
- } as MessageEvent);
- });
-
- expect(result.current.sseAttentionLevels["session-0"]).toBe("respond");
- });
-
- it("preserves referential stability when attention levels do not change", () => {
- const sessions = makeSessions(1);
- const { result } = renderHook(() => useSessionEvents(sessions));
-
- const ts = new Date().toISOString();
-
- act(() => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts },
- ],
- }),
- } as MessageEvent);
- });
-
- const firstLevels = result.current.sseAttentionLevels;
-
- act(() => {
- eventSourceMock!.onmessage!.call(eventSourceMock, {
- data: JSON.stringify({
- type: "snapshot",
- sessions: [
- { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts },
- ],
- }),
- } as MessageEvent);
- });
-
- expect(result.current.sseAttentionLevels).toBe(firstLevels);
- });
-
- it("resets sseAttentionLevels to new initialAttentionLevels when initialSessions changes", () => {
- const sessions1 = makeSessions(1);
- let currentSessions = sessions1;
- let currentLevels: Record | undefined = { "session-0": "respond" as const };
-
- const { result, rerender } = renderHook(() =>
- useSessionEvents(currentSessions, null, undefined, currentLevels),
- );
-
- expect(result.current.sseAttentionLevels).toEqual({ "session-0": "respond" });
-
- const sessions2 = makeSessions(1).map((s) => ({ ...s, id: "session-new" }));
- const levels2 = { "session-new": "working" as const };
- currentSessions = sessions2;
- currentLevels = levels2;
- rerender();
-
- expect(result.current.sseAttentionLevels).toEqual({ "session-new": "working" });
- expect(result.current.sseAttentionLevels["session-0"]).toBeUndefined();
- });
- });
-});
diff --git a/packages/web/src/hooks/useSessionEvents.ts b/packages/web/src/hooks/useSessionEvents.ts
index 7eae5e8c7..524e748aa 100644
--- a/packages/web/src/hooks/useSessionEvents.ts
+++ b/packages/web/src/hooks/useSessionEvents.ts
@@ -1,11 +1,11 @@
"use client";
import { useEffect, useReducer, useRef } from "react";
-import type {
- AttentionLevel,
- DashboardSession,
- GlobalPauseState,
- SSESnapshotEvent,
+import {
+ getAttentionLevel,
+ type AttentionLevel,
+ type DashboardSession,
+ type SSESnapshotEvent,
} from "@/lib/types";
/** Debounce before fetching full session list after membership change. */
@@ -23,14 +23,13 @@ export type SSEAttentionMap = Readonly>;
interface State {
sessions: DashboardSession[];
- globalPause: GlobalPauseState | null;
connectionStatus: ConnectionStatus;
/** Attention levels from the latest SSE snapshot (server-computed, includes PR state). */
sseAttentionLevels: SSEAttentionMap;
}
type Action =
- | { type: "reset"; sessions: DashboardSession[]; globalPause: GlobalPauseState | null; sseAttentionLevels?: SSEAttentionMap }
+ | { type: "reset"; sessions: DashboardSession[]; sseAttentionLevels?: SSEAttentionMap }
| { type: "snapshot"; patches: SSESnapshotEvent["sessions"] }
| { type: "setConnection"; status: ConnectionStatus };
@@ -40,7 +39,6 @@ function reducer(state: State, action: Action): State {
return {
...state,
sessions: action.sessions,
- globalPause: action.globalPause,
...(action.sseAttentionLevels !== undefined
? { sseAttentionLevels: action.sseAttentionLevels }
: {}),
@@ -61,12 +59,7 @@ function reducer(state: State, action: Action): State {
return s;
}
changed = true;
- return {
- ...s,
- status: patch.status,
- activity: patch.activity,
- lastActivityAt: patch.lastActivityAt,
- };
+ return { ...s, status: patch.status, activity: patch.activity, lastActivityAt: patch.lastActivityAt };
});
// Build attention level map from server-computed values
@@ -102,13 +95,11 @@ function createMembershipKey(
export function useSessionEvents(
initialSessions: DashboardSession[],
- initialGlobalPause?: GlobalPauseState | null,
project?: string,
initialAttentionLevels?: SSEAttentionMap,
): State {
const [state, dispatch] = useReducer(reducer, {
sessions: initialSessions,
- globalPause: initialGlobalPause ?? null,
connectionStatus: "connected" as ConnectionStatus,
sseAttentionLevels: initialAttentionLevels ?? ({} as SSEAttentionMap),
});
@@ -118,9 +109,10 @@ export function useSessionEvents(
const refreshingRef = useRef(false);
const refreshTimerRef = useRef | null>(null);
const pendingMembershipKeyRef = useRef(null);
- const lastRefreshAtRef = useRef(Date.now());
+ const lastRefreshAtRef = useRef(0);
const disconnectedTimerRef = useRef | null>(null);
+ // Reset state when server-rendered props change (e.g. full page refresh)
useEffect(() => {
sessionsRef.current = state.sessions;
}, [state.sessions]);
@@ -129,12 +121,14 @@ export function useSessionEvents(
dispatch({
type: "reset",
sessions: initialSessions,
- globalPause: initialGlobalPause ?? null,
sseAttentionLevels: initialAttentionLevelsRef.current ?? ({} as SSEAttentionMap),
});
- }, [initialSessions, initialGlobalPause]);
+ }, [initialSessions]);
useEffect(() => {
+ // Reset so the new project gets an immediate first refresh on its first SSE snapshot
+ lastRefreshAtRef.current = 0;
+
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
const es = new EventSource(url);
let disposed = false;
@@ -172,20 +166,31 @@ export function useSessionEvents(
void fetch(sessionsUrl, { signal: refreshController.signal })
.then((res) => (res.ok ? res.json() : null))
.then(
- (updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null) => {
- if (disposed || refreshController.signal.aborted || !updated?.sessions) return;
+ (updated: { sessions?: DashboardSession[] } | null) => {
+ if (disposed || refreshController.signal.aborted || !updated?.sessions) {
+ // Update timestamp even for non-OK responses to prevent retry storms
+ if (!disposed && !refreshController.signal.aborted) {
+ lastRefreshAtRef.current = Date.now();
+ }
+ return;
+ }
lastRefreshAtRef.current = Date.now();
+ const sseAttentionLevels = Object.fromEntries(
+ updated.sessions.map((s) => [s.id, getAttentionLevel(s)]),
+ ) as SSEAttentionMap;
dispatch({
type: "reset",
sessions: updated.sessions,
- globalPause: updated.globalPause ?? null,
+ sseAttentionLevels,
});
},
)
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
console.warn("[useSessionEvents] refresh failed:", err);
+ // Update timestamp on failure to prevent retry loops on every SSE snapshot
+ lastRefreshAtRef.current = Date.now();
})
.finally(() => {
if (activeRefreshController === refreshController) {
diff --git a/packages/web/src/lib/__tests__/dashboard-page-data.fast-path.test.ts b/packages/web/src/lib/__tests__/dashboard-page-data.fast-path.test.ts
new file mode 100644
index 000000000..7ceb794d8
--- /dev/null
+++ b/packages/web/src/lib/__tests__/dashboard-page-data.fast-path.test.ts
@@ -0,0 +1,138 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const hoisted = vi.hoisted(() => ({
+ getServicesMock: vi.fn(),
+ getSCMMock: vi.fn(),
+ sessionToDashboardMock: vi.fn(),
+ resolveProjectMock: vi.fn(),
+ enrichSessionPRMock: vi.fn(),
+ enrichSessionsMetadataFastMock: vi.fn(),
+ listDashboardOrchestratorsMock: vi.fn(),
+ filterProjectSessionsMock: vi.fn(),
+ filterWorkerSessionsMock: vi.fn(),
+ resolveGlobalPauseMock: vi.fn(),
+ getAllProjectsMock: vi.fn(),
+ getPrimaryProjectIdMock: vi.fn(),
+ getProjectNameMock: vi.fn(),
+}));
+
+vi.mock("@/lib/services", () => ({
+ getServices: hoisted.getServicesMock,
+ getSCM: hoisted.getSCMMock,
+}));
+
+vi.mock("@/lib/serialize", () => ({
+ sessionToDashboard: hoisted.sessionToDashboardMock,
+ resolveProject: hoisted.resolveProjectMock,
+ enrichSessionPR: hoisted.enrichSessionPRMock,
+ enrichSessionsMetadataFast: hoisted.enrichSessionsMetadataFastMock,
+ listDashboardOrchestrators: hoisted.listDashboardOrchestratorsMock,
+}));
+
+vi.mock("@/lib/project-utils", () => ({
+ filterProjectSessions: hoisted.filterProjectSessionsMock,
+ filterWorkerSessions: hoisted.filterWorkerSessionsMock,
+}));
+
+vi.mock("@/lib/global-pause", () => ({
+ resolveGlobalPause: hoisted.resolveGlobalPauseMock,
+}));
+
+vi.mock("@/lib/project-name", () => ({
+ getAllProjects: hoisted.getAllProjectsMock,
+ getPrimaryProjectId: hoisted.getPrimaryProjectIdMock,
+ getProjectName: hoisted.getProjectNameMock,
+}));
+
+import { getDashboardPageData } from "@/lib/dashboard-page-data";
+
+describe("getDashboardPageData fast path", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ hoisted.getAllProjectsMock.mockReturnValue([
+ { id: "docs", name: "Docs" },
+ { id: "mono", name: "Mono" },
+ ]);
+ hoisted.getPrimaryProjectIdMock.mockReturnValue("docs");
+ hoisted.getProjectNameMock.mockReturnValue("Docs");
+ hoisted.resolveGlobalPauseMock.mockReturnValue({ reason: "paused" });
+ hoisted.listDashboardOrchestratorsMock.mockReturnValue([{ id: "orch-1", projectId: "docs", projectName: "Docs" }]);
+ hoisted.enrichSessionsMetadataFastMock.mockResolvedValue(undefined);
+ });
+
+ it("runs fast enrichment, uses cache-only PR hydration, and infers merged/closed state for terminal cache misses even without SCM", async () => {
+ const noPrCore = { id: "session-no-pr", status: "working", pr: null };
+ const closedCore = { id: "session-closed", status: "killed", pr: { number: 2 } };
+ const mergedCore = { id: "session-merged", status: "merged", pr: { number: 3 } };
+ const allSessions = [noPrCore, closedCore, mergedCore];
+
+ const dashboardNoPr = { id: "session-no-pr", pr: null };
+ const dashboardClosed = { id: "session-closed", pr: { state: "open", enriched: false } };
+ const dashboardMerged = { id: "session-merged", pr: { state: "open", enriched: false } };
+
+ hoisted.getServicesMock.mockResolvedValue({
+ config: { projects: { docs: { id: "docs" } } },
+ registry: { scm: "registry" },
+ sessionManager: { list: vi.fn().mockResolvedValue(allSessions) },
+ });
+ hoisted.filterProjectSessionsMock.mockReturnValue(allSessions);
+ hoisted.filterWorkerSessionsMock.mockReturnValue(allSessions);
+ hoisted.sessionToDashboardMock
+ .mockReturnValueOnce(dashboardNoPr)
+ .mockReturnValueOnce(dashboardClosed)
+ .mockReturnValueOnce(dashboardMerged);
+ hoisted.resolveProjectMock.mockImplementation((core) => ({ id: core.id }));
+ hoisted.getSCMMock
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce({ provider: "github" });
+
+ const pageData = await getDashboardPageData("docs");
+
+ expect(hoisted.enrichSessionsMetadataFastMock).toHaveBeenCalledWith(
+ allSessions,
+ [dashboardNoPr, dashboardClosed, dashboardMerged],
+ { projects: { docs: { id: "docs" } } },
+ { scm: "registry" },
+ );
+ expect(hoisted.enrichSessionPRMock).toHaveBeenCalledTimes(1);
+ expect(hoisted.enrichSessionPRMock).toHaveBeenCalledWith(
+ dashboardMerged,
+ { provider: "github" },
+ mergedCore.pr,
+ { cacheOnly: true },
+ );
+ expect(dashboardClosed.pr.state).toBe("closed");
+ expect(dashboardMerged.pr.state).toBe("merged");
+ expect(pageData.sessions).toEqual([dashboardNoPr, dashboardClosed, dashboardMerged]);
+ });
+
+ it("does not block SSR indefinitely when fast metadata enrichment hangs", async () => {
+ vi.useFakeTimers();
+
+ try {
+ const core = { id: "session-hung", status: "working", pr: null };
+ const dashboard = { id: "session-hung", pr: null };
+
+ hoisted.getServicesMock.mockResolvedValue({
+ config: { projects: { mono: { id: "mono" } } },
+ registry: { scm: "registry" },
+ sessionManager: { list: vi.fn().mockResolvedValue([core]) },
+ });
+ hoisted.filterProjectSessionsMock.mockReturnValue([core]);
+ hoisted.filterWorkerSessionsMock.mockReturnValue([core]);
+ hoisted.sessionToDashboardMock.mockReturnValue(dashboard);
+ hoisted.enrichSessionsMetadataFastMock.mockImplementation(
+ () => new Promise(() => {}),
+ );
+
+ const pageDataPromise = getDashboardPageData("mono");
+ await vi.advanceTimersByTimeAsync(3_000);
+ const pageData = await pageDataPromise;
+
+ expect(pageData.sessions).toEqual([dashboard]);
+ expect(hoisted.enrichSessionPRMock).not.toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/packages/web/src/lib/__tests__/format.test.ts b/packages/web/src/lib/__tests__/format.test.ts
index c1d42260c..c32d746a8 100644
--- a/packages/web/src/lib/__tests__/format.test.ts
+++ b/packages/web/src/lib/__tests__/format.test.ts
@@ -121,16 +121,14 @@ describe("getSessionTitle", () => {
expect(getSessionTitle(session)).toBe("feat: add auth");
});
- it("returns agent summary over issue title", () => {
+ it("returns issue title over agent summary", () => {
const session = makeSession({
summary: "Implementing OAuth2 authentication with JWT tokens",
summaryIsFallback: false,
issueTitle: "Add user authentication",
branch: "feat/auth",
});
- expect(getSessionTitle(session)).toBe(
- "Implementing OAuth2 authentication with JWT tokens",
- );
+ expect(getSessionTitle(session)).toBe("Add user authentication");
});
it("skips fallback summaries in favor of issue title", () => {
@@ -143,16 +141,14 @@ describe("getSessionTitle", () => {
expect(getSessionTitle(session)).toBe("Add authentication to API");
});
- it("uses fallback summary when no issue title is available", () => {
+ it("uses branch before fallback summary when no issue title is available", () => {
const session = makeSession({
summary: "You are working on GitHub issue #42: Add authentication to API...",
summaryIsFallback: true,
issueTitle: null,
branch: "feat/issue-42",
});
- expect(getSessionTitle(session)).toBe(
- "You are working on GitHub issue #42: Add authentication to API...",
- );
+ expect(getSessionTitle(session)).toBe("Issue 42");
});
it("returns issue title when no summary exists", () => {
@@ -182,15 +178,56 @@ describe("getSessionTitle", () => {
expect(getSessionTitle(session)).toBe("working");
});
- it("prefers fallback summary over branch when no issue title", () => {
+ it("returns branch before summary when no issue title exists", () => {
const session = makeSession({
summary: "You are working on Linear ticket INT-1327: Refactor session manager",
summaryIsFallback: true,
issueTitle: null,
branch: "feat/INT-1327",
});
- expect(getSessionTitle(session)).toBe(
- "You are working on Linear ticket INT-1327: Refactor session manager",
- );
+ expect(getSessionTitle(session)).toBe("INT 1327");
+ });
+
+ it("returns quality summary when neither issue title nor branch exists", () => {
+ const session = makeSession({
+ summary: "Investigating flaky PR enrichment",
+ summaryIsFallback: false,
+ issueTitle: null,
+ branch: null,
+ });
+ expect(getSessionTitle(session)).toBe("Investigating flaky PR enrichment");
+ });
+
+ it("uses pinnedSummary from metadata before live summary when no branch or issue title", () => {
+ const session = makeSession({
+ summary: "Drifting live summary from latest agent output",
+ summaryIsFallback: false,
+ issueTitle: null,
+ branch: null,
+ metadata: { pinnedSummary: "Stable pinned title" },
+ });
+ expect(getSessionTitle(session)).toBe("Stable pinned title");
+ });
+
+ it("skips pinnedSummary when branch is present (branch takes priority)", () => {
+ const session = makeSession({
+ summary: "Live summary",
+ summaryIsFallback: false,
+ issueTitle: null,
+ branch: "feat/my-feature",
+ metadata: { pinnedSummary: "Pinned summary" },
+ });
+ expect(getSessionTitle(session)).toBe("My Feature");
+ });
+
+ it("falls through to live summary when pinnedSummary is empty", () => {
+ const session = makeSession({
+ summary: "Live quality summary",
+ summaryIsFallback: false,
+ issueTitle: null,
+ branch: null,
+ metadata: { pinnedSummary: "" },
+ });
+ expect(getSessionTitle(session)).toBe("Live quality summary");
});
});
diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts
index 7742aaf1e..4e1e9892c 100644
--- a/packages/web/src/lib/__tests__/serialize.test.ts
+++ b/packages/web/src/lib/__tests__/serialize.test.ts
@@ -20,6 +20,7 @@ import {
enrichSessionAgentSummary,
enrichSessionIssueTitle,
enrichSessionsMetadata,
+ enrichSessionsMetadataFast,
computeStats,
} from "../serialize";
import { prCache, prCacheKey } from "../cache";
@@ -190,6 +191,51 @@ describe("sessionToDashboard", () => {
expect(dashboard.summaryIsFallback).toBe(false);
});
+ it("should use live agentInfo summary even when pinnedSummary is set in metadata", () => {
+ const coreSession = createCoreSession({
+ agentInfo: {
+ summary: "Latest live summary from agent",
+ summaryIsFallback: false,
+ agentSessionId: "abc123",
+ },
+ metadata: { pinnedSummary: "First pinned summary" },
+ });
+ const dashboard = sessionToDashboard(coreSession);
+
+ // pinnedSummary must NOT replace dashboard.summary — live summary wins
+ expect(dashboard.summary).toBe("Latest live summary from agent");
+ expect(dashboard.summaryIsFallback).toBe(false);
+ // pinnedSummary remains accessible via metadata for title selection
+ expect(dashboard.metadata["pinnedSummary"]).toBe("First pinned summary");
+ });
+
+ it("should use metadata summary when pinnedSummary is also set (pinnedSummary only for titles)", () => {
+ const coreSession = createCoreSession({
+ agentInfo: null,
+ metadata: { pinnedSummary: "Pinned summary", summary: "Metadata summary" },
+ });
+ const dashboard = sessionToDashboard(coreSession);
+
+ // pinnedSummary must NOT override the regular metadata summary
+ expect(dashboard.summary).toBe("Metadata summary");
+ expect(dashboard.summaryIsFallback).toBe(false);
+ });
+
+ it("should use agentInfo summary regardless of pinnedSummary value", () => {
+ const coreSession = createCoreSession({
+ agentInfo: {
+ summary: "Agent summary",
+ summaryIsFallback: false,
+ agentSessionId: "abc123",
+ },
+ metadata: { pinnedSummary: "" },
+ });
+ const dashboard = sessionToDashboard(coreSession);
+
+ expect(dashboard.summary).toBe("Agent summary");
+ expect(dashboard.summaryIsFallback).toBe(false);
+ });
+
it("should convert PRInfo to DashboardPR with defaults", () => {
const pr = createPRInfo();
const coreSession = createCoreSession({ pr });
@@ -204,7 +250,8 @@ describe("sessionToDashboard", () => {
expect(dashboard.pr?.deletions).toBe(0);
expect(dashboard.pr?.ciStatus).toBe("none");
expect(dashboard.pr?.reviewDecision).toBe("none");
- expect(dashboard.pr?.mergeability.blockers).toContain("Data not loaded");
+ expect(dashboard.pr?.mergeability.blockers).toEqual([]);
+ expect(dashboard.pr?.enriched).toBe(false);
});
it("should set pr to null when session has no PR", () => {
@@ -808,6 +855,41 @@ describe("enrichSessionsMetadata", () => {
expect(dashboard.issueTitle).toBe("Fix auth bug");
});
+ it("starts issue-title fetches before agent summaries finish", async () => {
+ let resolveSummary: ((value: { summary: string; summaryIsFallback: false; agentSessionId: string }) => void) | null = null;
+
+ const tracker = mockTracker("Fix auth bug");
+ const agent = {
+ ...mockAgent(),
+ getSessionInfo: vi.fn().mockImplementation(
+ () => new Promise((resolve) => {
+ resolveSummary = resolve;
+ }),
+ ),
+ } as Agent;
+ const registry = mockRegistry(tracker, agent);
+
+ const core = createCoreSession({ issueId: `${urlBase}-parallel` });
+ const dashboard = sessionToDashboard(core);
+
+ const enrichmentPromise = enrichSessionsMetadata([core], [dashboard], testConfig, registry);
+ await Promise.resolve();
+
+ expect(tracker.getIssue).toHaveBeenCalledTimes(1);
+ expect(dashboard.issueLabel).toBe("#42");
+ expect(dashboard.summary).toBeNull();
+
+ resolveSummary?.({
+ summary: "Implementing auth fix",
+ summaryIsFallback: false,
+ agentSessionId: "abc",
+ });
+ await enrichmentPromise;
+
+ expect(dashboard.summary).toBe("Implementing auth fix");
+ expect(dashboard.issueTitle).toBe("Fix auth bug");
+ });
+
it("should skip sessions without issue URLs", async () => {
const tracker = mockTracker();
const agent = mockAgent();
@@ -948,6 +1030,61 @@ describe("enrichSessionsMetadata", () => {
});
});
+describe("enrichSessionsMetadataFast", () => {
+ it("should enrich issue labels and agent summaries but NOT issue titles", async () => {
+ const urlBase = "https://github.com/test/repo/issues/fast";
+ const tracker: Tracker = {
+ name: "mock-tracker",
+ getIssue: vi.fn().mockResolvedValue({ id: "99", title: "Should not be called", url: urlBase }),
+ issueLabel: vi.fn().mockReturnValue("#99"),
+ } as unknown as Tracker;
+ const agent: Agent = {
+ getSessionInfo: vi.fn().mockResolvedValue({
+ summary: "Fast summary",
+ summaryIsFallback: false,
+ agentSessionId: "abc",
+ }),
+ } as unknown as Agent;
+ const registry = {
+ get: vi.fn().mockImplementation((slot: string) => {
+ if (slot === "tracker") return tracker;
+ if (slot === "agent") return agent;
+ return null;
+ }),
+ } as unknown as PluginRegistry;
+
+ const config: OrchestratorConfig = {
+ projects: {
+ test: {
+ path: "/test",
+ repo: "test/repo",
+ defaultBranch: "main",
+ sessionPrefix: "test-",
+ tracker: { plugin: "mock-tracker" },
+ },
+ } as Record,
+ defaults: { agent: "mock-agent", runtime: "tmux" },
+ configPath: "/test/config.yaml",
+ } as OrchestratorConfig;
+
+ const core = createCoreSession({
+ issueId: `${urlBase}-fast`,
+ agentInfo: undefined,
+ });
+ const dashboard = sessionToDashboard(core);
+
+ await enrichSessionsMetadataFast([core], [dashboard], config, registry);
+
+ // Issue label should be set (synchronous)
+ expect(dashboard.issueLabel).toBe("#99");
+ // Agent summary should be set (local I/O)
+ expect(dashboard.summary).toBe("Fast summary");
+ // Issue title should NOT be set (tracker API is not called by fast path)
+ expect(dashboard.issueTitle).toBeNull();
+ expect(tracker.getIssue).not.toHaveBeenCalled();
+ });
+});
+
describe("computeStats", () => {
function makeDashboard(overrides: Partial = {}): DashboardSession {
return {
@@ -1030,11 +1167,12 @@ describe("basicPRToDashboard defaults", () => {
expect(dashboard.pr?.reviewDecision).not.toBe("changes_requested");
});
- it("should have explicit blocker indicating data not loaded", () => {
+ it("should mark unenriched PR with enriched: false", () => {
const pr = createPRInfo();
const coreSession = createCoreSession({ pr });
const dashboard = sessionToDashboard(coreSession);
- expect(dashboard.pr?.mergeability.blockers).toContain("Data not loaded");
+ expect(dashboard.pr?.enriched).toBe(false);
+ expect(dashboard.pr?.mergeability.blockers).toEqual([]);
});
});
diff --git a/packages/web/src/lib/async-utils.ts b/packages/web/src/lib/async-utils.ts
new file mode 100644
index 000000000..9224169b9
--- /dev/null
+++ b/packages/web/src/lib/async-utils.ts
@@ -0,0 +1,19 @@
+/**
+ * Race a promise against a timeout. Returns `true` if the promise settled
+ * (resolved or rejected) before the deadline, `false` if the timeout won.
+ * The timer is always cleaned up regardless of outcome.
+ */
+export async function settlesWithin(promise: Promise, timeoutMs: number): Promise {
+ let timeoutId: ReturnType | null = null;
+ const timeoutPromise = new Promise((resolve) => {
+ timeoutId = setTimeout(() => resolve(false), timeoutMs);
+ });
+
+ try {
+ return await Promise.race([promise.then(() => true).catch(() => true), timeoutPromise]);
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+}
diff --git a/packages/web/src/lib/dashboard-page-data.ts b/packages/web/src/lib/dashboard-page-data.ts
index 830b6e168..efb6e2419 100644
--- a/packages/web/src/lib/dashboard-page-data.ts
+++ b/packages/web/src/lib/dashboard-page-data.ts
@@ -1,21 +1,24 @@
+import "server-only";
+
import { cache } from "react";
-import type { DashboardSession, DashboardOrchestratorLink } from "@/lib/types";
+import { TERMINAL_STATUSES, type DashboardSession, type DashboardOrchestratorLink } from "@/lib/types";
import { getServices, getSCM } from "@/lib/services";
import {
sessionToDashboard,
resolveProject,
enrichSessionPR,
- enrichSessionsMetadata,
+ enrichSessionsMetadataFast,
listDashboardOrchestrators,
} from "@/lib/serialize";
-import { prCache, prCacheKey } from "@/lib/cache";
import { getPrimaryProjectId, getProjectName, getAllProjects, type ProjectInfo } from "@/lib/project-name";
import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils";
-import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause";
+import { settlesWithin } from "@/lib/async-utils";
+
+const FAST_METADATA_ENRICH_TIMEOUT_MS = 3_000;
+
interface DashboardPageData {
sessions: DashboardSession[];
- globalPause: GlobalPauseState | null;
orchestrators: DashboardOrchestratorLink[];
projectName: string;
projects: ProjectInfo[];
@@ -47,7 +50,6 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
const projectFilter = resolveDashboardProjectFilter(project);
const pageData: DashboardPageData = {
sessions: [],
- globalPause: null,
orchestrators: [],
projectName: getDashboardProjectName(projectFilter),
projects: getAllProjects(),
@@ -58,73 +60,43 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
const { config, registry, sessionManager } = await getServices();
const allSessions = await sessionManager.list();
- pageData.globalPause = resolveGlobalPause(allSessions);
-
const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects);
pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);
const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects);
pageData.sessions = coreSessions.map(sessionToDashboard);
- const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000));
- await Promise.race([
- enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry),
- metaTimeout,
- ]);
-
- const terminalStatuses = new Set(["merged", "killed", "cleanup", "done", "terminated"]);
- const enrichPromises = coreSessions.map((core, index) => {
- if (!core.pr) return Promise.resolve();
-
- const cacheKey = prCacheKey(core.pr.owner, core.pr.repo, core.pr.number);
- const cached = prCache.get(cacheKey);
-
- if (cached) {
- const sessionPR = pageData.sessions[index]?.pr;
- if (sessionPR) {
- sessionPR.state = cached.state;
- sessionPR.title = cached.title;
- sessionPR.additions = cached.additions;
- sessionPR.deletions = cached.deletions;
- sessionPR.ciStatus = cached.ciStatus as
- | "none"
- | "pending"
- | "passing"
- | "failing";
- sessionPR.reviewDecision = cached.reviewDecision as
- | "none"
- | "pending"
- | "approved"
- | "changes_requested";
- sessionPR.ciChecks = cached.ciChecks.map((check) => ({
- name: check.name,
- status: check.status as "pending" | "running" | "passed" | "failed" | "skipped",
- url: check.url,
- }));
- sessionPR.mergeability = cached.mergeability;
- sessionPR.unresolvedThreads = cached.unresolvedThreads;
- sessionPR.unresolvedComments = cached.unresolvedComments;
- }
-
- if (
- terminalStatuses.has(core.status) ||
- cached.state === "merged" ||
- cached.state === "closed"
- ) {
- return Promise.resolve();
- }
- }
+ // Fast enrichment: issue labels (sync) + agent summaries (local disk I/O).
+ // Keep a hard cap here so a slow local agent plugin can't stall SSR indefinitely.
+ await settlesWithin(
+ enrichSessionsMetadataFast(coreSessions, pageData.sessions, config, registry),
+ FAST_METADATA_ENRICH_TIMEOUT_MS,
+ );
+ // PR cache hits only (in-memory lookup, no SCM API calls)
+ // TERMINAL_STATUSES includes merged, killed, cleanup, done, terminated, errored
+ for (let i = 0; i < coreSessions.length; i++) {
+ const core = coreSessions[i];
+ if (!core.pr) continue;
const projectConfig = resolveProject(core, config.projects);
const scm = getSCM(registry, projectConfig);
- if (!scm) return Promise.resolve();
- return enrichSessionPR(pageData.sessions[index], scm, core.pr);
- });
- const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 4_000));
- await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]);
+ if (scm) {
+ await enrichSessionPR(pageData.sessions[i], scm, core.pr, { cacheOnly: true });
+ }
+
+ // For cache-miss PRs, infer terminal PR state from lifecycle status
+ // to avoid showing merged/closed PRs as "open" until client refresh.
+ const sessionPR = pageData.sessions[i].pr;
+ if (sessionPR && !sessionPR.enriched && TERMINAL_STATUSES.has(core.status)) {
+ if (core.status === "merged") {
+ sessionPR.state = "merged";
+ } else if (core.status === "killed") {
+ sessionPR.state = "closed";
+ }
+ }
+ }
} catch {
pageData.sessions = [];
- pageData.globalPause = null;
pageData.orchestrators = [];
}
diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts
index 8f81a890e..a2928a2c3 100644
--- a/packages/web/src/lib/format.ts
+++ b/packages/web/src/lib/format.ts
@@ -29,30 +29,35 @@ export function humanizeBranch(branch: string): string {
*
* Fallback chain (ordered by signal quality):
* 1. PR title — human-visible deliverable name
- * 2. Quality summary — real agent-generated summary (not a fallback)
- * 3. Issue title — human-written task description
- * 4. Any summary — even a fallback excerpt is better than nothing
- * 5. Humanized branch — last resort with semantic content
- * 6. Status text — absolute fallback
+ * 2. Issue title — human-written task description
+ * 3. Humanized branch — stable task identifier when no explicit title exists
+ * 4. Pinned summary — first quality summary, stable across agent updates
+ * 5. Quality summary — live summary, but can drift as the session evolves
+ * 6. Any summary — even a fallback excerpt is better than nothing
+ * 7. Status text — absolute fallback
*/
export function getSessionTitle(session: DashboardSession): string {
// 1. PR title — always best
if (session.pr?.title) return session.pr.title;
- // 2. Quality summary — skip fallback summaries (truncated spawn prompts)
+ // 2. Issue title — human-written task description
+ if (session.issueTitle) return session.issueTitle;
+
+ // 3. Humanized branch — stable semantic fallback
+ if (session.branch) return humanizeBranch(session.branch);
+
+ // 4. Pinned summary — first quality summary, stable across agent updates
+ const pinnedSummary = session.metadata["pinnedSummary"];
+ if (pinnedSummary) return pinnedSummary;
+
+ // 5. Quality summary — skip fallback summaries (truncated spawn prompts)
if (session.summary && !session.summaryIsFallback) {
return session.summary;
}
- // 3. Issue title — human-written task description
- if (session.issueTitle) return session.issueTitle;
-
- // 4. Any summary — even fallback excerpts beat branch names
+ // 6. Any summary — even fallback excerpts beat raw status text
if (session.summary) return session.summary;
- // 5. Humanized branch
- if (session.branch) return humanizeBranch(session.branch);
-
- // 6. Status
+ // 7. Status
return session.status;
}
diff --git a/packages/web/src/lib/global-pause.ts b/packages/web/src/lib/global-pause.ts
deleted file mode 100644
index 2811aada6..000000000
--- a/packages/web/src/lib/global-pause.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import {
- GLOBAL_PAUSE_REASON_KEY,
- GLOBAL_PAUSE_SOURCE_KEY,
- GLOBAL_PAUSE_UNTIL_KEY,
- isOrchestratorSession,
- parsePauseUntil,
-} from "@composio/ao-core";
-
-export interface GlobalPauseState {
- pausedUntil: string;
- reason: string;
- sourceSessionId: string | null;
-}
-
-export function resolveGlobalPause(
- sessions: Array<{ id: string; metadata: Record }>,
-): GlobalPauseState | null {
- for (const session of sessions) {
- if (!isOrchestratorSession(session)) continue;
- const parsed = parsePauseUntil(session.metadata[GLOBAL_PAUSE_UNTIL_KEY]);
- if (!parsed || parsed.getTime() <= Date.now()) continue;
-
- return {
- pausedUntil: parsed.toISOString(),
- reason: session.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
- sourceSessionId: session.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null,
- };
- }
-
- return null;
-}
diff --git a/packages/web/src/lib/observability.ts b/packages/web/src/lib/observability.ts
index da33da03d..284273188 100644
--- a/packages/web/src/lib/observability.ts
+++ b/packages/web/src/lib/observability.ts
@@ -1,3 +1,5 @@
+import "server-only";
+
import {
createCorrelationId,
createProjectObserver,
diff --git a/packages/web/src/lib/orchestrator-utils.ts b/packages/web/src/lib/orchestrator-utils.ts
new file mode 100644
index 000000000..ce4efeb6b
--- /dev/null
+++ b/packages/web/src/lib/orchestrator-utils.ts
@@ -0,0 +1,26 @@
+import type { Session } from "@composio/ao-core";
+import { isOrchestratorSession, isTerminalSession } from "@composio/ao-core/types";
+import type { Orchestrator } from "@/components/OrchestratorSelector";
+
+/**
+ * Filter and map sessions to orchestrator DTOs.
+ * Shared between page.tsx and API route to ensure consistent orchestrator listing.
+ */
+export function mapSessionsToOrchestrators(
+ sessions: Session[],
+ sessionPrefix: string,
+ projectName: string,
+ allSessionPrefixes?: string[],
+): Orchestrator[] {
+ return sessions
+ .filter((s) => isOrchestratorSession(s, sessionPrefix, allSessionPrefixes) && !isTerminalSession(s))
+ .map((s) => ({
+ id: s.id,
+ projectId: s.projectId,
+ projectName,
+ status: s.status,
+ activity: s.activity,
+ createdAt: s.createdAt?.toISOString() ?? null,
+ lastActivityAt: s.lastActivityAt?.toISOString() ?? null,
+ }));
+}
diff --git a/packages/web/src/lib/project-name.ts b/packages/web/src/lib/project-name.ts
index d6fdae75a..0aaf920c5 100644
--- a/packages/web/src/lib/project-name.ts
+++ b/packages/web/src/lib/project-name.ts
@@ -1,9 +1,12 @@
+import "server-only";
+
import { cache } from "react";
import { loadConfig } from "@composio/ao-core";
export interface ProjectInfo {
id: string;
name: string;
+ sessionPrefix?: string;
}
export const getProjectName = cache((): string => {
@@ -37,6 +40,7 @@ export const getAllProjects = cache((): ProjectInfo[] => {
return Object.entries(config.projects).map(([id, project]) => ({
id,
name: project.name ?? id,
+ sessionPrefix: project.sessionPrefix ?? id,
}));
} catch {
return [];
diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts
index 179d00eec..1807034a9 100644
--- a/packages/web/src/lib/project-utils.ts
+++ b/packages/web/src/lib/project-utils.ts
@@ -44,6 +44,16 @@ export function filterWorkerSessions(
projectFilter: string | null | undefined,
projects: Record,
): T[] {
- const workers = sessions.filter((s) => !isOrchestratorSession(s));
+ const allSessionPrefixes = Object.entries(projects).map(
+ ([projectId, p]) => p.sessionPrefix ?? projectId,
+ );
+ const workers = sessions.filter(
+ (s) =>
+ !isOrchestratorSession(
+ s,
+ projects[s.projectId]?.sessionPrefix ?? s.projectId,
+ allSessionPrefixes,
+ ),
+ );
return filterProjectSessions(workers, projectFilter, projects);
}
diff --git a/packages/web/src/lib/scm-webhooks.ts b/packages/web/src/lib/scm-webhooks.ts
index 105dca848..5e2472f92 100644
--- a/packages/web/src/lib/scm-webhooks.ts
+++ b/packages/web/src/lib/scm-webhooks.ts
@@ -1,3 +1,5 @@
+import "server-only";
+
import {
TERMINAL_STATUSES,
type OrchestratorConfig,
@@ -34,7 +36,7 @@ export function findWebhookProjects(
pathname: string,
): WebhookProjectMatch[] {
return Object.entries(config.projects).flatMap(([projectId, project]) => {
- if (!project.scm) return [];
+ if (!project.scm?.plugin) return [];
const webhookPath = getProjectWebhookPath(project);
if (!webhookPath || webhookPath !== pathname) return [];
const scm = registry.get("scm", project.scm.plugin);
diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts
index d8668cb49..d24372b6a 100644
--- a/packages/web/src/lib/serialize.ts
+++ b/packages/web/src/lib/serialize.ts
@@ -1,3 +1,5 @@
+import "server-only";
+
/**
* Core Session → DashboardSession serialization.
*
@@ -73,8 +75,17 @@ export function listDashboardOrchestrators(
sessions: Session[],
projects: Record,
): DashboardOrchestratorLink[] {
+ const allSessionPrefixes = Object.entries(projects).map(
+ ([projectId, p]) => p.sessionPrefix ?? projectId,
+ );
return sessions
- .filter((session) => isOrchestratorSession(session))
+ .filter((session) =>
+ isOrchestratorSession(
+ session,
+ projects[session.projectId]?.sessionPrefix ?? session.projectId,
+ allSessionPrefixes,
+ ),
+ )
.map((session) => ({
id: session.id,
projectId: session.projectId,
@@ -109,10 +120,11 @@ function basicPRToDashboard(pr: PRInfo): DashboardPR {
ciPassing: false, // Conservative default
approved: false,
noConflicts: true, // Optimistic default (conflicts are rare)
- blockers: ["Data not loaded"], // Explicit blocker
+ blockers: [],
},
unresolvedThreads: 0,
unresolvedComments: [],
+ enriched: false,
};
}
@@ -143,6 +155,7 @@ export async function enrichSessionPR(
dashboard.pr.mergeability = cached.mergeability;
dashboard.pr.unresolvedThreads = cached.unresolvedThreads;
dashboard.pr.unresolvedComments = cached.unresolvedComments;
+ dashboard.pr.enriched = true;
return true;
}
@@ -225,6 +238,9 @@ export async function enrichSessionPR(
}));
}
+ // Mark as enriched — we attempted SCM API calls and applied whatever succeeded
+ dashboard.pr.enriched = true;
+
// Add rate-limit warning blocker if most requests failed
// (but we still applied any successful results above)
if (
@@ -349,28 +365,45 @@ export async function enrichSessionIssueTitle(
}
/**
- * Enrich dashboard sessions with metadata (issue labels, agent summaries, issue titles).
- * Orchestrates sync + async enrichment in parallel. Does NOT enrich PR data — callers
- * handle that separately since strategies differ (e.g. terminal-session cache optimization).
+ * Fast-path metadata enrichment: issue labels (sync) + agent summaries (local I/O).
+ * Does NOT call tracker API for issue titles — use enrichSessionsMetadata() for full enrichment.
*/
-export async function enrichSessionsMetadata(
+export async function enrichSessionsMetadataFast(
coreSessions: Session[],
dashboardSessions: DashboardSession[],
config: OrchestratorConfig,
registry: PluginRegistry,
): Promise {
- // Resolve projects once per session (avoids repeated Object.entries lookups)
+ const { summaryPromises } = prepareSessionMetadataEnrichment(
+ coreSessions,
+ dashboardSessions,
+ config,
+ registry,
+ );
+
+ await Promise.allSettled(summaryPromises);
+}
+
+function prepareSessionMetadataEnrichment(
+ coreSessions: Session[],
+ dashboardSessions: DashboardSession[],
+ config: OrchestratorConfig,
+ registry: PluginRegistry,
+): {
+ projects: Array;
+ summaryPromises: Promise[];
+} {
const projects = coreSessions.map((core) => resolveProject(core, config.projects));
- // Enrich issue labels (synchronous — must run before async title enrichment)
+ // Issue labels (synchronous string parsing, no API calls)
projects.forEach((project, i) => {
- if (!dashboardSessions[i].issueUrl || !project?.tracker) return;
+ if (!dashboardSessions[i].issueUrl || !project?.tracker?.plugin) return;
const tracker = registry.get("tracker", project.tracker.plugin);
if (!tracker) return;
enrichSessionIssue(dashboardSessions[i], tracker, project);
});
- // Enrich agent summaries (reads agent's JSONL — local I/O, not an API call)
+ // Agent summaries (local disk I/O — reads agent JSONL)
const summaryPromises = coreSessions.map((core, i) => {
if (dashboardSessions[i].summary) return Promise.resolve();
const agentName = projects[i]?.agent ?? config.defaults.agent;
@@ -380,12 +413,32 @@ export async function enrichSessionsMetadata(
return enrichSessionAgentSummary(dashboardSessions[i], core, agent);
});
- // Enrich issue titles (fetches from tracker API, cached with TTL)
+ return { projects, summaryPromises };
+}
+
+/**
+ * Full metadata enrichment: issue labels, agent summaries, AND issue titles (tracker API).
+ * Used by /api/sessions for complete data. For SSR fast path, use enrichSessionsMetadataFast().
+ */
+export async function enrichSessionsMetadata(
+ coreSessions: Session[],
+ dashboardSessions: DashboardSession[],
+ config: OrchestratorConfig,
+ registry: PluginRegistry,
+): Promise {
+ const { projects, summaryPromises } = prepareSessionMetadataEnrichment(
+ coreSessions,
+ dashboardSessions,
+ config,
+ registry,
+ );
+
+ // Issue-title fetches depend on labels being set, but can run in parallel with summary I/O.
const issueTitlePromises = projects.map((project, i) => {
if (!dashboardSessions[i].issueUrl || !dashboardSessions[i].issueLabel) {
return Promise.resolve();
}
- if (!project?.tracker) return Promise.resolve();
+ if (!project?.tracker?.plugin) return Promise.resolve();
const tracker = registry.get("tracker", project.tracker.plugin);
if (!tracker) return Promise.resolve();
return enrichSessionIssueTitle(dashboardSessions[i], tracker, project);
diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts
index e815bf69f..7715136f8 100644
--- a/packages/web/src/lib/services.ts
+++ b/packages/web/src/lib/services.ts
@@ -1,3 +1,5 @@
+import "server-only";
+
/**
* Server-side singleton for core services.
*
@@ -137,7 +139,7 @@ async function labelIssuesForVerification(
for (const session of mergedSessions) {
const key = `${session.projectId}:${session.issueId}`;
const project = config.projects[session.projectId];
- if (!project?.tracker) {
+ if (!project?.tracker?.plugin) {
processedIssues.add(key);
continue;
}
@@ -180,7 +182,7 @@ async function relabelReopenedIssues(
registry: PluginRegistry,
): Promise {
for (const [, project] of Object.entries(config.projects)) {
- if (!project.tracker) continue;
+ if (!project.tracker?.plugin) continue;
const tracker = registry.get("tracker", project.tracker.plugin);
if (!tracker?.listIssues || !tracker.updateIssue) continue;
@@ -225,8 +227,16 @@ export async function pollBacklog(): Promise {
// Detect reopened issues: open state + agent:done label → relabel as agent:backlog
await relabelReopenedIssues(config, registry);
+ const allSessionPrefixes = Object.entries(config.projects).map(
+ ([id, p]) => p.sessionPrefix ?? id,
+ );
const workerSessions = allSessions.filter(
- (session) => !isOrchestratorSession(session) && !TERMINAL_STATUSES.has(session.status),
+ (session) =>
+ !isOrchestratorSession(
+ session,
+ config.projects[session.projectId]?.sessionPrefix ?? session.projectId,
+ allSessionPrefixes,
+ ) && !TERMINAL_STATUSES.has(session.status),
);
const activeIssueIds = new Set(
workerSessions
@@ -240,7 +250,7 @@ export async function pollBacklog(): Promise {
for (const [projectId, project] of Object.entries(config.projects)) {
if (availableSlots <= 0) break;
- if (!project.tracker) continue;
+ if (!project.tracker?.plugin) continue;
const tracker = registry.get("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
@@ -352,7 +362,7 @@ export async function getBacklogIssues(): Promise("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
@@ -380,7 +390,7 @@ export async function getVerifyIssues(): Promise("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
@@ -404,6 +414,6 @@ export async function getVerifyIssues(): Promise("scm", project.scm.plugin);
}
diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts
index db820c221..47a7e8594 100644
--- a/packages/web/src/lib/types.ts
+++ b/packages/web/src/lib/types.ts
@@ -17,9 +17,6 @@ export type {
PRState,
} from "@composio/ao-core/types";
-// Re-export global pause state from shared lib (provider-agnostic state contract)
-export type { GlobalPauseState } from "./global-pause";
-
import {
ACTIVITY_STATE,
SESSION_STATUS,
@@ -99,6 +96,9 @@ export interface DashboardPR {
mergeability: DashboardMergeability;
unresolvedThreads: number;
unresolvedComments: DashboardUnresolvedComment[];
+ /** Whether this PR has been enriched with live SCM data (or cache hit).
+ * `false` means only basic data from the session metadata is available. */
+ enriched?: boolean;
}
/**
@@ -169,6 +169,12 @@ export function isPRRateLimited(pr: DashboardPR): boolean {
return pr.mergeability.blockers.includes("API rate limited or unavailable");
}
+/** Returns true when a PR has not yet been enriched with live SCM data.
+ * Only returns true for explicit `false` — undefined (legacy data) is treated as enriched. */
+export function isPRUnenriched(pr: DashboardPR): boolean {
+ return pr.enriched === false;
+}
+
/**
* Returns true when a PR is open and all merge criteria are met.
* Does NOT return true for merged or closed PRs — those are already done.
@@ -207,7 +213,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
if (session.status === "mergeable" || session.status === "approved") {
return "merge";
}
- if (session.pr?.mergeability.mergeable) {
+ if (session.pr && !isPRUnenriched(session.pr) && session.pr.mergeability.mergeable) {
return "merge";
}
@@ -236,7 +242,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
if (session.status === "ci_failed" || session.status === "changes_requested") {
return "review";
}
- if (session.pr && !isPRRateLimited(session.pr)) {
+ if (session.pr && !isPRRateLimited(session.pr) && !isPRUnenriched(session.pr)) {
const pr = session.pr;
if (pr.ciStatus === CI_STATUS.FAILING) return "review";
if (pr.reviewDecision === "changes_requested") return "review";
@@ -247,7 +253,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
if (session.status === "review_pending") {
return "pending";
}
- if (session.pr && !isPRRateLimited(session.pr)) {
+ if (session.pr && !isPRRateLimited(session.pr) && !isPRUnenriched(session.pr)) {
const pr = session.pr;
if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending";
if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts
index d0f48db74..8e6585d6b 100644
--- a/packages/web/vitest.config.ts
+++ b/packages/web/vitest.config.ts
@@ -16,8 +16,42 @@ export default defineConfig({
},
},
resolve: {
- alias: {
- "@": resolve(__dirname, "./src"),
- },
+ alias: [
+ { find: "@composio/ao-core/types", replacement: resolve(__dirname, "../core/src/types.ts") },
+ {
+ find: "@composio/ao-core",
+ replacement: resolve(__dirname, "../core/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-runtime-tmux",
+ replacement: resolve(__dirname, "../plugins/runtime-tmux/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-agent-claude-code",
+ replacement: resolve(__dirname, "../plugins/agent-claude-code/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-agent-opencode",
+ replacement: resolve(__dirname, "../plugins/agent-opencode/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-workspace-worktree",
+ replacement: resolve(__dirname, "../plugins/workspace-worktree/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-scm-github",
+ replacement: resolve(__dirname, "../plugins/scm-github/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-tracker-github",
+ replacement: resolve(__dirname, "../plugins/tracker-github/src/index.ts"),
+ },
+ {
+ find: "@composio/ao-plugin-tracker-linear",
+ replacement: resolve(__dirname, "../plugins/tracker-linear/src/index.ts"),
+ },
+ { find: "server-only", replacement: resolve(__dirname, "./src/__tests__/server-only-mock.ts") },
+ { find: "@", replacement: resolve(__dirname, "./src") },
+ ],
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 830107e4a..ba10b54e6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -620,6 +620,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.4(react@19.2.4)
+ server-only:
+ specifier: ^0.0.1
+ version: 0.0.1
ws:
specifier: ^8.19.0
version: 8.19.0
@@ -631,6 +634,9 @@ importers:
specifier: ^1.1.0
version: 1.1.0
devDependencies:
+ '@next/bundle-analyzer':
+ specifier: ^15.1.0
+ version: 15.5.14
'@tailwindcss/postcss':
specifier: ^4.0.0
version: 4.1.18
@@ -915,6 +921,10 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
+ '@discoveryjs/json-ext@0.5.7':
+ resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
+ engines: {node: '>=10.0.0'}
+
'@emnapi/runtime@1.8.1':
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
@@ -1558,6 +1568,9 @@ packages:
'@manypkg/get-packages@1.1.3':
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
+ '@next/bundle-analyzer@15.5.14':
+ resolution: {integrity: sha512-Q8rK9QXAqnJ2Ct1XXRJALBccINg9rX0Zl8QSPrYCuL/J1c+k7cfGcRTUNDAP9WIvhYk8JfxZEm1JpcHi+MUKiw==}
+
'@next/env@15.5.12':
resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==}
@@ -1637,6 +1650,9 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -2139,6 +2155,10 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+ acorn-walk@8.3.5:
+ resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
+ engines: {node: '>=0.4.0'}
+
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
@@ -2344,6 +2364,10 @@ packages:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
+ commander@7.2.0:
+ resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+ engines: {node: '>= 10'}
+
composio-core@0.5.39:
resolution: {integrity: sha512-7BeSFlfRzr1cbIfGYJW4jQ3BHwaObOaFKiRJIFuWOmvOrTABl1hbxGkWPA3C+uFw9CFXbZhrLWNyD7lhYy2Scg==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
@@ -2386,6 +2410,9 @@ packages:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
+ debounce@1.2.1:
+ resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2443,6 +2470,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ duplexer@0.1.2:
+ resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
+
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -2742,6 +2772,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ gzip-size@6.0.0:
+ resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
+ engines: {node: '>=10'}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -2850,6 +2884,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-plain-object@5.0.0:
+ resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
+ engines: {node: '>=0.10.0'}
+
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -3186,6 +3224,10 @@ packages:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3281,6 +3323,10 @@ packages:
zod:
optional: true
+ opener@1.5.2:
+ resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
+ hasBin: true
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -3570,6 +3616,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ server-only@0.0.1:
+ resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
+
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -3596,6 +3645,10 @@ packages:
simple-wcswidth@1.1.2:
resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==}
+ sirv@2.0.4:
+ resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
+ engines: {node: '>= 10'}
+
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -3767,6 +3820,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
tough-cookie@5.1.2:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'}
@@ -4027,6 +4084,11 @@ packages:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
+ webpack-bundle-analyzer@4.10.1:
+ resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
+ engines: {node: '>= 10.13.0'}
+ hasBin: true
+
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
@@ -4071,6 +4133,18 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
+ ws@7.5.10:
+ resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
+ engines: {node: '>=8.3.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
@@ -4481,6 +4555,8 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
+ '@discoveryjs/json-ext@0.5.7': {}
+
'@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
@@ -4995,6 +5071,13 @@ snapshots:
globby: 11.1.0
read-yaml-file: 1.1.0
+ '@next/bundle-analyzer@15.5.14':
+ dependencies:
+ webpack-bundle-analyzer: 4.10.1
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
'@next/env@15.5.12': {}
'@next/swc-darwin-arm64@15.5.12':
@@ -5052,6 +5135,8 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@polka/url@1.0.0-next.29': {}
+
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.57.1':
@@ -5587,6 +5672,10 @@ snapshots:
dependencies:
acorn: 8.15.0
+ acorn-walk@8.3.5:
+ dependencies:
+ acorn: 8.15.0
+
acorn@8.15.0: {}
agent-base@7.1.4: {}
@@ -5776,6 +5865,8 @@ snapshots:
commander@13.1.0: {}
+ commander@7.2.0: {}
+
composio-core@0.5.39(@ai-sdk/openai@3.0.29(zod@3.25.76))(@cloudflare/workers-types@4.20260214.0)(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/openai@1.2.7(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0))(ai@6.0.86(zod@3.25.76))(langchain@1.2.24(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76)):
dependencies:
'@ai-sdk/openai': 3.0.29(zod@3.25.76)
@@ -5836,6 +5927,8 @@ snapshots:
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
+ debounce@1.2.1: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -5872,6 +5965,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ duplexer@0.1.2: {}
+
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.286: {}
@@ -6225,6 +6320,10 @@ snapshots:
graceful-fs@4.2.11: {}
+ gzip-size@6.0.0:
+ dependencies:
+ duplexer: 0.1.2
+
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@@ -6312,6 +6411,8 @@ snapshots:
is-number@7.0.0: {}
+ is-plain-object@5.0.0: {}
+
is-potential-custom-element-name@1.0.1: {}
is-subdir@1.2.0:
@@ -6645,6 +6746,8 @@ snapshots:
mri@1.2.0: {}
+ mrmime@2.0.1: {}
+
ms@2.1.3: {}
mustache@4.2.0: {}
@@ -6734,6 +6837,8 @@ snapshots:
ws: 8.19.0
zod: 3.25.76
+ opener@1.5.2: {}
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -7008,6 +7113,8 @@ snapshots:
semver@7.7.4: {}
+ server-only@0.0.1: {}
+
sharp@0.34.5:
dependencies:
'@img/colour': 1.0.0
@@ -7054,6 +7161,12 @@ snapshots:
simple-wcswidth@1.1.2: {}
+ sirv@2.0.4:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
sisteransi@1.0.5: {}
slash@3.0.0: {}
@@ -7202,6 +7315,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ totalist@3.0.1: {}
+
tough-cookie@5.1.2:
dependencies:
tldts: 6.1.86
@@ -7462,6 +7577,25 @@ snapshots:
webidl-conversions@7.0.0: {}
+ webpack-bundle-analyzer@4.10.1:
+ dependencies:
+ '@discoveryjs/json-ext': 0.5.7
+ acorn: 8.15.0
+ acorn-walk: 8.3.5
+ commander: 7.2.0
+ debounce: 1.2.1
+ escape-string-regexp: 4.0.0
+ gzip-size: 6.0.0
+ html-escaper: 2.0.2
+ is-plain-object: 5.0.0
+ opener: 1.5.2
+ picocolors: 1.1.1
+ sirv: 2.0.4
+ ws: 7.5.10
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
@@ -7506,6 +7640,8 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.1.2
+ ws@7.5.10: {}
+
ws@8.19.0: {}
xml-name-validator@5.0.0: {}