chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions

Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* @file The machine-wide limit on `git clone`s running at once, and each
* clone's progress. A clone's index-pack resolves deltas on every core it
* finds, so a few big (~1 GB) clones started together pinned all of them and
* stalled the dashboard and every pane beside it. Clones past the limit wait
* here, in arrival order, and each one gets its share of the cores
* (`threads`) — the clone's own timeout starts only once it runs.
* `AGENTHUB_CLONE_CONCURRENCY` overrides the default (half the cores, at
* least 1). A clone the browser named with a token (`track`) reports its
* place in the queue, then git's own `--progress` lines (`report`), to
* `GET /api/lanes/clone/progress/:token` — only to the user who started it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const os = require("node:os");
/** A finished clone's progress stays readable this long, then is dropped. */
const KEEP_DONE_MS = 60_000;
const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
function cores() {
return Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) || 1);
}
/** How many clones may run at once. */
function concurrency() {
const set = Number.parseInt(process.env.AGENTHUB_CLONE_CONCURRENCY, 10);
if (Number.isInteger(set) && set > 0) return set;
return Math.max(1, Math.floor(cores() / 2));
}
/** index-pack threads for one clone: the cores split between the running ones. */
function threads() {
return Math.max(1, Math.floor(cores() / concurrency()));
}
let running = 0;
/** `{start, job}` in arrival order; `job` is null for an untracked clone. */
const waiting = [];
/** token → job: `{owner, state, phase, label, percent, detail, finishedAt}`. */
const jobs = new Map();
function next() {
while (running < concurrency() && waiting.length) {
running++;
const { start, job } = waiting.shift();
if (job) job.state = "running";
start();
}
}
/** Runs `fn` once a clone slot is free; resolves or rejects with it. */
async function withCloneSlot(fn, job = null) {
await new Promise((start) => {
waiting.push({ start, job });
next();
});
try {
return await fn();
} finally {
running--;
next();
}
}
/** `{running, waiting, limit}` — for a status read or a test. */
function cloneQueueState() {
return { running, waiting: waiting.length, limit: concurrency() };
}
function sweep(now = Date.now()) {
for (const [token, job] of jobs) {
if (job.finishedAt && now - job.finishedAt > KEEP_DONE_MS) jobs.delete(token);
}
}
/**
* A progress record for the clone the browser named `token`, owned by
* `owner` (`scope.ownerFor`). Throws `EBADFIELD` for a malformed token or one
* still in use.
*/
function track(token, owner) {
sweep();
if (typeof token !== "string" || !TOKEN_RE.test(token) || jobs.has(token)) {
throw Object.assign(new Error("progressToken: 16–64 of [A-Za-z0-9_-], unused"), {
code: "EBADFIELD",
});
}
const job = {
owner,
state: "queued",
phase: null,
label: null,
percent: null,
detail: null,
finishedAt: null,
};
jobs.set(token, job);
return job;
}
/** Marks a tracked clone over: `done` or `failed`. */
function finish(job, ok) {
if (!job) return;
job.state = ok ? "done" : "failed";
job.finishedAt = Date.now();
}
/** git's progress words → a phase the UI translates; anything else is `other`. */
const PHASES = [
[/^remote: /, "remote"],
[/^Receiving objects/, "receiving"],
[/^Resolving deltas/, "resolving"],
[/^Updating files/, "checkout"],
];
const LINE_RE = /^((?:remote: )?[A-Za-z][A-Za-z ]*?):\s+(\d{1,3})% \(([^)]*)\)(.*)$/;
/**
* Feeds a chunk of `git clone --progress` stderr into `job`: the LAST
* complete progress line in it wins (git redraws a line with `\r`).
*/
function report(job, chunk) {
if (!job) return;
const lines = String(chunk).split(/[\r\n]+/);
for (let i = lines.length - 1; i >= 0; i--) {
const m = LINE_RE.exec(lines[i].trim());
if (!m) continue;
job.label = m[1].replace(/^remote: /, "");
job.phase = (PHASES.find(([re]) => re.test(m[1])) || [null, "other"])[1];
job.percent = Math.min(100, Number(m[2]));
// "1234/5678), 120.50 MiB | 5.20 MiB/s, done." → counts + size + speed
job.detail =
`${m[3]}${m[4]}`
.replace(/,\s*done\.?$/, "")
.replace(/^,\s*/, "")
.trim() || null;
return;
}
}
/**
* What `GET /api/lanes/clone/progress/:token` answers, or null when there is
* no such clone or it is not `owner`'s. A queued clone carries its 1-based
* `position` among the waiting ones.
*/
function progress(token, owner) {
sweep();
const job = jobs.get(token);
if (!job || job.owner !== owner) return null;
const position = waiting.findIndex((w) => w.job === job);
return {
state: job.state,
position: position >= 0 ? position + 1 : null,
...cloneQueueState(),
phase: job.phase,
label: job.label,
percent: job.percent,
detail: job.detail,
};
}
/** A progress-free tail of git's stderr for an error message. */
function stripProgress(stderr) {
return String(stderr || "")
.split(/[\r\n]+/)
.filter((l) => l.trim() && !LINE_RE.test(l.trim()))
.join("\n");
}
module.exports = {
withCloneSlot,
cloneQueueState,
concurrency,
threads,
track,
finish,
report,
progress,
stripProgress,
};