feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's groups drawn as a folder tree (compacted chains, file counts, fold, stage/unstage a whole folder) - AI-suggested commit message for what is staged (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku) - AI Router: optional "Behaves as" Claude model id, written as modelPicker options into the router's --settings file (issue #56) - Sidebar: session rows show last activity, live rows their start time; a closed Terminal row can be removed (DELETE /api/run/history/:id) - Every pane's CLI starts only once a client is attached; claude panes no longer forced inline; tmux scrollbar one column wide - Update notifier: "Check for updates" reopens a closed notice
This commit is contained in:
@@ -164,6 +164,21 @@ describe("ai-routers — update", () => {
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||||
});
|
||||
|
||||
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
|
||||
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
|
||||
assert.equal(created.behavesAs, "claude-opus-5-5");
|
||||
assert.equal(
|
||||
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
|
||||
"claude-opus-5-5"
|
||||
);
|
||||
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
|
||||
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
|
||||
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
|
||||
code: "EBADMODEL",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — remove", () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file Tests for server/lib/commit-message.js against a real git repo and a
|
||||
* fake `claude` script: only staged, in-lane, non-secret changes reach the
|
||||
* model; its argv keeps tools, hooks and persistence off; its answer is
|
||||
* cleaned; and every failure is a coded error.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { suggestCommitMessage, buildInput } = require("../lib/commit-message");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commitmsg-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
const SUB = path.join(REPO, "sub");
|
||||
const ARGS = path.join(ROOT, "args.txt");
|
||||
const STDIN = path.join(ROOT, "stdin.txt");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
execFileSync("git", ["-c", "user.email=t@h", "-c", "user.name=t", ...args], { cwd: REPO, env });
|
||||
};
|
||||
const write = (file, body) => {
|
||||
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(REPO, file), body);
|
||||
};
|
||||
/** A fake claude: records argv and stdin, prints `reply`, exits `code`. */
|
||||
function fakeClaude(reply, code = 0) {
|
||||
const bin = path.join(ROOT, `claude-${Math.random().toString(36).slice(2)}`);
|
||||
fs.writeFileSync(
|
||||
bin,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$@" > '${ARGS}'\ncat > '${STDIN}'\nprintf '${reply}'\nexit ${code}\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
return bin;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
fs.mkdirSync(REPO);
|
||||
g("init", "-q", "-b", "main");
|
||||
write("a.txt", "one\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-q", "-m", "feat: seed");
|
||||
});
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
describe("commit-message", () => {
|
||||
it("refuses with ENOSTAGED when nothing is staged", async () => {
|
||||
write("a.txt", "one\nunstaged\n");
|
||||
await assert.rejects(buildInput(REPO), { code: "ENOSTAGED" });
|
||||
});
|
||||
|
||||
it("sends only staged, non-secret changes and keeps tools/hooks/persistence off", async () => {
|
||||
write("b.txt", "staged line\n");
|
||||
write(".claude/settings.local.json", '{"env":{"KEY":"sk-secret"}}\n');
|
||||
g("add", "-f", "b.txt", ".claude/settings.local.json");
|
||||
const msg = await suggestCommitMessage(REPO, { bin: fakeClaude("```\\nfeat: add b\\n```") });
|
||||
assert.equal(msg, "feat: add b");
|
||||
const stdin = fs.readFileSync(STDIN, "utf8");
|
||||
assert.match(stdin, /feat: seed/);
|
||||
assert.match(stdin, /staged line/);
|
||||
assert.doesNotMatch(stdin, /unstaged|sk-secret|settings\.local/);
|
||||
const argv = fs.readFileSync(ARGS, "utf8").split("\n");
|
||||
for (const flag of ["-p", "--safe-mode", "--no-session-persistence", "--tools"]) {
|
||||
assert.ok(argv.includes(flag), flag);
|
||||
}
|
||||
assert.equal(argv[argv.indexOf("--tools") + 1], "");
|
||||
});
|
||||
|
||||
it("scopes a subdirectory lane to its own staged files", async () => {
|
||||
write("sub/c.txt", "inside\n");
|
||||
g("add", "sub/c.txt");
|
||||
const input = await buildInput(SUB);
|
||||
assert.match(input, /inside/);
|
||||
assert.doesNotMatch(input, /staged line/);
|
||||
});
|
||||
|
||||
it("reports a failing or silent CLI as EAI", async () => {
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
|
||||
code: "EAI",
|
||||
});
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("") }), { code: "EAI" });
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,15 @@ describe("dashboard-runs", () => {
|
||||
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
|
||||
});
|
||||
|
||||
it("forgets a closed Terminal tab and nothing of another tool", () => {
|
||||
start("agenthub-lane-9", { laneId: 9, tool: "claude" });
|
||||
start("agenthub-lane-9-1", { laneId: 9, tool: "shell" });
|
||||
assert.equal(runs.forgetShellRun("agenthub-lane-9"), false);
|
||||
assert.ok(runs.getRun("agenthub-lane-9"));
|
||||
assert.equal(runs.forgetShellRun("agenthub-lane-9-1"), true);
|
||||
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -185,7 +185,8 @@ describe("lane-sources instructions and launch", () => {
|
||||
let argv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
|
||||
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
||||
|
||||
@@ -1105,6 +1105,11 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
|
||||
assert.equal(bogus.status, 400);
|
||||
assert.equal(bogus.body.error.code, "EBADACTION");
|
||||
|
||||
// Its own route, not an action: never reaches claude with nothing staged.
|
||||
const suggest = await request("POST", `/api/lanes/${laneId}/git/commit-message`, {});
|
||||
assert.equal(suggest.status, 400);
|
||||
assert.equal(suggest.body.error.code, "ENOSTAGED");
|
||||
});
|
||||
|
||||
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
|
||||
|
||||
@@ -75,11 +75,9 @@ describe("pty-run", () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
|
||||
const flag = path.join(bin, "attached");
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "tmux"),
|
||||
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
fs.writeFileSync(path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const child = spawn("sh", ["-c", argv[2]], {
|
||||
@@ -492,11 +490,43 @@ describe("pty-run", () => {
|
||||
return calls.find((c) => c[0] === "new-session");
|
||||
}
|
||||
|
||||
/** The pane's own command line — everything tmux passes after the `--`. */
|
||||
/** The CLI's own command line: what tmux passes after the `--`, less the
|
||||
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
|
||||
function paneArgv(newSessionCall) {
|
||||
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
||||
assert.match(argv[2], /session_attached.*exec "\$@"$/);
|
||||
return argv.slice(4);
|
||||
}
|
||||
|
||||
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
|
||||
// A claude drawn at tmux's birth size duplicated its banner on the first
|
||||
// resize; the argv rides as positional parameters, never script text.
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
|
||||
);
|
||||
const argv = paneArgv(call);
|
||||
assert.ok(argv.includes("a'; rm -rf / #"));
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const out = execFileSync(
|
||||
"sh",
|
||||
[
|
||||
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
||||
"sh",
|
||||
"printf",
|
||||
"%s|",
|
||||
"x y",
|
||||
"$HOME",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
assert.equal(out, "x y|$HOME|");
|
||||
});
|
||||
|
||||
it("spawnRun builds a codex argv for a codex lane", () => {
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
@@ -583,15 +613,11 @@ describe("pty-run", () => {
|
||||
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
|
||||
});
|
||||
|
||||
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
|
||||
it("leaves a claude pane's screen mode to the user's own tui setting", () => {
|
||||
// Forcing it inline duplicated the transcript on every pane narrowing.
|
||||
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
|
||||
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
|
||||
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
|
||||
tmux.__reset();
|
||||
const codex = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
|
||||
);
|
||||
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
|
||||
assert.ok(!envFlags(claude).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
|
||||
});
|
||||
|
||||
describe("catalog tools", () => {
|
||||
@@ -719,7 +745,11 @@ describe("pty-run", () => {
|
||||
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
live = true;
|
||||
t.mock.timers.tick(3000);
|
||||
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
|
||||
// waiting for a client to attach.
|
||||
t.mock.timers.tick(4999);
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
t.mock.timers.tick(1);
|
||||
assert.deepEqual(
|
||||
calls.find((c) => c[0] === "set-buffer"),
|
||||
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
|
||||
|
||||
@@ -119,6 +119,33 @@ describe("routerForSpawn — Claude", () => {
|
||||
it("adds nothing for native", () => {
|
||||
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
|
||||
});
|
||||
|
||||
it("writes no modelPicker without behavesAs, so the user's own one still applies", () => {
|
||||
const { name } = saveNineRouter();
|
||||
spawnFor("claude", name);
|
||||
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
|
||||
assert.equal("modelPicker" in written, false);
|
||||
});
|
||||
|
||||
it("maps every distinct router model name with behavesAs (issue #56)", () => {
|
||||
const { name } = routers.createRouter({
|
||||
label: "9Router combo",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "agent",
|
||||
models: { haiku: "agent-fast", subagent: "claude-haiku-4-5-20251001" },
|
||||
behavesAs: "claude-opus-5-5",
|
||||
});
|
||||
spawnFor("claude", name);
|
||||
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
|
||||
// A bare claude-* id is one Claude Code knows; mapping it would override
|
||||
// its real handling.
|
||||
assert.deepEqual(written.modelPicker, {
|
||||
options: [
|
||||
{ model: "agent", behavesAs: "claude-opus-5-5" },
|
||||
{ model: "agent-fast", behavesAs: "claude-opus-5-5" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("routerForSpawn — Codex", () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("tmux wrapper", () => {
|
||||
"-t",
|
||||
"=agenthub-lane-1:",
|
||||
"pane-scrollbars-style",
|
||||
"fg=brightwhite,bg=black,width=2,pad=0",
|
||||
"fg=brightwhite,bg=black,width=1,pad=0",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -189,6 +189,21 @@ function normalizeModels(model, models) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A known Claude model id that Claude Code should treat the router's own model
|
||||
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
|
||||
* The user picks it, never AgentHub: it also sets the context window Claude
|
||||
* Code compacts at, and a router may route a name to a non-Claude model.
|
||||
*/
|
||||
function normalizeBehavesAs(value) {
|
||||
const clean = typeof value === "string" ? value.trim() : null;
|
||||
if (clean === "") return "";
|
||||
if (!clean || !/^claude-[a-z0-9][a-z0-9.-]{0,99}$/.test(clean)) {
|
||||
throw err("EBADMODEL", "behavesAs must be a Claude model id, e.g. claude-opus-5-5");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function assertNotBuiltin(name) {
|
||||
if (name === BUILTIN_NAME) {
|
||||
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
|
||||
@@ -250,7 +265,7 @@ function getRouter(name) {
|
||||
|
||||
/**
|
||||
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
||||
* models?:Record<string,string>}} input
|
||||
* models?:Record<string,string>, behavesAs?:string}} input
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createRouter(input = {}) {
|
||||
@@ -265,6 +280,7 @@ function createRouter(input = {}) {
|
||||
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
|
||||
model,
|
||||
models: normalizeModels(model, input.models),
|
||||
behavesAs: input.behavesAs === undefined ? "" : normalizeBehavesAs(input.behavesAs),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -297,6 +313,10 @@ function updateRouter(name, changes = {}) {
|
||||
changes.model === undefined && changes.models === undefined
|
||||
? stored.models
|
||||
: normalizeModels(model, changes.models ?? stored.models),
|
||||
behavesAs:
|
||||
changes.behavesAs === undefined
|
||||
? (stored.behavesAs ?? "")
|
||||
: normalizeBehavesAs(changes.behavesAs),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @file commit-message.js
|
||||
* @description Suggests a commit message for a lane's STAGED changes with the
|
||||
* user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with
|
||||
* no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on
|
||||
* disk (`--no-session-persistence`), so the call neither acts on the repo nor
|
||||
* shows up as a session in the dashboard. Read-only on git; the diff is scoped
|
||||
* to the lane's cwd and never carries a secret file (`isSecretOnDisk`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const worktree = require("./worktree");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { isSecretOnDisk } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
|
||||
|
||||
/** Diff characters sent to the model; the rest is cut and marked. */
|
||||
const MAX_DIFF_CHARS = 60_000;
|
||||
/** A `claude -p` that has not answered by then is killed. */
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
const PROMPT =
|
||||
"Write a git commit message for the staged diff given on stdin. " +
|
||||
"Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " +
|
||||
"matching the style of the recent subjects listed there. Add a short body after a blank line only " +
|
||||
"when the change needs explaining. Output ONLY the commit message — no code fences, no commentary.";
|
||||
|
||||
function bad(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/** The text the model reads: recent subjects, the staged file list, the diff. */
|
||||
async function buildInput(dir) {
|
||||
const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const paths = [];
|
||||
for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p);
|
||||
if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace");
|
||||
|
||||
let diff;
|
||||
try {
|
||||
diff = (
|
||||
await git(
|
||||
dir,
|
||||
["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths],
|
||||
{ maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 }
|
||||
)
|
||||
).stdout;
|
||||
} catch (err) {
|
||||
// An oversized diff: git's partial output is still the useful head.
|
||||
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
|
||||
diff = String(err.git.stdout);
|
||||
}
|
||||
if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
|
||||
|
||||
const log = await git(dir, ["log", "-10", "--format=%s"])
|
||||
.then((r) => r.stdout.trim())
|
||||
.catch(() => ""); // no commit yet
|
||||
|
||||
return [
|
||||
log ? `Recent commit subjects:\n${log}` : "",
|
||||
`Staged files:\n${paths.join("\n")}`,
|
||||
`Staged diff:\n${diff}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/** Run `bin` with `args`, `input` on stdin; resolve its stdout. */
|
||||
function run(bin, args, input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let errOut = "";
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (errOut += d));
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(bad("EAI", err.message));
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) return resolve(out);
|
||||
const why = signal ? "timed out" : (errOut || out).trim().slice(-500) || `exit ${code}`;
|
||||
reject(bad("EAI", `claude failed: ${why}`));
|
||||
});
|
||||
child.stdin.on("error", () => {}); // a CLI that exits early closes stdin
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested message for what `dir` has staged.
|
||||
* @param {string} dir the lane's (or session worktree's) cwd
|
||||
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function suggestCommitMessage(dir, { bin } = {}) {
|
||||
const input = await buildInput(dir);
|
||||
const claude = bin || aiTools.detect("claude", { fresh: true }).path;
|
||||
if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH");
|
||||
const out = await run(
|
||||
claude,
|
||||
["-p", "--safe-mode", "--no-session-persistence", "--tools", "", "--model", "haiku", PROMPT],
|
||||
input
|
||||
);
|
||||
const message = out
|
||||
.trim()
|
||||
.replace(/^```[a-z]*\n?|\n?```$/g, "")
|
||||
.trim();
|
||||
if (!message) throw bad("EAI", "claude returned an empty message");
|
||||
return message;
|
||||
}
|
||||
|
||||
module.exports = { suggestCommitMessage, buildInput };
|
||||
@@ -165,6 +165,14 @@ function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const forgetShellStmt = db.prepare(`DELETE FROM dashboard_runs WHERE id = ? AND tool = 'shell'`);
|
||||
|
||||
/** Drop a closed Terminal tab's row — a shell has no session, so the row is
|
||||
* only the sidebar's "recently closed" entry. Never touches another tool's. */
|
||||
function forgetShellRun(id) {
|
||||
return forgetShellStmt.run(id).changes > 0;
|
||||
}
|
||||
|
||||
function getRun(id) {
|
||||
try {
|
||||
return getStmt.get({ id }) || null;
|
||||
@@ -281,6 +289,7 @@ module.exports = {
|
||||
patchRun,
|
||||
listRuns,
|
||||
getRun,
|
||||
forgetShellRun,
|
||||
reconcileOrphans,
|
||||
routerForSession,
|
||||
laneForSession,
|
||||
|
||||
+37
-19
@@ -60,6 +60,23 @@ const ALLOWED_PERMISSION_MODES = new Set([
|
||||
]);
|
||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/**
|
||||
* `sh` that waits until a client is attached to the pane — 2 s at most
|
||||
* ({@link CLIENT_WAIT_MS}) — before what follows it runs. A session is born
|
||||
* 80×24 and takes the browser's size only on attach (and `enableMouse`'s
|
||||
* scrollbar narrows it before that). Anything drawn before then is wrong:
|
||||
* a shell's prompt (issue #55), and an inline `claude`'s banner — tmux
|
||||
* re-wraps the 80-column rules into two lines each on the resize, pushing the
|
||||
* banner's first lines into history, and claude's redraw clears only the
|
||||
* visible screen, so they stay above it as a duplicate. Not "the first
|
||||
* SIGWINCH": the scrollbar fires one before any client is there. A tmux that
|
||||
* cannot answer ends the wait at once, i.e. the old behaviour.
|
||||
*/
|
||||
const WAIT_FOR_CLIENT =
|
||||
'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' +
|
||||
" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;";
|
||||
const CLIENT_WAIT_MS = 2000;
|
||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||
@@ -452,11 +469,10 @@ function spawnRun(args) {
|
||||
// journals and the router layer `-p <name>` loads live there — and the
|
||||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||
// Claude Code inline even under `"tui": "fullscreen"`: on the alternate
|
||||
// screen tmux keeps no history and draws no pane scrollbar, so the
|
||||
// dashboard terminal had nothing to drag. The user's own terminals keep
|
||||
// their setting — this reaches only panes AgentHub starts.
|
||||
...(tool === "claude" ? { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
||||
...routerSpawn.env,
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
@@ -472,12 +488,18 @@ function spawnRun(args) {
|
||||
} catch {
|
||||
/* pane gone mid-paste — nothing to deliver to */
|
||||
}
|
||||
}, entry.prompt.delayMs);
|
||||
}, entry.prompt.delayMs + CLIENT_WAIT_MS); // the CLI may start up to that late
|
||||
if (timer.unref) timer.unref();
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
tmux.newSession({ name: id, cwd, argv, env });
|
||||
// argv rides as sh's positional parameters, never inside the script text.
|
||||
tmux.newSession({
|
||||
name: id,
|
||||
cwd,
|
||||
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv],
|
||||
env,
|
||||
});
|
||||
record();
|
||||
schedulePaste();
|
||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||
@@ -530,18 +552,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
};
|
||||
// The session is born 80×24 and only takes the browser's size when the pane
|
||||
// attaches. A shell started at once printed its prompt at 80 columns (a long
|
||||
// cwd wrapped), and readline's redraw after the resize left the cursor on top
|
||||
// of the prompt (issue #55). So wait until a client is attached — 2 s at
|
||||
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
|
||||
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
|
||||
// resizes the pane before any client is there. A tmux that cannot answer
|
||||
// ends the wait at once, i.e. the old behaviour.
|
||||
const waitForSize =
|
||||
"i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" +
|
||||
' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l';
|
||||
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
|
||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||||
tmux.newSession({
|
||||
name: id,
|
||||
cwd,
|
||||
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`],
|
||||
env,
|
||||
});
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* theirs, not a choice made here:
|
||||
*
|
||||
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
||||
* profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
|
||||
* profile's eight `env` keys (plus `modelPicker` rows when the profile
|
||||
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
|
||||
* (0600). The command-line layer outranks the project-local and user
|
||||
* settings (verified on 2.1.286), so two sessions of one workspace can run
|
||||
* on two routers, and the key stays out of argv — only the path is there.
|
||||
@@ -73,12 +74,29 @@ function writeSecret(file, body) {
|
||||
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
|
||||
}
|
||||
|
||||
/**
|
||||
* `modelPicker` rows telling Claude Code which known model each of the router's
|
||||
* own names behaves as — without one, a name like 9Router's `agent` draws an
|
||||
* "isn't described by this version's model catalog" warning and a 200k
|
||||
* compact window (issue #56). Only when the profile sets `behavesAs`: this
|
||||
* layer's `modelPicker` replaces the user's own for the session. A bare
|
||||
* `claude-*` id is skipped, since Claude Code already knows it.
|
||||
*/
|
||||
function modelPicker(profile) {
|
||||
if (!profile.behavesAs) return {};
|
||||
const names = new Set([profile.model, ...Object.values(profile.models || {})]);
|
||||
const options = [...names]
|
||||
.filter((m) => m && !m.startsWith("claude-"))
|
||||
.map((model) => ({ model, behavesAs: profile.behavesAs }));
|
||||
return options.length ? { modelPicker: { options } } : {};
|
||||
}
|
||||
|
||||
function writeClaudeSettings(profile) {
|
||||
const file = claudeSettingsPath(profile.name);
|
||||
// Empty values are left out: a blank key in the command-line layer would
|
||||
// blank the user's own setting, not leave it alone.
|
||||
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
|
||||
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
|
||||
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -99,13 +99,15 @@ function enableMouse(name) {
|
||||
execImpl(["set-option", "-t", pane(name), "pane-scrollbars", "on"]);
|
||||
// tmux's default (`fg=white,bg=black,width=1`) is a one-column track in
|
||||
// ANSI black — the terminal theme's border colour, almost the background —
|
||||
// so it reads as no scrollbar at all. Two columns, bright slider.
|
||||
// so it reads as no scrollbar at all. Bright slider. ONE column: tmux 3.7c
|
||||
// reserves `width` columns but paints only the first, so a second one is
|
||||
// never cleared and shows stale cells — a ghost second scrollbar.
|
||||
execImpl([
|
||||
"set-option",
|
||||
"-t",
|
||||
pane(name),
|
||||
"pane-scrollbars-style",
|
||||
"fg=brightwhite,bg=black,width=2,pad=0",
|
||||
"fg=brightwhite,bg=black,width=1,pad=0",
|
||||
]);
|
||||
} catch {
|
||||
/* tmux < 3.7 */
|
||||
|
||||
@@ -674,6 +674,37 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/commit-message": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Suggest a commit message for the staged changes",
|
||||
description:
|
||||
"Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools \"\" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.",
|
||||
operationId: "suggestLaneCommitMessage",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "The suggested message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { description: "ENOSTAGED / ENOTOOL / ENOWORKTREE" },
|
||||
403: { description: "Cross-origin request refused" },
|
||||
404: { description: "No such workspace" },
|
||||
502: { description: "EAI — the claude CLI failed" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/{action}": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
@@ -857,6 +857,45 @@ const paths = {
|
||||
},
|
||||
},
|
||||
|
||||
"/api/run/history/{id}": {
|
||||
delete: {
|
||||
tags: ["Run"],
|
||||
summary: "Forget a closed Terminal tab",
|
||||
description:
|
||||
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
|
||||
operationId: "runHistoryForget",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "The history row's id (RunHistoryItem.id).",
|
||||
example: "agenthub-lane-5-2@2026-10-06T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Removed",
|
||||
content: { "application/json": { example: { removed: "agenthub-lane-5-2" } } },
|
||||
},
|
||||
403: ebadOrigin403,
|
||||
404: {
|
||||
description: "No closed Terminal tab with that id (ENOTFOUND)",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "That terminal is still open (ERUNLIVE)",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/run/cwds": {
|
||||
get: {
|
||||
tags: ["Run"],
|
||||
|
||||
@@ -15,6 +15,7 @@ const path = require("node:path");
|
||||
const { db } = require("../db");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
|
||||
const { suggestCommitMessage } = require("../lib/commit-message");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const {
|
||||
listPipelines,
|
||||
@@ -1061,6 +1062,27 @@ router.get("/:id/git/branches", async (req, res) => {
|
||||
* rule about what each action may do lives in server/lib/git-ops.js, not here.
|
||||
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
||||
*/
|
||||
/**
|
||||
* A commit message suggested by the user's `claude` CLI for what is STAGED
|
||||
* (server/lib/commit-message.js). Not a git write — it only reads the index —
|
||||
* but same-origin, because each call spends the user's Claude quota.
|
||||
* Registered before `/:id/git/:action`, which would answer EBADACTION.
|
||||
*/
|
||||
router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json({ message: await suggestCommitMessage(t.cwd) });
|
||||
} catch (err) {
|
||||
const status = ["ENOSTAGED", "ENOTOOL"].includes(err.code)
|
||||
? 400
|
||||
: err.code === "EAI"
|
||||
? 502
|
||||
: 500;
|
||||
res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
|
||||
@@ -158,6 +158,23 @@ router.get("/history", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Forget one closed Terminal tab (`tool = 'shell'`) from the history — the
|
||||
* sidebar's remove. A live pane is refused; another tool's row is a 404.
|
||||
*/
|
||||
router.delete("/history/:id", (req, res) => {
|
||||
if (runs.listRuns().some((h) => h.id === req.params.id)) {
|
||||
return res.status(409).json({ error: { code: "ERUNLIVE", message: "terminal is still open" } });
|
||||
}
|
||||
const dr = require("../lib/dashboard-runs");
|
||||
if (!dr.forgetShellRun(req.params.id)) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOTFOUND", message: "no closed terminal with that id" } });
|
||||
}
|
||||
res.json({ removed: req.params.id });
|
||||
});
|
||||
|
||||
/**
|
||||
* Suggest plausible working directories. Pulls from:
|
||||
* - "dashboard": the dashboard server's cwd
|
||||
|
||||
Reference in New Issue
Block a user