555 lines
22 KiB
JavaScript
555 lines
22 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 four sessions, one per Workspace split pane: pane 0 is
|
||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
|
||
* `agenthub-lane-<laneId>-<slot>`.
|
||
*
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const tmux = require("./tmux");
|
||
const { resolveRouter, routerForSpawn } = require("./router-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-3]))?$/;
|
||
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
|
||
const MAX_PANE_SLOT = 3;
|
||
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"]);
|
||
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;
|
||
}
|
||
|
||
function buildClaudeArgv({
|
||
model,
|
||
permissionMode,
|
||
effort,
|
||
resumeSessionId,
|
||
forkSession = false,
|
||
newSessionId = null,
|
||
worktreeName = null,
|
||
initialPrompt,
|
||
addDirs = [],
|
||
systemPromptFile = null,
|
||
routerArgv = [],
|
||
}) {
|
||
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");
|
||
// The session's AI Router: `--settings=<file>` (router-apply.js).
|
||
argv.push(...routerArgv);
|
||
// 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.
|
||
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,
|
||
routerArgv = [],
|
||
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(...routerArgv);
|
||
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);
|
||
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.router] AI Router 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`).
|
||
*/
|
||
function spawnRun(args) {
|
||
const {
|
||
laneId,
|
||
slot = 0,
|
||
cwd,
|
||
tool = "claude",
|
||
model,
|
||
permissionMode,
|
||
effort,
|
||
sandbox,
|
||
approval,
|
||
resumeSessionId,
|
||
forkSession,
|
||
worktree,
|
||
inWorktree,
|
||
resumeLast,
|
||
bypass,
|
||
initialPrompt,
|
||
router,
|
||
} = 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");
|
||
}
|
||
// 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");
|
||
}
|
||
if (worktree === true && entry.launcher !== "claude") {
|
||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||
}
|
||
const routerProfile = resolveRouter(tool, router); // ENOTFOUND, EUNSUPPORTED
|
||
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 routerSpawn = routerForSpawn(tool, routerProfile);
|
||
// 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(
|
||
{
|
||
addDirs,
|
||
systemPrompt,
|
||
systemPromptFile,
|
||
tool,
|
||
model,
|
||
permissionMode,
|
||
effort,
|
||
sandbox,
|
||
approval,
|
||
resumeSessionId,
|
||
forkSession: forkSession === true,
|
||
newSessionId,
|
||
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
|
||
resumeLast,
|
||
bypass: bypass === true,
|
||
initialPrompt,
|
||
routerArgv: routerSpawn.argv,
|
||
},
|
||
entry
|
||
);
|
||
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
|
||
|
||
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,
|
||
router: routerProfile ? routerProfile.name : 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 Router also needs the key its layer's `env_key`
|
||
// names. A claude one needs nothing here — its `--settings` file carries the
|
||
// router — 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 router 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() } : {}),
|
||
...routerSpawn.env,
|
||
...(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
|
||
// 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();
|
||
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);
|
||
}
|
||
|
||
// 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) {
|
||
const laneId = laneIdFromRunId(id);
|
||
const live = tmux.hasSession(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. */
|
||
function listRuns() {
|
||
return tmux.listSessions("agenthub-lane-").map(publicRun);
|
||
}
|
||
|
||
module.exports = {
|
||
spawnRun,
|
||
killRun,
|
||
getRun,
|
||
listRuns,
|
||
laneIdFromRunId,
|
||
runIdForLane,
|
||
MAX_PANE_SLOT,
|
||
SHELL_COMMANDS,
|
||
};
|