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:
@@ -22,6 +22,7 @@ const {
|
||||
normalizeGeo,
|
||||
normalizeTier,
|
||||
accumulateBucket,
|
||||
longArgs,
|
||||
} = require("../server/lib/token-usage");
|
||||
|
||||
const {
|
||||
@@ -822,6 +823,11 @@ function writeSessionTokens(dbModule, sessionId, tokensByModel, opts = {}) {
|
||||
tokens.webFetch || 0,
|
||||
tokens.codeExec || 0
|
||||
);
|
||||
// The exact path also clears a stale subset; the default one only raises.
|
||||
const long = longArgs(tokens);
|
||||
const key = [sessionId, tokens.model, tokens.speed, tokens.geo, tokens.tier];
|
||||
if (opts.exact) stmts.setLongTokens.run(...(long || [0, 0, 0, 0, 0]), ...key);
|
||||
else if (long) stmts.raiseLongTokens.run(...long, ...key);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
@@ -852,6 +858,11 @@ function subagentTokenRows(tokensByModel) {
|
||||
web_search_requests: b.webSearch || 0,
|
||||
web_fetch_requests: b.webFetch || 0,
|
||||
code_execution_requests: b.codeExec || 0,
|
||||
long_input_tokens: b.longInput || 0,
|
||||
long_output_tokens: b.longOutput || 0,
|
||||
long_cache_read_tokens: b.longCacheRead || 0,
|
||||
long_cache_write_tokens: b.longCacheWrite || 0,
|
||||
long_cache_write_1h_tokens: b.longCacheWrite1h || 0,
|
||||
};
|
||||
const hasUsage =
|
||||
row.input_tokens ||
|
||||
|
||||
@@ -247,4 +247,4 @@ if (require.main === module) {
|
||||
|
||||
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
|
||||
// a previous checkout install left behind (they would double-count events).
|
||||
module.exports = { installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
|
||||
module.exports = { HOOK_TYPES, installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
|
||||
|
||||
@@ -35,6 +35,7 @@ const crypto = require("crypto");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
|
||||
const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
|
||||
const { isQuit } = require("../server/lib/quit");
|
||||
const { getServerInfoPath } = require("../server/lib/server-info");
|
||||
const { isOurEntry } = require("./install-hooks");
|
||||
|
||||
@@ -178,7 +179,8 @@ function fastPathOk(rt = runtimeDir()) {
|
||||
// worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one
|
||||
// Task spawning two subagent rows.)
|
||||
if (hasLegacyHooks()) return false;
|
||||
return serverIsLive();
|
||||
// Quit (server/lib/quit.js): nothing to start until the user starts it.
|
||||
return serverIsLive() || isQuit(rt);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- node version gate */
|
||||
@@ -740,7 +742,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
// running process must be replaced"; `unitStale` is "the unit names a cache
|
||||
// directory that is no longer this one", which is invisible until a reboot
|
||||
// and must be repaired even while a perfectly healthy server is up.
|
||||
const needsServer = movedRoot || !serverIsLive();
|
||||
// A Quit holds every start off (an explicit `--force` from
|
||||
// /agenthub-update overrides it); the unit is still repaired below.
|
||||
const quit = !force && isQuit(rt);
|
||||
if (quit) log(rt, "AgentHub was quit — not starting it (agenthub start brings it back)");
|
||||
const needsServer = !quit && (movedRoot || !serverIsLive());
|
||||
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
|
||||
if (needsServer || unitStale) {
|
||||
const unit = {
|
||||
|
||||
@@ -127,6 +127,12 @@ function unitIsActive(name) {
|
||||
return String(res.stdout || "").trim() === "active";
|
||||
}
|
||||
|
||||
/** `KillMode=process`: a stop or an update's restart ends the server alone.
|
||||
* tmux forks its server from whichever process first asks for a session —
|
||||
* the dashboard itself, after a boot with autostart on — so it can sit in
|
||||
* this unit's cgroup, and the default `control-group` would kill it and every
|
||||
* lane pane with it. Panes close only on Quit (`server/lib/quit.js`), which
|
||||
* kills them itself. The launchd twin is `AbandonProcessGroup`. */
|
||||
function unitContent({ description, workingDir, execStart, env }) {
|
||||
const envLines = Object.entries(env)
|
||||
.map(([k, v]) => `Environment=${k}=${v}`)
|
||||
@@ -142,6 +148,7 @@ WorkingDirectory=${workingDir}
|
||||
ExecStart=${execStart}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=process
|
||||
${envLines}
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -42,6 +42,13 @@ const env = {
|
||||
AGENTHUB_SETTINGS_FILE: path.join(tmuxDir, "settings.env"),
|
||||
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
|
||||
AGENTHUB_PRICING_SYNC: "0",
|
||||
// Nor run the developer's real agy (it may update itself) or send their
|
||||
// real Cursor, Grok, Kimi or OpenCode login for the quota.
|
||||
AGENTHUB_AGY_USAGE: "0",
|
||||
XDG_CONFIG_HOME: tmuxDir,
|
||||
XDG_DATA_HOME: tmuxDir,
|
||||
GROK_HOME: tmuxDir,
|
||||
KIMI_CODE_HOME: tmuxDir,
|
||||
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
||||
};
|
||||
delete env.TMUX;
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file tray.js — the AgentHub icon in the OS status area, as its own process
|
||||
* (started and kept alive by `server/lib/tray.js`; `agenthub tray` runs it in
|
||||
* the foreground). Draws through `systray2`'s helper binary, polls the
|
||||
* dashboard's /api/health, and offers Open, Quit/Start (the CLI's own
|
||||
* `agenthub stop|start`, so the icon stops and starts AgentHub exactly as the
|
||||
* terminal does) and Hide this icon (the same off flag as Settings → System).
|
||||
* One per data dir: `<data dir>/runtime/tray.json`, touched as a heartbeat.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const { spawn } = require("child_process");
|
||||
const { dashboardUrl } = require("../server/lib/dashboard-url");
|
||||
const { getDataDir } = require("../server/lib/claude-home");
|
||||
const tray = require("../server/lib/tray");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
function loadSysTray() {
|
||||
try {
|
||||
return require("systray2").default;
|
||||
} catch {
|
||||
// A plugin install keeps its dependencies in the runtime dir; the CLI
|
||||
// launcher does not put that on NODE_PATH the way the server's unit does.
|
||||
return require(path.join(getDataDir(), "runtime", "node_modules", "systray2")).default;
|
||||
}
|
||||
}
|
||||
|
||||
/** npm drops the executable bit on systray2's bundled helper binaries. */
|
||||
function chmodHelper() {
|
||||
if (process.platform === "win32") return;
|
||||
const name = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
|
||||
for (const id of ["systray2", path.join(getDataDir(), "runtime", "node_modules", "systray2")]) {
|
||||
try {
|
||||
fs.chmodSync(path.join(path.dirname(require.resolve(id)), "traybin", name), 0o755);
|
||||
return;
|
||||
} catch {
|
||||
/* try the next location */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUp(base) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`${base}/api/health`, { timeout: 2000 }, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on("timeout", () => req.destroy());
|
||||
req.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin"
|
||||
? ["open", [url]]
|
||||
: process.platform === "win32"
|
||||
? ["cmd", ["/c", "start", "", url]]
|
||||
: ["xdg-open", [url]];
|
||||
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
||||
}
|
||||
|
||||
function cli(command) {
|
||||
spawn(process.execPath, [path.join(ROOT, "bin", "agenthub.js"), command], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const other = tray.liveTray();
|
||||
if (other && other.pid !== process.pid) {
|
||||
console.log(`AgentHub's icon is already running (pid ${other.pid})`);
|
||||
return;
|
||||
}
|
||||
tray.writePid(process.pid, ROOT);
|
||||
chmodHelper();
|
||||
|
||||
const SysTray = loadSysTray();
|
||||
const item = (title, extra = {}) => ({ title, tooltip: "", enabled: true, ...extra });
|
||||
const status = item("AgentHub", { enabled: false });
|
||||
const open = item("Open AgentHub");
|
||||
// Quit closes every terminal session, so it takes a second click (the web
|
||||
// UI asks in a dialog; a tray menu has none).
|
||||
const quitYes = item("Yes, quit — closes every terminal session");
|
||||
const quit = item("Quit AgentHub", { items: [quitYes] });
|
||||
const start = item("Start AgentHub", { hidden: true });
|
||||
const hide = item("Hide this icon");
|
||||
const systray = new SysTray({
|
||||
menu: {
|
||||
icon: fs.readFileSync(path.join(ROOT, "client", "public", "favicon.ico")).toString("base64"),
|
||||
isTemplateIcon: false,
|
||||
title: "",
|
||||
tooltip: "AgentHub",
|
||||
items: [status, open, quit, start, hide],
|
||||
},
|
||||
});
|
||||
|
||||
let up = null;
|
||||
// After a Quit/Start click, "Stopping…"/"Starting…" stays until the server
|
||||
// reaches the asked-for state (or 30 s pass) — not undone by the next poll.
|
||||
let want = null;
|
||||
let waitUntil = 0;
|
||||
let base = dashboardUrl();
|
||||
// systray2 finds an item by the id it stamped on OUR object — so update in place.
|
||||
const show = (it, changes) => {
|
||||
Object.assign(it, changes);
|
||||
systray.sendAction({ type: "update-item", item: it });
|
||||
};
|
||||
const host = () => base.replace(/^https?:\/\//, "");
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
fs.utimesSync(tray.pidPath(), now, now);
|
||||
} catch {
|
||||
tray.writePid(process.pid, ROOT); // the file went missing: claim it again
|
||||
}
|
||||
base = dashboardUrl();
|
||||
const next = await isUp(base);
|
||||
if (Date.now() < waitUntil && next !== want) return;
|
||||
waitUntil = 0;
|
||||
if (next === up) return;
|
||||
up = next;
|
||||
show(status, { title: up ? `AgentHub — running (${host()})` : "AgentHub — stopped" });
|
||||
show(open, { enabled: up });
|
||||
show(quit, { hidden: !up });
|
||||
show(start, { hidden: up, enabled: true, title: "Start AgentHub" });
|
||||
};
|
||||
|
||||
const exit = () => {
|
||||
try {
|
||||
if (tray.liveTray()?.pid === process.pid) fs.rmSync(tray.pidPath(), { force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
systray.kill(true);
|
||||
setTimeout(() => process.exit(0), 2000); // a helper that never started cannot be killed
|
||||
};
|
||||
process.on("SIGTERM", exit);
|
||||
process.on("SIGINT", exit);
|
||||
|
||||
systray.onClick(({ item: clicked }) => {
|
||||
if (clicked.__id === open.__id) openUrl(base);
|
||||
else if (clicked.__id === quitYes.__id) {
|
||||
cli("stop");
|
||||
show(quit, { hidden: true });
|
||||
show(start, { hidden: false, enabled: false, title: "Stopping…" });
|
||||
[up, want, waitUntil] = [null, false, Date.now() + 30_000];
|
||||
} else if (clicked.__id === start.__id) {
|
||||
cli("start");
|
||||
show(start, { enabled: false, title: "Starting…" });
|
||||
[up, want, waitUntil] = [null, true, Date.now() + 30_000];
|
||||
} else if (clicked.__id === hide.__id) {
|
||||
tray.setEnabled(false); // writes the off flag and signals this process
|
||||
}
|
||||
});
|
||||
|
||||
systray
|
||||
.ready()
|
||||
.then(() => {
|
||||
systray.onExit(() => process.exit(0)); // the helper exists only once ready
|
||||
poll();
|
||||
setInterval(poll, tray.HEARTBEAT_MS);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`AgentHub icon could not start: ${err?.message || err}`);
|
||||
exit();
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { main };
|
||||
Reference in New Issue
Block a user