feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+179 -11
View File
@@ -1,6 +1,8 @@
/**
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
* stage/unstage a file, commit, discard a file, switch branch, create or
* stage/unstage a file, commit (the index, everything, or exactly the ticked
* `paths`), discard a file, add an untracked file to the workspace's
* .gitignore (`ignore`), switch branch, create or
* delete a branch, publish a repo that has no remote yet, and merge a branch
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
@@ -59,7 +61,7 @@ const {
operationInProgress,
branchRef,
} = require("./worktree");
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const { hunkPatch, selectionPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const gitCredentials = require("./git-credentials");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
@@ -91,6 +93,7 @@ const ACTIONS = new Set([
"revert",
"undo-commit",
"amend",
"ignore",
]);
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
@@ -476,6 +479,11 @@ async function runGitAction(dir, action, body = {}) {
// would switch it to committing the working-tree copy (unstaged edits
// included) — so a subdirectory lane refuses rather than sweep another
// actor's staged work outside it into this lane's commit.
// `paths` (the panel's ticked files): exactly those, whatever the index
// holds — `--only` leaves every other change, staged or not, outside.
if (hasHunks(body)) return done(action, await commitSelection(dir, ["-m", message], body));
if (Array.isArray(body.paths))
return done(action, await commitPaths(dir, ["-m", message], body));
await assertNothingStagedOutside(dir);
if (body.only === "staged") {
// The panel's per-file staging only means something if a commit can
@@ -497,6 +505,8 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await git(dir, ["commit", "-m", message]));
}
if (action === "ignore") return ignorePaths(dir, body);
if (action === "branch-create" || action === "branch-delete") {
return branchAction(dir, action, body);
}
@@ -627,7 +637,9 @@ async function commitSha(dir, sha) {
/** How many parents a commit has (0 = root, 2+ = a merge commit). */
async function parentCount(dir, sha) {
return (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1;
return (
(await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1
);
}
/**
@@ -646,7 +658,13 @@ async function assertUnpushedHead(dir, sha) {
throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace");
}
const on = (
await git(dir, ["for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes"])
await git(dir, [
"for-each-ref",
"--contains",
"HEAD",
"--format=%(refname:short)",
"refs/remotes",
])
).stdout.trim();
if (on) {
throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`);
@@ -670,7 +688,8 @@ async function historyAction(dir, action, body) {
const open = await operationInProgress(dir);
const conflicts = open ? await unmergedPaths(dir) : [];
// Conflicts: an operation that STARTED, finished from the banner.
if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
if (conflicts.length)
return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
// Nothing to resolve, so nothing may be left behind either: a pick
// whose changes are already here (EEMPTY), or a commit git could not
// make (no identity, a failing signer) — which can leave the changes
@@ -687,8 +706,14 @@ async function historyAction(dir, action, body) {
.split("\0")
.filter(Boolean);
if (touched.length) {
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched])
.catch(() => {});
await git(dir, [
"restore",
"--source=HEAD",
"--staged",
"--worktree",
"--",
...touched,
]).catch(() => {});
}
if (empty) throw bad("EEMPTY", "those changes are already on this branch");
throw e;
@@ -709,19 +734,162 @@ async function historyAction(dir, action, body) {
// amend: the index as it stands folds into the last commit, like `commit`
// with `only: "staged"`; no message keeps the old one.
await assertUnpushedHead(dir, body.sha);
await assertNothingStagedOutside(dir);
const message = typeof body.message === "string" ? body.message.trim() : "";
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
const msgArgs = message ? ["-m", message] : ["--no-edit"];
if (hasHunks(body)) {
return done(action, await commitSelection(dir, ["--amend", ...msgArgs], body));
}
if (Array.isArray(body.paths)) {
// No ticked file: `--only` with no path rewords and leaves the index out.
if (!body.paths.length) {
if (!message) throw bad("ENOCHANGES", "no file ticked and no new message to amend with");
return done(action, await git(dir, ["commit", "--amend", "--only", ...msgArgs]));
}
return done(action, await commitPaths(dir, ["--amend", ...msgArgs], body));
}
await assertNothingStagedOutside(dir);
if (!message) {
const staged = [...(await changedPaths(dir)).values()].some(
({ code }) => code[0] !== " " && code[0] !== "?"
);
if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with");
}
return done(
action,
await git(dir, ["commit", "--amend", ...(message ? ["-m", message] : ["--no-edit"])])
return done(action, await git(dir, ["commit", "--amend", ...msgArgs]));
}
/**
* `git commit --only -- <paths>`: the working-tree content of exactly the
* paths the request ticked (JetBrains' commit), each one `git status` reports
* changed. An untracked one is added first — `--only` takes only paths git
* knows — and taken back out of the index if the commit then fails.
*/
async function commitPaths(dir, args, body) {
const paths = await resolvePaths(dir, body);
const conflicted = paths.find((p) => CONFLICT_CODES.has(p.code));
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
// A rename's original path rides along, or its deletion stays behind.
const all = paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
if (untracked.length) await git(dir, ["add", "--", ...untracked]);
try {
return await git(dir, ["commit", ...args, "--only", "--", ...all]);
} catch (e) {
if (untracked.length)
await git(dir, ["rm", "--cached", "-q", "--", ...untracked]).catch(() => {});
throw e;
}
}
/** Whether the request names change blocks (`hunks`), not just whole files. */
function hasHunks(body) {
return !!body.hunks && typeof body.hunks === "object" && Object.keys(body.hunks).length > 0;
}
/** Most files one hunk-level commit may name partially. */
const MAX_PARTIAL_FILES = 300;
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
*/
async function commitSelection(dir, args, body) {
const picks = Object.entries(body.hunks);
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
for (const [, list] of picks) {
const ok =
Array.isArray(list) &&
list.length > 0 &&
list.every(
(x) =>
x &&
typeof x.hunk === "string" &&
Array.isArray(x.blocks) &&
x.blocks.length > 0 &&
x.blocks.every((b) => Number.isInteger(b) && b >= 0)
);
if (!ok) throw bad("EBADPATH", "hunks: each file needs [{hunk, blocks: [n, …]}]");
}
const whole = Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : [];
const part = await resolvePaths(dir, { paths: picks.map(([p]) => p) });
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
for (const p of part) {
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
if (whole.some((w) => w.path === p.path)) {
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
}
}
const wholePaths = whole.flatMap((p) =>
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
);
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-commit-"));
const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
try {
await git(dir, (await hasHead(dir)) ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], {
env,
});
if (wholePaths.length) await git(dir, ["add", "-A", "--", ...wholePaths], { env });
const file = path.join(tmp, "part.patch");
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
for (const [p, list] of picks) {
const patch = await selectionPatch(dir, p, body.context, list);
if (!patch)
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
await fs.promises.writeFile(file, patch);
// The patch names paths from the repo root, so it is applied there.
await git(top, ["apply", "--cached", ...zero, file], { env });
}
const out = await git(dir, ["commit", ...args], { env });
await git(dir, ["reset", "-q", "--", ...wholePaths, ...part.map((p) => p.path)]).catch(
() => {}
);
return out;
} finally {
await fs.promises.rm(tmp, { recursive: true, force: true });
}
}
/** A path as one literal .gitignore line, anchored at the file's folder. */
function ignoreLine(p) {
return `/${p.replace(/[\\*?[]/g, "\\$&").replace(/ $/, "\\ ")}`;
}
/**
* Add to .gitignore: one anchored line per path in the WORKSPACE's own
* .gitignore (its cwd — a subdirectory workspace never writes above itself),
* only for a path `git status` lists as untracked, never a line it already
* has, and never through a symlink or onto anything but a regular file.
*/
async function ignorePaths(dir, body) {
const paths = await resolvePaths(dir, body);
const tracked = paths.find((p) => p.code !== "??");
if (tracked)
throw bad("ETRACKED", `${tracked.path} is tracked: only an untracked file can be ignored`);
// A line break in a name would write a second, arbitrary pattern.
if (paths.some((p) => /[\r\n]/.test(p.path)))
throw bad("EBADPATH", "a path with a line break cannot be ignored");
const file = path.join(dir, ".gitignore");
const st = await fs.promises.lstat(file).catch(() => null);
if (st && !st.isFile()) throw bad("EBADPATH", ".gitignore is not a regular file");
const old = st ? await fs.promises.readFile(file, "utf8") : "";
const have = new Set(old.split(/\r?\n/));
const lines = [...new Set(paths.map((p) => ignoreLine(p.path)))].filter((l) => !have.has(l));
if (lines.length) {
const sep = old && !old.endsWith("\n") ? "\n" : "";
await fs.promises.appendFile(file, `${sep}${lines.join("\n")}\n`);
}
return done("ignore", { stdout: lines.map((l) => `${l}\n`).join(""), stderr: "" });
}
/** Create or delete a branch WITHOUT touching the working tree. */