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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user