Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/acp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ export class AcpClient {
this.log(
`spawning installed built-in agent ${resolvedBuiltInLaunch.packageName}${resolvedBuiltInLaunch.packageVersion ? `@${resolvedBuiltInLaunch.packageVersion}` : ""} via ${spawnCommand} ${args.join(" ")}`,
);
} else if (resolvedBuiltInLaunch?.source === "global-path") {
this.log(
`spawning globally installed built-in agent ${resolvedBuiltInLaunch.packageName} via PATH binary ${spawnCommand}`,
);
} else if (resolvedBuiltInLaunch?.source === "package-exec") {
this.log(
`spawning built-in agent ${resolvedBuiltInLaunch.packageName}@${resolvedBuiltInLaunch.packageRange} via current Node package exec bridge ${spawnCommand} ${args.join(" ")}`,
Expand Down
46 changes: 45 additions & 1 deletion src/agent-registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -17,7 +18,7 @@ type BuiltInAgentPackageSpec = {
};

type BuiltInAgentLaunch = {
source: "installed" | "package-exec";
source: "installed" | "global-path" | "package-exec";
command: string;
args: string[];
packageName: string;
Expand All @@ -33,6 +34,7 @@ type BuiltInLaunchResolverOptions = {
resolvePackageRoot?: (packageName: string) => string;
execPath?: string;
resolveNpmCliPath?: (execPath: string) => string;
resolveGlobalBinPath?: (binName: string) => string | undefined;
};

export const AGENT_REGISTRY: Record<string, string> = {
Expand Down Expand Up @@ -264,12 +266,54 @@ export function resolvePackageExecBuiltInAgentLaunch(
}
}

function defaultResolveGlobalBinPath(binName: string): string | undefined {
try {
const result = execFileSync(process.platform === "win32" ? "where" : "which", [binName], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
// `where` on Windows may return multiple lines; take the first
const firstLine = result.split(/\r?\n/)[0]?.trim();
return firstLine && firstLine.length > 0 ? firstLine : undefined;
} catch {
return undefined;
}
}

export function resolveGlobalPathAgentLaunch(
agentCommand: string,
options: BuiltInLaunchResolverOptions = {},
): BuiltInAgentLaunch | undefined {
const spec = findBuiltInAgentPackage(agentCommand);
if (!spec) {
return undefined;
}

const resolveGlobalBinPath = options.resolveGlobalBinPath ?? defaultResolveGlobalBinPath;

const binPath = resolveGlobalBinPath(spec.preferredBinName);
if (!binPath) {
return undefined;
}

return {
source: "global-path",
command: binPath,
args: [],
packageName: spec.packageName,
packageRange: spec.packageRange,
binPath,
};
}

export function resolveBuiltInAgentLaunch(
agentCommand: string,
options: BuiltInLaunchResolverOptions = {},
): BuiltInAgentLaunch | undefined {
return (
resolveInstalledBuiltInAgentLaunch(agentCommand, options) ??
resolveGlobalPathAgentLaunch(agentCommand, options) ??
resolvePackageExecBuiltInAgentLaunch(agentCommand, options)
);
}
Expand Down
52 changes: 52 additions & 0 deletions test/agent-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DEFAULT_AGENT_NAME,
listBuiltInAgents,
resolveBuiltInAgentLaunch,
resolveGlobalPathAgentLaunch,
resolveInstalledBuiltInAgentLaunch,
resolvePackageExecBuiltInAgentLaunch,
resolveAgentCommand,
Expand Down Expand Up @@ -149,6 +150,56 @@ test("resolvePackageExecBuiltInAgentLaunch bridges built-ins through the current
});
});

test("resolveGlobalPathAgentLaunch uses a globally installed binary from PATH", () => {
const launch = resolveGlobalPathAgentLaunch(AGENT_REGISTRY.claude, {
resolveGlobalBinPath: (binName) =>
binName === "claude-agent-acp" ? "/usr/local/bin/claude-agent-acp" : undefined,
});

assert.deepEqual(launch, {
source: "global-path",
command: "/usr/local/bin/claude-agent-acp",
args: [],
packageName: BUILT_IN_AGENT_PACKAGES.claude.packageName,
packageRange: BUILT_IN_AGENT_PACKAGES.claude.packageRange,
binPath: "/usr/local/bin/claude-agent-acp",
});
});

test("resolveGlobalPathAgentLaunch returns undefined when binary is not on PATH", () => {
const launch = resolveGlobalPathAgentLaunch(AGENT_REGISTRY.claude, {
resolveGlobalBinPath: () => undefined,
});

assert.equal(launch, undefined);
});

test("resolveGlobalPathAgentLaunch ignores non-built-in commands", () => {
assert.equal(
resolveGlobalPathAgentLaunch("custom-acp-server --stdio", {
resolveGlobalBinPath: () => "/usr/local/bin/custom-acp-server",
}),
undefined,
);
});

test("resolveBuiltInAgentLaunch prefers global PATH over package-exec when local install unavailable", () => {
const npmCliPath = path.join(os.tmpdir(), "acpx-test-global-npm-cli.js");
const launch = resolveBuiltInAgentLaunch(AGENT_REGISTRY.codex, {
execPath: "/tmp/node",
resolvePackageRoot: () => {
throw new Error("adapter not installed locally");
},
resolveGlobalBinPath: (binName) =>
binName === "codex-acp" ? "/opt/homebrew/bin/codex-acp" : undefined,
existsSync: (candidate) => candidate === npmCliPath,
resolveNpmCliPath: () => npmCliPath,
});

assert.equal(launch?.source, "global-path");
assert.equal(launch?.command, "/opt/homebrew/bin/codex-acp");
});

test("resolveBuiltInAgentLaunch accepts the legacy Claude npm exec default", () => {
const npmCliPath = path.join(os.tmpdir(), "acpx-test-claude-npm-cli.js");
const launch = resolveBuiltInAgentLaunch(
Expand All @@ -159,6 +210,7 @@ test("resolveBuiltInAgentLaunch accepts the legacy Claude npm exec default", ()
resolvePackageRoot: () => {
throw new Error("adapter not installed");
},
resolveGlobalBinPath: () => undefined,
resolveNpmCliPath: () => npmCliPath,
},
);
Expand Down