Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions server/agents-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, spyOn } from "bun:test";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import * as herdr from "./herdr/client.ts";
import { HerdrError } from "./herdr/client.ts";
import { createServer } from "./index.ts";
import type { ShellAgentLocator } from "./shell-agent.ts";

describe("GET /api/agents", () => {
let server: ReturnType<typeof createServer>;
let stateDir: string;
let bin: string;
const savedPath = process.env["PATH"];
// the shell-started agents this server finds: gjc here, omo not
const located: string[] = [];
const shellAgents = { executable: async (kind: string) => { located.push(kind); return kind === "gjc" ? "/opt/gjc/bin/gjc" : null; }, stop: async () => undefined } as unknown as ShellAgentLocator;
const manifests = spyOn(herdr, "agentManifests").mockImplementation(async () => ({ manifests: [{ agent: "codex" }, { agent: "pi" }, { agent: "someagent" }] }) as unknown as Awaited<ReturnType<typeof herdr.agentManifests>>);
const url = (path: string) => `http://127.0.0.1:${server.port}${path}`;

beforeAll(() => {
stateDir = mkdtempSync(join(tmpdir(), "ovis-agents-route-"));
bin = mkdtempSync(join(tmpdir(), "ovis-agents-bin-"));
// only codex is on this server's PATH
writeFileSync(join(bin, "codex"), "#!/bin/sh\n");
chmodSync(join(bin, "codex"), 0o755);
server = createServer({ port: 0, hostname: "127.0.0.1", stateDir, token: "", machines: false, sidecar: false, shellAgents });
});
afterEach(() => {
process.env["PATH"] = savedPath;
located.length = 0;
manifests.mockClear();
});
afterAll(() => {
server.stop();
manifests.mockRestore();
rmSync(stateDir, { recursive: true, force: true });
rmSync(bin, { recursive: true, force: true });
});

it("offers herdr's agents, Oh My Pi and Claude Code always, and the shell agents this server finds, by label", async () => {
process.env["PATH"] = bin;
const response = await fetch(url("/api/agents"));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
agents: [
{ kind: "claude", label: "Claude Code", installed: false },
{ kind: "codex", label: "Codex", installed: true },
{ kind: "gjc", label: "Gajae Code", installed: true },
{ kind: "omp", label: "Oh My Pi", installed: false },
{ kind: "pi", label: "pi", installed: false },
{ kind: "someagent", label: "someagent", installed: false },
],
});
expect(located.sort()).toEqual(["gjc", "omo"]);
});

it("passes herdr's own error through, and refuses anything but GET", async () => {
manifests.mockImplementationOnce(async () => { throw new HerdrError("connect_failed", "herdr is not answering"); });
const unreachable = await fetch(url("/api/agents"));
expect(unreachable.status).toBe(502);
expect(((await unreachable.json()) as { error: { code: string } }).error.code).toBe("connect_failed");
const post = await fetch(url("/api/agents"), { method: "POST", headers: { origin: url("") } });
expect(post.status).toBe(400);
expect(((await post.json()) as { error: { code: string } }).error.code).toBe("method_not_allowed");
});
});
55 changes: 55 additions & 0 deletions server/agents-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* GET /api/agents: the agent kinds this PC can start, with display labels: herdr's agent manifests,
* Oh My Pi and Claude Code always, and the shell-started agents this server finds (shell-agent.ts).
* The dispatcher in index.ts runs the access gate first.
*/
import type { AgentKind } from "../shared/agents.ts";
import { agentManifests } from "./herdr/client.ts";
import { badRequest, errorResponse, jsonResponse, setRequestRoute } from "./http.ts";
import { SHELL_AGENTS, type ShellAgentLocator } from "./shell-agent.ts";

const AGENT_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
omp: "Oh My Pi",
omo: "OmO",
gjc: "Gajae Code",
pi: "pi",
gemini: "Gemini CLI",
cursor: "Cursor",
opencode: "OpenCode",
copilot: "GitHub Copilot",
kimi: "Kimi",
amp: "Amp",
hermes: "Hermes",
grok: "Grok",
agy: "Antigravity",
devin: "Devin",
cmd: "Command Code",
};

/** The answer for the agent catalog route, or null when the path is not it. `shellAgents`: the server's own locator, which starts them too. */
export async function handleAgentsRequest(request: Request, pathname: string, shellAgents: Pick<ShellAgentLocator, "executable">): Promise<Response | null> {
if (pathname === "/api/agents") {
setRequestRoute(pathname);
if (request.method !== "GET") return badRequest("method_not_allowed", "use GET");
try {
const kinds = new Set((await agentManifests()).manifests.map((manifest) => manifest.agent));
kinds.add("omp");
kinds.add("claude");
// not herdr kinds: offered where this server can run them (see shell-agent.ts)
// the locator the start asks too, so what is offered is what starts
const shellStarted = new Set<string>();
for (const kind of Object.keys(SHELL_AGENTS)) if (await shellAgents.executable(kind)) shellStarted.add(kind);
for (const kind of shellStarted) kinds.add(kind);
const agents: AgentKind[] = [...kinds]
// an agent's command is its kind; herdr starts the others in its own environment, so only this server's PATH speaks for them
.map((kind) => ({ kind, label: AGENT_LABELS[kind] ?? kind, installed: shellStarted.has(kind) || Bun.which(kind, { PATH: process.env["PATH"] ?? "" }) !== null }))
.sort((left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind));
return jsonResponse({ agents });
} catch (error) {
return errorResponse(error);
}
}
return null;
}
49 changes: 5 additions & 44 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { readHerdrProfiles, type HerdrMachineProfile } from "./herdr-profiles.ts
import { accessSync, constants, readFileSync } from "node:fs";
import { dirname, join } from "node:path";

import type { AgentKind, HealthAuth, HerdrPane, PanePlan, PendingMessage, ProjectFavicon } from "../shared/protocol.ts";
import type { HealthAuth, HerdrPane, PanePlan, PendingMessage, ProjectFavicon } from "../shared/protocol.ts";
import { DEFAULT_PORT } from "../shared/protocol.ts";
import { authClient, deviceCookieValue, bearerMatches, handleAuthRequest, isAuthenticated, isSecureRequest, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts";
import { cameThroughProxy, decideAccess, isLoopbackAddress, isLoopbackBind, isServeOwnerRequest, lanExposed } from "./access.ts";
Expand All @@ -26,12 +26,12 @@ import { compressResponse } from "./compress.ts";
import { claudePaneSession } from "./conversation.ts";
import { handleTasksRequest } from "./tasks-routes.ts";
import { within } from "./claude-subagents.ts";
import { SHELL_AGENTS, ShellAgentLocator } from "./shell-agent.ts";
import { ShellAgentLocator } from "./shell-agent.ts";
import { handleAgentsRequest } from "./agents-routes.ts";
import { claudeSessionTempDir } from "./claude-store.ts";
import { fileResponse, findFile, crossSiteFileRead, forgetPageMedia, pageMediaResponse, pageMediaSource, PAGE_MEDIA_PATH } from "./file-view.ts";
import { findProjectFavicon } from "./project-favicon.ts";
import {
agentManifests,
herdrSocketPath,
paneGet,
ping,
Expand Down Expand Up @@ -89,26 +89,6 @@ const DRAFT_CLEAR_WAIT_MS = 1_500;
const DRAFT_CLEAR_POLL_MS = 50;


const AGENT_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
omp: "Oh My Pi",
omo: "OmO",
gjc: "Gajae Code",
pi: "pi",
gemini: "Gemini CLI",
cursor: "Cursor",
opencode: "OpenCode",
copilot: "GitHub Copilot",
kimi: "Kimi",
amp: "Amp",
hermes: "Hermes",
grok: "Grok",
agy: "Antigravity",
devin: "Devin",
cmd: "Command Code",
};

/** The temp folder of the Claude Code session in a pane, or null when the pane runs none or it has none. */
async function claudeTempFolder(paneId: string): Promise<string | null> {
try {
Expand Down Expand Up @@ -512,27 +492,8 @@ export function createServer(
}
}

if (pathname === "/api/agents") {
setRequestRoute(pathname);
if (request.method !== "GET") return badRequest("method_not_allowed", "use GET");
try {
const kinds = new Set((await agentManifests()).manifests.map((manifest) => manifest.agent));
kinds.add("omp");
kinds.add("claude");
// not herdr kinds: offered where this server can run them (see shell-agent.ts)
// the locator the start asks too, so what is offered is what starts
const shellStarted = new Set<string>();
for (const kind of Object.keys(SHELL_AGENTS)) if (await shellAgents.executable(kind)) shellStarted.add(kind);
for (const kind of shellStarted) kinds.add(kind);
const agents: AgentKind[] = [...kinds]
// an agent's command is its kind; herdr starts the others in its own environment, so only this server's PATH speaks for them
.map((kind) => ({ kind, label: AGENT_LABELS[kind] ?? kind, installed: shellStarted.has(kind) || Bun.which(kind, { PATH: process.env["PATH"] ?? "" }) !== null }))
.sort((left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind));
return jsonResponse({ agents });
} catch (error) {
return errorResponse(error);
}
}
const agentsResponse = await handleAgentsRequest(request, pathname, shellAgents);
if (agentsResponse) return agentsResponse;

const integrationsResponse = await handleIntegrationsRequest(request, pathname);
if (integrationsResponse) return integrationsResponse;
Expand Down
7 changes: 7 additions & 0 deletions shared/agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** GET /api/agents: one agent kind herdr can start (`agent.start` kind), with a display label. */
export interface AgentKind {
kind: string;
label: string;
/** whether the server finds the agent's command (its kind) on its PATH; absent from an older bridge */
installed?: boolean;
}
8 changes: 1 addition & 7 deletions shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,6 @@ export interface ConversationResponse {

export type { PluginActionContext, PluginAction, PluginActions, PluginActionsResponse, PluginActionRequest, PluginActionResult } from "./plugins.ts";

/** GET /api/agents: one agent kind herdr can start (`agent.start` kind), with a display label. */
/** GET /api/workspace/directories: the folders inside one directory, for the folder browser. */
export interface DirectoryListing {
/** the directory listed, absolute */
Expand Down Expand Up @@ -790,12 +789,7 @@ export interface RepoDiffFilePatch {
limits: { max_patch_bytes: number; context_lines: number };
}

export interface AgentKind {
kind: string;
label: string;
/** whether the server finds the agent's command (its kind) on its PATH; absent from an older bridge */
installed?: boolean;
}
export type { AgentKind } from "./agents.ts";

export type { AgentIntegration, IntegrationsResponse } from "./integrations.ts";

Expand Down
Loading