feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's Active runs hides what the other panes show; stop/reset/remove kill every slot. No per-pane lane picker any more. - Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js) with the read path's guards, existing text files only, and a baseSha stale check (409 ESTALE). A file it cannot take says why. - Per-hunk review bar under each hunk's last changed line: prev/next, "n of N", Undo, Keep. Review diffs no longer nest their own scroll box (unless windowed), so navigation moves the one real scroller. - Files tree lists untracked-not-ignored files and colours entries by git status (M/U/A/D/R), folders holding changes get a dot. - Changes panel: Unstage all on the single commit/Stage-all row; sizes Narrow (30%) / Medium / Full screen, no 70/30. - Lane card: the needs-you banner is gone (the strip tile keeps it).
This commit is contained in:
+2
-1
@@ -528,8 +528,9 @@ Request body shape:
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
|
||||
|
||||
|
||||
@@ -458,6 +458,33 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
|
||||
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
|
||||
});
|
||||
|
||||
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
|
||||
write(LANE, "src/main.ts", "m\n");
|
||||
write(LANE, ".gitignore", "*.log\n");
|
||||
gc(LANE, "add", "-A");
|
||||
gc(LANE, "commit", "-qm", "src");
|
||||
write(LANE, "a.txt", "modified\n");
|
||||
write(LANE, "new.txt", "fresh\n");
|
||||
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
|
||||
write(LANE, "noise.log", "ignored\n");
|
||||
|
||||
const root = await listTree(LANE, {});
|
||||
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
|
||||
assert.equal(by["a.txt"].status, " M");
|
||||
assert.equal(by["new.txt"].status, "??");
|
||||
assert.equal(by["keep.txt"].status, undefined);
|
||||
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
|
||||
assert.equal(by.gen.changed, true);
|
||||
assert.equal(by.src.changed, undefined);
|
||||
|
||||
// Inside a folder git reports only as "gen/": still untracked, still marked.
|
||||
const deep = await listTree(LANE, { subdir: "gen/deep" });
|
||||
assert.equal(deep.entries[0].status, "??");
|
||||
const all = await listTreeAll(LANE);
|
||||
assert.equal(all.levels["gen"][0].changed, true);
|
||||
assert.deepEqual(all.levels[""], root.entries);
|
||||
});
|
||||
|
||||
it("reads every level of the tree in one pass, grouped like listTree", async () => {
|
||||
write(LANE, "src/api/x.ts", "x\n");
|
||||
write(LANE, "src/main.ts", "m\n");
|
||||
|
||||
@@ -959,6 +959,47 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
assert.ok(tree.body.entries.some((e) => e.name === "a.txt" && e.dir === false));
|
||||
});
|
||||
|
||||
it("saves a file from the editor over the exact bytes it loaded, and nothing else", async () => {
|
||||
const opened = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=work`);
|
||||
assert.equal(typeof opened.body.sha, "string");
|
||||
|
||||
const saved = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "a.txt",
|
||||
content: "one\nTWO\nthree",
|
||||
baseSha: opened.body.sha,
|
||||
});
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
// The final newline the file had survives an editor that drops it.
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
|
||||
assert.notEqual(saved.body.sha, opened.body.sha);
|
||||
|
||||
// Saving over the OLD stamp is a conflict, never a silent overwrite.
|
||||
const stale = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "a.txt",
|
||||
content: "clobbered",
|
||||
baseSha: opened.body.sha,
|
||||
});
|
||||
assert.equal(stale.status, 409);
|
||||
assert.equal(stale.body.error.code, "ESTALE");
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
|
||||
|
||||
const escape = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "../../etc/passwd",
|
||||
content: "x",
|
||||
baseSha: "0".repeat(40),
|
||||
});
|
||||
assert.equal(escape.status, 400);
|
||||
assert.equal(escape.body.error.code, "EBADPATH");
|
||||
|
||||
const secret = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: ".claude/settings.local.json",
|
||||
content: "{}",
|
||||
baseSha: "0".repeat(40),
|
||||
});
|
||||
assert.equal(secret.status, 403);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
});
|
||||
|
||||
it("stages one file, commits only the index, and refuses an unchanged path", async () => {
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTHREE\n");
|
||||
fs.writeFileSync(path.join(dir, "b.txt"), "left behind\n");
|
||||
|
||||
@@ -55,6 +55,8 @@ describe("pty-attach", () => {
|
||||
|
||||
it("accepts a well-formed runId", () => {
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
|
||||
});
|
||||
|
||||
it("turns tmux mouse mode on for the session it attaches to", () => {
|
||||
|
||||
@@ -58,6 +58,26 @@ describe("pty-run", () => {
|
||||
assert.ok(newSessionCall.includes("opus"));
|
||||
});
|
||||
|
||||
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const handle = pty.spawnRun({ laneId: 42, slot: 2, cwd: "/tmp/repo" });
|
||||
assert.equal(handle.id, "agenthub-lane-42-2");
|
||||
assert.equal(handle.laneId, 42);
|
||||
assert.equal(handle.slot, 2);
|
||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
|
||||
(err) => err.code === "EBADSLOT"
|
||||
);
|
||||
});
|
||||
|
||||
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
|
||||
// Before this guard the ENOENT escaped from three calls down as
|
||||
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
|
||||
|
||||
+74
-16
@@ -24,6 +24,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -667,6 +668,7 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
|
||||
let text;
|
||||
let binary = false;
|
||||
let sha;
|
||||
if (rev === "work") {
|
||||
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
|
||||
// compares REAL paths, not the string the caller sent.
|
||||
@@ -695,6 +697,9 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
}
|
||||
binary = looksBinary(buf);
|
||||
text = binary ? "" : buf.toString("utf8");
|
||||
// What an editor must send back to save over THIS content and nothing
|
||||
// newer (lane-file-write.js). Only for a whole, textual file.
|
||||
if (!binary && buf.length <= MAX_FILE_VIEW_BYTES) sha = contentSha(buf);
|
||||
} else {
|
||||
try {
|
||||
// `./`: resolve from the lane's cwd, not the repo root (a bare
|
||||
@@ -715,7 +720,12 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
}
|
||||
const lines = text ? text.split("\n") : [];
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
return { path: file, rev, binary, truncated, lines };
|
||||
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
|
||||
}
|
||||
|
||||
/** The version stamp of a file's bytes, for a stale-save check. */
|
||||
function contentSha(buf) {
|
||||
return crypto.createHash("sha1").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -753,18 +763,67 @@ async function lsFiles(dir, args) {
|
||||
return { all: all.filter(Boolean), cut };
|
||||
}
|
||||
|
||||
/** What the explorer lists: tracked files plus untracked ones git does not
|
||||
* ignore — the new file an agent just wrote is the one you want to open. */
|
||||
const TREE_LS = ["--cached", "--others", "--exclude-standard"];
|
||||
|
||||
/**
|
||||
* One directory level of the working tree, for the file explorer. Tracked
|
||||
* entries only (same `.gitignore` reasoning as `listPaths`), folded into
|
||||
* A lane's working-copy status for decorating the explorer, from ONE
|
||||
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file
|
||||
* (untracked inside a folded untracked dir included), `dirChanged(path)` is
|
||||
* whether anything under a folder is.
|
||||
*/
|
||||
async function treeStatus(dir) {
|
||||
const codes = new Map();
|
||||
const folded = []; // `normal` reports a wholly untracked folder as "dir/"
|
||||
const dirs = new Set();
|
||||
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
|
||||
if (p.endsWith("/")) folded.push(p);
|
||||
else codes.set(p, code);
|
||||
const parts = p.replace(/\/$/, "").split("/");
|
||||
for (let i = 1; i <= parts.length - (p.endsWith("/") ? 0 : 1); i++) {
|
||||
dirs.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
const inFolded = (p) => folded.some((f) => p.startsWith(f));
|
||||
return {
|
||||
fileStatus: (p) => codes.get(p) ?? (inFolded(p) ? "??" : undefined),
|
||||
dirChanged: (p) => dirs.has(p) || inFolded(`${p}/`),
|
||||
};
|
||||
}
|
||||
|
||||
/** A folded level's entries, each carrying its status (see `treeStatus`). */
|
||||
function treeEntries(prefix, dirNames, files, st) {
|
||||
return [
|
||||
...[...dirNames].sort().map((name) => {
|
||||
const p = `${prefix}${name}`;
|
||||
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
|
||||
}),
|
||||
...files
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((f) => {
|
||||
const status = st.fileStatus(f.path);
|
||||
return status ? { ...f, status } : f;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* One directory level of the working tree, for the file explorer: tracked
|
||||
* files plus untracked-but-not-ignored ones (`TREE_LS`), folded into
|
||||
* directories at this level — a tree that lists a repo's every file at once is
|
||||
* both slow to render and not what "expand a folder" means.
|
||||
* both slow to render and not what "expand a folder" means. A changed file
|
||||
* carries its porcelain `status`, a folder holding one `changed: true`.
|
||||
*/
|
||||
async function listTree(dir, { subdir = "" } = {}) {
|
||||
const clean = String(subdir || "")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
|
||||
const { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []);
|
||||
const [{ all, cut }, st] = await Promise.all([
|
||||
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
|
||||
treeStatus(dir),
|
||||
]);
|
||||
const prefix = clean ? `${clean}/` : "";
|
||||
const dirs = new Set();
|
||||
const files = [];
|
||||
@@ -774,10 +833,7 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
if (slash === -1) files.push({ name: rest, path: p, dir: false });
|
||||
else dirs.add(rest.slice(0, slash));
|
||||
}
|
||||
const entries = [
|
||||
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
const entries = treeEntries(prefix, dirs, files, st);
|
||||
return {
|
||||
path: clean,
|
||||
entries: entries.slice(0, MAX_PATHS),
|
||||
@@ -817,13 +873,13 @@ async function hunkPatch(dir, file, header, context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every level of the tracked tree from ONE `git ls-files`, keyed by directory
|
||||
* Every level of the explorer tree (tracked + untracked-not-ignored) from ONE `git ls-files`, keyed by directory
|
||||
* path ("" is the root), each level shaped and capped exactly like
|
||||
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
|
||||
* per folder, which on a large repo is thousands of git processes.
|
||||
*/
|
||||
async function listTreeAll(dir) {
|
||||
const { all, cut } = await lsFiles(dir, []);
|
||||
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]);
|
||||
const byDir = new Map();
|
||||
const level = (d) => {
|
||||
let l = byDir.get(d);
|
||||
@@ -842,11 +898,7 @@ async function listTreeAll(dir) {
|
||||
const levels = {};
|
||||
let truncated = cut;
|
||||
for (const [d, { dirs, files }] of byDir) {
|
||||
const prefix = d ? `${d}/` : "";
|
||||
const entries = [
|
||||
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
|
||||
if (entries.length > MAX_PATHS) truncated = true;
|
||||
levels[d] = entries.slice(0, MAX_PATHS);
|
||||
}
|
||||
@@ -855,6 +907,12 @@ async function listTreeAll(dir) {
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
contentSha,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
looksBinary,
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
hunkPatch,
|
||||
laneStatus,
|
||||
listCommits,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file Saves one text file in a lane's working tree, for the Changes panel's
|
||||
* single-file editor. The one filesystem WRITE of the lane file surface, kept
|
||||
* out of git-ops.js on purpose: that module only ever touches paths `git
|
||||
* status` reports as changed, and an editor may save any file.
|
||||
*
|
||||
* The guards are the READ path's own (git-diff.js), so the editor can never
|
||||
* write where the viewer could not read: a lexical `lanePath`, a realpath
|
||||
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
|
||||
* — the lane router's key file — at any depth or through any symlink. On top:
|
||||
* only an EXISTING, textual file within the viewer's size cap, and only over
|
||||
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
|
||||
* never silently overwrites what the agent wrote in the meantime (ESTALE).
|
||||
* The file keeps its CRLF line endings and its final newline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const {
|
||||
contentSha,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
looksBinary,
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
} = require("./git-diff");
|
||||
|
||||
function bad(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite `requested` in lane `dir` with `content`. Throws EBADPATH,
|
||||
* ESECRET, ENOFILE, EBADCONTENT, ETOOBIG, EBINARY or ESTALE.
|
||||
*/
|
||||
async function writeLaneFile(dir, requested, content, baseSha) {
|
||||
const file = lanePath(requested);
|
||||
if (await isSecretOnDisk(dir, file)) {
|
||||
throw bad("ESECRET", "that file holds secrets and is not writable here");
|
||||
}
|
||||
if (typeof content !== "string") throw bad("EBADCONTENT", "content must be a string");
|
||||
if (typeof baseSha !== "string" || !/^[0-9a-f]{40}$/.test(baseSha)) {
|
||||
throw bad("EBADCONTENT", "baseSha is required");
|
||||
}
|
||||
if (Buffer.byteLength(content) > MAX_FILE_VIEW_BYTES) {
|
||||
throw bad("ETOOBIG", "file is too large to edit here");
|
||||
}
|
||||
const abs = await resolveInLane(dir, file);
|
||||
|
||||
let current;
|
||||
try {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
if (!stat.isFile()) throw bad("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file is too large to edit here");
|
||||
current = await fs.promises.readFile(abs);
|
||||
} catch (err) {
|
||||
if (err.code === "ETOOBIG" || err.code === "ENOFILE") throw err;
|
||||
throw bad("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
if (looksBinary(current)) throw bad("EBINARY", "binary files are not editable here");
|
||||
if (contentSha(current) !== baseSha) {
|
||||
throw bad("ESTALE", "the file changed on disk since it was opened — reload it first");
|
||||
}
|
||||
|
||||
// A browser textarea hands back LF only, and the viewer drops the final
|
||||
// newline: put back what the file had, so a one-word fix is a one-line diff.
|
||||
const text = current.toString("utf8");
|
||||
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
||||
let out = content.replace(/\r\n/g, "\n");
|
||||
if (eol === "\r\n") out = out.replace(/\n/g, "\r\n");
|
||||
if (text.endsWith("\n") && out && !out.endsWith(eol)) out += eol;
|
||||
|
||||
await fs.promises.writeFile(abs, out);
|
||||
return { path: file };
|
||||
}
|
||||
|
||||
module.exports = { writeLaneFile };
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
const tmux = require("./tmux");
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-\d+$/;
|
||||
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
|
||||
|
||||
/**
|
||||
* Reject anything that isn't exactly `agenthub-lane-<digits>` before it can ever
|
||||
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-3>]` before it can ever
|
||||
* reach a tmux/PTY command — the trust boundary for this WS path, since a
|
||||
* validated runId is the only thing standing between an authenticated WS
|
||||
* client and naming an arbitrary session on the host.
|
||||
|
||||
+21
-4
@@ -21,6 +21,10 @@
|
||||
*
|
||||
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
|
||||
*
|
||||
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
|
||||
* `agenthub-lane-<laneId>-<slot>`.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -35,7 +39,9 @@ try {
|
||||
/* db-less environment, skip persistence */
|
||||
}
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
|
||||
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
|
||||
const MAX_PANE_SLOT = 3;
|
||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||||
// it asks only for what its classifier reads as risky. `default` is kept for
|
||||
@@ -55,8 +61,13 @@ const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
|
||||
|
||||
function runIdForLane(laneId) {
|
||||
return `agenthub-lane-${laneId}`;
|
||||
function runIdForLane(laneId, slot = 0) {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
function slotFromRunId(id) {
|
||||
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
|
||||
return m ? Number(m[2] || 0) : null;
|
||||
}
|
||||
|
||||
function laneIdFromRunId(id) {
|
||||
@@ -149,6 +160,7 @@ function buildArgv(args) {
|
||||
function spawnRun(args) {
|
||||
const {
|
||||
laneId,
|
||||
slot = 0,
|
||||
cwd,
|
||||
tool = "claude",
|
||||
model,
|
||||
@@ -164,6 +176,9 @@ function spawnRun(args) {
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
}
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
|
||||
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
|
||||
}
|
||||
if (typeof cwd !== "string" || !cwd) {
|
||||
throw makeErr("EBADCWD", "cwd is required");
|
||||
}
|
||||
@@ -215,7 +230,7 @@ function spawnRun(args) {
|
||||
);
|
||||
}
|
||||
|
||||
const id = runIdForLane(laneId);
|
||||
const id = runIdForLane(laneId, slot);
|
||||
const startedAt = Date.now();
|
||||
const argv = buildArgv({
|
||||
tool,
|
||||
@@ -323,6 +338,7 @@ function publicRun(id) {
|
||||
return {
|
||||
id,
|
||||
laneId,
|
||||
slot: slotFromRunId(id),
|
||||
status: live ? "running" : "gone",
|
||||
tool: row?.tool || "claude",
|
||||
cwd: row?.cwd || null,
|
||||
@@ -353,5 +369,6 @@ module.exports = {
|
||||
listRuns,
|
||||
laneIdFromRunId,
|
||||
runIdForLane,
|
||||
MAX_PANE_SLOT,
|
||||
SHELL_COMMANDS,
|
||||
};
|
||||
|
||||
+77
-21
@@ -27,6 +27,7 @@ const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { applyRouterToLane } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
LANES_ROOT,
|
||||
@@ -855,6 +856,26 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Save one text file in the lane from the viewer's editor. Guards live in
|
||||
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
|
||||
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
|
||||
*/
|
||||
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
const body = req.body || {};
|
||||
try {
|
||||
await writeLaneFile(lane.cwd, body.path, body.content, body.baseSha);
|
||||
res.json({ available: true, ...(await readFileAt(lane.cwd, body.path, "work")) });
|
||||
} catch (err) {
|
||||
const status =
|
||||
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
|
||||
(["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500);
|
||||
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/** Tracked paths matching `?q=`, for quick-open. */
|
||||
router.get("/:id/git/ls", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
@@ -877,7 +898,7 @@ router.get("/:id/git/tree-all", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** One directory level of tracked files, for the file explorer. */
|
||||
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
|
||||
router.get("/:id/git/tree", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
@@ -1165,26 +1186,44 @@ 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. */
|
||||
/** Every tmux session a lane holds: its recorded `run_id` plus the extra
|
||||
* split-pane sessions (`agenthub-lane-<id>-<slot>`), which never touch it. */
|
||||
function laneRunIds(lane) {
|
||||
const ids = new Set(
|
||||
runs
|
||||
.listRuns()
|
||||
.filter((r) => r.laneId === lane.id)
|
||||
.map((r) => r.id)
|
||||
);
|
||||
if (lane.run_id) ids.add(lane.run_id);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Kill a lane's runs and wait for their tmux sessions to exit before touching its cwd. */
|
||||
async function stopLaneRun(lane) {
|
||||
if (!lane.run_id) return;
|
||||
try {
|
||||
runs.killRun(lane.run_id);
|
||||
} catch {
|
||||
/* a concurrently completed run is already safe */
|
||||
const ids = laneRunIds(lane);
|
||||
if (ids.length === 0) return;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
runs.killRun(id);
|
||||
} catch {
|
||||
/* a concurrently completed run is already safe */
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
|
||||
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`
|
||||
);
|
||||
for (const id of ids) {
|
||||
let run = runs.getRun(id);
|
||||
while (run && run.status !== "gone") {
|
||||
if (Date.now() >= deadline) {
|
||||
throw lifecycleError(
|
||||
"ERUNTIMEOUT",
|
||||
`lane run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
|
||||
);
|
||||
}
|
||||
await wait(RUN_EXIT_POLL_MS);
|
||||
run = runs.getRun(id);
|
||||
}
|
||||
await wait(RUN_EXIT_POLL_MS);
|
||||
run = runs.getRun(lane.run_id);
|
||||
}
|
||||
lanesLib.updateLane(lane.id, { run_id: null });
|
||||
}
|
||||
@@ -1587,9 +1626,18 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// The pane a `start` targets: 0 is the lane's own session (`run_id`), 1–3
|
||||
// are the Workspace split panes' extra sessions on the same lane.
|
||||
let startedRunId = null;
|
||||
try {
|
||||
switch (action) {
|
||||
case "start": {
|
||||
const slot = body.slot === undefined ? 0 : body.slot;
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||||
});
|
||||
}
|
||||
// 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
|
||||
@@ -1603,12 +1651,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// 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;
|
||||
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id;
|
||||
const live = liveId ? runs.getRun(liveId) : null;
|
||||
if (live && live.status === "running") {
|
||||
return { conflict: true };
|
||||
}
|
||||
const handle = runs.spawnRun({
|
||||
laneId: current.id,
|
||||
slot,
|
||||
initialPrompt: body.prompt || "",
|
||||
cwd: current.cwd,
|
||||
// The tool is the LANE's fact, never the request's: a client that
|
||||
@@ -1626,7 +1676,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
resumeLast: body.resumeLast === true,
|
||||
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
|
||||
});
|
||||
lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||
startedRunId = handle.id;
|
||||
// An extra pane's session is not the lane's run: `run_id` stays the
|
||||
// pane-0 session every other path (reset, router restart) names.
|
||||
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||
return {};
|
||||
});
|
||||
if (outcome.missing) {
|
||||
@@ -1641,9 +1694,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
case "stop": {
|
||||
// A lane with no live run is already stopped — say so, don't 500.
|
||||
if (lane.run_id) {
|
||||
for (const id of laneRunIds(lane)) {
|
||||
try {
|
||||
runs.killRun(lane.run_id);
|
||||
runs.killRun(id);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
@@ -1677,7 +1730,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
|
||||
broadcastLane(lane.id);
|
||||
res.json({ lane: payload(lanesLib.getLane(lane.id)) });
|
||||
res.json({
|
||||
lane: payload(lanesLib.getLane(lane.id)),
|
||||
...(startedRunId ? { run_id: startedRunId } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ function initWebSocket(server) {
|
||||
}
|
||||
|
||||
let ptyWss = null;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+)$/;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-3])?)$/;
|
||||
|
||||
/**
|
||||
* The attaching terminal's own size, off the attach URL's query. tmux resizes
|
||||
|
||||
Reference in New Issue
Block a user