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:
@@ -57,7 +57,7 @@ const BUILTIN_NAME = "native";
|
||||
*/
|
||||
const NATIVE = Object.freeze({
|
||||
name: BUILTIN_NAME,
|
||||
label: "Native (Claude / Codex)",
|
||||
label: "Native",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @file The AI Tools catalog: which AI coding CLIs AgentHub can run in a lane
|
||||
* (`server/data/ai-tools/*.json` plus overrides in `DASHBOARD_AI_TOOLS_DIR`),
|
||||
* which of them are installed (a walk of the PATH directories — no process per
|
||||
* tool), and the user's enable/default choice (`<data dir>/ai-tools.json`).
|
||||
* A catalog file is data only: its args and env reach an argv in pty-run.js,
|
||||
* never a shell, and a custom file may not claim a hand-written `launcher`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const BUILTIN_DIR = path.join(__dirname, "..", "data", "ai-tools");
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
||||
const CMD_RE = /^[A-Za-z0-9._+-]+$/;
|
||||
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const LAUNCHERS = new Set(["claude", "codex"]);
|
||||
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
||||
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
|
||||
|
||||
let dirsOverride = null;
|
||||
let catalog = null; // Map id -> tool, in catalog order
|
||||
const detected = new Map();
|
||||
|
||||
function makeErr(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
const isStrArray = (v, re) =>
|
||||
Array.isArray(v) && v.every((s) => typeof s === "string" && s && (!re || re.test(s)));
|
||||
|
||||
/** A normalised tool, or null when the file breaks any rule. */
|
||||
function validate(doc, source) {
|
||||
if (!doc || typeof doc !== "object") return null;
|
||||
if (typeof doc.id !== "string" || !ID_RE.test(doc.id)) return null;
|
||||
if (typeof doc.label !== "string" || !doc.label.trim()) return null;
|
||||
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
|
||||
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) return null;
|
||||
for (const k of ["args", "resume", "bypass"]) {
|
||||
if (doc[k] !== undefined && !isStrArray(doc[k])) return null;
|
||||
}
|
||||
if (doc.bypassEnv !== undefined) {
|
||||
const e = doc.bypassEnv;
|
||||
if (!e || typeof e !== "object" || Array.isArray(e)) return null;
|
||||
if (!Object.entries(e).every(([k, v]) => ENV_RE.test(k) && typeof v === "string")) return null;
|
||||
}
|
||||
let launcher = null;
|
||||
if (doc.launcher !== undefined) {
|
||||
if (source !== "builtin" || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
|
||||
return null;
|
||||
launcher = doc.launcher;
|
||||
}
|
||||
let prompt = null;
|
||||
if (!launcher) {
|
||||
const p = doc.prompt;
|
||||
if (!p || !PROMPT_MODES.has(p.mode)) return null;
|
||||
if (p.mode === "flag" && (typeof p.flag !== "string" || !p.flag.startsWith("-"))) return null;
|
||||
if (p.mode === "argv" && p.separator !== undefined && p.separator !== "--") return null;
|
||||
prompt = { mode: p.mode };
|
||||
if (p.mode === "flag") prompt.flag = p.flag;
|
||||
if (p.mode === "argv" && p.separator) prompt.separator = p.separator;
|
||||
if (p.mode === "after-start") {
|
||||
const d = Number(p.delayMs);
|
||||
prompt.delayMs = Number.isFinite(d) && d >= 0 && d <= 60000 ? d : 3000;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.label.trim(),
|
||||
detect: doc.detect,
|
||||
requires: doc.requires || [],
|
||||
args: doc.args || [],
|
||||
homepageUrl:
|
||||
typeof doc.homepageUrl === "string" && /^https:\/\//.test(doc.homepageUrl)
|
||||
? doc.homepageUrl
|
||||
: null,
|
||||
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
|
||||
prompt,
|
||||
resume: doc.resume || null,
|
||||
bypass: doc.bypass || null,
|
||||
bypassEnv: doc.bypassEnv || null,
|
||||
launcher,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
const dirs = dirsOverride || [BUILTIN_DIR, process.env.DASHBOARD_AI_TOOLS_DIR].filter(Boolean);
|
||||
const byId = new Map();
|
||||
dirs.forEach((dir, i) => {
|
||||
const source = i === 0 ? "builtin" : "custom";
|
||||
let files = [];
|
||||
try {
|
||||
files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
} catch {
|
||||
return; // dir absent — fine
|
||||
}
|
||||
for (const f of files) {
|
||||
let tool = null;
|
||||
try {
|
||||
tool = validate(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")), source);
|
||||
} catch {
|
||||
/* fall through to the log line */
|
||||
}
|
||||
if (!tool) {
|
||||
console.warn(`ai-tools: skipping invalid catalog file ${path.join(dir, f)}`);
|
||||
continue;
|
||||
}
|
||||
// Custom beats built-in; within one dir the first file wins.
|
||||
if (!byId.has(tool.id) || (source === "custom" && byId.get(tool.id).source === "builtin")) {
|
||||
byId.set(tool.id, tool);
|
||||
}
|
||||
}
|
||||
});
|
||||
const rank = (t) => (t.id === "claude" ? 0 : t.id === "codex" ? 1 : 2);
|
||||
const sorted = [...byId.values()].sort(
|
||||
(a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label)
|
||||
);
|
||||
return new Map(sorted.map((t) => [t.id, t]));
|
||||
}
|
||||
|
||||
function cat() {
|
||||
if (!catalog) catalog = load();
|
||||
return catalog;
|
||||
}
|
||||
|
||||
const list = () => [...cat().values()];
|
||||
const get = (id) => cat().get(id) || null;
|
||||
const has = (id) => typeof id === "string" && cat().has(id);
|
||||
|
||||
function which(name) {
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const p = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(p).isFile()) {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return p;
|
||||
}
|
||||
} catch {
|
||||
/* not here */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `{installed, path, command}` for one tool, cached until refresh() unless `fresh`. */
|
||||
function detect(id, { fresh = false } = {}) {
|
||||
if (!fresh && detected.has(id)) return detected.get(id);
|
||||
const tool = get(id);
|
||||
let out = { installed: false, path: null, command: tool ? tool.detect[0] : null };
|
||||
if (tool) {
|
||||
for (const name of tool.detect) {
|
||||
const p = which(name);
|
||||
if (p) {
|
||||
out = { installed: tool.requires.every((r) => which(r)), path: p, command: name };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
detected.set(id, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
catalog = null;
|
||||
detected.clear();
|
||||
}
|
||||
|
||||
const statePath = () => path.join(getDataDir(), "ai-tools.json");
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(statePath(), "utf8"));
|
||||
return {
|
||||
disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [],
|
||||
default: typeof s.default === "string" ? s.default : null,
|
||||
};
|
||||
} catch {
|
||||
return { disabled: [], default: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
fs.mkdirSync(path.dirname(statePath()), { recursive: true });
|
||||
const tmp = `${statePath()}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
|
||||
fs.renameSync(tmp, statePath());
|
||||
}
|
||||
|
||||
const isEnabled = (id) => !readState().disabled.includes(id);
|
||||
|
||||
function requireTool(id) {
|
||||
if (!has(id)) throw makeErr("ENOTOOL", `unknown AI tool: ${id}`);
|
||||
}
|
||||
|
||||
function setEnabled(id, enabled) {
|
||||
requireTool(id);
|
||||
const s = readState();
|
||||
s.disabled = s.disabled.filter((x) => x !== id);
|
||||
if (!enabled) {
|
||||
s.disabled.push(id);
|
||||
if (s.default === id) s.default = null;
|
||||
}
|
||||
writeState(s);
|
||||
}
|
||||
|
||||
function setDefault(id) {
|
||||
requireTool(id);
|
||||
if (!detect(id).installed || !isEnabled(id)) {
|
||||
throw makeErr("EBADDEFAULT", `${id} must be installed and enabled to be the default`);
|
||||
}
|
||||
writeState({ ...readState(), default: id });
|
||||
}
|
||||
|
||||
function effectiveDefault() {
|
||||
const s = readState();
|
||||
const ok = (id) => has(id) && detect(id).installed && !s.disabled.includes(id);
|
||||
if (s.default && ok(s.default)) return s.default;
|
||||
if (ok("claude")) return "claude";
|
||||
return list().find((t) => ok(t.id))?.id || null;
|
||||
}
|
||||
|
||||
/** The GET /api/ai-tools body — flags only, never the arg arrays. */
|
||||
function view() {
|
||||
const disabled = new Set(readState().disabled);
|
||||
return {
|
||||
tools: list().map((t) => {
|
||||
const d = detect(t.id);
|
||||
return {
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
command: d.command,
|
||||
homepageUrl: t.homepageUrl,
|
||||
installed: d.installed,
|
||||
path: d.path,
|
||||
enabled: !disabled.has(t.id),
|
||||
source: t.source,
|
||||
launcher: t.launcher,
|
||||
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
||||
resume: !!t.resume,
|
||||
bypass: !!(t.bypass || t.bypassEnv),
|
||||
routerCapable: ROUTER_CAPABLE.has(t.id),
|
||||
};
|
||||
}),
|
||||
default: effectiveDefault(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: `[builtinDir, customDir]`, or null for the real ones. */
|
||||
function __setCatalogDirs(dirs) {
|
||||
dirsOverride = dirs;
|
||||
refresh();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
get,
|
||||
has,
|
||||
detect,
|
||||
refresh,
|
||||
readState,
|
||||
isEnabled,
|
||||
setEnabled,
|
||||
setDefault,
|
||||
effectiveDefault,
|
||||
view,
|
||||
ROUTER_CAPABLE,
|
||||
__setCatalogDirs,
|
||||
};
|
||||
@@ -89,7 +89,7 @@ function recordRun(handle) {
|
||||
started_at: startedAt,
|
||||
ended_at: endedAt,
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
tool: handle.tool === "codex" || handle.tool === "antigravity" ? handle.tool : "claude",
|
||||
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
|
||||
+13
-4
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
const { db } = require("../db");
|
||||
const aiTools = require("./ai-tools");
|
||||
const {
|
||||
DEFAULT_PIPELINE_ID,
|
||||
listPipelines,
|
||||
@@ -97,12 +98,20 @@ function validateKind(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
||||
|
||||
/**
|
||||
* A lane may be created for any AI Tools catalog id, but not for one the user
|
||||
* disabled. Only creation asks — an existing lane on a tool disabled since
|
||||
* keeps working.
|
||||
*/
|
||||
function validateTool(tool) {
|
||||
if (!VALID_TOOLS.has(tool)) {
|
||||
if (!aiTools.has(tool)) {
|
||||
throw Object.assign(new Error(`unknown tool: ${tool}`), { code: "EBADTOOL" });
|
||||
}
|
||||
if (!aiTools.isEnabled(tool)) {
|
||||
throw Object.assign(new Error(`${tool} is disabled in Settings → AI Tools`), {
|
||||
code: "EDISABLED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -888,7 +897,7 @@ function clearCodexPipelines() {
|
||||
|
||||
module.exports = {
|
||||
DEAD_SEC,
|
||||
VALID_TOOLS,
|
||||
validateTool,
|
||||
detectionIsStale,
|
||||
canonicalCwd,
|
||||
createLane,
|
||||
|
||||
+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
|
||||
|
||||
@@ -185,12 +185,15 @@ function applyToCodexLane(lane, profile) {
|
||||
* @param {{id:number, cwd:string, tool:string}} lane the lane row
|
||||
* @param {string} profileName a saved profile's name, or `"native"`
|
||||
*/
|
||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||
|
||||
function applyRouterToLane(lane, profileName) {
|
||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||
// `agy` has no endpoint/model override to write, so only native fits.
|
||||
if (lane.tool === "antigravity") {
|
||||
if (profile.builtin) return { tool: "antigravity", file: null, profile: profile.name };
|
||||
throw Object.assign(new Error("an Antigravity lane cannot use an AI Router"), {
|
||||
// Only Claude and Codex have an endpoint/model override to write; any other
|
||||
// AI tool runs on its vendor's own settings, so native is the one fit.
|
||||
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) {
|
||||
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name };
|
||||
throw Object.assign(new Error(`a ${lane.tool} lane cannot use an AI Router`), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Merge the user's login-shell PATH into `process.env.PATH`. A systemd
|
||||
* unit or LaunchAgent snapshots PATH when it is installed, so a CLI installed
|
||||
* later into a new directory (nvm, ~/.local/bin, a vendor installer) is
|
||||
* invisible to AI Tools detection and to every lane pane. `$SHELL -ilc` runs
|
||||
* the rc files where those installers add to PATH; delimiters discard
|
||||
* whatever the rc files print, stdin is closed so a prompt gets EOF, and a
|
||||
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. Adapted from Orca's
|
||||
* `hydrate-shell-path.ts` (MIT).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARK = "__AGENTHUB_PATH__";
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} whether a shell PATH was read and merged.
|
||||
*/
|
||||
function hydrateShellPath({
|
||||
spawn = execFile,
|
||||
shell = process.env.SHELL || "/bin/bash",
|
||||
timeoutMs = 5000,
|
||||
} = {}) {
|
||||
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_SHELL_PATH || "")) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(
|
||||
shell,
|
||||
["-ilc", `printf '${MARK}%s${MARK}' "$PATH"`],
|
||||
{ timeout: timeoutMs, encoding: "utf8" },
|
||||
(err, stdout) => {
|
||||
const m = String(stdout || "").match(new RegExp(`${MARK}(.*?)${MARK}`, "s"));
|
||||
if (!m) return resolve(false); // timeout, spawn error, no output
|
||||
const have = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
||||
const add = m[1].split(path.delimiter).filter((d) => d && !have.includes(d));
|
||||
process.env.PATH = [...have, ...new Set(add)].join(path.delimiter);
|
||||
resolve(true);
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return resolve(false);
|
||||
}
|
||||
// An rc file that reads stdin gets EOF instead of hanging until the timeout.
|
||||
child?.stdin?.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { hydrateShellPath };
|
||||
@@ -137,6 +137,19 @@ function sendText(name, text) {
|
||||
execImpl(["send-keys", "-t", pane(name), "Enter"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste `text` into the pane as one bracketed paste, then press Enter. Unlike
|
||||
* `sendText` it may span lines: the text goes into a named buffer through an
|
||||
* argv element (`set-buffer -- <text>`), never a shell, and `-d` deletes the
|
||||
* buffer after the paste so a prompt does not linger in tmux's buffer list.
|
||||
*/
|
||||
function pasteText(name, text) {
|
||||
const buf = `agenthub-paste-${name}`;
|
||||
execImpl(["set-buffer", "-b", buf, "--", text]);
|
||||
execImpl(["paste-buffer", "-p", "-d", "-b", buf, "-t", pane(name)]);
|
||||
execImpl(["send-keys", "-t", pane(name), "Enter"]);
|
||||
}
|
||||
|
||||
/** Idempotent — a session that's already gone is not an error. */
|
||||
function killSession(name) {
|
||||
try {
|
||||
@@ -176,6 +189,7 @@ module.exports = {
|
||||
paneCommand,
|
||||
sendCommand,
|
||||
sendText,
|
||||
pasteText,
|
||||
killSession,
|
||||
listSessions,
|
||||
isTmuxAvailable,
|
||||
|
||||
Reference in New Issue
Block a user