8ecb96e9fc
--allow-dangerously-skip-permissions (bypass in the pane's shift+tab cycle) made Claude Code show its Bypass Permissions warning on any account that had not accepted it yet — every new member's first session, which the Chat UI cannot answer, though nobody asked for bypass. Now passed only when the run starts in bypassPermissions (asking is consent to see it) or the account's user settings already hold skipDangerousModePermissionPrompt (pty-run bypassAccepted; a member's read owner-checked from their home). The host, which accepted long ago, keeps the flag. Test: pty-run.
810 lines
35 KiB
JavaScript
810 lines
35 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`, `codex` or `agy` (Antigravity) in the tmux pane based on
|
||
* the tool parameter.
|
||
*
|
||
* A lane can hold up to ten sessions (`MAX_PANE_SLOT`): slot 0 is
|
||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–9 are
|
||
* `agenthub-lane-<laneId>-<slot>`. A slot holds an AI tool (`spawnRun`) or a
|
||
* plain shell (`spawnShell`, a Terminal tab).
|
||
*
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
const tmux = require("./tmux");
|
||
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
|
||
const { desktopEnv } = require("./desktop-env");
|
||
const { getCodexHome } = require("./codex-home");
|
||
const aiTools = require("./ai-tools");
|
||
|
||
let dashboardRuns = null;
|
||
try {
|
||
dashboardRuns = require("./dashboard-runs");
|
||
} catch {
|
||
/* db-less environment, skip persistence */
|
||
}
|
||
|
||
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/;
|
||
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
|
||
* Workspace groups them into tabs of up to four split panes each). */
|
||
const MAX_PANE_SLOT = 9;
|
||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||
// it asks only for what its classifier reads as risky. `default` is kept for
|
||
// callers that stored it, though this CLI now spells that one `manual`.
|
||
const ALLOWED_PERMISSION_MODES = new Set([
|
||
"auto",
|
||
"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"]);
|
||
|
||
/**
|
||
* `sh` that waits until a client is attached to the pane — 2 s at most
|
||
* ({@link CLIENT_WAIT_MS}) — before what follows it runs. A session is born
|
||
* 80×24 and takes the browser's size only on attach (and `enableMouse`'s
|
||
* scrollbar narrows it before that). Anything drawn before then is wrong:
|
||
* a shell's prompt (issue #55), and an inline `claude`'s banner — tmux
|
||
* re-wraps the 80-column rules into two lines each on the resize, pushing the
|
||
* banner's first lines into history, and claude's redraw clears only the
|
||
* visible screen, so they stay above it as a duplicate. Not "the first
|
||
* SIGWINCH": the scrollbar fires one before any client is there. A tmux that
|
||
* cannot answer ends the wait at once, i.e. the old behaviour.
|
||
*/
|
||
const WAIT_FOR_CLIENT =
|
||
'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' +
|
||
" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;";
|
||
const CLIENT_WAIT_MS = 2000;
|
||
|
||
/**
|
||
* `sh` that moves into `$1` itself. tmux applies `-c` only while its server's
|
||
* own cwd still exists: the server is forked from the dashboard's cwd — a
|
||
* plugin cache dir, deleted by `pruneRetiredPluginRoots` after an update the
|
||
* server outlives — and from then on every pane started in that deleted dir,
|
||
* where `claude` dies at once and a shell prints `getcwd` errors (issue #69).
|
||
*/
|
||
const IN_CWD = 'cd -- "$1" 2>/dev/null; shift;';
|
||
|
||
/**
|
||
* A pane's tmux command, run in `cwd`: `argv` behind {@link WAIT_FOR_CLIENT},
|
||
* or — with no `argv` — the login shell behind it. Windows has no `sh` (a
|
||
* WinGet tmux runs native programs), and an `sh -c` there died at once and
|
||
* took the session with it, so it gets the command itself, or nothing for
|
||
* tmux's own default shell.
|
||
* ponytail: no wait for the attach on Windows, so a pane may first draw at
|
||
* 80×24 (issue #55); port the wait to PowerShell if that shows.
|
||
*/
|
||
function paneArgv(argv, cwd, platform = process.platform) {
|
||
if (platform === "win32") return argv || [];
|
||
// cwd and argv ride as sh's positional parameters, never inside the script text.
|
||
return argv
|
||
? ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$@"`, "sh", cwd, ...argv]
|
||
: ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$SHELL" -l`, "sh", cwd];
|
||
}
|
||
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, slot = 0) {
|
||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||
}
|
||
|
||
function slotFromRunId(id) {
|
||
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
|
||
return m ? Number(m[2] || 0) : null;
|
||
}
|
||
|
||
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).
|
||
*/
|
||
/**
|
||
* A prompt as an argv element no CLI can read as an option: one that starts
|
||
* with "-" (`-c fix it`) would otherwise be parsed as flags — `claude -c`
|
||
* continues the last conversation, `-p` turns the pane into print mode. A
|
||
* leading space keeps it a positional and changes nothing the model reads.
|
||
*/
|
||
function promptArg(p) {
|
||
return p.startsWith("-") ? ` ${p}` : p;
|
||
}
|
||
|
||
// A prompt past INLINE_PROMPT_BYTES reaches the pane in a 0600 file, not in
|
||
// tmux's argv: one tmux message holds 16 KB with the env and the rest of the
|
||
// argv beside it (`command too long`, issue #68). The pane's own sh reads it
|
||
// back as the CLI's last argument and deletes the file. No CLI can take one
|
||
// argument past Linux's MAX_ARG_STRLEN (128 KB, E2BIG), so that is refused.
|
||
const INLINE_PROMPT_BYTES = 4 * 1024;
|
||
const MAX_PROMPT_BYTES = 128 * 1024 - 2; // the NUL, and promptArg's space
|
||
const READ_PROMPT = 'p=$(cat "$1") && rm -f "$1" && shift && exec "$@" "$p"';
|
||
|
||
function promptFileArgv(id, argv) {
|
||
const dir = path.join(require("./claude-home").getDataDir(), "run-prompts");
|
||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||
const file = path.join(dir, `${id}.txt`);
|
||
fs.writeFileSync(file, argv[argv.length - 1], { mode: 0o600 });
|
||
return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
|
||
}
|
||
|
||
/**
|
||
* Whether the account a pane runs as has accepted Claude Code's bypass
|
||
* warning — Claude writes `skipDangerousModePermissionPrompt: true` to its
|
||
* user settings once it is. A member's (S3) is read from their home, owner-
|
||
* checked; the host's from CLAUDE_HOME. Unreadable reads as "not yet".
|
||
*/
|
||
function bypassAccepted(member) {
|
||
const file = member
|
||
? path.join(member.home, ".claude", "settings.json")
|
||
: path.join(require("./claude-home").getClaudeHome(), "settings.json");
|
||
try {
|
||
const text = require("./run-as").readFileSafe(file, "utf8", { max: 1 << 20 });
|
||
return JSON.parse(text).skipDangerousModePermissionPrompt === true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function buildClaudeArgv({
|
||
model,
|
||
permissionMode,
|
||
effort,
|
||
resumeSessionId,
|
||
forkSession = false,
|
||
newSessionId = null,
|
||
worktreeName = null,
|
||
initialPrompt,
|
||
addDirs = [],
|
||
systemPromptFile = null,
|
||
proxyArgv = [],
|
||
offerBypass = true,
|
||
platform = process.platform,
|
||
}) {
|
||
const argv = ["claude"];
|
||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||
// argument up to the next option, so it goes first, where `--permission-mode`
|
||
// always ends it — after the prompt it would eat the prompt as a directory.
|
||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||
argv.push("--permission-mode", permissionMode || "auto");
|
||
// Inside a pane Claude Code sees $TMUX and opens agent-team teammates with
|
||
// `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads
|
||
// back is wrong and the follow-up respawn-pane/set-option land on the
|
||
// leader's own pane — the session is replaced by a bare cmd.exe. psmux also
|
||
// answers a target that does not exist (`list-panes -t <s>:claude-swarm`)
|
||
// with the CURRENT pane instead of an error, so any teammate path can hit
|
||
// the leader: Claude 2.1.296 dies on a named Agent call (older builds only
|
||
// failed with "Could not determine pane count for swarm window").
|
||
if (platform === "win32") argv.push("--teammate-mode", "in-process");
|
||
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
|
||
argv.push(...proxyArgv);
|
||
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
||
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
|
||
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
|
||
// the prompt as the worktree's name.
|
||
if (worktreeName) argv.push(`--worktree=${worktreeName}`);
|
||
// From a file: the instructions can be long, and the whole argv has to fit
|
||
// in one tmux message. `=` form, so the path is never read as an option.
|
||
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
|
||
// 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.
|
||
// Only once the account has accepted Claude Code's bypass warning
|
||
// (`offerBypass`, see bypassAccepted): before that the flag alone puts the
|
||
// warning in front of a brand-new account's first session — one the Chat UI
|
||
// cannot answer — although nobody asked for bypass.
|
||
if (offerBypass) 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);
|
||
else if (newSessionId) argv.push("--session-id", newSessionId);
|
||
// A fork resumes the history into a NEW session id; the original is left as
|
||
// it was.
|
||
if (resumeSessionId && forkSession) argv.push("--fork-session");
|
||
if (initialPrompt) argv.push(promptArg(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,
|
||
bypass,
|
||
initialPrompt,
|
||
proxyArgv = [],
|
||
addDirs = [],
|
||
systemPrompt = null,
|
||
systemPromptFile = null,
|
||
}) {
|
||
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(...proxyArgv);
|
||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||
else if (resumeLast) argv.push("resume", "--last");
|
||
if (model) argv.push("-m", model);
|
||
// 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}`);
|
||
for (const dir of addDirs) argv.push("--add-dir", dir);
|
||
// `-c` parses its value as TOML; a JSON string literal is a valid TOML basic
|
||
// string, so any text — quotes, newlines — arrives as one string.
|
||
if (systemPrompt) {
|
||
const { codexInstructions, tomlString } = require("./lane-sources");
|
||
const text = codexInstructions(systemPrompt, systemPromptFile);
|
||
if (text) argv.push("-c", `developer_instructions=${tomlString(text)}`);
|
||
}
|
||
if (initialPrompt) argv.push(promptArg(initialPrompt));
|
||
return argv;
|
||
}
|
||
|
||
/**
|
||
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
|
||
* ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
|
||
* catalog declares a model flag). Every piece but the prompt and the
|
||
* validated model 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 buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
|
||
const argv = [bin, ...entry.args];
|
||
if (model && entry.model) argv.push(entry.model.flag, model);
|
||
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, promptArg(initialPrompt));
|
||
if (initialPrompt && p.mode === "argv")
|
||
// A `--` separator already ends the options; without one, guard the prompt.
|
||
argv.push(...(p.separator ? [p.separator, initialPrompt] : [promptArg(initialPrompt)]));
|
||
return argv;
|
||
}
|
||
|
||
function buildArgv(args, entry) {
|
||
if (entry.launcher === "codex") return buildCodexArgv(args);
|
||
if (entry.launcher === "claude") return buildClaudeArgv(args);
|
||
// S3: a member's pane cannot enter the host's home — it runs the copy every
|
||
// account shares (server/lib/shared-tools.js).
|
||
if (require("./run-as").accountForLane(args.laneId)) {
|
||
const shared = require("./shared-tools").sharedPath(entry.detect[0]);
|
||
if (!shared) {
|
||
throw makeErr(
|
||
"ENOTSHARED",
|
||
`${entry.label || entry.id} is not installed for every account yet — an admin runs \`agenthub server tools publish\``
|
||
);
|
||
}
|
||
return buildCatalogArgv(entry, shared, 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);
|
||
}
|
||
|
||
/**
|
||
* @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.forkSession] resume `resumeSessionId` into a new
|
||
* session id (`claude --fork-session`); Claude only.
|
||
* @param {boolean} [args.worktree] start a NEW Claude session in its own git
|
||
* worktree (`claude --worktree=<name>`, name made here); Claude only.
|
||
* @param {boolean} [args.inWorktree] `cwd` is a session's worktree (a resume
|
||
* into it, resolved by the caller from the session row) — recorded only.
|
||
* @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.proxy] AI Proxy profile THIS session runs on — a
|
||
* name only, its key and endpoint come from the store; null/"native" = the
|
||
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
|
||
*/
|
||
/** S3: a pane's cwd must be its workspace's account's (both null outside S3). */
|
||
function assertCwdAccount(laneId, cwd) {
|
||
const runAs = require("./run-as");
|
||
if (runAs.accountForPath(cwd)?.login !== runAs.accountForLane(laneId)?.login) {
|
||
throw makeErr("ECWDACCOUNT", "that folder belongs to another account than this workspace");
|
||
}
|
||
}
|
||
|
||
function spawnRun(args) {
|
||
const {
|
||
laneId,
|
||
slot = 0,
|
||
cwd,
|
||
tool = "claude",
|
||
model,
|
||
permissionMode,
|
||
effort,
|
||
sandbox,
|
||
approval,
|
||
resumeSessionId,
|
||
forkSession,
|
||
worktree,
|
||
inWorktree,
|
||
resumeLast,
|
||
bypass,
|
||
initialPrompt,
|
||
proxy,
|
||
} = args || {};
|
||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||
}
|
||
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
|
||
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
|
||
}
|
||
if (typeof cwd !== "string" || !cwd) {
|
||
throw makeErr("EBADCWD", "cwd is required");
|
||
}
|
||
if (typeof initialPrompt === "string" && Buffer.byteLength(initialPrompt) > MAX_PROMPT_BYTES) {
|
||
throw makeErr(
|
||
"EBADPROMPT",
|
||
`the prompt is ${Math.ceil(Buffer.byteLength(initialPrompt) / 1024)} KB; an AI CLI takes at most 128 KB as its first message — attach the text as a file instead`
|
||
);
|
||
}
|
||
// 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(
|
||
"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(", ")}`
|
||
);
|
||
}
|
||
// A model is one argv element after a model flag; one starting with `-`
|
||
// would be read as a flag of its own.
|
||
if (model != null && model !== "" && !aiTools.validModel(model)) {
|
||
throw makeErr("EBADMODEL", "model is not a valid model id");
|
||
}
|
||
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");
|
||
}
|
||
// Only the hand-written launchers can resume a NAMED session; a catalog
|
||
// tool would silently start fresh under a "resume".
|
||
if (resumeSessionId && !entry.launcher) {
|
||
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
|
||
}
|
||
if (worktree === true && entry.launcher !== "claude") {
|
||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||
}
|
||
// A personal AI Proxy resolves only for its owner's workspace.
|
||
const lane = require("./lanes").getLane(laneId);
|
||
const laneOwner = lane?.owner_id ?? null;
|
||
// S3, before anything is written for this pane: it runs as its workspace
|
||
// FOLDER's account with its OWNER's token, so the two must agree (live
|
||
// role: a non-admin owns no host folder), and the cwd must be that same
|
||
// account's — else the host would run in a member's folder (their hooks,
|
||
// their .mcp.json) or a member in someone else's.
|
||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
|
||
// own key, and an empty pool leaves the machine's login in charge.
|
||
const poolAccount =
|
||
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
|
||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||
const codexPool =
|
||
entry.launcher === "codex" && !proxyProfile
|
||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||
: null;
|
||
const paneCred = require("./auth").newPaneToken();
|
||
if (forkSession === true) {
|
||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||
if (entry.launcher !== "claude") {
|
||
throw makeErr("EUNSUPPORTED", `${tool} cannot fork a session`);
|
||
}
|
||
}
|
||
|
||
// 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, slot);
|
||
const startedAt = Date.now();
|
||
// Claude Code does not exclude the worktrees it makes, so the main repo
|
||
// would list `.claude/` as untracked; the root's info/exclude hides them.
|
||
if (worktree === true) {
|
||
require("./git-exclude").excludeFromRepoRoot(cwd, [".claude/worktrees/"]);
|
||
}
|
||
// The workspace's own folders, context files and instructions — read from
|
||
// its rows here, the one place both spawn paths pass through, never from a
|
||
// request (server/lib/lane-sources.js).
|
||
const { addDirs, systemPrompt, systemPromptFile } = require("./lane-sources").launchExtras(
|
||
laneId,
|
||
tool
|
||
);
|
||
const proxySpawn = proxyForSpawn(
|
||
tool,
|
||
proxyProfile,
|
||
paneCred?.token,
|
||
require("./run-as").accountForLane(laneId),
|
||
id
|
||
);
|
||
// A new Claude session gets its id from us, so the run knows the session it
|
||
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
||
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||
const newSessionId =
|
||
entry.launcher === "claude" && !resumeSessionId ? require("crypto").randomUUID() : null;
|
||
const argv = buildArgv(
|
||
{
|
||
laneId,
|
||
addDirs,
|
||
systemPrompt,
|
||
systemPromptFile,
|
||
tool,
|
||
model,
|
||
permissionMode,
|
||
effort,
|
||
sandbox,
|
||
approval,
|
||
resumeSessionId,
|
||
forkSession: forkSession === true,
|
||
newSessionId,
|
||
// Asking for bypass is consent to see its warning; otherwise only an
|
||
// account that already accepted it gets bypass in its shift+tab cycle.
|
||
offerBypass:
|
||
permissionMode === "bypassPermissions" ||
|
||
(entry.launcher === "claude" && bypassAccepted(require("./run-as").accountForLane(laneId))),
|
||
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
|
||
resumeLast,
|
||
bypass: bypass === true,
|
||
initialPrompt,
|
||
proxyArgv: [...proxySpawn.argv, ...(codexPool ? codexPool.argv : [])],
|
||
},
|
||
entry
|
||
);
|
||
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
|
||
// S3: a member's pane runs as them (tmux.js starts it on their server).
|
||
const member = require("./run-as").accountForLane(laneId);
|
||
// Every launcher puts the prompt LAST; Windows has no sh to read it back.
|
||
// A member's pane cannot read the host's 0700 run-prompts dir — and needs
|
||
// no file: its whole argv already travels in its launch file, not tmux's.
|
||
const paneCmd =
|
||
initialPrompt &&
|
||
!pasteLater &&
|
||
!member &&
|
||
process.platform !== "win32" &&
|
||
Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES
|
||
? promptFileArgv(id, argv)
|
||
: argv;
|
||
|
||
const record = () => {
|
||
if (!dashboardRuns) return;
|
||
dashboardRuns.recordRun({
|
||
id,
|
||
// A fork's own id arrives with its first hook (bindRunSession).
|
||
sessionId: newSessionId || (forkSession !== true && 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,
|
||
model: model || null,
|
||
permissionMode: permissionMode || "auto",
|
||
effort: effort || null,
|
||
resumeSessionId: resumeSessionId || null,
|
||
worktree: worktree === true || inWorktree === true,
|
||
proxy: proxyProfile ? proxyProfile.name : null,
|
||
account: (poolAccount || codexPool)?.name ?? null,
|
||
paneTokenHash: paneCred ? paneCred.hash : 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 session on an AI Proxy also needs the key its layer's `env_key`
|
||
// names. A claude one needs nothing here — its `--settings` file carries the
|
||
// proxy — and a session on the vendor's own API needs nothing either.
|
||
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
|
||
// server normally runs as a systemd user service with neither, so a pane it
|
||
// 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),
|
||
// Which pane this is: the CLI's hooks inherit it, so the dashboard learns
|
||
// which session each pane holds (routes/hooks.js → bindRunSession) — the
|
||
// sidebar opens a session's own pane by it.
|
||
AGENTHUB_RUN_ID: id,
|
||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||
...desktopEnv(),
|
||
// A codex pane must read the same home the dashboard reads — its rollout
|
||
// journals and the proxy layer `-p <name>` loads live there — and the
|
||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||
// pushes lines into history its redraw cannot clear). The user's own
|
||
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
||
...proxySpawn.env,
|
||
// The pool account's setup-token: outranks the machine's `/login` for
|
||
// this process only. Through tmux `-e`, never typed into the pane.
|
||
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
|
||
...(codexPool ? codexPool.env : {}),
|
||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||
};
|
||
// S3: a member's pane gets their home, the shared tools and none of the
|
||
// host's desktop.
|
||
if (member) {
|
||
const own = require("./run-as").memberPaneEnv(member, env, { codex: tool === "codex" });
|
||
for (const k of Object.keys(env)) delete env[k];
|
||
Object.assign(env, own);
|
||
}
|
||
|
||
// `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 + CLIENT_WAIT_MS); // the CLI may start up to that late
|
||
if (timer.unref) timer.unref();
|
||
};
|
||
|
||
if (!tmux.hasSession(id)) {
|
||
codexPool?.place();
|
||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), 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
|
||
// 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.
|
||
codexPool?.place();
|
||
tmux.sendCommand(id, paneArgv(paneCmd, cwd), 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
|
||
// 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",
|
||
`workspace ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
|
||
);
|
||
}
|
||
|
||
return getRun(id);
|
||
}
|
||
|
||
/**
|
||
* A Workspace "Terminal" tab: the user's own default shell (tmux's, run with
|
||
* nothing after `--`) in a lane's pane slot. No AI tool, no prompt, nothing
|
||
* from the request but the slot — the cwd is the lane's. Recorded as tool
|
||
* `shell` so the tab can say what it is. The caller has already made sure the
|
||
* slot holds no live session.
|
||
*/
|
||
function spawnShell({ laneId, slot = 0, cwd }) {
|
||
if (!Number.isInteger(laneId)) throw makeErr("EBADLANE", "laneId must be an integer");
|
||
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
|
||
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
|
||
}
|
||
if (!tmux.isTmuxAvailable()) throw makeErr("ETMUXMISSING", "tmux is not installed");
|
||
const id = runIdForLane(laneId, slot);
|
||
const paneCred = require("./auth").newPaneToken();
|
||
const env = {
|
||
AGENTHUB_LANE_ID: String(laneId),
|
||
AGENTHUB_RUN_ID: id,
|
||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||
...desktopEnv(),
|
||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||
};
|
||
const lane = require("./lanes").getLane(laneId);
|
||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, lane.owner_id ?? null); // as spawnRun
|
||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT, as spawnRun
|
||
const member = require("./run-as").accountForLane(laneId);
|
||
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||
tmux.newSession({ name: id, cwd, argv: paneArgv(null, cwd), env: paneEnv });
|
||
dashboardRuns?.recordRun({
|
||
id,
|
||
sessionId: null,
|
||
mode: "interactive",
|
||
cwd,
|
||
tool: "shell",
|
||
model: null,
|
||
permissionMode: null,
|
||
effort: null,
|
||
resumeSessionId: null,
|
||
worktree: false,
|
||
proxy: null,
|
||
paneTokenHash: paneCred ? paneCred.hash : null,
|
||
prompt: "",
|
||
status: "running",
|
||
startedAt: Date.now(),
|
||
endedAt: null,
|
||
exitCode: null,
|
||
laneId,
|
||
});
|
||
return getRun(id);
|
||
}
|
||
|
||
// Only a dashboard lane pane is a run. Without this, `DELETE /api/run/main`
|
||
// would kill the user's own tmux session named `main`.
|
||
function killRun(id) {
|
||
if (laneIdFromRunId(id) === null || !tmux.hasSession(id)) return false;
|
||
tmux.killSession(id);
|
||
if (dashboardRuns) {
|
||
dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() });
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function publicRun(id, live = tmux.hasSession(id)) {
|
||
const laneId = laneIdFromRunId(id);
|
||
const row = dashboardRuns ? dashboardRuns.getRun(id) : null;
|
||
return {
|
||
id,
|
||
laneId,
|
||
slot: slotFromRunId(id),
|
||
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,
|
||
// The pane works in a git worktree of its own (`claude --worktree`).
|
||
worktree: !!row?.worktree,
|
||
startedAt: row?.started_at || null,
|
||
promptPreview: row?.prompt_preview || null,
|
||
};
|
||
}
|
||
|
||
function getRun(id) {
|
||
if (laneIdFromRunId(id) === null) return null;
|
||
return publicRun(id);
|
||
}
|
||
|
||
/**
|
||
* Computed fresh from tmux state every call — see file header. A listed
|
||
* session is live by definition, so no has-session per row.
|
||
*/
|
||
function listRuns() {
|
||
return tmux.listSessions("agenthub-lane-").map((id) => publicRun(id, true));
|
||
}
|
||
|
||
module.exports = {
|
||
spawnRun,
|
||
spawnShell,
|
||
killRun,
|
||
getRun,
|
||
listRuns,
|
||
laneIdFromRunId,
|
||
runIdForLane,
|
||
MAX_PANE_SLOT,
|
||
SHELL_COMMANDS,
|
||
paneArgv,
|
||
buildClaudeArgv,
|
||
};
|