From 1bc237198c925fca93ca549062c24253bab2545c Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:46:55 +0700 Subject: [PATCH] feat(run): rewrite routes for the tmux backend, drop stdin-message endpoint --- server/__tests__/lane-lifecycle.test.js | 15 +- server/__tests__/run.test.js | 514 ++++-------------------- server/routes/run.js | 94 ++--- 3 files changed, 105 insertions(+), 518 deletions(-) diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index 6fbd841..fd2048c 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -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 () => { diff --git a/server/__tests__/run.test.js b/server/__tests__/run.test.js index c402170..952470a 100644 --- a/server/__tests__/run.test.js +++ b/server/__tests__/run.test.js @@ -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ĩ */ - 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; - } }); }); diff --git a/server/routes/run.js b/server/routes/run.js index 957616b..d4dfeec 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -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);