feat(ai-tools): catalog of AI coding CLIs, detection, enable/default, lanes for any tool (#40 A1)
server/lib/ai-tools.js loads server/data/ai-tools/*.json (38 tools, data from Orca, MIT) plus DASHBOARD_AI_TOOLS_DIR, detects each CLI by walking PATH, and keeps enable/default in <data dir>/ai-tools.json. shell-path.js merges the login shell's PATH at startup and on Refresh. /api/ai-tools lists, refreshes and toggles; /api/run/binary resolves through the catalog. Lanes accept any catalog id (EDISABLED for a disabled one on creation, ENOTOOL at start once a tool left the catalog). pty-run keeps the Claude/Codex builders and builds every other tool's argv from its catalog file, pasting an after-start prompt through a tmux buffer; every pane gets the hydrated PATH. A per-run bypass switch appends the catalog's bypass args (Codex included). POST /api/run now forwards sandbox/approval/resumeLast, which it dropped. agenthub lanes add gains --tool.
This commit is contained in:
+74
-30
@@ -32,6 +32,7 @@
|
||||
const tmux = require("./tmux");
|
||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
const aiTools = require("./ai-tools");
|
||||
|
||||
let dashboardRuns = null;
|
||||
try {
|
||||
@@ -56,7 +57,6 @@ const ALLOWED_PERMISSION_MODES = new Set([
|
||||
]);
|
||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||
@@ -120,6 +120,7 @@ function buildCodexArgv({
|
||||
approval,
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
}) {
|
||||
@@ -131,30 +132,42 @@ function buildCodexArgv({
|
||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||
else if (resumeLast) argv.push("resume", "--last");
|
||||
if (model) argv.push("-m", model);
|
||||
if (sandbox) argv.push("-s", sandbox);
|
||||
if (approval) argv.push("-a", approval);
|
||||
// The bypass flag turns off both sandbox and approvals, so the two flags it
|
||||
// replaces are not sent beside it.
|
||||
if (bypass) argv.push(...(aiTools.get("codex")?.bypass || []));
|
||||
else {
|
||||
if (sandbox) argv.push("-s", sandbox);
|
||||
if (approval) argv.push("-a", approval);
|
||||
}
|
||||
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
|
||||
if (initialPrompt) argv.push(initialPrompt);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity's `agy` has no model, effort or permission-mode flag, so only
|
||||
* resume and the first prompt reach it. `-i` keeps the session interactive
|
||||
* after that prompt (`-p` would print and exit). `--dangerously-skip-permissions`
|
||||
* is never emitted, same rule as Codex's bypass flags.
|
||||
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
|
||||
* ...bypass?, ...prompt]`. Every piece but the prompt comes from the catalog
|
||||
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||
*/
|
||||
function buildAntigravityArgv({ resumeLast, initialPrompt }) {
|
||||
const argv = ["agy"];
|
||||
if (resumeLast) argv.push("--continue");
|
||||
if (initialPrompt) argv.push("-i", initialPrompt);
|
||||
function buildCatalogArgv(entry, bin, { resumeLast, bypass, initialPrompt }) {
|
||||
const argv = [bin, ...entry.args];
|
||||
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
||||
const p = entry.prompt;
|
||||
if (initialPrompt && p.mode === "flag") argv.push(p.flag, initialPrompt);
|
||||
if (initialPrompt && p.mode === "argv")
|
||||
argv.push(...(p.separator ? [p.separator] : []), initialPrompt);
|
||||
return argv;
|
||||
}
|
||||
|
||||
function buildArgv(args) {
|
||||
if (args.tool === "codex") return buildCodexArgv(args);
|
||||
if (args.tool === "antigravity") return buildAntigravityArgv(args);
|
||||
return buildClaudeArgv(args);
|
||||
function buildArgv(args, entry) {
|
||||
if (entry.launcher === "codex") return buildCodexArgv(args);
|
||||
if (entry.launcher === "claude") return buildClaudeArgv(args);
|
||||
const d = aiTools.detect(entry.id);
|
||||
// By absolute path when found; the bare name otherwise, so a CLI missing
|
||||
// from PATH dies in the pane with the shell's own "not found".
|
||||
return buildCatalogArgv(entry, d.path || entry.detect[0], args);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +182,8 @@ function buildArgv(args) {
|
||||
* @param {string} [args.approval]
|
||||
* @param {string} [args.resumeSessionId]
|
||||
* @param {boolean} [args.resumeLast]
|
||||
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
||||
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
||||
* @param {string} [args.initialPrompt]
|
||||
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
|
||||
* (`lanes.router_profile`), never a request field.
|
||||
@@ -186,6 +201,7 @@ function spawnRun(args) {
|
||||
approval,
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
} = args || {};
|
||||
@@ -198,9 +214,10 @@ function spawnRun(args) {
|
||||
if (typeof cwd !== "string" || !cwd) {
|
||||
throw makeErr("EBADCWD", "cwd is required");
|
||||
}
|
||||
if (!VALID_TOOLS.has(tool)) {
|
||||
throw makeErr("EBADTOOL", `tool must be one of: ${Array.from(VALID_TOOLS).join(", ")}`);
|
||||
}
|
||||
// A lane whose tool has since left the catalog keeps its row; it just
|
||||
// cannot start until the catalog knows the tool again.
|
||||
const entry = aiTools.get(tool);
|
||||
if (!entry) throw makeErr("ENOTOOL", `unknown AI tool: ${tool}`);
|
||||
if (tool === "codex") {
|
||||
if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) {
|
||||
throw makeErr(
|
||||
@@ -248,18 +265,23 @@ function spawnRun(args) {
|
||||
|
||||
const id = runIdForLane(laneId, slot);
|
||||
const startedAt = Date.now();
|
||||
const argv = buildArgv({
|
||||
tool,
|
||||
model,
|
||||
permissionMode,
|
||||
effort,
|
||||
sandbox,
|
||||
approval,
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
});
|
||||
const argv = buildArgv(
|
||||
{
|
||||
tool,
|
||||
model,
|
||||
permissionMode,
|
||||
effort,
|
||||
sandbox,
|
||||
approval,
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
},
|
||||
entry
|
||||
);
|
||||
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
|
||||
|
||||
const record = () => {
|
||||
if (!dashboardRuns) return;
|
||||
@@ -300,15 +322,36 @@ function spawnRun(args) {
|
||||
// starts has no clipboard at all — pasting an image into `claude` there
|
||||
// answers "no image found in clipboard" while the same CLI pastes fine in a
|
||||
// terminal. Never overrides what this spawn already needs.
|
||||
// PATH is the dashboard's own, hydrated from the login shell: a pane's env
|
||||
// comes from the tmux server, and an npm-installed CLI (`#!/usr/bin/env
|
||||
// node`) dies there without the `node` the user's shell would have found.
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
|
||||
// `after-start` tools take no prompt on their command line: paste it once
|
||||
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||
// add readiness detection (Orca's) if a tool drops prompts.
|
||||
const schedulePaste = () => {
|
||||
if (!pasteLater) return;
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
if (tmux.hasSession(id)) tmux.pasteText(id, pasteLater);
|
||||
} catch {
|
||||
/* pane gone mid-paste — nothing to deliver to */
|
||||
}
|
||||
}, entry.prompt.delayMs);
|
||||
if (timer.unref) timer.unref();
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
tmux.newSession({ name: id, cwd, argv, env });
|
||||
record();
|
||||
schedulePaste();
|
||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||
// The session exists but its pane is sitting at a bare shell prompt — a
|
||||
// `agenthub lanes shell`, or a `claude` that already exited. Adopting it
|
||||
@@ -318,6 +361,7 @@ function spawnRun(args) {
|
||||
// instead of erroring or opening a second session.
|
||||
tmux.sendCommand(id, argv, env, cwd);
|
||||
record();
|
||||
schedulePaste();
|
||||
}
|
||||
// Pane is running something (a live `claude`, an editor, a build): adopt
|
||||
// silently, same convention as this repo's server port-adoption logic — no
|
||||
|
||||
Reference in New Issue
Block a user