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:
2026-09-28 10:57:10 +07:00
parent 0aa7fc232e
commit 77917203fd
59 changed files with 1804 additions and 84 deletions
+18 -5
View File
@@ -14,6 +14,7 @@ const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const aiTools = require("../lib/ai-tools");
const {
listPipelines,
getPipeline,
@@ -333,7 +334,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
);
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
@@ -370,7 +371,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
broadcastLane(lane.id);
return res.status(201).json({ lane: payload(lane), created: true });
} catch (err) {
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADTOOL") {
if (
err.code === "EBADCWD" ||
err.code === "EBADPIPELINE" ||
err.code === "EBADTOOL" ||
err.code === "EDISABLED"
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
@@ -457,7 +463,9 @@ router.post("/", sameOriginGuard, (req, res) => {
err.code === "EBADCWD" ||
err.code === "EBADPIPELINE" ||
err.code === "EBADKIND" ||
err.code === "EBADFIELD"
err.code === "EBADFIELD" ||
err.code === "EBADTOOL" ||
err.code === "EDISABLED"
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
@@ -1044,8 +1052,12 @@ router.get("/:id/ci/status", async (req, res) => {
router.post("/worktree", sameOriginGuard, async (req, res) => {
const body = req.body || {};
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
// Checked before any git work: a worktree for a tool that cannot get a lane
// would be left behind with no row pointing at it.
try {
lanesLib.validateTool(body.tool === undefined ? "claude" : body.tool);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
const sourceRepo = body.sourceRepo;
@@ -1709,6 +1721,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
sandbox: body.sandbox,
approval: body.approval,
resumeLast: body.resumeLast === true,
bypass: body.bypass === true,
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
});
startedRunId = handle.id;