Files
AgentHub/server/routes/lanes.js
T
nntrivi2001 5ba9e07d78 feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
2026-09-17 22:20:32 +07:00

1430 lines
54 KiB
JavaScript

/**
* @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 {
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 { applyRouterToLane } = require("../lib/router-apply");
const { sameOriginGuard } = require("./run");
const { preflight } = require("../lib/lane-preflight");
const {
LANES_ROOT,
addWorktree,
gitFacts,
isGitRepo,
isValidBranchName,
listBranches,
removeWorktree,
resetWorktree,
resolveBase,
slugify,
} = require("../lib/worktree");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
const { installAgents } = require("../lib/lane-agents");
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,
removeLaneData,
} = 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's session last emitted an event; null if never. */
function lastEventAge(lane) {
if (!lane.session_id) return null;
const row = db
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
.get(lane.session_id);
if (!row || !row.last) return null;
const t = Date.parse(row.last);
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
}
/**
* 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) {
if (!lane.run_id) return lane;
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);
}
function payload(lane) {
const healed = healRunId(lane);
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) => {
const lanes = lanesLib.listLanes().map(payload);
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: "lane must be an id" } });
}
}
const events =
laneId === null
? db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id"
)
.all()
: 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").all()
: 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 ORDER BY id").all();
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" },
});
}
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);
res.json({ branches, current });
} catch (err) {
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
}
});
/**
* 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.
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir();
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
// 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.
const owner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
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,
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") {
return res.status(400).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.
*/
router.post("/gc", sameOriginGuard, (req, res) => {
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.
*/
router.get("/browse", (req, res) => {
const raw =
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
const resolved = path.resolve(raw);
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" } });
}
let entries = [];
try {
entries = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => {
const full = path.join(resolved, e.name);
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
})
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries });
});
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
res.json({ lane: payload(lane) });
});
router.post("/", sameOriginGuard, (req, res) => {
try {
const lane = lanesLib.createLane(req.body || {});
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"
) {
return res.status(400).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 lane 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: "lane not found" } });
}
let lane;
try {
lane = lanesLib.updateLane(req.params.id, req.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: "lane 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);
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: "lane 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: "lane 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: "lane 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: "lane 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: "lane 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, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane 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, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
res.json(proofLib.ensureProofLink(lane));
});
/**
* Install the ship-feature-lane pipeline's agent templates (qc-local,
* senior-gate-reviewer) into this lane's own .claude/agents/. Static file
* copy — no templating, no credentials to inject (this repo has no
* seed-account system yet; see the E3 design spec's Decisions table).
* Never automatic, same as proof-link: a session calls this explicitly.
*/
router.post("/:id/agents/install", sameOriginGuard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
try {
res.json(await installAgents(lane));
} catch (err) {
if (err.code === "ENOTGITREPO") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
/**
* 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, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane 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 } });
}
});
/**
* POST /:id/router/apply — point this lane at one AI Router profile (or back at
* `native`). Body: `{ profile: "<name>" }`.
*
* The profile name is the only thing taken from the request: what gets written,
* and where, is derived from the lane row (`server/lib/router-apply.js`). The
* lane's `router_profile` column is set in the same call so a later spawn knows
* which `-p` layer a codex pane needs.
*/
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
const profile = (req.body || {}).profile;
if (typeof profile !== "string" || !profile) {
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
}
try {
const result = applyRouterToLane(lane, profile);
lanesLib.setRouterProfile(lane.id, profile);
// Every other mutating lane route broadcasts; without it the client's
// cached row keeps the OLD router_profile, and the next lane switch
// re-reads that stale row and flips the picker back.
broadcastLane(lane.id);
res.json(result);
} catch (err) {
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
res.status(status).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, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane 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: "lane 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 lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
try {
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
} catch {
res.json({ available: false });
}
});
/**
* 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 || {};
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
const sourceRepo = body.sourceRepo;
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",
},
});
}
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",
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 lane 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", "lane state changed since preflight");
err.expected = expected;
err.current = current;
throw err;
}
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Kill a lane run and wait for the tmux session to exit before touching its cwd. */
async function stopLaneRun(lane) {
if (!lane.run_id) return;
try {
runs.killRun(lane.run_id);
} catch {
/* a concurrently completed run is already safe */
}
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
let run = runs.getRun(lane.run_id);
while (run && run.status !== "gone") {
if (Date.now() >= deadline) {
throw lifecycleError(
"ERUNTIMEOUT",
`lane run ${lane.run_id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
);
}
await wait(RUN_EXIT_POLL_MS);
run = runs.getRun(lane.run_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: err.code, 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 },
});
}
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);
if (!lane) {
res.status(404).json({ error: { code: "ENOLANE", message: "lane 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, (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, 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, (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: "lane 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, 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: "lane 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", "lane not found");
await stopLaneRun(lockedLane);
// A running stack holds files open in the very directory reset and remove
// are about to rewrite or delete, and its processes would outlive the lane
// still bound to its ports. Stop it before touching git. 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);
if (
(action === "reset" || (action === "remove" && current.kind === "managed")) &&
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") {
// Drop the lane's own database(s) before anything else — its state
// directory (the drop-created marker) is about to be deleted too.
const profile = resolveProfile(current);
if (profile && current.slot) await removeLaneData(current, profile);
// Forgetting an adopted lane only removes dashboard metadata. The
// filesystem destroy guard is deliberately reached only for managed
// worktrees, where removal can actually touch a directory.
if (current.kind === "managed") await removeWorktree(current);
// Runtime bookkeeping outlives the row otherwise: 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);
}
}
try {
switch (action) {
case "start": {
// 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 };
const live = current.run_id ? runs.getRun(current.run_id) : null;
if (live && live.status === "running") {
return { conflict: true };
}
const handle = runs.spawnRun({
laneId: current.id,
initialPrompt: body.prompt || "",
cwd: current.cwd,
// The tool is the LANE's fact, never the request's: a client that
// asked for the other CLI would otherwise get a pane that doesn't
// match the lane's badge, its history, or its binary probe.
tool: current.tool || "claude",
// Same reasoning as `tool`: the router is the lane's fact, written
// into its config when the lane was created.
routerProfile: current.router_profile || null,
model: body.model,
permissionMode: body.permissionMode,
effort: body.effort,
sandbox: body.sandbox,
approval: body.approval,
resumeLast: body.resumeLast === true,
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
});
lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
return {};
});
if (outcome.missing) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
}
if (outcome.conflict) {
return res
.status(409)
.json({ error: { code: "ERUNLIVE", message: "lane already has a live run" } });
}
break;
}
case "stop": {
// A lane with no live run is already stopped — say so, don't 500.
if (lane.run_id) {
try {
runs.killRun(lane.run_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 lane's run is no longer supported via REST — open the lane'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 } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
broadcastLane(lane.id);
res.json({ lane: payload(lanesLib.getLane(lane.id)) });
});
router.delete("/:id", sameOriginGuard, (req, res) => {
const ok = lanesLib.deleteLane(req.params.id);
if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
broadcast("lane_update", { removed: Number(req.params.id) });
res.json({ ok: true });
});
module.exports = router;
module.exports.broadcastLane = broadcastLane;