feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)

Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
2026-09-29 16:09:19 +07:00
parent 7ec5a7cd85
commit 969b06253c
95 changed files with 9278 additions and 1696 deletions
@@ -0,0 +1,223 @@
/**
* @file ClaudeAgentHubSettings.tsx
* @description Settings → Claude Code → AgentHub: the dashboard's own settings
* that belong to Claude Code — Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed
* into Claude Code with a Reinstall button, and the CLAUDE_HOME the server
* reads transcripts and config from. Moved here from Settings → Dashboard
* (#40 part B).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from "lucide-react";
import { api } from "../../lib/api";
import { Skeleton } from "../Skeleton";
import { HomeDirSetting } from "./HomeDirSetting";
import { PricingSettings } from "./PricingSettings";
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
/**
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
* the config it lists, which lives under that home).
* @returns The three sections, each with its own id.
*/
export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () => void } = {}) {
const { t } = useTranslation("settings");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hooks, setHooks] = useState<HooksInfo | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{
key: string;
message: string;
isError: boolean;
} | null>(null);
const load = useCallback(async () => {
try {
const infoRes = await api.settings.info();
setHooks(infoRes.hooks);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
load();
}, [load]);
// Hooks can be (re)installed from a terminal while this tab is open; keep the
// badge current the way the old Settings page did, every 10 s.
useEffect(() => {
const id = setInterval(() => {
api.settings
.info()
.then((r) => setHooks(r.hooks))
.catch(() => {});
}, 10000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (!actionResult) return;
const timeout = setTimeout(() => setActionResult(null), 5000);
return () => clearTimeout(timeout);
}, [actionResult]);
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionLoading(key);
setActionResult(null);
try {
const message = await fn();
setActionResult({ key, message, isError: false });
await load();
} catch (err) {
setActionResult({
key,
message: t("messages.actionFailed", {
message: err instanceof Error ? err.message : t("messages.unknownError"),
}),
isError: true,
});
} finally {
setActionLoading(null);
}
};
const handleReinstallHooks = () =>
runAction("hooks", async () => {
const res = await api.settings.reinstallHooks();
return res.ok ? t("hooks.success") : t("hooks.failed");
});
const actionBanner = (keys: string[]) => {
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
if (!match) return null;
return (
<div
className={`px-3 py-2 rounded-lg text-xs ${
match.isError
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-status-success/10 border border-status-success/20 text-status-success"
}`}
>
{match.message}
</div>
);
};
if (loading) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
);
}
return (
<div className="space-y-8">
<PricingSettings vendor="claude" allowReset />
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{/* ─── HOOK CONFIGURATION ─── */}
<section id="hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-fg-muted" />
{t("hooks.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
{hooks?.installed ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
</span>
)}
</div>
<button
onClick={handleReinstallHooks}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "hooks" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("hooks.reinstall")}
</button>
</div>
{actionBanner(["hooks"])}
{hooks && (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{Object.entries(hooks.hooks).map(([hook, active]) => (
<div
key={hook}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
>
{active ? (
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
) : (
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
)}
<span className="text-fg-secondary truncate">{hook}</span>
</div>
))}
</div>
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
</>
)}
</div>
</section>
<HomeDirSetting
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
placeholder={t("claudeHome.placeholder")}
load={() =>
api.settings.claudeHome
.get()
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
}
save={(p) =>
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
const missing = r.routers?.missing ?? [];
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
})
}
/>
</div>
);
}