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
+1 -14
View File
@@ -1195,22 +1195,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
});
});
it("filters GET /api/run/history by laneId and leaves non-lane runs unlabelled", async () => {
it("filters GET /api/run/history by laneId", async () => {
const lane = await adoptedLane("history-filter");
const other = await adoptedLane("history-filter-other");
let laneRunId;
let plainRunId;
await withFakeClaude("history", "process.exit(0);\n", async () => {
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "lane run" });
laneRunId = started.body.lane.run_id;
await waitForRelease(lane.id);
const plain = await request("POST", "/api/run", {
prompt: "plain run",
mode: "headless",
cwd: ROOT,
});
assert.equal(plain.status, 201);
plainRunId = plain.body.id;
});
const filtered = await request("GET", `/api/run/history?laneId=${lane.id}`);
@@ -1223,11 +1215,6 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
const empty = await request("GET", `/api/run/history?laneId=${other.id}`);
assert.deepEqual(empty.body.items, []);
// POST /api/run is unchanged: its row carries no lane.
const all = await request("GET", "/api/run/history?limit=500");
const plainRow = all.body.items.find((it) => it.id === plainRunId);
assert.equal(plainRow.lane_id, null);
});
it("releases the lane when the run exits on its own", async () => {
+74 -440
View File
@@ -1,28 +1,22 @@
// server/__tests__/run.test.js
/**
* @file run.test.js
* @description Tests for the Run feature: spawner injection, route
* validation, same-origin guard, cwd suggestions, resume validation,
* envelope storage / attach, and end-to-end handle lifecycle. Uses a fake
* child (PassThrough streams + EventEmitter) so we never invoke the real
* `claude` binary.
* @description Route tests for the terminal-run feature: same-origin guard,
* laneId/cwd validation, spawn/kill/list against a mocked tmux backend.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const http = require("node:http");
const { PassThrough } = require("node:stream");
const { EventEmitter } = require("node:events");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-test-"));
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-route-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { createApp } = require("../index");
const runs = require("../lib/run-spawner");
const runRoute = require("../routes/run");
const tmux = require("../lib/tmux");
let server;
let BASE;
@@ -66,44 +60,25 @@ function fetchJson(p, opts = {}) {
});
}
function makeFakeChild() {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.killed = false;
child.kill = function (sig) {
this.killed = true;
setImmediate(() => this.emit("exit", sig === "SIGTERM" ? 143 : 0, sig || null));
};
return child;
}
describe("/api/run", () => {
before(async () => {
const app = createApp();
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
const port = server.address().port;
BASE = `http://127.0.0.1:${port}`;
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
// The SQLite DB lives under TMP and better-sqlite3 holds it open, so on
// Windows rmSync hits EPERM (can't remove a dir with an open handle).
// maxRetries covers transient locks; the try/catch makes the rest
// best-effort — a leftover temp dir must not fail the suite (the OS
// reclaims os.tmpdir()).
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
/* best-effort */
}
});
beforeEach(() => {
runs.__reset();
tmux.__reset();
});
it("rejects cross-origin browser requests", async () => {
@@ -115,429 +90,88 @@ describe("/api/run", () => {
});
it("allows requests with no Origin (CLI/curl)", async () => {
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") {
const e = new Error("no server running");
e.status = 1;
throw e;
}
return "";
});
const { status, body } = await fetchJson("/api/run");
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
assert.deepEqual(body.items, []);
});
it("allows localhost Origin", async () => {
const { status } = await fetchJson("/api/run", {
headers: { Origin: "http://localhost:5173" },
it("POST / rejects a missing laneId", async () => {
const { status, body } = await fetchJson("/api/run", { method: "POST", body: { cwd: TMP } });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADLANE");
});
it("POST / rejects a non-existent cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { laneId: 1, cwd: "/definitely/not/a/real/path" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("POST / spawns a tmux session and GET /:id finds it", async () => {
let hasSessionCalls = 0;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
hasSessionCalls++;
// First call (inside spawnRun): not yet running. Every call after
// (GET /:id) sees it as running.
if (hasSessionCalls === 1) {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
}
return "";
});
const spawned = await fetchJson("/api/run", { method: "POST", body: { laneId: 9, cwd: TMP } });
assert.equal(spawned.status, 201);
assert.equal(spawned.body.id, "ccam-lane-9");
const fetched = await fetchJson(`/api/run/${spawned.body.id}`);
assert.equal(fetched.status, 200);
assert.equal(fetched.body.status, "running");
});
it("DELETE /:id kills a live tmux session", async () => {
tmux.__setExecImpl(() => ""); // has-session succeeds; kill-session succeeds
const { status, body } = await fetchJson("/api/run/ccam-lane-9", { method: "DELETE" });
assert.equal(status, 200);
assert.deepEqual(body, { ok: true });
});
it("POST / requires prompt", async () => {
const { status, body } = await fetchJson("/api/run", { method: "POST", body: {} });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADPROMPT");
});
it("POST / rejects non-existent cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "/nope/does/not/exist" },
it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("POST / rejects relative cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "./relative" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id returns 404 for unknown id", async () => {
const { status, body } = await fetchJson("/api/run/does-not-exist");
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("DELETE /:id returns 404 for unknown id", async () => {
const { status } = await fetchJson("/api/run/does-not-exist", { method: "DELETE" });
const { status } = await fetchJson("/api/run/ccam-lane-999", { method: "DELETE" });
assert.equal(status, 404);
});
it("POST /:id/message rejects empty text", async () => {
const { status, body } = await fetchJson("/api/run/x/message", {
method: "POST",
body: {},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADINPUT");
it("GET /tmux reports availability from the tmux wrapper", async () => {
tmux.__setExecImpl(() => "tmux 3.4");
const { body } = await fetchJson("/api/run/tmux");
assert.equal(body.available, true);
});
// ── /api/run/cwds suggestions ─────────────────────────────────────
it("GET /cwds returns dashboard + home suggestions with absolute paths", async () => {
it("GET /cwds still returns suggested directories (unchanged behavior)", async () => {
const { status, body } = await fetchJson("/api/run/cwds");
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
const kinds = body.items.map((i) => i.kind);
assert.ok(kinds.includes("dashboard"), "dashboard cwd present");
assert.ok(kinds.includes("home"), "home present");
for (const it of body.items) {
assert.equal(typeof it.path, "string");
// path.isAbsolute is platform-aware: "/x" on POSIX, "C:\\x" on Windows.
assert.ok(path.isAbsolute(it.path), "absolute path");
assert.equal(typeof it.label, "string");
}
});
// ── /api/run/binary probe ─────────────────────────────────────────
it("GET /binary returns shape { found, path }", async () => {
const { status, body } = await fetchJson("/api/run/binary");
assert.equal(status, 200);
assert.equal(typeof body.found, "boolean");
if (body.found) assert.equal(typeof body.path, "string");
});
// ── Resume validation ─────────────────────────────────────────────
it("POST / rejects bad resumeSessionId format", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", resumeSessionId: "x" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADSESSION");
});
it("POST / rejects unknown effort level", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", effort: "ludicrous" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADEFFORT");
});
it("POST / rejects resumeSessionId with headless mode", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: {
prompt: "hi",
mode: "headless",
resumeSessionId: "deadbeef-cafe-1234-5678-feedfacefeed",
},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADMODE");
});
// ── HTTP GET /:id?envelopes=1 (attach payload) ────────────────────
it("GET /:id?envelopes=1 returns the in-memory envelope log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"sX"}\n`);
await new Promise((r) => setImmediate(r));
const { status, body } = await fetchJson(`/api/run/${handle.id}?envelopes=1`);
assert.equal(status, 200);
assert.ok(Array.isArray(body.envelopes));
assert.equal(body.envelopes.length, 1);
assert.equal(body.envelopes[0].type, "system");
});
it("GET /files returns paths matching q, skipping node_modules", async () => {
// Build a tiny fixture under tmp so the test is hermetic.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "run-files-"));
fs.mkdirSync(path.join(tmp, "src"));
fs.mkdirSync(path.join(tmp, "node_modules", "leftover-pkg"), { recursive: true });
fs.writeFileSync(path.join(tmp, "README.md"), "x");
fs.writeFileSync(path.join(tmp, "src", "index.ts"), "x");
fs.writeFileSync(path.join(tmp, "node_modules", "leftover-pkg", "x.js"), "x");
try {
const { status, body } = await fetchJson(
`/api/run/files?cwd=${encodeURIComponent(tmp)}&q=index`
);
assert.equal(status, 200);
assert.deepEqual(body.items.sort(), ["src/index.ts"]);
// No q → returns top-level files (excluding node_modules)
const all = await fetchJson(`/api/run/files?cwd=${encodeURIComponent(tmp)}`);
assert.ok(all.body.items.includes("README.md"));
assert.ok(!all.body.items.some((p) => p.startsWith("node_modules")));
} finally {
try {
fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup (Windows may hold a handle) */
}
}
});
it("GET /files rejects missing/invalid cwd", async () => {
const { status, body } = await fetchJson("/api/run/files?cwd=/does/not/exist");
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id without ?envelopes returns metadata only", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
const { body } = await fetchJson(`/api/run/${handle.id}`);
assert.equal(body.envelopes, undefined);
assert.equal(body.envelopeCount, 1);
});
});
describe("run-spawner unit", () => {
beforeEach(() => {
runs.__reset();
});
it("injected child parses stream-json envelopes and broadcasts", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(
`{"type":"system","subtype":"init","session_id":"sess-abc","model":"opus"}\n`
);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
// Allow the line parser to flush
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "running");
assert.equal(live.sessionId, "sess-abc");
assert.equal(live.envelopeCount, 2);
});
it("sendInput writes a stream-json envelope to stdin", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
// Force into running state via a parsed envelope first
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
await new Promise((r) => setImmediate(r));
const chunks = [];
fake.stdin.on("data", (c) => chunks.push(c.toString()));
runs.sendInput(handle.id, "follow-up");
await new Promise((r) => setImmediate(r));
const written = chunks.join("");
const lines = written.trim().split("\n");
const obj = JSON.parse(lines[lines.length - 1]);
assert.equal(obj.type, "user");
assert.equal(obj.message.content, "follow-up");
});
it("sendInput rejects on headless handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "headless" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /only conversation mode/);
});
it("kill marks handle as killed and emits exit", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
runs.killRun(handle.id);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "killed");
});
it("escalates to SIGKILL when SIGTERM was delivered but the child has not exited", async () => {
const fake = makeFakeChild();
const signals = [];
fake.kill = function (signal) {
this.killed = true;
signals.push(signal);
if (signal === "SIGKILL") setImmediate(() => this.emit("exit", 137, signal));
return true;
};
const handle = runs.__injectChildForTest({ child: fake });
const originalSetTimeout = global.setTimeout;
global.setTimeout = (callback, delay, ...args) => {
if (delay === 5000) {
callback(...args);
return { unref: () => {} };
}
return originalSetTimeout(callback, delay, ...args);
};
try {
runs.killRun(handle.id);
} finally {
global.setTimeout = originalSetTimeout;
}
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
assert.notEqual(runs.getRun(handle.id).actualExitedAt, null);
});
it("exit with code 0 marks completed", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "completed");
assert.equal(live.exitCode, 0);
});
it("exit with non-zero code marks error", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 1, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "error");
});
it("malformed JSON lines do not crash; go to stderr buffer", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.stdout.write("not valid json\n");
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.match(live.stderrTail, /parse-error/);
});
it("listRuns returns handles sorted newest first", async () => {
const a = runs.__injectChildForTest({ child: makeFakeChild() });
await new Promise((r) => setTimeout(r, 5));
const b = runs.__injectChildForTest({ child: makeFakeChild() });
const list = runs.listRuns();
assert.equal(list[0].id, b.id);
assert.equal(list[1].id, a.id);
});
});
describe("sameOriginGuard helper", () => {
it("loopback Origin passes", () => {
const next = () => "OK";
const res = {};
const result = runRoute.__sameOriginGuard(
{ headers: { origin: "http://127.0.0.1:4820" } },
res,
next
);
assert.equal(result, "OK");
});
it("missing Origin passes (CLI use case)", () => {
const next = () => "OK";
const result = runRoute.__sameOriginGuard({ headers: {} }, {}, next);
assert.equal(result, "OK");
});
it("non-loopback Origin is blocked", () => {
let captured = null;
const res = {
status(code) {
captured = { code };
return this;
},
json(body) {
captured.body = body;
return this;
},
};
runRoute.__sameOriginGuard({ headers: { origin: "http://attacker.com" } }, res, () => {});
assert.equal(captured.code, 403);
assert.equal(captured.body.error.code, "EBADORIGIN");
});
});
describe("run-spawner extras", () => {
beforeEach(() => {
runs.__reset();
});
it("getRun (no opts) returns metadata only — no envelopes field", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.envelopeCount, 2);
assert.equal(live.envelopes, undefined);
});
it("getRun({includeEnvelopes:true}) returns the in-memory log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"x"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.ok(Array.isArray(live.envelopes));
assert.equal(live.envelopes.length, 2);
assert.equal(live.envelopes[0].type, "system");
});
it("listRuns surfaces resumeSessionId (null for fresh)", async () => {
runs.__injectChildForTest({ child: makeFakeChild(), mode: "conversation" });
const list = runs.listRuns();
assert.equal(list.length, 1);
assert.equal(list[0].resumeSessionId, null);
});
it("killRun is idempotent on already-completed handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.equal(runs.getRun(handle.id).status, "completed");
// Second kill on a completed handle should be a safe no-op (returns true).
assert.equal(runs.killRun(handle.id), true);
assert.equal(runs.getRun(handle.id).status, "completed");
});
it("killRun returns false for an unknown id", () => {
assert.equal(runs.killRun("does-not-exist"), false);
});
it("sendInput throws ENOTFOUND for unknown id", () => {
assert.throws(() => runs.sendInput("nope", "hi"), /not found/);
});
it("sendInput throws ENOTRUNNING when handle has already exited", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /run is (completed|killed|error)/);
});
it("sendInput rejects empty text", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, ""), /text is required/);
});
it("envelope log is capped at 500 entries", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
let line = "";
for (let i = 0; i < 600; i++) line += `{"type":"assistant","i":${i}}\n`;
fake.stdout.write(line);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.equal(live.envelopeCount, 600);
assert.equal(live.envelopes.length, 500);
// The cap drops the OLDEST entries — last entry should be the latest.
assert.equal(live.envelopes[live.envelopes.length - 1].i, 599);
});
it("getMaxConcurrent respects RUN_MAX_CONCURRENT env override", () => {
const orig = process.env.RUN_MAX_CONCURRENT;
try {
process.env.RUN_MAX_CONCURRENT = "7";
assert.equal(runs.getMaxConcurrent(), 7);
process.env.RUN_MAX_CONCURRENT = "garbage";
assert.ok(runs.getMaxConcurrent() >= 1, "falls back to default on non-numeric");
delete process.env.RUN_MAX_CONCURRENT;
assert.ok(runs.getMaxConcurrent() >= 1);
} finally {
if (orig != null) process.env.RUN_MAX_CONCURRENT = orig;
else delete process.env.RUN_MAX_CONCURRENT;
}
});
});
+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);