0a86dfb8c5
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.
The two CLIs are configured differently, and the difference is theirs:
- a claude lane gets eight `env` keys merged into its own
`.claude/settings.local.json` (`.local` because the value holds a key);
- a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
`codex -p <name>` loads — because Codex has NO project-level config.
Its base `config.toml` is never touched.
Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).
Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.
Three things this had to get right, each found by review and verified
against the real tools rather than assumed:
- `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
supported"), so every codex lane on a router would have failed to start.
The layer writes "responses", and writes `env_key` only when the profile
actually has a key — naming an unset variable is itself a startup error.
- The key must never be TYPED into a pane. `tmux.sendCommand` used
`send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
every attached browser and bash appends to history; it now takes over the
idle pane with `respawn-pane -k -e`, passing env through argv the way
`newSession` always has. Verified on tmux 3.4: multi-element argv arrives
intact and the pane renders no trace of the value.
- Attaching COPIES a profile, so editing or deleting one in Settings has to
reach the lanes already using it. An edit re-applies it (a rotated key
would otherwise never reach the lane still holding the revoked one); a
delete resets those lanes to native and removes the Codex layer file.
Both report per-lane failures, which the tab surfaces.
`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.
DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
342 lines
13 KiB
JavaScript
342 lines
13 KiB
JavaScript
/**
|
|
* @file pty-run.js
|
|
* @description Owns the tmux-backed run lifecycle for the dashboard's
|
|
* terminal-run feature: Start (create-or-adopt), Resume (`--resume`), Kill,
|
|
* and List. Unlike the old run-spawner.js, there is no in-memory handle Map —
|
|
* `listRuns`/`getRun` are computed fresh from `tmux list-sessions` on every
|
|
* call, the same "computed fact, never a stored one" principle this repo
|
|
* already applies to lane runtime up/down (see CLAUDE.md). A session killed
|
|
* out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects
|
|
* on the next read instead of leaving a ghost "running" row.
|
|
*
|
|
* Start/Resume is create-or-reuse: when the lane's tmux session already
|
|
* exists but its pane sits at a shell prompt, the argv is typed into that
|
|
* pane instead of being dropped on the floor by a silent adopt.
|
|
*
|
|
* Every session is named `agenthub-lane-<laneId>` so a real terminal can attach
|
|
* to the exact same session (`tmux attach -t agenthub-lane-<id>`, or
|
|
* `agenthub lanes shell`) — that's the whole point: the dashboard both creates
|
|
* the session (one-click Start/Resume) and is just one of possibly several
|
|
* attached clients tmux already keeps in sync.
|
|
*
|
|
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const tmux = require("./tmux");
|
|
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
|
|
|
let dashboardRuns = null;
|
|
try {
|
|
dashboardRuns = require("./dashboard-runs");
|
|
} catch {
|
|
/* db-less environment, skip persistence */
|
|
}
|
|
|
|
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
|
|
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
|
// 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"]);
|
|
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.
|
|
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
|
|
|
|
function runIdForLane(laneId) {
|
|
return `agenthub-lane-${laneId}`;
|
|
}
|
|
|
|
function laneIdFromRunId(id) {
|
|
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
|
|
return m ? Number(m[1]) : null;
|
|
}
|
|
|
|
function makeErr(code, message) {
|
|
const err = new Error(message);
|
|
err.code = code;
|
|
return err;
|
|
}
|
|
|
|
/**
|
|
* Build the pane's command argv for Claude. Unlike the old stream-json spawner
|
|
* there is no headless/conversation split — every run is a live interactive
|
|
* pane, so an initial prompt (when given) is a trailing POSITIONAL argument:
|
|
* `claude` treats a bare positional as the first turn's message and stays
|
|
* interactive afterward (unlike `-p`, which forces print-and-exit and closes
|
|
* stdin).
|
|
*/
|
|
function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
|
|
const argv = ["claude"];
|
|
argv.push("--permission-mode", permissionMode || "acceptEdits");
|
|
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
|
|
// it on: `claude --help` — "Enable bypassing all permission checks as an
|
|
// option, without it being enabled by default." Without it the cycle is only
|
|
// plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could
|
|
// never reach bypass from inside the pane, while a real terminal could.
|
|
// The starting mode is still whatever --permission-mode says above.
|
|
argv.push("--allow-dangerously-skip-permissions");
|
|
if (model) argv.push("--model", model);
|
|
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
|
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
|
if (initialPrompt) argv.push(initialPrompt);
|
|
return argv;
|
|
}
|
|
|
|
/**
|
|
* Codex's CLI shape differs from Claude's in two ways that matter here:
|
|
* `resume` is a SUBCOMMAND (so it precedes every flag) and reasoning effort has
|
|
* no flag of its own — it rides on a `-c` config override. The prompt stays a
|
|
* trailing positional, same as Claude, so the pane stays interactive.
|
|
*/
|
|
function buildCodexArgv({
|
|
model,
|
|
effort,
|
|
sandbox,
|
|
approval,
|
|
resumeSessionId,
|
|
resumeLast,
|
|
initialPrompt,
|
|
routerProfile,
|
|
}) {
|
|
const argv = ["codex"];
|
|
// Before the subcommand and before the prompt: `-p` layers
|
|
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
|
|
// lands after the positional prompt is not an option any more.
|
|
argv.push(...routerArgvForSpawn({ tool: "codex", router_profile: routerProfile }));
|
|
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);
|
|
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
|
|
if (initialPrompt) argv.push(initialPrompt);
|
|
return argv;
|
|
}
|
|
|
|
function buildArgv(args) {
|
|
return args.tool === "codex" ? buildCodexArgv(args) : buildClaudeArgv(args);
|
|
}
|
|
|
|
/**
|
|
* @param {object} args
|
|
* @param {number} args.laneId
|
|
* @param {string} args.cwd
|
|
* @param {string} [args.tool="claude"]
|
|
* @param {string} [args.model]
|
|
* @param {string} [args.permissionMode]
|
|
* @param {string} [args.effort]
|
|
* @param {string} [args.sandbox]
|
|
* @param {string} [args.approval]
|
|
* @param {string} [args.resumeSessionId]
|
|
* @param {boolean} [args.resumeLast]
|
|
* @param {string} [args.initialPrompt]
|
|
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
|
|
* (`lanes.router_profile`), never a request field.
|
|
*/
|
|
function spawnRun(args) {
|
|
const {
|
|
laneId,
|
|
cwd,
|
|
tool = "claude",
|
|
model,
|
|
permissionMode,
|
|
effort,
|
|
sandbox,
|
|
approval,
|
|
resumeSessionId,
|
|
resumeLast,
|
|
initialPrompt,
|
|
routerProfile,
|
|
} = args || {};
|
|
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
|
throw makeErr("EBADLANE", "laneId must be an integer");
|
|
}
|
|
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(", ")}`);
|
|
}
|
|
if (tool === "codex") {
|
|
if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) {
|
|
throw makeErr(
|
|
"EBADSANDBOX",
|
|
`sandbox must be one of: ${Array.from(CODEX_SANDBOXES).join(", ")}`
|
|
);
|
|
}
|
|
if (approval != null && approval !== "" && !CODEX_APPROVALS.has(approval)) {
|
|
throw makeErr(
|
|
"EBADAPPROVAL",
|
|
`approval must be one of: ${Array.from(CODEX_APPROVALS).join(", ")}`
|
|
);
|
|
}
|
|
}
|
|
if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) {
|
|
throw makeErr(
|
|
"EBADMODE",
|
|
`permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}`
|
|
);
|
|
}
|
|
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
|
|
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
|
|
}
|
|
if (
|
|
resumeSessionId != null &&
|
|
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId))
|
|
) {
|
|
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
|
}
|
|
|
|
// A run IS a tmux session, so an absent binary is a precondition failure, not
|
|
// a spawn failure. Without this the caller gets `spawnSync tmux ENOENT` from
|
|
// three directories down — a message that names no fix — and the route maps
|
|
// it to 400, blaming the request for the host's missing package. Checked here
|
|
// rather than in each route because both spawn paths (POST /api/run and
|
|
// POST /api/lanes/:id/start) come through this function.
|
|
if (!tmux.isTmuxAvailable()) {
|
|
throw makeErr(
|
|
"ETMUXMISSING",
|
|
"tmux is not installed, and every run the dashboard starts is a tmux session. " +
|
|
"Install it (sudo apt install tmux / brew install tmux; on Windows run AgentHub under WSL2), " +
|
|
"then restart the dashboard. Everything else keeps working without it."
|
|
);
|
|
}
|
|
|
|
const id = runIdForLane(laneId);
|
|
const startedAt = Date.now();
|
|
const argv = buildArgv({
|
|
tool,
|
|
model,
|
|
permissionMode,
|
|
effort,
|
|
sandbox,
|
|
approval,
|
|
resumeSessionId,
|
|
resumeLast,
|
|
initialPrompt,
|
|
routerProfile,
|
|
});
|
|
|
|
const record = () => {
|
|
if (!dashboardRuns) return;
|
|
dashboardRuns.recordRun({
|
|
id,
|
|
sessionId: resumeSessionId || null,
|
|
// `dashboard_runs.mode` is NOT NULL. Passing null made every insert throw
|
|
// inside recordRun's best-effort catch, so no tmux-backed run was ever
|
|
// persisted: publicRun then read its cwd/model/prompt/startedAt off a row
|
|
// that did not exist (a blank row dated Jan 1 1970 in the runs modal), and
|
|
// session_id never landed either, so a resumed session stayed listed as a
|
|
// separate `external` row. Every run here is one interactive pane.
|
|
mode: "interactive",
|
|
cwd,
|
|
tool: tool === "codex" ? "codex" : "claude",
|
|
model: model || null,
|
|
permissionMode: permissionMode || "acceptEdits",
|
|
effort: effort || null,
|
|
resumeSessionId: resumeSessionId || null,
|
|
prompt: initialPrompt || "",
|
|
status: "running",
|
|
startedAt,
|
|
endedAt: null,
|
|
exitCode: null,
|
|
laneId,
|
|
});
|
|
};
|
|
|
|
// Every pane the dashboard starts carries its lane id. A `agenthub stage` call
|
|
// from inside it then resolves the lane exactly, instead of matching on cwd —
|
|
// which is ambiguous now that one directory can back both a `claude` lane and
|
|
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
|
|
// A codex lane on an AI Router also needs the key its layer's `env_key`
|
|
// names. A claude lane needs nothing here — its settings file already carries
|
|
// the router — and a lane on the vendor's own API needs nothing either.
|
|
const env = {
|
|
AGENTHUB_LANE_ID: String(laneId),
|
|
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
|
};
|
|
|
|
if (!tmux.hasSession(id)) {
|
|
tmux.newSession({ name: id, cwd, argv, env });
|
|
record();
|
|
} 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
|
|
// silently here would swallow the whole request: a Resume would spawn no
|
|
// `--resume` and an initial prompt would never be typed, while the API
|
|
// still answered 200. Run the argv in the pane the user already sees
|
|
// instead of erroring or opening a second session.
|
|
tmux.sendCommand(id, argv, env, cwd);
|
|
record();
|
|
}
|
|
// Pane is running something (a live `claude`, an editor, a build): adopt
|
|
// silently, same convention as this repo's server port-adoption logic — no
|
|
// error, no duplicate session. Attaching shows the user what is running.
|
|
//
|
|
// Adopting is only honest when the request asked for nothing more than a
|
|
// pane. A Resume or an initial prompt cannot be delivered into a pane that is
|
|
// already running an agent (typing over it is what the shell-pane branch
|
|
// above exists to avoid), so answering 200 there reports work that never
|
|
// happened: the user sees "resumed" while no `--resume` was ever spawned.
|
|
else if (resumeSessionId || initialPrompt) {
|
|
throw makeErr(
|
|
"ERUNLIVE",
|
|
`lane ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
|
|
);
|
|
}
|
|
|
|
return getRun(id);
|
|
}
|
|
|
|
function killRun(id) {
|
|
if (!id || !tmux.hasSession(id)) return false;
|
|
tmux.killSession(id);
|
|
if (dashboardRuns) {
|
|
dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function publicRun(id) {
|
|
const laneId = laneIdFromRunId(id);
|
|
const live = tmux.hasSession(id);
|
|
const row = dashboardRuns ? dashboardRuns.getRun(id) : null;
|
|
return {
|
|
id,
|
|
laneId,
|
|
status: live ? "running" : "gone",
|
|
tool: row?.tool || "claude",
|
|
cwd: row?.cwd || null,
|
|
model: row?.model || null,
|
|
permissionMode: row?.permission_mode || null,
|
|
effort: row?.effort || null,
|
|
resumeSessionId: row?.resume_session_id || null,
|
|
sessionId: row?.session_id || null,
|
|
startedAt: row?.started_at || null,
|
|
promptPreview: row?.prompt_preview || null,
|
|
};
|
|
}
|
|
|
|
function getRun(id) {
|
|
if (!id) return null;
|
|
return publicRun(id);
|
|
}
|
|
|
|
/** Computed fresh from tmux state every call — see file header. */
|
|
function listRuns() {
|
|
return tmux.listSessions("agenthub-lane-").map(publicRun);
|
|
}
|
|
|
|
module.exports = {
|
|
spawnRun,
|
|
killRun,
|
|
getRun,
|
|
listRuns,
|
|
laneIdFromRunId,
|
|
runIdForLane,
|
|
SHELL_COMMANDS,
|
|
};
|