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
5 changes: 5 additions & 0 deletions changelog.d/catalog-install-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed
- The install catalog says what installs on a real PC showed: Oh My Pi's installer stops while an
older Bun is on PATH, Devin's may wait at its own sign-in until you press Ctrl+C, and OpenCode's
puts its folder first on PATH ahead of an opencode of your own. Copilot gets its `copilot login`
Comment thread
devswha marked this conversation as resolved.
Resolved
sign-in, and Hermes, whose version check is slow, is no longer shown without a version.
6 changes: 4 additions & 2 deletions docs/agent-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ supported coding agent and the always-on assistants. For each it holds the vendo
command per platform (macOS, Linux, Windows), what that command needs on the PC, how Ovis tells the
agent is installed (its binary and version flag), how it is signed in to afterwards, and the oldest
version an Ovis feature for it needs. `GET /api/agent-catalog` answers it for the PC that is asked,
with what is installed there (see `shared/protocol.ts`).
with what is installed there (see `shared/protocol.ts`). A version flag gets 5 s to answer; an agent
whose flag is slower (Hermes loads the whole agent for it) sets `detect.version_timeout_ms`, at most
30 s. The first catalog read waits for every agent's flag, so it can take that long.

A version is read only from what the version flag prints on stdout when it succeeds. A flag that
exits with an error or does not start is `version_error: "failed"`, and one that hangs is
Expand Down Expand Up @@ -55,7 +57,7 @@ The server looks for an agent's binary on its own `PATH`, then on the `PATH` its
sets (read at most once a minute; not on Windows). Most installers put agents in `~/.local/bin` or
the like, which a login shell adds and a server run as a service often lacks: without the second
look, an agent that was just installed would read as not installed. It runs the binary's version
flag with no input and a 5 s limit, and reads the first dotted version it prints. A version is kept until the executable file changes (path, size or
flag with no input and a 5 s limit (longer where the entry sets `detect.version_timeout_ms`), and reads the first dotted version it prints. A version is kept until the executable file changes (path, size or
modification time), so an install or update shows on the next read; a run that times out is tried
again after a minute. On Windows, Command Code's `cmd` is the name of Windows' own command prompt,
so Ovis does not say whether it is installed there.
35 changes: 26 additions & 9 deletions server/agent-catalog-detect.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { basename, delimiter, join } from "node:path";

import { AGENT_CATALOG, type AgentCatalogEntry } from "../shared/agent-catalog.ts";
import { AgentDetector, catalogLocator, LOGIN_PATH_KEPT_MS, runVersion, TIMEOUT_KEPT_MS, versionSpawn } from "./agent-catalog-detect.ts";
Expand Down Expand Up @@ -29,7 +29,7 @@ describe.skipIf(process.platform === "win32")("AgentDetector", () => {
stub("claude", "echo '2.1.311 (Claude Code)'");
stub("codex", "echo 'codex-cli 0.150.0'");
stub("omp", "echo 'usage: omp [options]'");
stub("hermes", "exec sleep 10");
stub("hermes", "echo 'Hermes Agent v0.21.5'");
});
afterAll(() => {
rmSync(bin, { recursive: true, force: true });
Expand Down Expand Up @@ -80,6 +80,22 @@ describe.skipIf(process.platform === "win32")("AgentDetector", () => {
expect(await detector.state(entry("omp"), "linux")).toEqual({ installed: true, binary: "omp", on_path: true, version: null, version_error: "unreadable", meets_min_version: null });
});

it("gives a slow version flag the longer limit its catalog entry sets, and every other one the default", async () => {
// Hermes answers its version flag in 2.3 to 6.7 s on a busy PC, past the 5 s default
expect(entry("hermes").detect.version_timeout_ms).toBeGreaterThan(7_000);
const limits: Record<string, number> = {};
// nothing is run: the limit each probe is given is all this reads
const run = async (executable: string, _args: readonly string[], timeoutMs: number): Promise<never> => {
limits[basename(executable)] = timeoutMs;
throw new Error("not run here");
};
const detector = new AgentDetector({ home: emptyHome, locate, timeoutMs: 300, run });
const slow = entry("claude");
await detector.state({ ...slow, detect: { ...slow.detect, version_timeout_ms: 12_345 } }, "linux");
await detector.state(entry("codex"), "linux");
expect(limits).toEqual({ claude: 12_345, codex: 300 });
});

it("never reads a version out of a flag that fails, or out of what it prints as an error", async () => {
// a wrapper whose target is gone, as a removed nvm Node leaves one: the shell's error names the
// missing path, Node's version and all, and exits 127
Expand All @@ -106,16 +122,17 @@ describe.skipIf(process.platform === "win32")("AgentDetector", () => {
});

it("kills a version flag that hangs, and asks again only after a while", async () => {
// gjc: an agent with no limit of its own, so the detector's 300 ms applies
stub("gjc", "exec sleep 10");
let now = 1_000;
const detector = new AgentDetector({ home: emptyHome, locate, timeoutMs: 300, now: () => now });
const started = performance.now();
expect(await detector.state(entry("hermes"), "linux")).toEqual({ installed: true, binary: "hermes", on_path: true, version: null, version_error: "timeout", meets_min_version: null });
expect(performance.now() - started).toBeLessThan(5_000);
await detector.state(entry("hermes"), "linux");
expect(runsOf("hermes")).toBe(1);
// the 10 s sleep is ended at the detector's 300 ms limit: the answer says it timed out
expect(await detector.state(entry("gjc"), "linux")).toEqual({ installed: true, binary: "gjc", on_path: true, version: null, version_error: "timeout", meets_min_version: null });
await detector.state(entry("gjc"), "linux");
expect(runsOf("gjc")).toBe(1);
now += TIMEOUT_KEPT_MS;
await detector.state(entry("hermes"), "linux");
expect(runsOf("hermes")).toBe(2);
await detector.state(entry("gjc"), "linux");
expect(runsOf("gjc")).toBe(2);
});

it("reports an agent not on PATH as not installed, and runs nothing where its name proves nothing", async () => {
Expand Down
6 changes: 3 additions & 3 deletions server/agent-catalog-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,14 @@ export class AgentDetector {
}
}

async #version(executable: string, fingerprint: string, args: readonly string[], path: string): Promise<Kept> {
async #version(executable: string, fingerprint: string, args: readonly string[], timeoutMs: number, path: string): Promise<Kept> {
const kept = this.#kept.get(executable);
if (kept?.fingerprint === fingerprint && (kept.retryAt === null || this.#now() < kept.retryAt)) return kept;
const running = this.#running.get(fingerprint);
if (running) return running;
const read = this.#slot(async (): Promise<Kept> => {
try {
const run = await this.#run(executable, args, this.#timeoutMs, path);
const run = await this.#run(executable, args, timeoutMs, path);
if (run.timedOut) return { fingerprint, version: null, error: "timeout", retryAt: this.#now() + TIMEOUT_KEPT_MS };
// a flag that failed proves the agent does not run: its error (a wrapper's missing target,
// with Node's version in the path) is never read for a version. A failure is asked again
Expand Down Expand Up @@ -278,7 +278,7 @@ export class AgentDetector {
if (fingerprint === null) continue;
// the agent's own folder first (npm's global folder holds its node), then the PATH it was found on
const path = [dirname(executable), this.#locate.searchPath?.() ?? process.env["PATH"] ?? ""].filter((part) => part !== "").join(delimiter);
const { version, error } = await this.#version(executable, fingerprint, entry.detect.version_args, path);
const { version, error } = await this.#version(executable, fingerprint, entry.detect.version_args, entry.detect.version_timeout_ms ?? this.#timeoutMs, path);
return {
installed: true,
binary,
Expand Down
2 changes: 2 additions & 0 deletions shared/agent-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ describe("agent catalog", () => {
}
if (entry.detect.binaries.length === 0 || entry.detect.binaries.some((binary) => !/^[a-z0-9][a-z0-9._-]*$/.test(binary))) at("plain binary names");
if (entry.detect.version_args.length === 0) at("a version flag");
const limit = entry.detect.version_timeout_ms;
if (limit !== undefined && !(Number.isInteger(limit) && limit > 5_000 && limit <= 30_000)) at(`a version-flag limit above the 5 s default and at most 30 s, not ${limit}`);
if (entry.sign_in !== null && entry.sign_in.provider !== null && !PROVIDERS[entry.sign_in.provider]) at(`sign-in provider ${entry.sign_in.provider}`);
if (entry.min_version !== null && (!/^\d+\.\d+(?:\.\d+)?$/.test(entry.min_version.version) || entry.min_version.reason === "")) at("a dotted minimum version and the feature that needs it");
}
Expand Down
21 changes: 14 additions & 7 deletions shared/agent-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export interface AgentCatalogEntry {
/** the executables that mean it is installed, in the order they are looked for */
binaries: readonly string[];
version_args: readonly string[];
/** how long its version flag may take, when longer than the detector's default (5 s) */
version_timeout_ms?: number;
/** platforms where finding the binary proves nothing (another program has its name), and why */
unreliable_on?: Readonly<Partial<Record<CatalogPlatform, string>>>;
/**
Expand Down Expand Up @@ -317,7 +319,8 @@ const method = (shell: InstallShell, command: string, requires: readonly Install
({ shell, command, requires, admin: extra.admin ?? false, known_issues: extra.known_issues ?? [], tested: extra.tested ?? [] });
const only = (primary: InstallMethod, ...alternatives: InstallMethod[]): PlatformInstall => ({ primary, alternatives });

const DEVIN_LOGIN = "Ends by starting a sign-in: with no browser it stops at \"Login canceled\" and exits 1, though Devin is installed.";
const DEVIN_LOGIN = "Ends by starting its own sign-in (devin setup): with no browser it stops at \"Login canceled\" and exits 1, or waits until you press Ctrl+C. Devin is installed either way: sign in afterwards with devin auth login.";
const OMP_BUN = "Stops when an older Bun (before 1.4.4) is on PATH, as Ovis's own installer may have put there: run bun upgrade first.";
const PI_NODE = "Needs Node.js 22.19 or later: without it, it asks in the terminal whether to install it, and without a terminal it stops.";
const PI_NODE_SUDO = "Needs Node.js 22.19 or later: without it, it asks in the terminal whether to install it (which may ask for sudo), and without a terminal it stops.";
const CURL_BASH: readonly InstallRequirement[] = [{ tool: "curl" }, { tool: "bash" }];
Expand Down Expand Up @@ -694,7 +697,7 @@ const CATALOG_ENTRIES: readonly AgentCatalogEntry[] = [
packages: ["brew:copilot-cli", "npm:@github/copilot", "winget:GitHub.Copilot"],
sources: [source("https://github.com/github/copilot-cli/blob/84ee80ea6490ce02d6000439356a609fcefbb9ae/README.md")],
detect: { binaries: ["copilot"], version_args: ["--version"], install_dirs: { macos: ["~/.local/bin"], linux: ["~/.local/bin"] } },
sign_in: { provider: "copilot", command: null, in_agent: "/login", api_keys: ["GH_TOKEN", "GITHUB_TOKEN"], required: true },
sign_in: { provider: "copilot", command: ["copilot", "login"], in_agent: "/login", api_keys: ["GH_TOKEN", "GITHUB_TOKEN"], required: true },
Comment thread
devswha marked this conversation as resolved.
Resolved
after_install: ["Needs an active GitHub Copilot plan."],
min_version: null,
},
Expand All @@ -715,7 +718,10 @@ const CATALOG_ENTRIES: readonly AgentCatalogEntry[] = [
sources: [source("https://github.com/anomalyco/opencode/blob/f023f7c36c090260a49c6e4da0ff69cbbd10c168/README.md"), source("https://opencode.ai/docs/cli/")],
detect: { binaries: ["opencode"], version_args: ["--version"], install_dirs: { macos: ["~/.opencode/bin"], linux: ["~/.opencode/bin"] } },
sign_in: { provider: "opencode", command: ["opencode", "auth", "login"], in_agent: null, api_keys: [], required: false },
after_install: ["OpenCode 1 and 2 are both run as opencode: installing 2 replaces 1."],
after_install: [
"OpenCode 1 and 2 are both run as opencode: installing 2 replaces 1.",
"Its installer for macOS and Linux puts ~/.opencode/bin first on PATH in your shell's startup file (~/.bashrc, ~/.zshrc): an opencode command of your own elsewhere (a wrapper in ~/.local/bin) is no longer the one that runs.",
],
min_version: { version: "2.0.0", reason: "answering its questions from Chat" },
},
{
Expand Down Expand Up @@ -785,9 +791,9 @@ const CATALOG_ENTRIES: readonly AgentCatalogEntry[] = [
kind: "harness",
description: "A coding agent with IDE features and built-in tools.",
install: {
macos: only(method("posix", "curl -fsSL https://omp.sh/install | sh", CURL_SH)),
linux: only(method("posix", "curl -fsSL https://omp.sh/install | sh", CURL_SH)),
windows: only(method("powershell", "irm https://omp.sh/install.ps1 | iex", POWERSHELL, { known_issues: ["Stops when an older Bun (before 1.4.4) is on PATH, as Ovis's own installer may have put there: run bun upgrade first."] })),
macos: only(method("posix", "curl -fsSL https://omp.sh/install | sh", CURL_SH, { known_issues: [OMP_BUN] })),
linux: only(method("posix", "curl -fsSL https://omp.sh/install | sh", CURL_SH, { known_issues: [OMP_BUN] })),
windows: only(method("powershell", "irm https://omp.sh/install.ps1 | iex", POWERSHELL, { known_issues: [OMP_BUN] })),
},
no_install: {},
url_prefixes: ["https://omp.sh/install"],
Expand Down Expand Up @@ -942,7 +948,8 @@ const CATALOG_ENTRIES: readonly AgentCatalogEntry[] = [
url_prefixes: ["https://hermes-agent.nousresearch.com/install.sh", "https://hermes-agent.nousresearch.com/install.ps1"],
packages: [],
sources: [source("https://hermes-agent.nousresearch.com/docs/getting-started/installation"), source("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/")],
detect: { binaries: ["hermes"], version_args: ["--version"], install_dirs: { macos: ["~/.local/bin"], linux: ["~/.local/bin"] } },
// its version flag loads the whole agent: 2.3 to 6.7 s on a busy PC
detect: { binaries: ["hermes"], version_args: ["--version"], version_timeout_ms: 15_000, install_dirs: { macos: ["~/.local/bin"], linux: ["~/.local/bin"] } },
sign_in: { provider: null, command: ["hermes", "setup"], in_agent: null, api_keys: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "XAI_API_KEY", "GOOGLE_API_KEY"], required: true },
after_install: [
"A messenger needs its own bot token too (Telegram: TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USERS).",
Expand Down
7 changes: 4 additions & 3 deletions shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,10 @@ export type { DevicePairedEvent, Machine, MachineEvent, MachineRoster, PaneTarge
* GET /api/agent-catalog -> AgentCatalogResponse (shared/agent-catalog.ts: every agent
* Ovis can install, with this PC's platform's official commands and whether each is
* installed here, at which version, read from its version flag's stdout when the flag exits
* 0 (5 s limit; kept until the executable file changes; a flag that fails, version_error
* "failed", or hangs, "timeout", is asked again after a minute, and such an agent does not
* run: agentRuns). Read only, no-store; a watch device may read it. A remote PC
* 0 (5 s limit, longer where the entry sets detect.version_timeout_ms; kept until the
* executable file changes; a flag that fails, version_error "failed", or hangs, "timeout", is
* asked again after a minute, and such an agent does not run: agentRuns). Read only,
* no-store; a watch device may read it. A remote PC
* from before the endpoint answers 404 through /api/machines/<id>/agent-catalog)
* POST /api/agent-catalog/install { agent, alternative? } -> { pane_id, workspace_id, command,
* shell } (opens a tab and types that agent's official command for this PC's platform into its
Expand Down
Loading