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
+89 -13
View File
@@ -657,7 +657,9 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
}
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file));
// `--parents` makes git REWRITE each parent to the file's own previous
// commit, so a file's history draws one joined-up line, not loose ends.
if (file) args.push("--parents", "--", lanePath(file));
const result = await git(dir, args);
const commits = [];
for (const record of result.stdout.split("\x1e")) {
@@ -682,6 +684,50 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
return commits;
}
/**
* Annotate (JetBrains' blame gutter): who last changed each line of the
* working-tree file. `git blame --porcelain` on the file on disk, so a line
* nobody committed yet is `null`. Same guards as a whole-file read: the
* lexical lane guard, a secret refused (ESECRET), and the file's size capped.
* Answers `{commits: {sha: {short, author, date, summary}}, lines: [sha|null]}`.
*/
async function blameFile(dir, requested) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
const st = await fs.promises.stat(path.join(dir, file)).catch(() => null);
if (!st?.isFile()) throw badRequest("ENOFILE", "no such file");
if (st.size > MAX_FILE_VIEW_BYTES) throw badRequest("ETOOBIG", "file too large to annotate");
const out = (
await git(dir, ["blame", "--porcelain", "--", file], { maxBuffer: 64 * 1024 * 1024 })
).stdout;
const commits = {};
const lines = [];
let sha = null;
for (const line of out.split("\n")) {
const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line);
if (head) {
sha = head[1];
if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) };
continue;
}
if (line.startsWith("\t")) {
lines.push(/^0+$/.test(sha) ? null : sha);
continue;
}
const c = commits[sha];
if (!c) continue;
const sp = line.indexOf(" ");
const key = line.slice(0, sp);
const val = line.slice(sp + 1);
if (key === "author") c.author = val;
else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString();
else if (key === "summary") c.summary = val;
}
return { path: file, commits, lines };
}
/**
* One file's full content at a revision, for the "read the whole file" view
* that a diff's three lines of context cannot answer.
@@ -981,7 +1027,18 @@ async function listTree(dir, { subdir = "" } = {}) {
* `reverse` says which side the patch will be applied to (see `onlyBlock`).
*/
async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) {
if (!header || (await isSecretOnDisk(dir, file))) return null;
if (!header) return null;
const diff = await diffHunks(dir, file, context);
const hunk = diff?.hunks.find((h) => h.slice(0, h.indexOf("\n")) === header);
if (!hunk) return null;
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
return body ? diff.head + (body.endsWith("\n") ? body : `${body}\n`) : null;
}
/** A tracked file's fresh `git diff HEAD` at `context`: its header lines and
* its hunks' texts, in order. Null for a secret, binary or too-large file. */
async function diffHunks(dir, file, context) {
if (await isSecretOnDisk(dir, file)) return null;
const u = `-U${normalizeContext(context)}`;
const raw = await readCapped(
dir,
@@ -992,16 +1049,31 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
const at = raw.indexOf("\n@@ ");
if (at < 0) return null;
const head = raw.slice(0, at + 1);
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
// line that does is a hunk boundary.
const hunk = raw
.slice(at + 1)
.split(/^(?=@@ )/m)
.find((h) => h.slice(0, h.indexOf("\n")) === header);
if (!hunk) return null;
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
return body ? head + (body.endsWith("\n") ? body : `${body}\n`) : null;
return { head: raw.slice(0, at + 1), hunks: raw.slice(at + 1).split(/^(?=@@ )/m) };
}
/**
* The hunk-level commit's patch for one file (JetBrains' partial commit):
* the file's header plus each picked hunk cut down to its picked `blocks`,
* in file order, to be applied FORWARD onto an index holding HEAD. Every
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
* git wrote it — `git apply` locates a hunk by its old side.
*/
async function selectionPatch(dir, file, context, picks) {
const diff = await diffHunks(dir, file, context);
if (!diff || !picks.length) return null;
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
if (picks.some((p) => at(p.hunk) < 0)) return null;
const bodies = [];
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
if (!body) return null;
bodies.push(body);
}
return diff.head + bodies.join("");
}
/**
@@ -1020,7 +1092,9 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
* checked side is unchanged. Null when the hunk has no such block.
*/
function onlyBlock(hunk, block, reverse) {
if (!Number.isInteger(block) || block < 0) return null;
// One block, or several (the hunk-level commit keeps every ticked one).
const keep = Array.isArray(block) ? block : [block];
if (!keep.length || keep.some((b) => !Number.isInteger(b) || b < 0)) return null;
const [header, ...lines] = hunk.replace(/\n$/, "").split("\n");
const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
if (!m) return null;
@@ -1040,7 +1114,7 @@ function onlyBlock(hunk, block, reverse) {
if (change && !inBlock) index += 1;
inBlock = change;
let kept = line;
if (change && index !== block) {
if (change && !keep.includes(index)) {
const fold = reverse ? "+" : "-";
kept = mark === fold ? ` ${line.slice(1)}` : null;
}
@@ -1050,7 +1124,7 @@ function onlyBlock(hunk, block, reverse) {
if (kept[0] !== "+") oldCount += 1;
if (kept[0] !== "-") newCount += 1;
}
if (block > index) return null;
if (Math.max(...keep) > index) return null;
return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n";
}
@@ -1103,6 +1177,8 @@ module.exports = {
resolveInLane,
MAX_FILE_VIEW_BYTES,
hunkPatch,
selectionPatch,
blameFile,
laneStatus,
listCommits,
listPaths,