Files
AgentHub/server/routes/lanes.js
T
nntrivi2001 0c2f49876d feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:10:29 +07:00

2443 lines
96 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Express router for lanes — the durable per-working-directory unit of
* parallel agent work. Read endpoints join each lane with its most recent event
* timestamp so liveness can be computed without a separate heartbeat, and every
* mutation re-broadcasts the lane over the existing WebSocket as `lane_update`.
* Orchestration is deliberately absent: the driving Claude session declares its
* own stage (`POST /:id/stage`); the dashboard never guesses a transition.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
const { sourceFolder } = require("../lib/lane-sources");
const { suggestCommitMessage } = require("../lib/commit-message");
const aiTools = require("../lib/ai-tools");
const {
listPipelines,
getPipeline,
nodeStates,
progressPct,
phaseIdx,
} = require("../lib/pipelines");
const laneFeatures = require("../lib/lane-features");
const proofLib = require("../lib/proof");
const { broadcast } = require("../websocket");
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const { resumeProxy } = require("../lib/proxy-apply");
const { sameOriginGuard } = require("./run");
const { isLocalRequest } = require("../lib/same-origin");
const scope = require("../lib/scope");
const { writeLaneFile } = require("../lib/lane-file-write");
const { pickPath, startDir } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight");
const {
LANES_ROOT,
addWorktree,
git,
gitFacts,
isGitRepo,
isValidBranchName,
listBranches,
listWorktrees,
resetWorktree,
resolveBase,
slugify,
} = require("../lib/worktree");
const {
collectChanges,
listCommits,
defaultBranch,
listPaths,
listTree,
listTreeAll,
readFileAt,
blameFile,
readImageAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
const { syncMcp } = require("../lib/lane-mcp");
const { HOOKS, runHook, resolveProfile, isIntegrationEnabled } = require("../lib/lane-profile");
const { slotDirs } = require("../lib/lane-slots");
const {
upLane,
downLane,
runtimeFacts,
requireProfile,
provisionLane,
resetLaneData,
} = require("../lib/lane-runtime");
const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc");
const { detectNode, scaffoldProfile } = require("../lib/lane-detect");
const { ciStatus } = require("../lib/ci-status");
const router = Router();
const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
/** Bytes of a hook log returned by default — enough to see a failure's tail. */
const LOG_TAIL_DEFAULT = 64 * 1024;
const LOG_TAIL_MAX = 1024 * 1024;
/**
* Seconds since this lane last showed life: its session's newest event or the
* start of its current run, whichever is newer; null if neither exists. The
* run counts because a new run keeps the PREVIOUS session_id until the new
* session's first hook rebinds it — judged by that old session alone, a run
* started a second ago read "dead" and, with no lane field changing after
* (a resume, or a hook ingested by another server on the same DB), stayed so
* until a refresh. Only with a session bound: a lane no hook ever gives one
* (a catalog tool, Codex without its hooks) has no signal, so stays null.
*/
const lastEventStmt = db.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?");
const runStartStmt = db.prepare("SELECT started_at FROM dashboard_runs WHERE id = ?");
function lastEventAge(lane) {
if (!lane.session_id) return null;
const times = [];
const row = lastEventStmt.get(lane.session_id);
if (row?.last) times.push(Date.parse(row.last));
if (lane.run_id) {
const run = runStartStmt.get(lane.run_id);
if (run?.started_at) times.push(Date.parse(run.started_at));
}
const t = Math.max(...times.filter((x) => !Number.isNaN(x)));
return Number.isFinite(t) ? Math.max(0, Math.round((Date.now() - t) / 1000)) : null;
}
/**
* Self-heals a stale `run_id`: a tmux-backed run has no exit event to push a
* release notification, so liveness is re-checked here, on every read,
* instead — the same "computed fact, never stored" principle this repo
* already applies to lane runtime up/down. A lane whose run_id points at a
* tmux session that's gone (the pane's process exited, or it was killed
* outside the dashboard entirely) gets released the next time anything reads
* it, exactly like the old push-based handler did, just pulled instead of
* pushed.
*/
function healRunId(lane, live = null) {
if (!lane.run_id) return lane;
// A member's pane on a server without S3 (a dev dashboard on the same DB):
// its tmux is not ours to ask, so "not listed" proves nothing.
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
if (live) {
if (live.has(lane.run_id)) return lane;
// Started a moment ago: a cached list may not show it yet (dashboard-runs.js).
const dashboardRuns = require("../lib/dashboard-runs");
const row = dashboardRuns.getRun(lane.run_id);
if (row && row.status === "running" && dashboardRuns.isYoungRun(row.started_at)) return lane;
} else {
const run = runs.getRun(lane.run_id);
if (run && run.status === "running") return lane;
}
lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
broadcastLane(lane.id);
return lanesLib.getLane(lane.id);
}
/**
* Heal every lane at once. A claude pane that exits fires a hook, which
* broadcasts the lane and so re-reads it; a codex or antigravity pane fires
* nothing, so without this sweep its lane read "running" until something
* else happened to fetch it. `live` (a `tmux.liveSessions()` set) answers
* every lane from one tmux call instead of a has-session per lane.
*/
function healAllRunIds(live = null) {
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane, live);
}
/**
* Re-broadcast the lanes ANOTHER process changed. Two dashboards may share one
* database, and hooks reach only one of them (the lowest port), so the other's
* browsers never heard about a lane the hooks moved until they refreshed.
* `PRAGMA data_version` moves only on another connection's commit, so the
* common pass is one pragma; after one, each lane whose `updated_at` differs
* from what this pass saw last is broadcast (a lane this server wrote itself
* may go out twice — the client drops an identical copy), and a vanished
* lane goes out as `removed`.
* ponytail: two writes in the same millisecond share an updated_at; a counter
* column would close that.
*/
let seenDataVersion = null;
const seenLanes = new Map();
const laneStampsStmt = db.prepare("SELECT id, updated_at FROM lanes");
function broadcastForeignLaneChanges() {
const out = { changed: [], removed: [] };
const version = db.pragma("data_version", { simple: true });
if (version === seenDataVersion) return out;
const primed = seenDataVersion !== null;
seenDataVersion = version;
const live = new Set();
for (const { id, updated_at } of laneStampsStmt.all()) {
live.add(id);
if (primed && seenLanes.get(id) !== updated_at) out.changed.push(id);
seenLanes.set(id, updated_at);
}
for (const id of [...seenLanes.keys()]) {
if (live.has(id)) continue;
seenLanes.delete(id);
if (primed) out.removed.push(id);
}
for (const id of out.changed) broadcastLane(id);
for (const id of out.removed) broadcast("lane_update", { removed: id });
return out;
}
function payload(lane, live = null) {
const healed = healRunId(lane, live);
return lanesLib.lanePayload(healed, lastEventAge(healed));
}
/** A feature row's pipeline view, computed the same way payload() computes
* it for a live lane — lets the client render an archived feature with the
* exact same PipelineMap component, no special-casing on the frontend. */
function featurePayload(feature) {
const pipeline = getPipeline(feature.pipeline);
return {
...feature,
pipeline_name: pipeline.name,
pipeline_nodes: nodeStates(pipeline, feature),
progress: progressPct(pipeline, feature),
};
}
/** Push the current state of one lane to every connected client. */
function broadcastLane(id) {
const lane = lanesLib.getLane(id);
if (lane) broadcast("lane_update", { lane: payload(lane) });
}
router.get("/", (req, res) => {
// Server mode: a member lists (and counts) only their own workspaces.
const visible = scope.visibleLaneIds(req);
const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id));
// ONE list-sessions answers every lane's run, instead of a synchronous
// has-session per lane (null when tmux could not be asked: per-lane then).
const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null;
const lanes = rows.map((l) => payload(l, live));
res.json({
lanes,
counts: {
total: lanes.length,
running: lanes.filter((l) => l.status === "running").length,
needs_you: lanes.filter((l) => l.needs_action).length,
dead: lanes.filter((l) => l.liveness === "dead").length,
},
});
});
// Registered before "/:id" so the literal path is not swallowed by the param.
router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() }));
/**
* How often each pipeline node has actually been passed, aggregated over every
* lane AND every archived feature — an archived feature is a finished run of a
* pipeline, so dropping it would throw away most of the history the moment a
* lane starts its next feature.
*
* A node is counted `entered` when a stage record resolves onto it; `failed`
* when that record says `result: "fail"`; `passed` otherwise; `withEvidence`
* for the subset of passes that left an artifact behind. Evidence is tracked
* separately rather than folded into the pass rate, because "claimed" and
* "proved" are not the same fact — the same distinction the pipeline map draws
* between `done` and `passed-no-evidence`. Also registered before "/:id".
*
* `?lane=<id>` narrows the same aggregation to one lane and its own archived
* features. The returned `lanes` list is never narrowed — it populates the
* picker, so it has to keep offering the way back to every other lane.
*/
router.get("/pipeline-stats", (req, res) => {
const laneFilter = req.query.lane;
let laneId = null;
if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") {
laneId = Number(laneFilter);
if (!Number.isInteger(laneId) || laneId < 1) {
return res
.status(400)
.json({ error: { code: "EBADLANE", message: "workspace must be an id" } });
}
}
// Server mode: a member's aggregate, lanes and picker cover only their own
// workspaces ("1" for everyone else). A `?lane=` of another user's never
// gets here — scopeGuard answers 404 first.
const own = scope.laneWhere(req, "lane_id");
const ownLanes = scope.laneWhere(req, "id");
const events =
laneId === null
? db
.prepare(
`SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE ${own.where} ORDER BY id`
)
.all(...own.params)
: db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
)
.all(laneId);
const acc = new Map();
const entryFor = (pipelineId) => {
const pipeline = getPipeline(pipelineId);
let entry = acc.get(pipeline.id);
if (!entry) {
entry = { id: pipeline.id, runs: new Set(), nodes: new Map(), template: pipeline };
acc.set(pipeline.id, entry);
}
return entry;
};
// Seed the templates the lanes in scope are configured with, before folding
// any events in. A lane that has recorded nothing still has to render its map
// — the picker is how you find out a pipeline is going unused, so a pipeline
// with zero runs must appear, at zero, rather than vanish.
const scopedLanes =
laneId === null
? db.prepare(`SELECT pipeline FROM lanes WHERE ${ownLanes.where}`).all(...ownLanes.params)
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
for (const row of scopedLanes) entryFor(row.pipeline);
// Fold the event stream down to one verdict per (run, node): a run that
// entered a node twice is still one pass of it, and a declaration always
// beats a detection of the same node — the agent's own claim is the stronger
// statement, so a node it declared must never be reported as merely inferred.
for (const ev of events) {
const entry = entryFor(ev.pipeline);
const pipeline = entry.template;
entry.runs.add(`${ev.lane_id}#${ev.run_seq}`);
const idx = phaseIdx(pipeline, ev.node_id);
if (idx === -1) continue; // a stage nothing in this pipeline is named after
const key = `${ev.lane_id}#${ev.run_seq}#${idx}`;
const prev = entry.nodes.get(key);
const declared = ev.source !== "detected";
if (prev && prev.declared && !declared) continue;
entry.nodes.set(key, {
idx,
declared: declared || (prev ? prev.declared : false),
failed: declared ? ev.result === "fail" : prev ? prev.failed : false,
evidence: (declared && !!ev.evidence) || (prev ? prev.evidence : false),
});
}
const pipelines = [...acc.values()]
.map((entry) => {
const nodes = entry.template.nodes.map((n) => ({
id: n.id,
label: n.label,
icon: n.icon,
gate: n.gate,
command: n.command,
entered: 0,
passed: 0,
withEvidence: 0,
failed: 0,
detected: 0,
}));
for (const v of entry.nodes.values()) {
const node = nodes[v.idx];
if (!node) continue;
if (!v.declared) {
// Reached, but only inferred from the hook stream. Counted apart from
// the pass rate: it says the work happened, not that a gate was met.
node.detected += 1;
continue;
}
node.entered += 1;
if (v.failed) node.failed += 1;
else {
node.passed += 1;
if (v.evidence) node.withEvidence += 1;
}
}
const name = entry.template.name || entry.id;
return { id: entry.id, name, runs: entry.runs.size, nodes };
})
// Busiest pipeline first: the one with the most recorded runs is the one
// the reader most likely came to look at.
.sort((a, b) => b.runs - a.runs);
const lanes = db
.prepare(`SELECT id, title, cwd FROM lanes WHERE ${ownLanes.where} ORDER BY id`)
.all(...ownLanes.params);
res.json({ pipelines, lanes });
});
/**
* Local branches of a candidate source repo, for the "Add lane" picker: pick
* a repo, then pick which branch to fork the new worktree from, instead of
* typing a branch name and hoping it exists. Same validation as `/worktree`
* (below), since a repo this can't resolve branches for can't be provisioned
* from either. Read-only, so no same-origin guard.
*/
router.get("/branches", async (req, res) => {
const sourceRepo = typeof req.query.repo === "string" ? req.query.repo : "";
if (!sourceRepo || !path.isAbsolute(sourceRepo) || !fs.existsSync(sourceRepo)) {
return res.status(400).json({
error: { code: "EBADSOURCEREPO", message: "repo must be an existing absolute path" },
});
}
// A developer names only a repo in their own folder: git would otherwise run
// AS whichever member owns the path (S3) and hand back their branches.
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
if (!(await isGitRepo(sourceRepo))) {
return res.status(400).json({
error: { code: "EBADSOURCEREPO", message: "repo is not a git repository" },
});
}
try {
const { branches, current } = await listBranches(sourceRepo);
// The repo's worktrees ride along because every question the Add-lane
// picker asks about a branch is really a question about them: which
// branches can still be checked out (git allows one worktree per branch,
// and `addWorktree` throws EBRANCHBUSY for the rest), and what this repo
// already has provisioned. One extra subprocess on a form the user is
// still typing into, versus a second round trip for facts that expire
// together. A repo whose worktree list cannot be read still answers with
// its branches — the picker just loses the busy markers.
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
res.json({ branches, current, worktrees });
} catch (err) {
res
.status(500)
.json({ error: { code: gitErrorCode(err), message: err.message, git: err.git } });
}
});
/** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */
const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]);
/** Server mode: the folder is, holds or lies in another user's workspace. */
function refuseOwned(res) {
return res.status(409).json({
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
});
}
/** Server mode with AGENTHUB_USERS_ROOT: a member's folder lies outside their own. */
function refuseFolder(res) {
return res.status(403).json({
error: { code: "EFOLDER", message: "a workspace must be inside your own folder" },
});
}
/**
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
* ever creating a duplicate. Also registered before "/:id".
*/
router.post("/ensure", sameOriginGuard, (req, res) => {
const body = req.body || {};
// Default the cwd to the user's home so callers that don't care about a
// specific directory (e.g. Sagi's mascot companion) get a real, existing
// path and lane uniqueness stays meaningful. Callers that do care pass one
// through unchanged.
// Canonical before the lookup, so the lookup and createLane's insert agree.
const cwd = lanesLib.canonicalCwd(
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
);
if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
if (!scope.folderAllowed(req, cwd)) return refuseFolder(res);
if (scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
// A cwd can now back one lane per tool (idx_lanes_cwd_tool) — "does a lane
// already own this (cwd, tool) pair" is the real question, so a request for
// a different tool than an existing lane at this cwd falls through to
// creating that tool's own lane instead of adopting the wrong one.
//
// `exact: true` is the Add-lane modal's question — "is THIS directory a
// lane" — so a lane on a PARENT directory does not answer it (#42: the
// modal closed on the parent lane and no new card ever appeared). Without
// it, a nested cwd still resolves to its owner, which is what the CLI and
// other callers adopting "the lane I am inside" rely on.
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
const strip = (p) => p.replace(/[\\/]+$/, "");
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
// Server mode: another user's workspace is not this one's to adopt.
if (owner && !scope.canSeeLane(req, owner.id)) {
return res.status(409).json({
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
});
}
if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane
// is how callers like Sagi adopt the lane, so don't clobber a real title
// with an empty one.
if (body.title && !owner.title) {
const updated = lanesLib.updateLane(owner.id, { title: body.title });
return res.json({ lane: payload(updated || owner), created: false });
}
return res.json({ lane: payload(owner), created: false });
}
try {
const lane = lanesLib.createLane({
cwd,
owner_id: scope.ownerFor(req),
title: body.title || "",
...(body.pipeline ? { pipeline: body.pipeline } : {}),
...(body.tool ? { tool: body.tool } : {}),
});
broadcastLane(lane.id);
return res.status(201).json({ lane: payload(lane), created: true });
} catch (err) {
if (
err.code === "EBADCWD" ||
err.code === "EBADPIPELINE" ||
err.code === "EBADTOOL" ||
err.code === "EDISABLED"
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
// re-read and return THEIR lane rather than reporting a conflict.
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
const winner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
if (winner) return res.json({ lane: payload(winner), created: false });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
});
/**
* Machine-wide housekeeping — not scoped to one lane. Reaps orphaned
* Playwright MCP processes and caps oversized hook logs across every lane
* on this machine. See server/lib/lane-gc.js for what "orphaned" and
* "oversized" mean.
*/
/**
* S3 (docs/plans/server-mode/S3-PLAN.md): the managed-worktree mode and the
* runtime layer run as the dashboard's own account and write into the
* workspace or LANES_ROOT, so they are not offered for a folder that belongs
* to a Linux account — such a workspace uses Repo mode and the prompt box's
* New worktree. True once it has answered 403 EMEMBERLANE.
*/
function refuseS3Folder(res, folder) {
// ownerForRead: a folder of a gone member is still a member's (accountForPath throws).
if (!require("../lib/run-as").ownerForRead(folder)) return false;
res.status(403).json({
error: {
code: "EMEMBERLANE",
message:
"not available for a workspace in a member's folder — use Repo mode and the prompt box's New worktree",
},
});
return true;
}
/** Route guard: `refuseS3Folder` for the workspace `:id`. */
function s3Guard(req, res, next) {
const lane = lanesLib.getLane(Number(req.params.id));
if (lane && refuseS3Folder(res, lane.cwd)) return;
next();
}
router.post("/gc", sameOriginGuard, (req, res) => {
// Machine-wide, so it cannot be narrowed to one member's workspaces.
if (scope.memberId(req) != null) {
return res
.status(403)
.json({ error: { code: "EADMIN", message: "only an admin may run housekeeping" } });
}
const dryRun = req.body?.dryRun === true;
try {
const reaped = reapOrphanMcp({ dryRun });
const capped = capOversizedLogs({ dryRun });
res.json({ reaped, capped });
} catch (err) {
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
/**
* Directory listing for the Add Lane modal's folder browser — browsers cannot
* expose absolute filesystem paths from a native picker, so path selection is
* done by browsing server-side instead. Read-only; registered ahead of
* "/:id" so the literal "browse" segment is never captured as a lane id.
* `files=1` (a file field's fallback) lists files too, and opens `path` the
* way the native picker would (`startDir`: `~`, a file's folder, else home).
* `hidden=1` lists dot entries too — never those at the top of a non-admin's
* own folder, where a member's logins live. Each entry carries its `mtime`
* (ms), and `home` is the folder the browser's Home place opens.
* An admin with a Linux account of their own (S3) starts in THEIR home, like
* any member, and the answer carries `hostHome` (the host account's home);
* `host=1` opens that one instead — a workspace there runs as the shared
* host account, so it is a deliberate tick in the dialog, never the default.
*/
router.get("/browse", (req, res) => {
// A developer browses only their own folder (by real path: a link in it
// cannot point the listing elsewhere) — the dashboard lists with its own
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
const own = scope.ownFolder(req);
const files = req.query.files === "1";
const hidden = req.query.hidden === "1";
// An admin is not held to a folder, but starts in their own home (S3).
let adminHome = null;
if (!own && req.user?.role === "admin") {
try {
adminHome = require("../lib/run-as").accountForUser(req.user.username)?.home || null;
} catch {
// No Linux account for this admin (EMEMBERGONE): the host's home, as before.
}
}
const startHome = adminHome && req.query.host !== "1" ? adminHome : own || os.homedir();
const raw =
typeof req.query.path === "string" && req.query.path.trim()
? files
? startDir(req.query.path)
: req.query.path
: startHome;
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
let stat;
try {
stat = fs.statSync(resolved);
} catch {
return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } });
}
if (!stat.isDirectory()) {
return res
.status(400)
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
}
const atOwnTop = Boolean(own) && resolved === lanesLib.canonicalCwd(own);
const mtimeOf = (full) => {
try {
return fs.statSync(full).mtimeMs;
} catch {
return null;
}
};
let entries = [];
try {
entries = fs
.readdirSync(resolved, { withFileTypes: true })
.filter(
(e) =>
(e.isDirectory() || (files && e.isFile())) &&
(!e.name.startsWith(".") || (hidden && !atOwnTop))
)
.map((e) => {
const full = path.join(resolved, e.name);
const mtime = mtimeOf(full);
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true, mtime };
return {
name: e.name,
path: full,
isGitRepo: fs.existsSync(path.join(full, ".git")),
mtime,
};
})
// Folders first, as every file picker lists them.
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
} catch {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const top = path.dirname(resolved) === resolved || atOwnTop;
const parent = top ? null : path.dirname(resolved);
res.json({
path: resolved,
parent,
entries,
home: startHome,
...(adminHome ? { hostHome: os.homedir() } : {}),
});
});
/**
* The folder browser's New folder: makes ONE folder `name` (a plain name —
* no separator, not `.`/`..`) in the existing folder `parent`, never with
* `-p` and never over an existing entry (`EEXISTS`). A non-admin makes one
* only inside their own folder, judged by real path like `GET /browse`, and
* in server mode a non-admin with no folder of their own makes none
* (`EFOLDER`) — a read of the host's disk is not a write to it. A folder in a
* member's home (S3) is made AS the member, never by the host.
*/
router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
const { parent, name } = req.body || {};
if (
typeof name !== "string" ||
!name ||
name === "." ||
name === ".." ||
/[/\\\0]/.test(name) ||
Buffer.byteLength(name) > 255
) {
return res
.status(400)
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
}
if (typeof parent !== "string" || !path.isAbsolute(parent)) {
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
}
const own = scope.ownFolder(req);
const limited = Boolean(req.user) && req.user.role !== "admin";
if (limited && !own) return refuseFolder(res);
const dir = own ? lanesLib.canonicalCwd(path.resolve(parent)) : path.resolve(parent);
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
try {
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
} catch {
return res
.status(400)
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
}
const target = path.join(dir, name);
if (fs.existsSync(target) || isSymlink(target)) {
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
}
try {
const runAs = require("../lib/run-as");
const acct = runAs.accountForPath(target);
if (acct) runAs.mkdirAs(acct, target);
else fs.mkdirSync(target);
} catch (err) {
const status = err.code === "EMEMBERGONE" ? 409 : err.code === "EEXIST" ? 409 : 500;
return res.status(status).json({
error: {
code: err.code === "EEXIST" ? "EEXISTS" : err.code || "EMKDIR",
message: err.message,
},
});
}
res.status(201).json({ path: target });
});
function isSymlink(p) {
try {
return fs.lstatSync(p).isSymbolicLink();
} catch {
return false;
}
}
/**
* The OS's own folder/file picker, shown on the SERVER's desktop
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
* Only a client on this machine may ask — anywhere else the dialog would pop
* up on a screen nobody is looking at (`EREMOTE`); that client, and a server
* with no desktop (`ENODIALOG`), falls back to `GET /browse` above. A request
* a reverse proxy forwarded arrives from loopback too, so a forwarding header
* counts as remote. An SSH tunnel carries no such header and cannot be told
* apart — the dialog is closed when the browser's request goes away.
*/
router.post("/pick-path", sameOriginGuard, async (req, res) => {
if (!isLocalRequest(req)) {
return res.status(403).json({
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
});
}
const kind = req.body?.kind === "file" ? "file" : "dir";
const ac = new AbortController();
res.on("close", () => {
if (!res.writableFinished) ac.abort();
});
try {
const picked = await pickPath(kind, req.body?.start, ac.signal);
if (ac.signal.aborted) return;
res.json(picked ? { path: picked } : { cancelled: true });
} catch (err) {
if (ac.signal.aborted) return;
const status = err.code === "EBUSY" ? 409 : err.code === "ENODIALOG" ? 501 : 500;
res.status(status).json({ error: { code: err.code || "EDIALOG", message: err.message } });
}
});
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ lane: payload(lane) });
});
router.post("/", sameOriginGuard, (req, res) => {
try {
const body = req.body || {};
const cwd = typeof body.cwd === "string" ? lanesLib.canonicalCwd(body.cwd) : body.cwd;
if (typeof cwd === "string" && !scope.folderAllowed(req, cwd)) return refuseFolder(res);
if (typeof cwd === "string" && scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
const lane = lanesLib.createLane({ ...body, owner_id: scope.ownerFor(req) });
broadcastLane(lane.id);
res.status(201).json({ lane: payload(lane) });
} catch (err) {
if (
err.code === "EBADCWD" ||
err.code === "EBADPIPELINE" ||
err.code === "EBADKIND" ||
err.code === "EBADFIELD" ||
err.code === "EBADTOOL" ||
err.code === "EDISABLED"
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
}
res.status(500).json({ error: { message: err.message } });
}
});
router.patch("/:id", sameOriginGuard, (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
// Server mode: a member edits only what a person types — never the columns
// that tie a workspace to a pane or a session (`run_id` pointed at another
// user's pane would let stop/reset kill it).
const body = req.body && typeof req.body === "object" ? req.body : {};
if (scope.memberId(req) != null) {
const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k));
if (off.length) {
return res.status(403).json({
error: { code: "EFIELD", message: `a member may not change ${off.join(", ")}` },
});
}
}
let lane;
try {
lane = lanesLib.updateLane(req.params.id, body);
} catch (err) {
// A bad `kind`, `pipeline` or field value is invalid input, not a server
// fault — every sibling route answers 400 here, so this one must too
// instead of throwing into Express. The SQLITE_CONSTRAINT family lands
// here for the same reason: a NOT NULL column patched to null is the
// caller's mistake, and enumerating which columns those are in JS would
// just be the schema written twice.
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE" || err.code === "EBADFIELD") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (String(err.code || "").startsWith("SQLITE_CONSTRAINT")) {
return res.status(400).json({ error: { code: "EBADFIELD", message: err.message } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
broadcastLane(lane.id);
res.json({ lane: payload(lane) });
});
router.post("/:id/stage", sameOriginGuard, (req, res) => {
const current = lanesLib.getLane(req.params.id);
if (!current) {
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
// A stage naming no node of this lane's pipeline used to be stored and then
// silently ignored by every reader — `agenthub stage test` on a ship-feature
// lane (whose node is `gates`) left the CI row reading "never reached" with
// no hint anything was wrong. Aliases still resolve, so this rejects typos
// and wrong-pipeline names, not shorthand.
const requested = req.body && req.body.stage;
if (requested !== undefined && requested !== null && requested !== "") {
const pipeline = getPipeline(current.pipeline);
// No template installed: the pipeline feature is dormant, so there is no
// stage to declare — say so instead of listing zero nodes.
if (!pipeline.nodes.length) {
return res.status(400).json({
error: {
code: "ENOPIPELINE",
message:
"no pipeline template is installed, so this workspace has no stages to declare " +
"(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)",
stages: [],
},
});
}
if (phaseIdx(pipeline, requested) === -1) {
return res.status(400).json({
error: {
code: "EBADSTAGE",
message:
`"${requested}" is not a stage of the "${pipeline.id}" pipeline. ` +
`Nodes: ${pipeline.nodes.map((n) => n.id).join(", ")}`,
stages: pipeline.nodes.map((n) => n.id),
},
});
}
}
const lane = lanesLib.setStage(req.params.id, req.body || {});
broadcastLane(lane.id);
res.json({ lane: payload(lane) });
});
router.get("/:id/preflight", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const action = String(req.query.action || "");
if (!["reset", "remove", "purge"].includes(action)) {
return res
.status(400)
.json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
}
try {
res.json(await preflight(lane, action));
} catch (err) {
res.status(500).json({ error: { code: err.code, message: err.message } });
}
});
/**
* Per-feature state and archive (B) — `lib/lane-features.js`. `GET
* /:id/features` lists every feature the lane has ever activated (archived
* or live); `GET /:id/features/:slug` shows one, including an archived
* one's saved pipeline; `POST /:id/features/activate` switches the live
* lane to a feature by slug, archiving whichever one was active first.
*/
router.get("/:id/features", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
});
router.get("/:id/features/:slug", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const feature = laneFeatures.getFeature(lane.id, req.params.slug);
if (!feature) {
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
}
res.json({ feature: featurePayload(feature) });
});
router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
if (!slug) {
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
}
try {
const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, {
title: req.body?.title,
});
broadcastLane(updated.id);
res.json({ lane: payload(updated), feature: featurePayload(feature) });
} catch (err) {
if (err.code === "EBADSLUG") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code, message: err.message } });
}
});
router.get("/:id/proof", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ features: proofLib.listProof(lane.id) });
});
router.get("/:id/proof/:slug/:group/:file", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).send("not found");
const p = proofLib.proofFile(lane.id, req.params.slug, req.params.group, req.params.file);
if (!p) return res.status(404).send("not found");
const ext = path.extname(p).toLowerCase();
const contentType =
ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "image/jpeg";
res.type(contentType).sendFile(p);
});
router.delete("/:id/proof/:slug", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
const result = proofLib.deleteProof(lane.id, {
slug: req.params.slug,
group: req.body?.group,
images: req.body?.images,
});
res.json(result);
} catch (err) {
if (err.code === "ENOFEATURE") {
return res.status(404).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EBADPATH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code, message: err.message } });
}
});
router.post("/:id/proof-link", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json(proofLib.ensureProofLink(lane));
});
/**
* Give this lane the same MCP servers as its source repo — relocates the
* source repo's already-configured mcpServers (from ~/.claude.json) into
* <lane>/.mcp.json, pins Playwright's proof output dir, seeds Chromium
* profiles. Never automatic, same as proof-link/agents-install: a session
* calls this explicitly. Never touches permissions/settings.local.json —
* see the F1 design spec for why.
*/
router.post("/:id/mcp/sync", sameOriginGuard, s3Guard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
res.json(await syncMcp(lane));
} catch (err) {
if (err.code === "ENOMCPCONFIG") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
/**
* Detect a Node.js project at this lane's OWN directory and scaffold
* .agenthub/profile/ if one is found — the HTTP equivalent of
* `agenthub lanes profile init`, always targeting lane.cwd (never an arbitrary
* path; the CLI's <repo> argument has no meaning here, this lane's own
* directory is the only sensible target). "No Node.js project detected" is
* a normal 200 outcome, not an error — most lanes won't be auto-detectable
* and that's fine, same as every other optional profile declaration.
*/
router.post("/:id/profile/init", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const facts = detectNode(lane.cwd);
if (!facts) {
return res.json({ scaffolded: false, reason: "no detectable Node.js project" });
}
try {
const result = scaffoldProfile(lane.cwd, facts, { force: req.body?.force === true });
res.json({ scaffolded: true, written: result.written, todos: result.todos });
} catch (err) {
if (err.code === "EPROFILEEXISTS") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
/**
* Whether a named integration (tracker, dev_qc, ci_wait, ...) is turned on
* for this lane — reads .agenthub/profile/integrations.env. Read-only, no
* sameOriginGuard needed (same reasoning GET /:id/git already documents:
* that guard exists for destructive actions).
*/
router.get("/:id/integrations/:name", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ enabled: isIntegrationEnabled(lane, req.params.name) });
});
/**
* A lane's working-copy facts: branch, short HEAD, that commit's subject, and
* the uncommitted counts. Read-only, so no same-origin guard — that guard
* exists for the destructive actions.
*
* Deliberately NOT part of `GET /api/lanes`: this shells out to git five
* times, and that payload is polled and re-broadcast on every hook-driven
* lane_update. Any failure — no such directory, not a repo, git itself
* erroring — is reported as `available: false` rather than a 500, because a
* lane pointing at a plain directory is a normal state, not a fault.
*
* The `/:id/:action` catch-all below cannot shadow this one — that route is a
* POST and Express matches on method as well as path. Verified by moving this
* registration after it: the suite stayed green.
*/
router.get("/:id/git", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({
available: true,
...(await gitFacts(t.cwd)),
// What the Changes panel's "vs base" compares against: the lane's own
// base, else the repo's default branch (an extra folder is another
// repo: always its own default branch).
base: (!t.source && t.lane.base_branch) || (await defaultBranch(t.cwd)),
...(t.worktree ? { worktree: t.worktree } : {}),
});
} catch {
res.json({ available: false });
}
});
/**
* The lane's code changes, in the three shapes the Workspace's Changes panel
* asks for — one route, because they are the same read at three depths:
*
* ?range=work|branch|commit — overview: every changed file + its counts
* &full=1 — review: the same list WITH every hunk
* &path=<file> — one file's hunks (opened from the overview)
* &context=<n> — lines of context per hunk (expand)
*
* `work` (default) is HEAD vs the working tree, untracked files included;
* `branch` is `<base>...HEAD` — the lane's base, else the repo's default
* branch — and answers 404-free with ENOBASE when neither exists; `commit` needs `&sha=` and shows that
* one commit. Read-only, so no same-origin guard — same reasoning as
* GET /:id/git. See server/lib/git-diff.js for the caps.
*/
router.get("/:id/git/changes", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
const full = req.query.full === "1" || req.query.full === "true";
try {
const changes = await collectChanges(cwd, {
range,
base: lane.base_branch,
sha: typeof req.query.sha === "string" ? req.query.sha : null,
file,
full,
context: req.query.context,
});
res.json({ available: true, ...changes });
} catch (err) {
if (["ENOBASE", "ENOFILECHANGE", "EBADRANGE", "EBADREV"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
// Not a git repo (an adopted lane pointed at a plain directory), or a repo
// with no commits yet. Same contract as GET /:id/git: a state, not a fault.
res.json({ available: false, range, files: [] });
}
});
/**
* The lane's recent commits, for the history list. `?file=` narrows it to the
* commits touching one path — the "history of this file" the single-file view
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
*/
router.get("/:id/git/log", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const commits = await listCommits(cwd, {
limit: req.query.limit,
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null,
});
res.json({ available: true, commits });
} catch (err) {
if (err.code === "EBADPATH" || err.code === "ENOBRANCH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false, commits: [] });
}
});
/**
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
* viewer's "show the entire file" mode. Path validation lives in
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
* touches the filesystem, and it is realpath-confined to the lane (symlinks
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
*/
router.get("/:id/git/file", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const file = await readFileAt(
cwd,
typeof req.query.path === "string" ? req.query.path : "",
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
);
res.json({ available: true, ...file });
} catch (err) {
// A secret file (where a proxy's API key once lived) is refused at every rev.
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false });
}
});
/**
* Annotate (`?path=`): who last changed each line of the working-tree file —
* git-diff.js's `blameFile`, with the whole-file read's guards (lexical lane
* guard, 403 ESECRET, a size cap). A read like `/git/file`, so unguarded too.
*/
router.get("/:id/git/blame", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const blame = await blameFile(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
res.json({ available: true, ...blame });
} catch (err) {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
// Not tracked (git blame has no history for it) or not a repo.
res.json({ available: false });
}
});
/**
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
* that picks the Content-Type). Served under a `sandbox` CSP with `nosniff`,
* so an SVG opened straight from this URL runs no script on the dashboard's
* origin.
*/
router.get("/:id/git/raw", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const { type, buf } = await readImageAt(
t.cwd,
typeof req.query.path === "string" ? req.query.path : "",
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
);
res.set({
"Content-Type": type,
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
});
res.send(buf);
} catch (err) {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: "EREAD", message: "could not read the image" } });
}
});
/**
* Save one text file in the lane from the viewer's editor. Guards live in
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
*/
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
const body = req.body || {};
try {
await writeLaneFile(cwd, body.path, body.content, body.baseSha);
res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) });
} catch (err) {
const status =
{ ESECRET: 403, EFOREIGN: 403, ESTALE: 409 }[err.code] ??
(["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500);
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
}
});
/** Tracked paths matching `?q=`, for quick-open. */
router.get("/:id/git/ls", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
try {
res.json({ available: true, ...(await listPaths(cwd, { q: req.query.q })) });
} catch {
res.json({ available: false, paths: [], truncated: false, total: 0 });
}
});
/** Every level of the tracked tree in one read, for "expand all folders". */
router.get("/:id/git/tree-all", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
try {
res.json({ available: true, ...(await listTreeAll(cwd)) });
} catch {
res.json({ available: false, levels: {}, truncated: false });
}
});
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
router.get("/:id/git/tree", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const subdir = typeof req.query.path === "string" ? req.query.path : "";
res.json({ available: true, ...(await listTree(cwd, { subdir })) });
} catch (err) {
if (err.code === "EBADPATH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false, path: "", entries: [], truncated: false });
}
});
/**
* The lane's own local branches, for the branch switcher. Separate from
* `GET /branches` (which takes a candidate repo PATH from the query, for a
* lane that does not exist yet); this one resolves the repo from the lane row,
* so no path ever comes from the request.
*/
router.get("/:id/git/branches", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const { branches, current } = await listBranches(cwd);
const worktrees = await listWorktrees(cwd).catch(() => []);
res.json({ available: true, branches, current, worktrees });
} catch {
res.json({ available: false, branches: [], current: null, worktrees: [] });
}
});
/**
* The git WRITES the dashboard offers — the list is `ACTIONS` in
* server/lib/git-ops.js (sync, stage/discard, commit/amend/undo-commit,
* branches, merge, cherry-pick, revert).
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
* the worktree's git dir, so a claude and a codex lane on one directory share
* it) so a push and a commit fired from two tabs or two lanes cannot
* interleave on one index, and thin — every
* rule about what each action may do lives in server/lib/git-ops.js, not here.
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
/**
* A commit message suggested by the user's `claude` CLI for what is STAGED
* (server/lib/commit-message.js). Not a git write — it only reads the index —
* but same-origin, because each call spends the user's Claude quota.
* Registered before `/:id/git/:action`, which would answer EBADACTION.
*/
router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const paths = Array.isArray(req.body?.paths) ? req.body.paths : undefined;
res.json({ message: await suggestCommitMessage(t.cwd, { paths }) });
} catch (err) {
const status = ["ENOSTAGED", "ENOTOOL", "EBADPATH"].includes(err.code)
? 400
: err.code === "EAI"
? 502
: 500;
res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } });
}
});
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
// cwd — or a root lane and a subdirectory lane of one repo — share one
// index, and their writes would otherwise race on index.lock. The git dir
// is per worktree, so sibling worktree lanes still run in parallel; a
// cwd that is not a repo falls back to its real path.
const gitDir = await git(cwd, ["rev-parse", "--absolute-git-dir"])
.then((r) => r.stdout.trim())
.catch(() => fs.promises.realpath(cwd).catch(() => cwd));
const result = await withLaneLock(`git:${gitDir}`, () =>
runGitAction(cwd, req.params.action, req.body || {})
);
res.json(result);
} catch (err) {
const badRequest = [
"EBADACTION",
"EBADMESSAGE",
"EBADBRANCH",
"ENOBRANCH",
"EBRANCHEXISTS",
"EBRANCHBUSY",
"EDIRTY",
"ENOCHANGES",
"EBADPATH",
"ECONFIRM",
"EPROTECTED",
"ESTAGEDOUTSIDE",
"ENOREMOTE",
"EUNTRACKED",
"EBADHOST",
"EBADCRED",
"EMEMBERCRED",
"ESUBDIR",
"EBADSIDE",
"EMARKERS",
"EBADURL",
"EREMOTEEXISTS",
"ENOCOMMITS",
"EBADSHA",
"EMERGECOMMIT",
"ENOPARENT",
"ETRACKED",
];
// A numeric code is git's own exit status (execFile reports one that way):
// git ran and refused — a rejected push, a failed credential, a diverged
// pull. That is the repository's state answering a valid request, not a
// server fault, so it is a 409 under the string code the client expects.
const exited = typeof err.code === "number";
// EHUNKGONE is the same kind of answer: the tree moved under the click.
const status = badRequest.includes(err.code)
? 400
: exited ||
[
"EHUNKGONE",
"EGITAUTH",
"EMERGING",
"ENOMERGE",
"ECONFLICTS",
"EPUSHED",
"EEMPTY",
"ESTALE",
"EMEMBERGONE",
].includes(err.code)
? 409
: 500;
// git's own stderr is the useful half of a failed push or pull (a
// rejection, a missing credential, a diverged branch), so it joins the
// message rather than riding a side field the client would have to know
// about — `message` is the one thing every caller already reads.
const detail = err.git ? (err.git.stderr || "").trim() : "";
res.status(status).json({
error: {
code: gitErrorCode(err),
message: detail ? `${err.message}\n${detail}` : err.message,
// EGITAUTH: which host wants a login, for the git bar's form. Never
// a secret — just protocol, host, a username hint and `rejected`.
...(err.auth ? { auth: err.auth } : {}),
},
});
}
});
/**
* Claude Desktop's "archive a worktree session": remove the git worktree a
* session of this workspace ran in (`claude --worktree`), then its branch when
* git agrees it is merged. The session is named, never a path; the tree comes
* from `sessionWorktree`. Refused while a live pane holds the session
* (`ELIVE`), and every refusal git itself makes — a dirty tree, an unmerged
* branch (kept and reported) — stands: nothing here forces.
*/
router.post("/:id/session-worktree/remove", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const sessionId = typeof req.body?.session === "string" ? req.body.session : "";
const held = runs
.listRuns()
.some((r) => r.laneId === lane.id && r.status === "running" && r.sessionId === sessionId);
if (held) {
return res.status(409).json({
error: { code: "ELIVE", message: "a pane is still running that session — exit it first" },
});
}
try {
res.json(await removeSessionWorktree(lane, sessionId, { confirm: req.body?.confirm }));
} catch (err) {
// git ran and refused (a dirty tree): the repository answering, a 409.
const refused = !!err.git;
const status = ["ECONFIRM", "ENOWORKTREE"].includes(err.code)
? 400
: err.code === "ELOCKED" || refused
? 409
: 500;
const detail = refused ? (err.git.stderr || "").trim() : "";
res.status(status).json({
error: {
code: refused ? "EGIT" : err.code || "EGIT",
message: detail ? `${err.message}\n${detail}` : err.message,
},
});
}
});
/**
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
* the wait/retry decision (docs/LANES.md "Orchestration: what AgentHub does NOT
* do"); this route only reports what `gh` currently knows. Read-only, no
* same-origin guard, same reasoning as GET /:id/git.
*/
router.get("/:id/ci/status", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const ref = typeof req.query.ref === "string" ? req.query.ref.trim() : "";
if (!ref) {
return res.status(400).json({ error: { code: "EBADREF", message: "ref is required" } });
}
try {
res.json(await ciStatus(lane.cwd, ref));
} catch (err) {
sendRuntimeError(res, err);
}
});
/**
* Create a dashboard-managed worktree lane. Git work happens after the 202
* response because provisioning a large repository can take seconds.
*/
router.post("/worktree", sameOriginGuard, async (req, res) => {
const body = req.body || {};
// Checked before any git work: a worktree for a tool that cannot get a lane
// would be left behind with no row pointing at it.
try {
lanesLib.validateTool(body.tool === undefined ? "claude" : body.tool);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
const sourceRepo = body.sourceRepo;
// Before any git work (which would already run as the member).
if (
typeof sourceRepo === "string" &&
path.isAbsolute(sourceRepo) &&
refuseS3Folder(res, lanesLib.canonicalCwd(sourceRepo))
) {
return;
}
if (
typeof sourceRepo !== "string" ||
!path.isAbsolute(sourceRepo) ||
!fs.existsSync(sourceRepo) ||
!(await isGitRepo(sourceRepo))
) {
return res.status(400).json({
error: {
code: "EBADSOURCEREPO",
message: "sourceRepo must be an existing absolute git repository",
},
});
}
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
if (scope.folderTakenByOther(req, lanesLib.canonicalCwd(sourceRepo))) return refuseOwned(res);
// A repo with no commit has nothing a worktree could branch from: git would
// fail later, in the background job, and leave a `failed` row behind. Refuse
// it here instead — that repo is a Repo lane until its first commit.
if (!(await listBranches(sourceRepo)).branches.length) {
const unborn = await git(sourceRepo, ["rev-parse", "--verify", "HEAD"]).then(
() => false,
() => true
);
if (unborn) {
return res.status(400).json({
error: {
code: "EEMPTYREPO",
message: "sourceRepo has no commits yet — add it as a Repo workspace and commit first",
},
});
}
}
let slug;
try {
slug = slugify(body.slug || body.title);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
const resolvedSourceRepo = path.resolve(sourceRepo);
const repoName = path.basename(resolvedSourceRepo);
const originalSlug = slug;
let dir = path.join(LANES_ROOT, `${repoName}__${slug}`);
let attempts = 0;
while (fs.existsSync(dir) && attempts < MAX_WORKTREE_DIRECTORY_ATTEMPTS) {
attempts += 1;
const suffix = attempts + 1;
slug = `${originalSlug}-${suffix}`;
dir = path.join(LANES_ROOT, `${repoName}__${slug}`);
}
if (fs.existsSync(dir)) {
return res.status(409).json({
error: {
code: "EWORKTREEDIRCOLLISION",
message: "could not allocate a unique worktree directory after 50 attempts",
},
});
}
let branch;
if (body.branch !== undefined) {
if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) {
return res.status(400).json({
error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" },
});
}
branch = body.branch;
} else {
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
branch = `${branchPrefix}${slug}`;
}
let lane;
try {
lane = lanesLib.createLane({
title: body.title || "",
cwd: dir,
branch,
kind: "managed",
owner_id: scope.ownerFor(req),
source_repo: resolvedSourceRepo,
base_branch: body.base || null,
slug,
...(body.pipeline ? { pipeline: body.pipeline } : {}),
...(body.tool ? { tool: body.tool } : {}),
});
lane = lanesLib.updateLane(lane.id, { status: "provisioning" });
} catch (err) {
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
res.status(202).json({ lane: payload(lane) });
void withLaneLock(lane.id, async () => {
try {
const baseBranch = await resolveBase(
resolvedSourceRepo,
body.base || process.env.LANE_BASE_BRANCH || "main"
);
await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch });
// base_branch is a provisioning fact, not a patchable field — see PATCHABLE.
lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch });
// A2 data isolation: only when the repo actually declares a profile — a
// worktree lane with none is a normal state (nothing about A1 required
// one either), so this is a no-op rather than a provisioning failure.
const worktreeLane = lanesLib.getLane(lane.id);
const profile = resolveProfile(worktreeLane);
if (profile) {
const onLine = (line, stream) =>
broadcast("lane_hook_output", { laneId: lane.id, hook: "provision", stream, line });
await provisionLane(worktreeLane, { onLine });
}
lanesLib.updateLane(lane.id, { status: "idle", notes: null });
} catch (err) {
lanesLib.updateLane(lane.id, {
status: "failed",
notes: err.git?.stderr || err.message,
});
}
broadcastLane(lane.id);
});
});
const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]);
const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]);
const RUN_EXIT_POLL_MS = 50;
// killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough
// time for that escalation and for Node to receive the child's real exit.
const RUN_EXIT_TIMEOUT_MS = 7500;
function lifecycleError(code, message) {
return Object.assign(new Error(message), { code });
}
function expectedFields(action) {
return action === "purge"
? ["sessions", "events", "tokenRows"]
: ["head", "dirty", "untracked", "unpushed"];
}
/** Refuse a destructive action when the facts shown in its preflight have moved. */
function assertExpectedPreflight(action, current, expected) {
const fields = expectedFields(action);
if (
!expected ||
typeof expected !== "object" ||
Array.isArray(expected) ||
fields.some((field) => !Object.hasOwn(expected, field))
) {
throw lifecycleError(
"EEXPECT",
`${action} requires a complete expect object with: ${fields.join(", ")}`
);
}
const changed = fields.some((field) => current[field] !== expected[field]);
if (changed) {
const err = lifecycleError("ESTALE", "workspace state changed since preflight");
err.expected = expected;
err.current = current;
throw err;
}
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Every tmux session a lane holds: its recorded `run_id` plus the extra
* split-pane sessions (`agenthub-lane-<id>-<slot>`), which never touch it. */
function laneRunIds(lane) {
const ids = new Set(
runs
.listRuns()
.filter((r) => r.laneId === lane.id)
.map((r) => r.id)
);
// Only this lane's own panes: a run_id naming another lane's is ignored.
if (lane.run_id && new RegExp(`^agenthub-lane-${lane.id}(-\\d+)?$`).test(lane.run_id)) {
ids.add(lane.run_id);
}
return [...ids];
}
/** Kill a lane's runs and wait for their tmux sessions to exit before touching its cwd. */
async function stopLaneRun(lane) {
const ids = laneRunIds(lane);
if (ids.length === 0) return;
for (const id of ids) {
try {
runs.killRun(id);
} catch {
/* a concurrently completed run is already safe */
}
}
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
for (const id of ids) {
let run = runs.getRun(id);
while (run && run.status !== "gone") {
if (Date.now() >= deadline) {
throw lifecycleError(
"ERUNTIMEOUT",
`workspace run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
);
}
await wait(RUN_EXIT_POLL_MS);
run = runs.getRun(id);
}
}
lanesLib.updateLane(lane.id, { run_id: null });
}
function sendLifecycleError(res, err) {
if (["ENOTMANAGED", "EOUTSIDEROOT", "ENOTWORKTREE", "EEXPECT"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
// ERUNLIVE: the lane's pane is busy with a live agent, so a resume/prompt
// could not be delivered — a conflict, not a bad request or a server fault.
if (["ESTALE", "EUNPUSHED", "ERUNLIVE"].includes(err.code)) {
return res.status(409).json({
error: {
code: err.code,
message: err.message,
...(err.code === "ESTALE" ? { expected: err.expected, current: err.current } : {}),
},
});
}
if (err.git) {
return res.status(500).json({
error: { code: gitErrorCode(err), message: err.message, stderr: err.git.stderr },
});
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
/* ---------------------------------------------------------------------------
* Runtime: a lane's own stack, isolated by slot-derived ports and directories.
*
* These routes are registered BEFORE the "/:id/:action" catch-all below, which
* would otherwise swallow "up" and "down" as unknown actions. They are also
* deliberately NOT folded into that catch-all: it drives a lane's Claude RUN,
* while these drive the application the lane is working on — two different
* lifecycles that happen to share a lane id.
*
* None of them writes `stage`, `status` or `notes`. A booted stack is not an
* agent at work, and only `slot`/`ports` describe the runtime.
* ------------------------------------------------------------------------ */
/** Map a runtime error onto its status code. */
function sendRuntimeError(res, err) {
const badRequest = [
"ENOPROFILE",
"ENOHOOK",
"EBADLANEDIR",
"EBADSVC",
"EBADBRANCH",
"EUNRESOLVED",
"EMERGEUNCOMMITTED",
"ENOMCPCONFIG",
];
if (badRequest.includes(err.code)) {
return res.status(400).json({
error: {
code: err.code,
message: err.message,
...(err.searched ? { searched: err.searched } : {}),
},
});
}
if (err.code === "ESLOTS") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EPORTBUSY") {
return res.status(409).json({
error: { code: err.code, message: err.message, port: err.preferred, pids: err.pids },
});
}
return res.status(500).json({
error: { code: err.code || "ERUNTIME", message: err.message, logPath: err.logPath },
});
}
/**
* The string `error.code` for an error that may have come out of `git()`.
* When git exits non-zero, execFile sets `err.code` to the exit STATUS (a
* number, e.g. 128); the client branches on string codes, so that becomes
* `EGIT`. A missing code (a timeout kill) is `EGIT` too; string codes pass.
*/
function gitErrorCode(err) {
return typeof err.code === "string" && err.code ? err.code : "EGIT";
}
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
/**
* Which tree a git route works in: the workspace's own folder, or — with
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
* `sessionWorktree` from the session ROW (owned by this workspace, registered
* by `git worktree list`), or — with `?source=<id>` — one of the workspace's
* extra folders, resolved by `sourceFolder` from its `lane_sources` ROW; never
* from a path in the request. An unresolvable session (400) or source (404,
* like another workspace's row) is never a silent fall back to the main
* folder: a commit or a discard must never land in the wrong tree.
*/
async function gitTargetOr404(req, res) {
const lane = laneOr404(req, res);
if (!lane) return null;
if (req.query.source !== undefined) {
try {
const cwd = sourceFolder(lane.id, req.query.source);
return { lane, cwd, worktree: null, source: true };
} catch (err) {
res.status(404).json({ error: { code: err.code, message: err.message } });
return null;
}
}
const session = req.query.session;
if (session === undefined) return { lane, cwd: lane.cwd, worktree: null };
try {
const wt = await sessionWorktree(lane, String(session));
return { lane, cwd: wt.path, worktree: { path: wt.path, branch: wt.branch } };
} catch (err) {
res.status(400).json({ error: { code: err.code, message: err.message } });
return null;
}
}
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);
if (!lane) {
res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
return null;
}
return lane;
}
/**
* What is running for this lane, computed fresh.
*
* Follows `GET /:id/git`'s contract: a lane with no profile answers
* `{available:false}` with HTTP 200, because that is a normal state and not a
* fault. It probes ports and stats pid files, which is why it is its own endpoint
* rather than a field on the polled lane list.
*/
router.get("/:id/runtime", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
res.json(await runtimeFacts(lane));
} catch (err) {
sendRuntimeError(res, err);
}
});
/**
* Tail one of the lane's hook or service logs.
*
* `:svc` is resolved against the log directory's real contents and the result is
* confined to that directory after `realpath`, so a name from the request can
* never escape it.
*/
router.get("/:id/logs/:svc", (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
if (!lane.slot) return res.json({ available: false });
const { logDir } = slotDirs(lane.slot);
const file = path.resolve(logDir, `${req.params.svc}.log`);
let real;
try {
real = fs.realpathSync(file);
const relative = path.relative(fs.realpathSync(logDir), real);
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("escapes log dir");
} catch {
return res.status(404).json({ error: { code: "ENOLOG", message: "no such log" } });
}
const requested = Number(req.query.tail);
const tail = Math.min(
Number.isInteger(requested) && requested > 0 ? requested : LOG_TAIL_DEFAULT,
LOG_TAIL_MAX
);
const { size } = fs.statSync(real);
const start = Math.max(0, size - tail);
const fd = fs.openSync(real, "r");
try {
const buffer = Buffer.alloc(size - start);
fs.readSync(fd, buffer, 0, buffer.length, start);
res.json({
available: true,
svc: req.params.svc,
size,
truncated: start > 0,
text: buffer.toString("utf8"),
});
} finally {
fs.closeSync(fd);
}
});
/**
* Boot the lane's stack. 202 + background, like `POST /worktree`: a build can run
* for minutes and the caller should not hold a socket open for it. Progress
* streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports`
* the boot may have changed.
*/
router.post("/:id/up", sameOriginGuard, s3Guard, (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
requireProfile(lane);
} catch (err) {
return sendRuntimeError(res, err);
}
const build = req.body?.build !== false;
const qc = req.body?.qc === true;
res.status(202).json({ ok: true, laneId: lane.id });
void withLaneLock(lane.id, async () => {
const onLine = (line, stream) =>
broadcast("lane_hook_output", { laneId: lane.id, hook: "up", stream, line });
try {
const facts = await upLane(lanesLib.getLane(lane.id), { build, qc, onLine });
broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
} catch (err) {
broadcast("lane_runtime", {
laneId: lane.id,
error: { code: err.code || "ERUNTIME", message: err.message },
});
}
broadcastLane(lane.id);
});
});
/** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */
router.post("/:id/down", sameOriginGuard, s3Guard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const result = await withLaneLock(lane.id, () => downLane(lanesLib.getLane(lane.id)));
const facts = await runtimeFacts(lanesLib.getLane(lane.id));
broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
res.json({ ok: true, killed: result.killed, runtime: facts });
} catch (err) {
sendRuntimeError(res, err);
}
});
/**
* Run one of the profile's hooks on the lane — the surface a driving session uses
* for `ci-gate`, `e2e`, `migrate` and friends.
*
* `:name` is checked against the hook allowlist BEFORE anything is spawned, and
* `args` travels as an array of strings straight into argv. Neither is ever
* joined into a command string.
*/
router.post("/:id/hook/:name", sameOriginGuard, s3Guard, (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const { name } = req.params;
if (!HOOKS.includes(name)) {
return res
.status(400)
.json({ error: { code: "ENOHOOK", message: `unknown hook ${name}`, allowed: HOOKS } });
}
const args = Array.isArray(req.body?.args) ? req.body.args.map(String) : [];
let profile;
try {
profile = requireProfile(lane);
} catch (err) {
return sendRuntimeError(res, err);
}
if (!lane.slot) {
return res.status(409).json({
error: { code: "ENOSLOT", message: "workspace has no runtime yet — bring it up first" },
});
}
res.status(202).json({ ok: true, laneId: lane.id, hook: name });
void withLaneLock(lane.id, async () => {
const onLine = (line, stream) =>
broadcast("lane_hook_output", { laneId: lane.id, hook: name, stream, line });
try {
const result = await runHook(lanesLib.getLane(lane.id), profile, name, args, { onLine });
broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: result.code });
} catch (err) {
broadcast("lane_hook_result", {
laneId: lane.id,
hook: name,
code: null,
error: { code: err.code || "ERUNTIME", message: err.message },
});
}
});
});
/**
* The ONE sanctioned merge in the ship-feature-lane pipeline: origin/development
* INTO a feature branch, gated by a migration-number collision preflight.
* Synchronous — a fetch + collision-check + merge is seconds of git work, not
* the minutes a build/test hook can take, so this follows GET /:id/git's
* pattern rather than the hook route's 202-and-broadcast.
*
* Returns 200 with {code: 0|4|5, ...} for every DOCUMENTED outcome — a
* migration collision or a left-in-place conflict is an expected result, not
* an HTTP error. A malformed request, a missing profile, or an out-of-order
* --continue is the only case that answers with an `error` body.
*
* Never writes stage/status/notes — same boundary the hook and runtime
* routes already keep; the caller decides what a collision or conflict means
* for the lane's declared stage.
*/
router.post("/:id/sync-base", sameOriginGuard, s3Guard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
let profile;
try {
profile = requireProfile(lane);
} catch (err) {
return sendRuntimeError(res, err);
}
const mode = ["check", "merge", "continue"].includes(req.body?.mode) ? req.body.mode : "merge";
const branch =
typeof req.body?.branch === "string" && req.body.branch ? req.body.branch : undefined;
try {
const result = await withLaneLock(lane.id, () => {
const current = lanesLib.getLane(lane.id);
if (mode === "check") return checkSync(current, profile, branch);
if (mode === "continue") return continueSync(current, profile, branch);
return mergeSync(current, profile, branch);
});
res.json(result);
} catch (err) {
sendRuntimeError(res, err);
}
});
/**
* Lane control. Deliberately thin: every action maps onto one existing
* lifecycle function. There is no queue, no chaining, no gate evaluation — the
* dashboard drives a lane, it does not orchestrate a pipeline.
*/
router.post("/:id/:action", sameOriginGuard, async (req, res) => {
const { action } = req.params;
if (!ACTIONS.has(action)) {
return res
.status(400)
.json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
}
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const body = req.body || {};
if (DESTRUCTIVE_ACTIONS.has(action)) {
if (body.confirm !== true) {
return res.status(400).json({
error: { code: "ECONFIRM", message: `${action} requires confirm: true` },
});
}
try {
const result = await withLaneLock(lane.id, async () => {
const lockedLane = lanesLib.getLane(lane.id);
if (!lockedLane) throw lifecycleError("ENOLANE", "workspace not found");
await stopLaneRun(lockedLane);
// A running stack holds files open in the very directory `reset` is about
// to rewrite, and either way its processes would outlive the lane still
// bound to its ports. Stop it before touching git or dropping the row.
// Idempotent and a no-op for a lane that was never brought up.
if (action === "reset" || action === "remove") {
await downLane(lanesLib.getLane(lane.id));
}
const current = lanesLib.getLane(lane.id);
const facts = await preflight(current, action);
assertExpectedPreflight(action, facts, body.expect);
// Only `reset` can lose committed work now — `remove` forgets the row and
// touches neither the worktree nor the branch, so unpushed commits are
// nothing it can discard.
if (action === "reset" && facts.unpushed > 0 && body.force !== true) {
throw lifecycleError(
"EUNPUSHED",
`${action} requires force: true when commits are unpushed`
);
}
if (action === "reset") {
await resetWorktree(current);
const resetLane = lanesLib.clearLane(current.id);
// A2 data isolation, only when the lane actually has both a profile
// and an allocated slot — a lane that was never brought up has
// nothing of this kind to reset.
const profile = resolveProfile(resetLane);
if (profile && resetLane.slot) {
const onLine = (line, stream) =>
broadcast("lane_hook_output", { laneId: resetLane.id, hook: "reset", stream, line });
await resetLaneData(resetLane, profile, { keepDb: body.keepDb === true, onLine });
}
return { lane: lanesLib.getLane(current.id) };
}
if (action === "remove") {
// `remove` forgets the lane, it does not destroy it. The worktree
// directory, its branch and the lane's own database(s) are the USER's
// data and outlive the dashboard's record of them — one mis-click here
// used to cost a whole branch's unpushed code, and no dashboard row is
// worth that. Deleting any of it is the user's own call, by hand
// (`git worktree remove` / `git branch -d` / dropping the db).
//
// Runtime bookkeeping is the exception, because it is AgentHub's and
// not the user's: pid files and hook
// logs under .state/lane<slot>/ would be inherited by whichever lane
// claims that slot next. Deleting the row is what frees the slot —
// usedSlots() reads the table, so there is nothing else to release.
if (current.slot) {
fs.rmSync(slotDirs(current.slot).stateDir, { recursive: true, force: true });
}
lanesLib.deleteLane(current.id);
return { removed: current.id };
}
return { purged: lanesLib.purgeLaneSessions(current.id) };
});
if (action === "remove") {
broadcast("lane_update", { removed: result.removed });
return res.json({ ok: true });
}
if (action === "purge") {
broadcastLane(lane.id);
return res.json({ ok: true, purged: result.purged });
}
broadcastLane(lane.id);
return res.json({ lane: payload(result.lane) });
} catch (err) {
if (err.code === "ENOLANE") {
return res.status(404).json({ error: { code: err.code, message: err.message } });
}
return sendLifecycleError(res, err);
}
}
// The pane a `start` targets: 0 is the lane's own session (`run_id`), 1–3
// are the Workspace split panes' extra sessions on the same lane.
let startedRunId = null;
try {
switch (action) {
case "start": {
// A folder that is gone (or never was) would start the pane in the
// account's home instead, under this workspace's name. EACCES is not
// "gone": a member's folder may be unreadable to this process.
try {
if (!fs.statSync(lane.cwd).isDirectory())
throw Object.assign(new Error(), { code: "ENOTDIR" });
} catch (err) {
// A member's new project folder is made for them, as them.
const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd);
if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
return res.status(409).json({
error: {
code: "ENOCWD",
message: `the workspace folder does not exist: ${lane.cwd}`,
},
});
}
}
// `"auto"`: the first free tab, picked under the lane lock below — a
// client's own pick comes from a list that may already be stale.
const auto = body.slot === "auto";
let slot = body.slot === undefined || auto ? 0 : body.slot;
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
return res.status(400).json({
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
});
}
const isLive = (laneId, s) =>
runs.getRun(runs.runIdForLane(laneId, s))?.status === "running";
// Under the lock: the free slot, or null when every tab is busy.
const pickSlot = (laneId) => {
if (!auto) return slot;
for (let s = 0; s <= runs.MAX_PANE_SLOT; s++) if (!isLive(laneId, s)) return s;
return null;
};
const allBusy = () =>
res.status(409).json({
error: { code: "ERUNLIVE", message: "every tab of this workspace is busy" },
});
// A Terminal tab: a plain shell in the slot. No agent is at work in
// it, so the lane's run_id/status stay as they are.
if (body.shell === true) {
const shellOutcome = await withLaneLock(lane.id, async () => {
const current = lanesLib.getLane(lane.id);
if (!current) return { missing: true };
const picked = pickSlot(current.id);
if (picked === null) return { allBusy: true };
slot = picked;
if (isLive(current.id, slot)) return { conflict: true };
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
return {};
});
if (shellOutcome.missing) {
return res
.status(404)
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
if (shellOutcome.allBusy) return allBusy();
if (shellOutcome.conflict) {
return res.status(409).json({
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
});
}
break;
}
// A session that ran in a worktree of its own resumes (or forks) THERE:
// Claude finds a session by the folder it ran in. Resolved from the
// session row, before the lock; anything else starts as it always did.
let resumeWorktree = null;
if (typeof body.resumeSessionId === "string" && body.resumeSessionId) {
resumeWorktree = await sessionWorktree(lane, body.resumeSessionId).catch(() => null);
const row = resumeWorktree
? null
: db
.prepare("SELECT cwd, lane_id FROM sessions WHERE id = ?")
.get(body.resumeSessionId);
// One of this workspace's worktree sessions whose worktree is gone
// (Claude removed it on exit): there is no folder to resume it in.
if (
row &&
row.lane_id === lane.id &&
/\/\.claude\/worktrees\/[^/]+\/?$/.test(row.cwd || "")
) {
return res.status(400).json({
error: { code: "ENOWORKTREE", message: "that session's worktree no longer exists" },
});
}
}
// The SESSION's AI tool: a resume keeps the CLI its session ran on
// (`sessions.tool`); a new session takes the catalog id the request
// names — known and enabled, only an id, never a command — and the
// workspace's own tool otherwise.
const resumeId = body.resumeSessionId || (body.resume ? lane.session_id : null);
let sessionTool = null;
if (resumeId) {
sessionTool =
db.prepare("SELECT tool FROM sessions WHERE id = ?").get(resumeId)?.tool || null;
} else if (body.tool !== undefined) {
try {
if (typeof body.tool !== "string") {
throw Object.assign(new Error("tool must be a string"), { code: "EBADTOOL" });
}
lanesLib.validateTool(body.tool); // EBADTOOL, EDISABLED
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
sessionTool = body.tool;
}
// Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into. Stop the first run before starting a
// second. The check and the spawn happen under the per-lane lock so that
// atomicity is guaranteed rather than an accident of this code having no
// `await` between them — a future edit that adds one must not reopen the
// race.
const outcome = await withLaneLock(lane.id, async () => {
const current = lanesLib.getLane(lane.id);
// A concurrent `remove` could have deleted the row while this request
// waited for the lock — the lock makes that race visible instead of
// spawning a run for a lane that no longer exists.
if (!current) return { missing: true };
// A session holds ONE pane: a second `--resume` of it would run two
// agents writing one transcript. Its pane is named instead. A fork
// makes a new session, so it is no duplicate.
const resumeTarget = body.resumeSessionId || (body.resume ? current.session_id : null);
if (resumeTarget && body.forkSession !== true) {
const holder = runs
.listRuns()
.find((r) => r.status === "running" && r.sessionId === resumeTarget);
if (holder) return { sessionLive: holder.id };
}
const picked = pickSlot(current.id);
if (picked === null) return { allBusy: true };
slot = picked;
// By the pane's own name, slot 0 too: a terminal tab in pane 0 is no
// run, so `run_id` does not name it, and a start there would take it over.
const live = runs.getRun(runs.runIdForLane(current.id, slot || 0));
if (live && live.status === "running") {
return { conflict: true };
}
const handle = runs.spawnRun({
laneId: current.id,
slot,
initialPrompt: body.prompt || "",
cwd: resumeWorktree ? resumeWorktree.path : current.cwd,
inWorktree: !!resumeWorktree,
// A NEW session in a worktree of its own (`claude --worktree`).
worktree: body.worktree === true && !body.resumeSessionId,
tool: sessionTool || current.tool || "claude",
// The SESSION's AI Proxy, by name; a resume that names none goes
// back to the one its session last ran on. `router` is the
// pre-rename field, still taken from an older client.
proxy:
(body.proxy ?? body.router) !== undefined
? (body.proxy ?? body.router)
: resumeProxy(
body.resumeSessionId || (body.resume ? current.session_id : null),
current.owner_id ?? null
),
model: body.model,
permissionMode: body.permissionMode,
effort: body.effort,
sandbox: body.sandbox,
approval: body.approval,
resumeLast: body.resumeLast === true,
bypass: body.bypass === true,
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
forkSession: body.forkSession === true,
});
startedRunId = handle.id;
// An extra pane's session is not the lane's run: `run_id` stays the
// pane-0 session every other path (reset, stop) names.
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
return {};
});
if (outcome.missing) {
return res
.status(404)
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
if (outcome.sessionLive) {
return res.status(409).json({
error: {
code: "ESESSIONLIVE",
message: "that session is already open in a tab",
run_id: outcome.sessionLive,
},
});
}
if (outcome.allBusy) return allBusy();
if (outcome.conflict) {
return res
.status(409)
.json({ error: { code: "ERUNLIVE", message: "that tab already has a live session" } });
}
break;
}
case "stop": {
// A lane with no live run is already stopped — say so, don't 500.
for (const id of laneRunIds(lane)) {
try {
runs.killRun(id);
} catch {
/* already gone */
}
}
lanesLib.updateLane(lane.id, { status: "idle", run_id: null });
break;
}
case "message": {
return res.status(400).json({
error: {
code: "EUNSUPPORTED",
message:
"sending input to a workspace's run is no longer supported via REST — open the workspace's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
},
});
}
case "clear":
lanesLib.clearLane(lane.id);
break;
default:
break;
}
} catch (err) {
// A host with no tmux cannot start a run, but nothing is broken on this
// side — 503 plus the install line, not a 500 the reader can only read as a
// dashboard bug.
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
? 400
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
? 409
: 500;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
broadcastLane(lane.id);
res.json({
lane: payload(lanesLib.getLane(lane.id)),
...(startedRunId ? { run_id: startedRunId } : {}),
});
});
router.delete("/:id", sameOriginGuard, (req, res) => {
const ok = lanesLib.deleteLane(req.params.id);
if (!ok)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
broadcast("lane_update", { removed: Number(req.params.id) });
res.json({ ok: true });
});
module.exports = router;
module.exports.broadcastLane = broadcastLane;
module.exports.healAllRunIds = healAllRunIds;
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;