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,199 @@
/**
* @file CodexHooksSettings.tsx
* @description Settings → Codex → AgentHub → Codex hooks: whether AgentHub's
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
* Install/Reinstall and Remove. Nothing is written until the user clicks;
* Codex itself asks the user to trust the hooks before it runs them, and this
* card says so while they wait, with a Refresh to re-read once they have
* (#40 part C2).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
CheckCircle,
Plug,
RefreshCw,
RotateCcw,
Trash2,
XCircle,
} from "lucide-react";
import { api } from "../../lib/api";
import type { CodexHooksStatus } from "../../lib/types";
import { Skeleton } from "../Skeleton";
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
/**
* The Codex hooks card.
* @returns A section with id `codex-hooks` for deep links.
*/
export function CodexHooksSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<CodexHooksStatus | null>(null);
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.settings.codexHooks
.get()
.then(setStatus)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, []);
const run = async (kind: "install" | "remove" | "get") => {
setBusy(kind);
setError(null);
try {
setStatus(await api.settings.codexHooks[kind]());
} catch (err) {
setError(
t("codexHooks.failed", { message: err instanceof Error ? err.message : String(err) })
);
} finally {
setBusy(null);
}
};
// Some of ours present but not all (an event added since, or one taken out
// by hand): still show them, and still offer Remove.
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
const eventLabel = (e: { installed: boolean; trusted: boolean }) =>
!e.installed
? t("codexHooks.eventMissing")
: e.trusted
? t("codexHooks.eventTrusted")
: t("codexHooks.eventUntrusted");
const chip = !status?.installed ? (
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
<XCircle className="w-3.5 h-3.5" /> {t("codexHooks.notInstalled")}
</span>
) : status.trusted ? (
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
<CheckCircle className="w-3.5 h-3.5" /> {t("codexHooks.trusted")}
</span>
) : (
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
<AlertTriangle className="w-3.5 h-3.5" /> {t("codexHooks.untrusted")}
</span>
);
return (
<section id="codex-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("codexHooks.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("codexHooks.description")}</p>
<div className="card p-5 space-y-4">
{!status && !error ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-5/6" />
</div>
) : null}
{status && !status.supported ? (
<p className="text-xs text-fg-muted">{t("codexHooks.unsupported")}</p>
) : null}
{status && status.supported ? (
<>
<div className="flex items-center justify-between flex-wrap gap-3">
{chip}
<div className="flex items-center gap-2">
<button
onClick={() => run("get")}
disabled={busy !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
{t("codexHooks.refresh")}
</button>
<button
onClick={() => run("install")}
disabled={busy !== null || !status.readable}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy === "install" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{status.installed ? t("codexHooks.reinstall") : t("codexHooks.install")}
</button>
{anyInstalled ? (
<button
onClick={() => run("remove")}
disabled={busy !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy === "remove" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
{t("codexHooks.remove")}
</button>
) : null}
</div>
</div>
{!status.readable ? (
<p className="text-xs text-status-danger">
{t("codexHooks.unreadable", { path: status.path, error: status.error ?? "" })}
</p>
) : null}
{status.installed && !status.trusted ? (
<p className="text-xs text-fg-secondary">{t("codexHooks.trustNote")}</p>
) : null}
{anyInstalled ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{Object.entries(status.events).map(([event, e]) => (
<div
key={event}
title={eventLabel(e)}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
>
{!e.installed ? (
<XCircle
aria-hidden="true"
className="w-3 h-3 text-status-danger flex-shrink-0"
/>
) : e.trusted ? (
<CheckCircle
aria-hidden="true"
className="w-3 h-3 text-status-success flex-shrink-0"
/>
) : (
<AlertTriangle
aria-hidden="true"
className="w-3 h-3 text-status-warning flex-shrink-0"
/>
)}
<span className="text-fg-secondary truncate">{event}</span>
<span className="sr-only">{eventLabel(e)}</span>
</div>
))}
</div>
) : null}
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
</>
) : null}
{error ? (
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
{error}
</div>
) : null}
</div>
</section>
);
}