feat(run): rewrite routes for the tmux backend, drop stdin-message endpoint

This commit is contained in:
2026-08-12 09:46:55 +07:00
parent 56744b360d
commit 1bc237198c
3 changed files with 105 additions and 518 deletions
+30 -64
View File
@@ -1,9 +1,9 @@
/**
* @file run.js
* @description HTTP routes for the dashboard's Run feature. Spawns and
* supervises `claude` processes (headless one-shot or multi-turn
* conversation), streams structured envelopes to the client over the
* existing WebSocket, and exposes a tiny CRUD-ish surface for run management.
* @description HTTP routes for the dashboard's terminal-run feature. Starts,
* resumes, kills, and lists tmux-backed `claude` sessions (one per lane),
* streamed to the client over a dedicated WebSocket path (see
* server/websocket.js `/ws-pty/:runId`) rather than this REST surface.
*
* Security model:
* - Local-first dashboard. The dashboard server is expected to bind to
@@ -22,7 +22,8 @@
const { Router } = require("express");
const fs = require("node:fs");
const path = require("node:path");
const runs = require("../lib/run-spawner");
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const router = Router();
@@ -96,11 +97,7 @@ function sanitiseCwd(input) {
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
router.get("/", (_req, res) => {
res.json({
items: runs.listRuns(),
maxConcurrent: runs.getMaxConcurrent(),
activeCount: runs.liveCount(),
});
res.json({ items: runs.listRuns() });
});
/**
@@ -125,12 +122,7 @@ router.get("/history", (req, res) => {
limit: Number.isFinite(limit) ? limit : 50,
laneId: Number.isFinite(laneId) ? laneId : null,
});
// Cross-reference with live handles so the UI can mark which history
// entries are still attached / running.
const liveIds = new Set();
for (const h of runs.listRuns()) {
if (h.id && (h.status === "running" || h.status === "spawning")) liveIds.add(h.id);
}
const liveIds = new Set(runs.listRuns().map((h) => h.id));
res.json({
items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
});
@@ -260,23 +252,15 @@ router.get("/binary", (_req, res) => {
});
});
router.get("/tmux", (_req, res) => {
res.json({ available: tmux.isTmuxAvailable() });
});
router.post("/", (req, res) => {
const body = req.body || {};
const prompt = typeof body.prompt === "string" ? body.prompt : "";
const mode = body.mode === "headless" ? "headless" : "conversation";
const model = typeof body.model === "string" && body.model ? body.model : null;
const resumeSessionId =
typeof body.resumeSessionId === "string" && body.resumeSessionId ? body.resumeSessionId : null;
const effort = typeof body.effort === "string" && body.effort ? body.effort : null;
const permissionMode =
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
? body.permissionMode
: "acceptEdits";
// Resuming a conversation can spawn with an empty prompt — claude waits
// on stdin until the user types a follow-up. Headless and fresh
// conversation runs still need a prompt to do anything.
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
return res.status(400).json({ error: { code: "EBADPROMPT", message: "prompt is required" } });
const laneId = Number.parseInt(String(body.laneId ?? ""), 10);
if (!Number.isInteger(laneId)) {
return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } });
}
let cwd;
try {
@@ -286,22 +270,22 @@ router.post("/", (req, res) => {
}
try {
const handle = runs.spawnRun({
prompt,
mode,
laneId,
cwd,
model,
permissionMode,
resumeSessionId,
effort,
model: typeof body.model === "string" && body.model ? body.model : null,
permissionMode:
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
? body.permissionMode
: "acceptEdits",
effort: typeof body.effort === "string" && body.effort ? body.effort : null,
resumeSessionId:
typeof body.resumeSessionId === "string" && body.resumeSessionId
? body.resumeSessionId
: null,
initialPrompt: typeof body.initialPrompt === "string" ? body.initialPrompt : "",
});
return res.status(201).json(runs.getRun(handle.id));
return res.status(201).json(handle);
} catch (err) {
if (err.code === "ECONCURRENCY") {
return res.status(429).json({
error: { code: err.code, message: err.message },
running: err.running || [],
});
}
if (err.code && err.code.startsWith("E")) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
@@ -309,27 +293,9 @@ router.post("/", (req, res) => {
}
});
router.post("/:id/message", (req, res) => {
const body = req.body || {};
const text = typeof body.text === "string" ? body.text : "";
if (!text) {
return res.status(400).json({ error: { code: "EBADINPUT", message: "text is required" } });
}
try {
const result = runs.sendInput(req.params.id, text);
return res.json(result);
} catch (err) {
const status = err.code === "ENOTFOUND" ? 404 : 400;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
});
router.get("/:id", (req, res) => {
// ?envelopes=1 includes the in-memory envelope history so the UI can
// re-attach to an active run started elsewhere and see what it missed.
const includeEnvelopes = req.query.envelopes === "1";
const handle = runs.getRun(req.params.id, { includeEnvelopes });
if (!handle) {
const handle = runs.getRun(req.params.id);
if (!handle || handle.status === "gone") {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
}
return res.json(handle);