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:
@@ -2,6 +2,11 @@
|
|||||||
# Override this if your Claude Code data is in a different location, e.g.:
|
# Override this if your Claude Code data is in a different location, e.g.:
|
||||||
# CLAUDE_HOME=~/.codefuse/engine/cc
|
# CLAUDE_HOME=~/.codefuse/engine/cc
|
||||||
# CLAUDE_HOME=~/.claude
|
# CLAUDE_HOME=~/.claude
|
||||||
|
# A value saved from Settings (CLAUDE_HOME, CODEX_HOME, a pinned DASHBOARD_DATA_DIR)
|
||||||
|
# goes to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE). This file wins
|
||||||
|
# over it, and the real environment wins over both; a key set here or in the real
|
||||||
|
# environment is reported as env-owned in Settings (a value saved there would be
|
||||||
|
# overridden again at the next start).
|
||||||
|
|
||||||
# ── Server / network ────────────────────────────────────────────────────────
|
# ── Server / network ────────────────────────────────────────────────────────
|
||||||
# Port to listen on (default: 4820)
|
# Port to listen on (default: 4820)
|
||||||
@@ -66,6 +71,27 @@
|
|||||||
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
|
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
|
||||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||||
|
|
||||||
|
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
||||||
|
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
||||||
|
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
||||||
|
# Where agy keeps its data, when not ~/.gemini (agy itself has no override).
|
||||||
|
# AGENTHUB_GEMINI_DIR=~/.gemini
|
||||||
|
|
||||||
|
# Poll interval (ms) for Codex's rollout journals ($CODEX_HOME/sessions/**):
|
||||||
|
# drives both the codex lane running/idle mirror and Codex sessions + tokens
|
||||||
|
# (0 disables both; default: 15000).
|
||||||
|
# DASHBOARD_CODEX_POLL_MS=15000
|
||||||
|
|
||||||
|
# Automatic pricing: a model seen in token usage with no rate row of its own
|
||||||
|
# gets one from LiteLLM's public price list. 0 / false / off disables it
|
||||||
|
# (default: enabled).
|
||||||
|
# AGENTHUB_PRICING_SYNC=0
|
||||||
|
|
||||||
|
# Where settings saved from the dashboard persist (default:
|
||||||
|
# $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env).
|
||||||
|
# Here or in the real environment; the file is located after this one loads.
|
||||||
|
# AGENTHUB_SETTINGS_FILE=/home/you/.config/agenthub/settings.env
|
||||||
|
|
||||||
# ── Remote Data Sources (SSH multi-machine collection) ───────────────────────
|
# ── Remote Data Sources (SSH multi-machine collection) ───────────────────────
|
||||||
# The dashboard can pull Claude Code history from other machines over SSH:
|
# The dashboard can pull Claude Code history from other machines over SSH:
|
||||||
# it rsyncs each enabled remote's ~/.claude/projects into a sandboxed staging
|
# it rsyncs each enabled remote's ~/.claude/projects into a sandboxed staging
|
||||||
|
|||||||
+193
-19
File diff suppressed because one or more lines are too long
+1
-1
@@ -214,7 +214,7 @@ After starting a Claude Code session, you should see:
|
|||||||
- **Agent Board** — a `Main Agent` card in the `Waiting` column until you type your first message; it flips to `Working` on `UserPromptSubmit` / `PreToolUse` and back to `Waiting` after each `Stop`
|
- **Agent Board** — a `Main Agent` card in the `Waiting` column until you type your first message; it flips to `Working` on `UserPromptSubmit` / `PreToolUse` and back to `Waiting` after each `Stop`
|
||||||
- **Activity Feed** — events streaming in as Claude Code uses tools
|
- **Activity Feed** — events streaming in as Claude Code uses tools
|
||||||
- **Dashboard** — stats updating in real-time
|
- **Dashboard** — stats updating in real-time
|
||||||
- **Settings page** — model pricing rules, hook configuration status, data export and cleanup tools
|
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → AgentHub**
|
||||||
|
|
||||||
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
|
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
|
||||||
|
|
||||||
|
|||||||
@@ -222,13 +222,35 @@ agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktre
|
|||||||
Every lane also carries the CLI that drives it — `claude` (the default) or
|
Every lane also carries the CLI that drives it — `claude` (the default) or
|
||||||
`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
|
`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
|
||||||
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
|
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||||
choices, and resumes with `codex resume --last`. Codex emits none of this
|
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
||||||
dashboard's hooks, so a Codex lane records its runs but stays absent from
|
are read from Codex's own rollout journals: every Codex session (a lane's or
|
||||||
Sessions and Analytics. A Codex lane also carries **no pipeline** — no stage,
|
not) appears in Sessions with a **Codex** badge and is priced like any other,
|
||||||
no pipeline map, nothing inferred or advanced; the dashboard only mirrors its
|
with OpenAI rates filled in automatically (Settings → Codex → AgentHub). For a
|
||||||
running/idle status from Codex's own rollout journals on disk
|
live feed, install **Codex hooks** from the same tab: the next time Codex starts
|
||||||
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
it asks you to trust them (**Trust all and continue**), and from then on each
|
||||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
tool call lands in the session's timeline, a codex lane turns running/idle as
|
||||||
|
each turn starts and ends, and "Codex needs your permission" shows on the lane
|
||||||
|
card and as a notification while Codex waits for an approval. A Codex lane also
|
||||||
|
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
|
||||||
|
advanced; without hooks the dashboard mirrors its running/idle status from the
|
||||||
|
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
|
||||||
|
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
||||||
|
|
||||||
|
An **Antigravity** lane runs Antigravity's `agy` CLI. Its sessions and token
|
||||||
|
usage are read from agy's own conversation files
|
||||||
|
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
|
||||||
|
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
|
||||||
|
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
|
||||||
|
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
|
||||||
|
the same tab, add a live feed: each tool call in the session's timeline and a
|
||||||
|
lane that turns running/idle as a run starts and ends. agy asks for no trust,
|
||||||
|
and it has no hook for a permission prompt, so an Antigravity lane never shows
|
||||||
|
"needs your permission". agy fires no hook when it exits, nor when a run ends
|
||||||
|
on an API error or a permission refused in headless mode, so a session gone
|
||||||
|
quiet is marked completed and its lane set idle — after ten minutes of quiet
|
||||||
|
for a session the hooks report on (a long tool call writes nothing, and after a
|
||||||
|
Stop agy may still be waiting for you), after two minutes for one without
|
||||||
|
hooks. Antigravity lanes carry no pipeline either.
|
||||||
|
|
||||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a lane has no
|
**Pipelines are dormant.** AgentHub ships no pipeline template, so a lane has no
|
||||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ npm run clear-data
|
|||||||
|
|
||||||
### Data management via Settings page
|
### Data management via Settings page
|
||||||
|
|
||||||
The Settings page (`/settings`) provides a UI for:
|
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
|
||||||
|
and **Hook Configuration** are not on its Dashboard tab: they live in the Claude
|
||||||
|
Code tab's **AgentHub** sub-tab (Settings → Claude Code → AgentHub), beside
|
||||||
|
CLAUDE_HOME; Codex and Antigravity have their own pricing and hooks in their
|
||||||
|
own tabs.
|
||||||
|
|
||||||
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
||||||
- **Hook Configuration** — check which hooks are installed and reinstall them
|
- **Hook Configuration** — check which hooks are installed and reinstall them
|
||||||
|
|||||||
+11
-3
@@ -186,6 +186,13 @@ client/
|
|||||||
│ │ ├── Sidebar.tsx
|
│ │ ├── Sidebar.tsx
|
||||||
│ │ ├── Layout.tsx
|
│ │ ├── Layout.tsx
|
||||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||||
|
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||||||
|
│ │ ├── settings/ # Settings panels, among them:
|
||||||
|
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
|
||||||
|
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → AgentHub: pricing, hook status, CLAUDE_HOME
|
||||||
|
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||||
|
│ │ │ ├── CodexHooksSettings.tsx # Codex → AgentHub → Codex hooks card
|
||||||
|
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||||
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
||||||
│ │
|
│ │
|
||||||
│ ├── pages/ # Route pages
|
│ ├── pages/ # Route pages
|
||||||
@@ -195,6 +202,7 @@ client/
|
|||||||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||||
│ │ ├── Workflows.tsx
|
│ │ ├── Workflows.tsx
|
||||||
│ │ ├── Settings.tsx
|
│ │ ├── Settings.tsx
|
||||||
|
│ │ ├── AntigravityConfig.tsx # Settings → Antigravity: Gemini pricing + Antigravity hooks
|
||||||
│ │ └── NotFound.tsx
|
│ │ └── NotFound.tsx
|
||||||
│ │
|
│ │
|
||||||
│ ├── lib/ # Core utilities & business logic
|
│ ├── lib/ # Core utilities & business logic
|
||||||
@@ -210,7 +218,7 @@ client/
|
|||||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||||
│ │
|
│ │
|
||||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
│ ├── i18n/ # Internationalization (en / vi)
|
||||||
│ ├── App.tsx # Root component + router setup
|
│ ├── App.tsx # Root component + router setup
|
||||||
│ ├── main.tsx # Entry point
|
│ ├── main.tsx # Entry point
|
||||||
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
|
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
|
||||||
@@ -239,7 +247,7 @@ graph TB
|
|||||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||||
Feed[ActivityFeed<br/>streaming event log]
|
Feed[ActivityFeed<br/>streaming event log]
|
||||||
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
||||||
Settings[Settings<br/>pricing + notifications + hooks]
|
Settings[SettingsHub<br/>Dashboard tab: appearance + notifications + data<br/>Claude Code / Codex → AgentHub, Antigravity:<br/>each tool's pricing + hooks]
|
||||||
|
|
||||||
App --> Layout
|
App --> Layout
|
||||||
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
||||||
@@ -285,7 +293,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
|
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
|
||||||
|
|
||||||
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME, Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → AgentHub), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
||||||
|
|
||||||
### State Strategy
|
### State Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||||
|
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||||
|
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||||
|
* list and session header look as before for every Claude row.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
|
|
||||||
|
/** A chip with the tool's name, or nothing for `claude` / an unset tool. */
|
||||||
|
export function SessionToolBadge({ tool }: { tool?: string | null }) {
|
||||||
|
if (!tool || tool === "claude") return null;
|
||||||
|
return <ToolChip tool={tool} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split out so the catalog hook only mounts for a non-Claude session.
|
||||||
|
function ToolChip({ tool }: { tool: string }) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const { label } = useAiTools();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center text-[10px] font-semibold text-violet-700 dark:text-violet-300 bg-violet-500/10 border border-violet-500/25 px-1.5 py-0.5 rounded-full"
|
||||||
|
title={t("toolBadgeTitle", { tool: label(tool) })}
|
||||||
|
>
|
||||||
|
{label(tool)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionToolBadge.test.tsx
|
||||||
|
* @description The session tool chip: nothing for a Claude Code session (so
|
||||||
|
* every hook row renders as before), the tool's name for a Codex one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionToolBadge } from "../SessionToolBadge";
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", () => ({ api: { aiTools: { list: () => new Promise(() => {}) } } }));
|
||||||
|
|
||||||
|
describe("SessionToolBadge", () => {
|
||||||
|
it("renders nothing for a Claude or unset tool", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<>
|
||||||
|
<SessionToolBadge tool="claude" />
|
||||||
|
<SessionToolBadge tool={undefined} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a Codex session's tool", () => {
|
||||||
|
render(<SessionToolBadge tool="codex" />);
|
||||||
|
expect(screen.getByText("Codex")).toHaveAttribute("title", "Recorded by Codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -893,7 +893,10 @@ function SessionPicker({
|
|||||||
if (sessions !== null && fetchedForCwd.current === cwd) return;
|
if (sessions !== null && fetchedForCwd.current === cwd) return;
|
||||||
fetchedForCwd.current = cwd;
|
fetchedForCwd.current = cwd;
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
|
// `claude --resume` can only resume a Claude session; a Codex lane at the
|
||||||
|
// same directory records its own sessions there too. Filtered by the
|
||||||
|
// server, before its limit.
|
||||||
|
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd, tool: "claude" })
|
||||||
.then((r) => setSessions(r.sessions))
|
.then((r) => setSessions(r.sessions))
|
||||||
.catch(() => setSessions([]));
|
.catch(() => setSessions([]));
|
||||||
}, [open, cwd, sessions]);
|
}, [open, cwd, sessions]);
|
||||||
|
|||||||
@@ -236,6 +236,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never offers a Codex session, which `claude --resume` cannot resume", async () => {
|
||||||
|
const { api } = await import("../../../lib/api");
|
||||||
|
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||||
|
sessions: [
|
||||||
|
{ id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
|
||||||
|
await screen.findByText("/Users/tester/lane-a");
|
||||||
|
// Filtered by the server, before its limit: a busy Codex lane at the same
|
||||||
|
// directory cannot push Claude sessions off the page.
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("starts the resume immediately when a session is picked", async () => {
|
it("starts the resume immediately when a session is picked", async () => {
|
||||||
const { api } = await import("../../../lib/api");
|
const { api } = await import("../../../lib/api");
|
||||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* @file AntigravityHooksSettings.tsx
|
||||||
|
* @description Settings → Antigravity → AgentHub → Antigravity hooks: whether
|
||||||
|
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
|
||||||
|
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
|
||||||
|
* written until the user clicks; agy asks for no trust, and has no permission
|
||||||
|
* hook — the note says both (#40 part D).
|
||||||
|
* @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 { AntigravityHooksStatus } 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 Antigravity hooks card.
|
||||||
|
* @returns A section with id `antigravity-hooks` for deep links.
|
||||||
|
*/
|
||||||
|
export function AntigravityHooksSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [status, setStatus] = useState<AntigravityHooksStatus | null>(null);
|
||||||
|
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.settings.antigravityHooks
|
||||||
|
.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.antigravityHooks[kind]());
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
t("antigravityHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
||||||
|
const chip = !status?.installed ? (
|
||||||
|
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
||||||
|
<XCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.notInstalled")}
|
||||||
|
</span>
|
||||||
|
) : status.enabled ? (
|
||||||
|
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.installed")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" /> {t("antigravityHooks.disabled")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="antigravity-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("antigravityHooks.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-fg-muted mb-4">{t("antigravityHooks.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("antigravityHooks.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("antigravityHooks.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("antigravityHooks.reinstall")
|
||||||
|
: t("antigravityHooks.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("antigravityHooks.remove")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!status.readable ? (
|
||||||
|
<p className="text-xs text-status-danger">
|
||||||
|
{t("antigravityHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-xs text-fg-secondary">{t("antigravityHooks.note")}</p>
|
||||||
|
|
||||||
|
{anyInstalled ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{Object.entries(status.events).map(([event, e]) => {
|
||||||
|
const label = e.installed
|
||||||
|
? t("antigravityHooks.eventInstalled")
|
||||||
|
: t("antigravityHooks.eventMissing");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event}
|
||||||
|
title={label}
|
||||||
|
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||||
|
>
|
||||||
|
{e.installed ? (
|
||||||
|
<CheckCircle
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-3 h-3 text-status-success flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<XCircle
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-3 h-3 text-status-danger flex-shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-fg-secondary truncate">{event}</span>
|
||||||
|
<span className="sr-only">{label}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* @file HomeDirSetting.tsx
|
||||||
|
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
||||||
|
* AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the
|
||||||
|
* server's refusal inline, the value in effect, and a warning when the real
|
||||||
|
* environment sets the variable (the environment wins at the next start, so a
|
||||||
|
* saved value would not survive a restart). The server validates and persists;
|
||||||
|
* this component only loads, submits and reports.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, FolderOpen } from "lucide-react";
|
||||||
|
|
||||||
|
export interface HomeDirValue {
|
||||||
|
value: string;
|
||||||
|
envOwned: boolean;
|
||||||
|
/** Shown after a save that succeeded only in part (e.g. a lane not updated). */
|
||||||
|
warning?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** `<section id>` — a deep-link target. */
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Optional second line under the description. */
|
||||||
|
note?: string;
|
||||||
|
placeholder: string;
|
||||||
|
load: () => Promise<HomeDirValue>;
|
||||||
|
save: (path: string) => Promise<HomeDirValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An editable home directory with its own load/save cycle.
|
||||||
|
* @returns The section, or its skeleton-free shell while loading.
|
||||||
|
*/
|
||||||
|
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [current, setCurrent] = useState<HomeDirValue | null>(null);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
load()
|
||||||
|
.then((v) => {
|
||||||
|
if (!live) return;
|
||||||
|
setCurrent(v);
|
||||||
|
setInput(v.value);
|
||||||
|
})
|
||||||
|
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
// `load` is a stable api call per mount; re-running on identity would loop.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!current || input === current.value) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const next = await save(input);
|
||||||
|
setCurrent(next);
|
||||||
|
setInput(next.value);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("homeDir.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="scroll-mt-24">
|
||||||
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||||
|
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-fg-muted mb-1">{description}</p>
|
||||||
|
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
|
||||||
|
|
||||||
|
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
aria-label={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInput(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !current || input === current.value}
|
||||||
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||||
|
{current && (
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{current?.envOwned && (
|
||||||
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||||
|
{t("homeDir.envOwned")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{current?.warning && (
|
||||||
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||||
|
{current.warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
/**
|
||||||
|
* @file PricingSettings.tsx
|
||||||
|
* @description Model Pricing — the per-model rates every session's cost is
|
||||||
|
* computed from — as shown in a tool's Settings → AgentHub sub-tab: Claude
|
||||||
|
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||||
|
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
||||||
|
* **Show all models** switch lists every row, and a row added outside the
|
||||||
|
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||||
|
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||||
|
* automatic sync owns (OpenAI's, Gemini's) straight after.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { eventBus } from "../../lib/eventBus";
|
||||||
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
|
import { getCurrentLocale } from "../../lib/format";
|
||||||
|
import { Skeleton } from "../Skeleton";
|
||||||
|
import type { ModelPricing, WSMessage } from "../../lib/types";
|
||||||
|
|
||||||
|
interface EditRow {
|
||||||
|
model_pattern: string;
|
||||||
|
display_name: string;
|
||||||
|
input_per_mtok: string;
|
||||||
|
output_per_mtok: string;
|
||||||
|
cache_read_per_mtok: string;
|
||||||
|
cache_write_per_mtok: string;
|
||||||
|
cache_write_1h_per_mtok: string;
|
||||||
|
fast_input_per_mtok: string;
|
||||||
|
fast_output_per_mtok: string;
|
||||||
|
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
|
||||||
|
// values are ignored). Generic: any model can carry a promo window.
|
||||||
|
intro_until: string;
|
||||||
|
intro_input_per_mtok: string;
|
||||||
|
intro_output_per_mtok: string;
|
||||||
|
intro_cache_read_per_mtok: string;
|
||||||
|
intro_cache_write_per_mtok: string;
|
||||||
|
intro_cache_write_1h_per_mtok: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRow: EditRow = {
|
||||||
|
model_pattern: "",
|
||||||
|
display_name: "",
|
||||||
|
input_per_mtok: "0",
|
||||||
|
output_per_mtok: "0",
|
||||||
|
cache_read_per_mtok: "0",
|
||||||
|
cache_write_per_mtok: "0",
|
||||||
|
cache_write_1h_per_mtok: "0",
|
||||||
|
fast_input_per_mtok: "0",
|
||||||
|
fast_output_per_mtok: "0",
|
||||||
|
intro_until: "",
|
||||||
|
intro_input_per_mtok: "0",
|
||||||
|
intro_output_per_mtok: "0",
|
||||||
|
intro_cache_read_per_mtok: "0",
|
||||||
|
intro_cache_write_per_mtok: "0",
|
||||||
|
intro_cache_write_1h_per_mtok: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const normalized =
|
||||||
|
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
|
||||||
|
const d = new Date(normalized);
|
||||||
|
return d.toLocaleString(getCurrentLocale(), {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Info popover for the Model Pricing section. Hover or focus the icon to see a
|
||||||
|
* three-section explanation: how prices are applied, how pattern matching
|
||||||
|
* works, and — for Claude — a reminder that prices must be edited manually
|
||||||
|
* when Anthropic publishes new rates; another vendor's rows come from
|
||||||
|
* LiteLLM's price list, so its tab says that instead. All copy is i18n-driven
|
||||||
|
* (settings.pricing.tooltip.*).
|
||||||
|
*
|
||||||
|
* The popover is fixed-positioned and clamped to the viewport so it never
|
||||||
|
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
|
||||||
|
* the Workflows stat tooltips.
|
||||||
|
*/
|
||||||
|
function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||||
|
const claude = vendor === "claude";
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||||
|
|
||||||
|
const positionPopover = useCallback(() => {
|
||||||
|
const btn = buttonRef.current;
|
||||||
|
const pop = popoverRef.current;
|
||||||
|
if (!btn) return;
|
||||||
|
const r = btn.getBoundingClientRect();
|
||||||
|
const w = pop?.offsetWidth ?? 320;
|
||||||
|
const h = pop?.offsetHeight ?? 240;
|
||||||
|
const margin = 8;
|
||||||
|
|
||||||
|
let left = r.right - w; // right-align with the icon
|
||||||
|
if (left < margin) left = margin;
|
||||||
|
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||||
|
let top = r.bottom + 8;
|
||||||
|
if (top + h > window.innerHeight - margin) {
|
||||||
|
top = Math.max(margin, r.top - h - 8);
|
||||||
|
}
|
||||||
|
setPos({ left, top });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
positionPopover();
|
||||||
|
const onScroll = () => positionPopover();
|
||||||
|
const onResize = () => positionPopover();
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
const raf = requestAnimationFrame(positionPopover);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [open, positionPopover]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={t("pricing.tooltip.title")}
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||||
|
>
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
role="tooltip"
|
||||||
|
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||||
|
style={{ left: pos.left, top: pos.top, width: 320 }}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
||||||
|
|
||||||
|
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||||
|
{t("pricing.tooltip.howItWorks")}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||||
|
{t("pricing.tooltip.howItWorksBody")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||||
|
{t("pricing.tooltip.patternsTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||||
|
{t("pricing.tooltip.patternsBody")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||||
|
{t(claude ? "pricing.tooltip.manualUpdates" : "pricing.tooltip.synced")}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||||
|
{t(claude ? "pricing.tooltip.manualUpdatesBody" : "pricing.tooltip.syncedBody")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||||
|
{t("pricing.tooltip.apiPricing")}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-secondary leading-snug">
|
||||||
|
{t(claude ? "pricing.tooltip.apiPricingBody" : "pricing.tooltip.apiPricingOtherBody")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PricingVendor = "claude" | "openai" | "google";
|
||||||
|
|
||||||
|
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
|
||||||
|
claude: /^claude-/,
|
||||||
|
openai: /^(gpt-|o\d|codex-)/,
|
||||||
|
google: /^gemini-/,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vendor: PricingVendor;
|
||||||
|
/** Offer Reset Defaults (it reseeds Claude's rates and resets only `claude-…` rows). */
|
||||||
|
allowReset?: boolean;
|
||||||
|
/** `<section id>` — a deep-link target. */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pricing table filtered to one vendor's rows.
|
||||||
|
* @returns The section, or a skeleton while the rules load.
|
||||||
|
*/
|
||||||
|
export function PricingSettings({ vendor, allowReset = false, id = "pricing" }: Props) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [pricing, setPricing] = useState<ModelPricing[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editingPattern, setEditingPattern] = useState<string | null>(null);
|
||||||
|
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const [actionResult, setActionResult] = useState<{
|
||||||
|
key: string;
|
||||||
|
message: string;
|
||||||
|
isError: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.pricing.list();
|
||||||
|
setPricing(res.pricing);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// A remote source can bring its own pricing rules; reload when one syncs.
|
||||||
|
useEffect(() => {
|
||||||
|
return eventBus.subscribe((msg: WSMessage) => {
|
||||||
|
if (isRemoteDataRefreshMessage(msg)) load();
|
||||||
|
});
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actionResult) return;
|
||||||
|
const timeout = setTimeout(() => setActionResult(null), 5000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [actionResult]);
|
||||||
|
|
||||||
|
const ours = (pattern: string) => VENDOR_PATTERN[vendor].test(pattern);
|
||||||
|
const rows = showAll ? pricing : pricing.filter((p) => ours(p.model_pattern));
|
||||||
|
const hidden = pricing.length - pricing.filter((p) => ours(p.model_pattern)).length;
|
||||||
|
|
||||||
|
const startEdit = (rule: ModelPricing) => {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingPattern(rule.model_pattern);
|
||||||
|
setEditRow({
|
||||||
|
model_pattern: rule.model_pattern,
|
||||||
|
display_name: rule.display_name,
|
||||||
|
input_per_mtok: String(rule.input_per_mtok),
|
||||||
|
output_per_mtok: String(rule.output_per_mtok),
|
||||||
|
cache_read_per_mtok: String(rule.cache_read_per_mtok),
|
||||||
|
cache_write_per_mtok: String(rule.cache_write_per_mtok),
|
||||||
|
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
|
||||||
|
fast_input_per_mtok: String(rule.fast_input_per_mtok),
|
||||||
|
fast_output_per_mtok: String(rule.fast_output_per_mtok),
|
||||||
|
intro_until: rule.intro_until ?? "",
|
||||||
|
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
|
||||||
|
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
|
||||||
|
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
|
||||||
|
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
|
||||||
|
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAdd = () => {
|
||||||
|
setEditingPattern(null);
|
||||||
|
setAdding(true);
|
||||||
|
setEditRow({ ...emptyRow });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingPattern(null);
|
||||||
|
setAdding(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
|
||||||
|
setError(t("pricing.validationRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const introUntil = editRow.intro_until.trim();
|
||||||
|
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
|
||||||
|
setError(t("pricing.introUntilInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.pricing.upsert({
|
||||||
|
model_pattern: editRow.model_pattern.trim(),
|
||||||
|
display_name: editRow.display_name.trim(),
|
||||||
|
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
|
||||||
|
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
|
||||||
|
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
|
||||||
|
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
|
||||||
|
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
|
||||||
|
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
|
||||||
|
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
|
||||||
|
// Always send the intro block so the UI is authoritative for it: an
|
||||||
|
// empty date clears the promo, a valid date persists the intro rates.
|
||||||
|
intro_until: introUntil || null,
|
||||||
|
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
|
||||||
|
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
|
||||||
|
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
|
||||||
|
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
|
||||||
|
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
|
||||||
|
});
|
||||||
|
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
|
||||||
|
setEditingPattern(null);
|
||||||
|
setAdding(false);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRule = async (pattern: string) => {
|
||||||
|
try {
|
||||||
|
await api.pricing.delete(pattern);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPricing = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
setActionResult(null);
|
||||||
|
setConfirmReset(false);
|
||||||
|
try {
|
||||||
|
// Only the Claude tab offers the button, and it resets only Claude's rows.
|
||||||
|
const res = await api.settings.resetPricing("claude");
|
||||||
|
setActionResult({
|
||||||
|
key: "reset-pricing",
|
||||||
|
message: t("pricing.resetResult", {
|
||||||
|
count: res.pricing.filter((p) => ours(p.model_pattern)).length,
|
||||||
|
}),
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setActionResult({
|
||||||
|
key: "reset-pricing",
|
||||||
|
message: t("messages.actionFailed", {
|
||||||
|
message: err instanceof Error ? err.message : t("messages.unknownError"),
|
||||||
|
}),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastUpdated =
|
||||||
|
rows.length > 0
|
||||||
|
? rows.reduce(
|
||||||
|
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
|
||||||
|
rows[0]!.updated_at
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isEditing = editingPattern !== null || adding;
|
||||||
|
|
||||||
|
const renderEditCells = () => (
|
||||||
|
<>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editRow.model_pattern}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
|
||||||
|
placeholder={t(`pricing.placeholders.${vendor}.pattern`)}
|
||||||
|
disabled={editingPattern !== null}
|
||||||
|
className="input w-full text-sm font-mono disabled:opacity-50"
|
||||||
|
autoFocus={adding}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editRow.display_name}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
|
||||||
|
placeholder={t(`pricing.placeholders.${vendor}.name`)}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editRow.input_per_mtok}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
|
||||||
|
className="input w-full text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editRow.output_per_mtok}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
|
||||||
|
className="input w-full text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editRow.cache_read_per_mtok}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
|
||||||
|
className="input w-full text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editRow.cache_write_per_mtok}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
|
||||||
|
className="input w-full text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editRow.cache_write_1h_per_mtok}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
|
||||||
|
className="input w-full text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editRow.fast_input_per_mtok}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
|
||||||
|
className="input w-full text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editRow.fast_output_per_mtok}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
|
||||||
|
className="input w-full text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
disabled={saving}
|
||||||
|
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
|
||||||
|
title={t("common:save")}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEdit}
|
||||||
|
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||||
|
title={t("common:cancel")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second edit row: the time-limited introductory-rate block. Rendered under
|
||||||
|
// the standard-rate cells whenever a row is being edited/added. Leaving the
|
||||||
|
// date empty means "no promo" — the rate inputs are then ignored. This is the
|
||||||
|
// ONLY place intro rates are entered, and it works for any model pattern (not
|
||||||
|
// just Sonnet 5), so a future model with a launch promo needs no code change.
|
||||||
|
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
|
||||||
|
{t(labelKey)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type={opts.date ? "text" : "number"}
|
||||||
|
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
|
||||||
|
value={editRow[key]}
|
||||||
|
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
|
||||||
|
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderIntroEditRow = () => (
|
||||||
|
<tr className="bg-surface-3">
|
||||||
|
<td colSpan={10} className="px-4 pb-3 pt-2">
|
||||||
|
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
|
||||||
|
{t("pricing.introRatesTitle")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
{introField("intro_until", "pricing.introUntil", { date: true })}
|
||||||
|
{introField("intro_input_per_mtok", "common:token.input")}
|
||||||
|
{introField("intro_output_per_mtok", "common:token.output")}
|
||||||
|
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
|
||||||
|
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
|
||||||
|
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section id={id} className="scroll-mt-24">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4 text-fg-muted" />
|
||||||
|
{t("pricing.title")}
|
||||||
|
<PricingInfoTooltip vendor={vendor} />
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hidden > 0 && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-xs text-fg-secondary cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showAll}
|
||||||
|
onChange={(e) => setShowAll(e.target.checked)}
|
||||||
|
className="accent-current"
|
||||||
|
/>
|
||||||
|
{t("pricing.showAll", { count: hidden })}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{allowReset && (
|
||||||
|
<button
|
||||||
|
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
|
||||||
|
disabled={isEditing || resetting}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
|
||||||
|
confirmReset
|
||||||
|
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
|
||||||
|
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
{confirmReset ? t("pricing.resetConfirm") : t("pricing.resetDefaults")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={startAdd}
|
||||||
|
disabled={isEditing}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionBanner(["reset-pricing"])}
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto mt-4">
|
||||||
|
<table className="w-full min-w-[1000px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||||
|
{t("pricing.pattern")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||||
|
{t("common:cost.model")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||||
|
{t("common:token.input")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||||
|
{t("common:token.output")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||||
|
{t("common:token.cacheRead")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||||
|
{t("pricing.cacheWrite5m")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||||
|
{t("pricing.cacheWrite1h")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||||
|
{t("pricing.fastInput")}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||||
|
{t("pricing.fastOutput")}
|
||||||
|
</th>
|
||||||
|
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||||
|
{t("common:actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.length === 0 && !adding && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="px-4 py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t(`pricing.empty.${vendor}`)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{rows.map((rule) =>
|
||||||
|
editingPattern === rule.model_pattern ? (
|
||||||
|
<Fragment key={rule.model_pattern}>
|
||||||
|
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||||
|
{renderIntroEditRow()}
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<tr key={rule.model_pattern} className="hover:bg-surface-4 transition-colors group">
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
|
||||||
|
{rule.model_pattern}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary">
|
||||||
|
{rule.display_name}
|
||||||
|
{rule.intro_until && (
|
||||||
|
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
|
||||||
|
{t("pricing.introNote", {
|
||||||
|
input: rule.intro_input_per_mtok,
|
||||||
|
output: rule.intro_output_per_mtok,
|
||||||
|
until: rule.intro_until,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||||
|
${rule.input_per_mtok}
|
||||||
|
{rule.intro_until && (
|
||||||
|
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||||
|
${rule.intro_input_per_mtok}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||||
|
${rule.output_per_mtok}
|
||||||
|
{rule.intro_until && (
|
||||||
|
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||||
|
${rule.intro_output_per_mtok}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||||
|
${rule.cache_read_per_mtok}
|
||||||
|
{rule.intro_until && (
|
||||||
|
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||||
|
${rule.intro_cache_read_per_mtok}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||||
|
${rule.cache_write_per_mtok}
|
||||||
|
{rule.intro_until && (
|
||||||
|
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||||
|
${rule.intro_cache_write_per_mtok}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||||
|
${rule.cache_write_1h_per_mtok}
|
||||||
|
{rule.intro_until && (
|
||||||
|
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||||
|
${rule.intro_cache_write_1h_per_mtok}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||||
|
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||||
|
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(rule)}
|
||||||
|
disabled={isEditing}
|
||||||
|
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
|
||||||
|
title={t("common:edit")}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteRule(rule.model_pattern)}
|
||||||
|
disabled={isEditing}
|
||||||
|
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
|
||||||
|
title={t("common:delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{adding && (
|
||||||
|
<>
|
||||||
|
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||||
|
{renderIntroEditRow()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastUpdated && (
|
||||||
|
<p className="text-xs text-fg-muted mt-3">
|
||||||
|
{t("pricing.lastUpdated")}
|
||||||
|
{formatTimestamp(lastUpdated)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @file AntigravityHooksSettings.test.tsx
|
||||||
|
* @description Settings → Antigravity → Antigravity hooks: not installed until
|
||||||
|
* the user clicks Install, then "Installed" with the note that agy asks no
|
||||||
|
* trust and has no permission hook; a hook switched off in hooks.json is
|
||||||
|
* reported; Refresh re-reads; Remove; a server refusal shown inline.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { AntigravityHooksSettings } from "../AntigravityHooksSettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import type { AntigravityHooksStatus } from "../../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { settings: { antigravityHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function status(installed: boolean, enabled = installed): AntigravityHooksStatus {
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
readable: true,
|
||||||
|
path: "/home/u/.gemini/config/hooks.json",
|
||||||
|
script: "/home/u/.gemini/config/agenthub-hook.sh",
|
||||||
|
installed,
|
||||||
|
enabled,
|
||||||
|
events: Object.fromEntries(
|
||||||
|
["PreInvocation", "PostToolUse", "Stop"].map((e) => [e, { installed }])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = api.settings.antigravityHooks;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
vi.mocked(hooks.get).mockReset().mockResolvedValue(status(false));
|
||||||
|
vi.mocked(hooks.install).mockReset().mockResolvedValue(status(true));
|
||||||
|
vi.mocked(hooks.remove).mockReset().mockResolvedValue(status(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AntigravityHooksSettings", () => {
|
||||||
|
it("installs on a click, and says agy has no permission hook", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AntigravityHooksSettings />);
|
||||||
|
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||||
|
expect(screen.getByText(/no hook for a permission prompt/)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||||
|
expect(hooks.install).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("PreInvocation")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("PreToolUse")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a hook switched off, re-reads on Refresh, and removes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(hooks.get)
|
||||||
|
.mockResolvedValueOnce(status(true, false))
|
||||||
|
.mockResolvedValueOnce(status(true, true));
|
||||||
|
render(<AntigravityHooksSettings />);
|
||||||
|
expect(await screen.findByText(/switched off/)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
|
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/switched off/)).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||||
|
expect(hooks.remove).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a server refusal inline", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(hooks.install).mockRejectedValue(new Error("hooks.json is not a JSON object"));
|
||||||
|
render(<AntigravityHooksSettings />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||||
|
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* @file ClaudeAgentHubSettings.test.tsx
|
||||||
|
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||||
|
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||||
|
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import type { ModelPricing } from "../../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||||
|
settings: {
|
||||||
|
info: vi.fn(),
|
||||||
|
reinstallHooks: vi.fn(),
|
||||||
|
resetPricing: vi.fn(),
|
||||||
|
claudeHome: { get: vi.fn(), set: vi.fn() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const RULE = {
|
||||||
|
model_pattern: "claude-opus-5-5%",
|
||||||
|
display_name: "Claude Opus 5.5",
|
||||||
|
input_per_mtok: 4,
|
||||||
|
output_per_mtok: 20,
|
||||||
|
cache_read_per_mtok: 0.2,
|
||||||
|
cache_write_per_mtok: 5,
|
||||||
|
cache_write_1h_per_mtok: 8,
|
||||||
|
fast_input_per_mtok: 8,
|
||||||
|
fast_output_per_mtok: 40,
|
||||||
|
updated_at: "2026-09-28 10:00:00",
|
||||||
|
} as ModelPricing;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
vi.mocked(api.pricing.list)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ pricing: [RULE] });
|
||||||
|
vi.mocked(api.settings.info)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({
|
||||||
|
hooks: {
|
||||||
|
installed: true,
|
||||||
|
path: "/home/u/.claude/settings.json",
|
||||||
|
hooks: { SessionStart: true, Stop: true },
|
||||||
|
},
|
||||||
|
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||||
|
vi.mocked(api.settings.claudeHome.get)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ claude_home: "/home/u/.claude" });
|
||||||
|
vi.mocked(api.settings.claudeHome.set)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } });
|
||||||
|
vi.mocked(api.settings.resetPricing).mockReset();
|
||||||
|
vi.mocked(api.settings.reinstallHooks)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ ok: true, hooks: { installed: true, hooks: {} } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ClaudeAgentHubSettings", () => {
|
||||||
|
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||||
|
const { container } = render(<ClaudeAgentHubSettings />);
|
||||||
|
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||||
|
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||||
|
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||||
|
}
|
||||||
|
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||||
|
expect(within(hooks).getByText("SessionStart")).toBeInTheDocument();
|
||||||
|
expect(within(hooks).getByText("/home/u/.claude/settings.json")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByDisplayValue("/home/u/.claude")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a new CLAUDE_HOME and reinstalls hooks", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = render(<ClaudeAgentHubSettings />);
|
||||||
|
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "/srv/claude");
|
||||||
|
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||||
|
await user.click(within(home).getByRole("button"));
|
||||||
|
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
|
||||||
|
|
||||||
|
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||||
|
await user.click(within(hooks).getByRole("button"));
|
||||||
|
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => {
|
||||||
|
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
claude_home: "/srv/claude",
|
||||||
|
routers: { missing: ["7 (api): my-router"] },
|
||||||
|
});
|
||||||
|
const onHomeChanged = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
|
||||||
|
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "/srv/claude");
|
||||||
|
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||||
|
const reads = vi.mocked(api.settings.info).mock.calls.length;
|
||||||
|
await user.click(within(home).getByRole("button"));
|
||||||
|
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument();
|
||||||
|
expect(onHomeChanged).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets pricing only on the second click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||||
|
render(<ClaudeAgentHubSettings />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||||
|
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||||
|
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @file CodexHooksSettings.test.tsx
|
||||||
|
* @description Settings → Codex → AgentHub → Codex hooks: not installed until
|
||||||
|
* the user clicks Install, then "waiting for your trust" with the note on how
|
||||||
|
* Codex asks for it, "trusted" once Codex's config says so, Remove, and a
|
||||||
|
* server refusal shown inline.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { CodexHooksSettings } from "../CodexHooksSettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import type { CodexHooksStatus } from "../../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { settings: { codexHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const EVENTS = ["SessionStart", "PreToolUse", "PermissionRequest", "Stop"];
|
||||||
|
function status(installed: boolean, trusted: boolean): CodexHooksStatus {
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
readable: true,
|
||||||
|
path: "/home/u/.codex/hooks.json",
|
||||||
|
script: "/home/u/.codex/agenthub-hook.sh",
|
||||||
|
installed,
|
||||||
|
trusted: installed && trusted,
|
||||||
|
events: Object.fromEntries(
|
||||||
|
EVENTS.map((e) => [e, { installed, trusted: installed && trusted }])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
vi.mocked(api.settings.codexHooks.get).mockReset().mockResolvedValue(status(false, false));
|
||||||
|
vi.mocked(api.settings.codexHooks.install).mockReset().mockResolvedValue(status(true, false));
|
||||||
|
vi.mocked(api.settings.codexHooks.remove).mockReset().mockResolvedValue(status(false, false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CodexHooksSettings", () => {
|
||||||
|
it("installs on a click, then says Codex still has to trust them", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CodexHooksSettings />);
|
||||||
|
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||||
|
expect(api.settings.codexHooks.install).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Trust all and continue/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows trusted hooks, and removes them", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(status(true, true));
|
||||||
|
render(<CodexHooksSettings />);
|
||||||
|
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Trust all and continue/)).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||||
|
expect(api.settings.codexHooks.remove).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the status on Refresh, e.g. once the user trusted the hooks in Codex", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.settings.codexHooks.get)
|
||||||
|
.mockResolvedValueOnce(status(true, false))
|
||||||
|
.mockResolvedValueOnce(status(true, true));
|
||||||
|
render(<CodexHooksSettings />);
|
||||||
|
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
|
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still offers Remove when only some of the hooks are there", async () => {
|
||||||
|
const partial = status(true, false);
|
||||||
|
partial.installed = false;
|
||||||
|
partial.events.Stop = { installed: false, trusted: false };
|
||||||
|
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(partial);
|
||||||
|
render(<CodexHooksSettings />);
|
||||||
|
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||||
|
// The chip, plus the missing event's screen-reader label.
|
||||||
|
expect(screen.getAllByText("Not installed")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a server refusal inline", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.settings.codexHooks.install).mockRejectedValue(
|
||||||
|
new Error("/home/u/.codex/hooks.json is not a JSON object; fix or move it first")
|
||||||
|
);
|
||||||
|
render(<CodexHooksSettings />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||||
|
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @file HomeDirSetting.test.tsx
|
||||||
|
* @description The shared editable home directory: loads the value in effect,
|
||||||
|
* Save is disabled until the path changes, a server refusal shows inline and
|
||||||
|
* changes nothing, an env-owned variable warns that a save will not survive a
|
||||||
|
* restart, and a partial-success warning from `save` is shown.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(load: HomeDirValue, save: (p: string) => Promise<HomeDirValue>) {
|
||||||
|
const saveFn = vi.fn(save);
|
||||||
|
const view = render(
|
||||||
|
<HomeDirSetting
|
||||||
|
id="x-home"
|
||||||
|
title="X_HOME"
|
||||||
|
description="desc"
|
||||||
|
placeholder="/path"
|
||||||
|
load={() => Promise.resolve(load)}
|
||||||
|
save={saveFn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return { saveFn, ...view };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HomeDirSetting", () => {
|
||||||
|
it("saves a changed path and shows the value now in effect", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { saveFn, container } = setup({ value: "/a", envOwned: false }, (p) =>
|
||||||
|
Promise.resolve({ value: p, envOwned: false })
|
||||||
|
);
|
||||||
|
const input = await screen.findByDisplayValue("/a");
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(container.querySelector("section#x-home")).not.toBeNull();
|
||||||
|
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "/b");
|
||||||
|
await user.click(button);
|
||||||
|
expect(saveFn).toHaveBeenCalledWith("/b");
|
||||||
|
expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/set by the environment/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a refusal inline and keeps the old value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup({ value: "/a", envOwned: false }, () =>
|
||||||
|
Promise.reject(new Error("Too broad for X_HOME: / contains your home directory"))
|
||||||
|
);
|
||||||
|
const input = await screen.findByDisplayValue("/a");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "/");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(await screen.findByText(/Too broad for X_HOME/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the environment owns the variable, and shows a save's warning", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup({ value: "/a", envOwned: true }, (p) =>
|
||||||
|
Promise.resolve({ value: p, envOwned: true, warning: "lane 3 not updated" })
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/set by the environment/)).toBeInTheDocument();
|
||||||
|
const input = screen.getByDisplayValue("/a");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "/b");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @file PricingSettings.test.tsx
|
||||||
|
* @description The shared Model Pricing table: each tool's tab shows its own
|
||||||
|
* vendor's rows, **Show all models** lists the rest, a row added outside the
|
||||||
|
* filter turns that switch on instead of vanishing, and Reset Defaults is
|
||||||
|
* offered only where asked for (the Claude tab).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { PricingSettings } from "../PricingSettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import type { ModelPricing } from "../../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||||
|
settings: { resetPricing: vi.fn() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const rule = (model_pattern: string, display_name: string) =>
|
||||||
|
({
|
||||||
|
model_pattern,
|
||||||
|
display_name,
|
||||||
|
input_per_mtok: 1,
|
||||||
|
output_per_mtok: 2,
|
||||||
|
cache_read_per_mtok: 0.1,
|
||||||
|
cache_write_per_mtok: 0,
|
||||||
|
cache_write_1h_per_mtok: 0,
|
||||||
|
fast_input_per_mtok: 0,
|
||||||
|
fast_output_per_mtok: 0,
|
||||||
|
updated_at: "2026-09-29 10:00:00",
|
||||||
|
}) as ModelPricing;
|
||||||
|
|
||||||
|
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
|
||||||
|
vi.mocked(api.pricing.upsert)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PricingSettings", () => {
|
||||||
|
it("shows the vendor's rows, and the rest behind Show all models", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PricingSettings vendor="openai" />);
|
||||||
|
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
|
||||||
|
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says where OpenAI prices come from when there are none", async () => {
|
||||||
|
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
|
||||||
|
render(<PricingSettings vendor="openai" />);
|
||||||
|
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns Show all on when a row outside the filter is added", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PricingSettings vendor="openai" />);
|
||||||
|
await screen.findByText("gpt-5.6-sol%");
|
||||||
|
await user.click(screen.getByRole("button", { name: /Add Model/i }));
|
||||||
|
const [pattern, name] = screen.getAllByRole("textbox");
|
||||||
|
await user.type(pattern!, "deepseek-v4%");
|
||||||
|
await user.type(name!, "DeepSeek V4");
|
||||||
|
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||||
|
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
|
||||||
|
});
|
||||||
|
await user.click(screen.getByTitle(/save/i));
|
||||||
|
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
|
||||||
|
render(<PricingSettings vendor="claude" allowReset />);
|
||||||
|
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
|
||||||
|
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||||
|
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
"hooks": "Hooks",
|
"hooks": "Hooks",
|
||||||
"keybindings": "Keybindings",
|
"keybindings": "Keybindings",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"memory": "Memory"
|
"memory": "Memory",
|
||||||
|
"agenthub": "AgentHub"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
"mcp": "MCP servers",
|
"mcp": "MCP servers",
|
||||||
"plugins": "Plugins"
|
"plugins": "Plugins",
|
||||||
|
"agenthub": "AgentHub"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
|
|||||||
@@ -39,5 +39,6 @@
|
|||||||
"timeline": "Timeline ({{shown}}/{{total}})",
|
"timeline": "Timeline ({{shown}}/{{total}})",
|
||||||
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
|
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
|
||||||
},
|
},
|
||||||
"remoteSourceBadgeTitle": "Collected from a remote machine"
|
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||||
|
"toolBadgeTitle": "Recorded by {{tool}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"subtitle": "Manage pricing, notifications, data, and hooks",
|
"subtitle": "Manage appearance, AI tools, notifications and data",
|
||||||
"exportData": "Export Data",
|
"exportData": "Export Data",
|
||||||
"acrossSessions": "Across all tracked sessions",
|
"acrossSessions": "Across all tracked sessions",
|
||||||
"basedOnUsage": "Based on per-model token usage",
|
"basedOnUsage": "Based on per-model token usage",
|
||||||
@@ -23,8 +23,6 @@
|
|||||||
"resetResult": "Reset to {{count}} default rules",
|
"resetResult": "Reset to {{count}} default rules",
|
||||||
"addModel": "Add Model",
|
"addModel": "Add Model",
|
||||||
"pattern": "Pattern",
|
"pattern": "Pattern",
|
||||||
"patternPlaceholder": "claude-opus-4%",
|
|
||||||
"namePlaceholder": "Claude Opus 4",
|
|
||||||
"validationRequired": "Pattern and display name are required",
|
"validationRequired": "Pattern and display name are required",
|
||||||
"lastUpdated": "Last updated: ",
|
"lastUpdated": "Last updated: ",
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
@@ -36,21 +34,41 @@
|
|||||||
"manualUpdates": "You must update prices manually",
|
"manualUpdates": "You must update prices manually",
|
||||||
"manualUpdatesBody": "Anthropic publishes pricing changes on their website - there is no live feed. When a new model launches or rates change, edit the matching rule (or add a new one above the catch-all) so newly recorded sessions are costed correctly. Already-stored sessions keep the price that was applied at ingest time.",
|
"manualUpdatesBody": "Anthropic publishes pricing changes on their website - there is no live feed. When a new model launches or rates change, edit the matching rule (or add a new one above the catch-all) so newly recorded sessions are costed correctly. Already-stored sessions keep the price that was applied at ingest time.",
|
||||||
"apiPricing": "Reference only - API rates",
|
"apiPricing": "Reference only - API rates",
|
||||||
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill."
|
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill.",
|
||||||
|
"synced": "Prices are filled in automatically",
|
||||||
|
"syncedBody": "A model a session uses with no rule of its own is priced from LiteLLM's public price list the first time it is seen. That row is never updated afterwards: edit it here when the vendor's rates change. Already-stored sessions keep the price that was applied at ingest time.",
|
||||||
|
"apiPricingOtherBody": "These are pay-as-you-go API rates. If you use this CLI on a subscription plan, your actual cost is a flat fee, not per-token - so these figures will usually be higher than what you really pay. Treat them as a usage estimate, not a bill."
|
||||||
},
|
},
|
||||||
"cacheWrite5m": "Cache Write (5m)",
|
"cacheWrite5m": "Cache Write (5m)",
|
||||||
"cacheWrite1h": "Cache Write (1h)",
|
"cacheWrite1h": "Cache Write (1h)",
|
||||||
"fastInput": "Fast Input",
|
"fastInput": "Fast Input",
|
||||||
"fastOutput": "Fast Output"
|
"fastOutput": "Fast Output",
|
||||||
|
"showAll": "Show all models ({{count}} more)",
|
||||||
|
"empty": {
|
||||||
|
"claude": "No Claude model prices. Reset Defaults restores them.",
|
||||||
|
"openai": "No OpenAI model prices yet. They are added automatically from LiteLLM's price list once a Codex session uses a model, or add one yourself.",
|
||||||
|
"google": "No Gemini model prices yet. They are added automatically from LiteLLM's price list once an Antigravity session uses a model, or add one yourself."
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"claude": {
|
||||||
|
"pattern": "claude-opus-4%",
|
||||||
|
"name": "Claude Opus 4"
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"pattern": "gpt-5%",
|
||||||
|
"name": "GPT 5"
|
||||||
|
},
|
||||||
|
"google": {
|
||||||
|
"pattern": "gemini-3.8-flash%",
|
||||||
|
"name": "Gemini 3.8 Flash"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"claudeHome": {
|
"claudeHome": {
|
||||||
"title": "CLAUDE_HOME",
|
"title": "CLAUDE_HOME",
|
||||||
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
|
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
|
||||||
"save": "Save",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"current": "Current:",
|
|
||||||
"placeholder": "/path/to/claude-home",
|
"placeholder": "/path/to/claude-home",
|
||||||
"saveFailed": "Failed to update CLAUDE_HOME"
|
"routersMissing": "Saved. These lanes name an AI Router profile the new home does not have, and start on Native until it is added there or they are re-pointed: {{lanes}}"
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"title": "Hook Configuration",
|
"title": "Hook Configuration",
|
||||||
@@ -450,6 +468,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"claudeCode": "Claude Code",
|
"claudeCode": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
|
"antigravity": "Antigravity",
|
||||||
"aiRouter": "AI Router",
|
"aiRouter": "AI Router",
|
||||||
"gitCredentials": "Git credentials"
|
"gitCredentials": "Git credentials"
|
||||||
},
|
},
|
||||||
@@ -556,5 +575,54 @@
|
|||||||
"colSaved": "Saved",
|
"colSaved": "Saved",
|
||||||
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
|
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
|
||||||
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
|
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
|
||||||
|
},
|
||||||
|
"homeDir": {
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"current": "Current:",
|
||||||
|
"saveFailed": "Failed to save the directory",
|
||||||
|
"envOwned": "This variable is set by the environment the dashboard started in, or by the checkout's .env — either wins at every start: a value saved here lasts until the next restart."
|
||||||
|
},
|
||||||
|
"codexHome": {
|
||||||
|
"title": "CODEX_HOME",
|
||||||
|
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex lane with it.",
|
||||||
|
"restartNote": "A codex lane already running keeps the home it started with, and shows as idle, until it is restarted.",
|
||||||
|
"placeholder": "/path/to/codex-home",
|
||||||
|
"routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}"
|
||||||
|
},
|
||||||
|
"codexHooks": {
|
||||||
|
"title": "Codex hooks",
|
||||||
|
"description": "Let Codex report to AgentHub as it works: each tool call in the session's timeline, \"Codex needs your permission\" on the lane card and as a notification, and running/idle the moment a turn starts or ends. Tokens and cost come from Codex's journals either way.",
|
||||||
|
"notInstalled": "Not installed",
|
||||||
|
"untrusted": "Installed — waiting for your trust in Codex",
|
||||||
|
"trusted": "Installed and trusted",
|
||||||
|
"trustNote": "Codex runs a hook only after you trust it. The next time Codex starts it asks you to review new hooks: choose Trust all and continue (or run /hooks). Until then Codex skips them.",
|
||||||
|
"install": "Install",
|
||||||
|
"reinstall": "Reinstall",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"remove": "Remove",
|
||||||
|
"eventTrusted": "Trusted in Codex",
|
||||||
|
"eventUntrusted": "Not trusted in Codex yet",
|
||||||
|
"eventMissing": "Not installed",
|
||||||
|
"unsupported": "Codex hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
|
||||||
|
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
|
||||||
|
"failed": "Could not change Codex hooks: {{message}}"
|
||||||
|
},
|
||||||
|
"antigravityHooks": {
|
||||||
|
"title": "Antigravity hooks",
|
||||||
|
"description": "Let Antigravity's CLI (agy) report to AgentHub as it works: each tool call in the session's timeline, and running/idle the moment a run starts or ends. Tokens and cost come from agy's conversation files either way.",
|
||||||
|
"notInstalled": "Not installed",
|
||||||
|
"installed": "Installed",
|
||||||
|
"disabled": "Installed but switched off in hooks.json",
|
||||||
|
"note": "agy loads hooks at its next start and asks for no trust. The file is global, so the Antigravity IDE runs the hook too; AgentHub ignores what the IDE sends. agy has no hook for a permission prompt, so an Antigravity lane cannot show \"needs your permission\".",
|
||||||
|
"install": "Install",
|
||||||
|
"reinstall": "Reinstall",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"remove": "Remove",
|
||||||
|
"eventInstalled": "Installed",
|
||||||
|
"eventMissing": "Not installed",
|
||||||
|
"unsupported": "Antigravity hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
|
||||||
|
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
|
||||||
|
"failed": "Could not change Antigravity hooks: {{message}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"hooks": "Hooks",
|
"hooks": "Hooks",
|
||||||
"keybindings": "Phím tắt",
|
"keybindings": "Phím tắt",
|
||||||
"settings": "Cài đặt",
|
"settings": "Cài đặt",
|
||||||
"memory": "Bộ nhớ"
|
"memory": "Bộ nhớ",
|
||||||
|
"agenthub": "AgentHub"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"memory": "Bộ nhớ",
|
"memory": "Bộ nhớ",
|
||||||
"config": "Cấu hình",
|
"config": "Cấu hình",
|
||||||
"mcp": "Máy chủ MCP",
|
"mcp": "Máy chủ MCP",
|
||||||
"plugins": "Plugin"
|
"plugins": "Plugin",
|
||||||
|
"agenthub": "AgentHub"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
|
|||||||
@@ -38,5 +38,6 @@
|
|||||||
"conversation": "Hội thoại",
|
"conversation": "Hội thoại",
|
||||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
||||||
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
|
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
|
||||||
}
|
},
|
||||||
|
"toolBadgeTitle": "Ghi lại bởi {{tool}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Cài đặt",
|
"title": "Cài đặt",
|
||||||
"subtitle": "Quản lý định giá, thông báo, dữ liệu và hooks",
|
"subtitle": "Quản lý giao diện, công cụ AI, thông báo và dữ liệu",
|
||||||
"exportData": "Xuất dữ liệu",
|
"exportData": "Xuất dữ liệu",
|
||||||
"acrossSessions": "Trên toàn bộ phiên đang được theo dõi",
|
"acrossSessions": "Trên toàn bộ phiên đang được theo dõi",
|
||||||
"basedOnUsage": "Dựa trên mức dùng token theo mô hình",
|
"basedOnUsage": "Dựa trên mức dùng token theo mô hình",
|
||||||
@@ -23,8 +23,6 @@
|
|||||||
"resetResult": "Đã đặt lại về {{count}} quy tắc mặc định",
|
"resetResult": "Đã đặt lại về {{count}} quy tắc mặc định",
|
||||||
"addModel": "Thêm mô hình",
|
"addModel": "Thêm mô hình",
|
||||||
"pattern": "Mẫu",
|
"pattern": "Mẫu",
|
||||||
"patternPlaceholder": "claude-opus-4%",
|
|
||||||
"namePlaceholder": "Claude Opus 4",
|
|
||||||
"validationRequired": "Bắt buộc nhập mẫu và tên hiển thị",
|
"validationRequired": "Bắt buộc nhập mẫu và tên hiển thị",
|
||||||
"lastUpdated": "Cập nhật lần cuối: ",
|
"lastUpdated": "Cập nhật lần cuối: ",
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
@@ -36,21 +34,41 @@
|
|||||||
"manualUpdates": "Bạn phải cập nhật giá thủ công",
|
"manualUpdates": "Bạn phải cập nhật giá thủ công",
|
||||||
"manualUpdatesBody": "Anthropic công bố thay đổi giá trên website của họ - không có nguồn dữ liệu trực tiếp. Khi có model mới ra mắt hoặc đơn giá thay đổi, hãy chỉnh sửa quy tắc tương ứng (hoặc thêm quy tắc mới ở phía trên quy tắc bao quát) để những phiên ghi nhận sau này được tính phí chính xác. Phiên đã lưu vẫn giữ nguyên giá đã áp dụng tại thời điểm nạp.",
|
"manualUpdatesBody": "Anthropic công bố thay đổi giá trên website của họ - không có nguồn dữ liệu trực tiếp. Khi có model mới ra mắt hoặc đơn giá thay đổi, hãy chỉnh sửa quy tắc tương ứng (hoặc thêm quy tắc mới ở phía trên quy tắc bao quát) để những phiên ghi nhận sau này được tính phí chính xác. Phiên đã lưu vẫn giữ nguyên giá đã áp dụng tại thời điểm nạp.",
|
||||||
"apiPricing": "Chỉ để tham khảo - đơn giá API",
|
"apiPricing": "Chỉ để tham khảo - đơn giá API",
|
||||||
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn."
|
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn.",
|
||||||
|
"synced": "Giá được điền tự động",
|
||||||
|
"syncedBody": "Một model mà phiên dùng nhưng chưa có quy tắc riêng sẽ được lấy giá từ bảng giá công khai của LiteLLM ở lần đầu gặp. Dòng đó không bao giờ tự cập nhật lại: hãy sửa ở đây khi nhà cung cấp đổi giá. Các phiên đã lưu vẫn giữ mức giá áp dụng lúc ghi nhận.",
|
||||||
|
"apiPricingOtherBody": "Đây là giá API trả theo mức dùng. Nếu bạn dùng CLI này theo gói thuê bao, chi phí thực là một khoản cố định chứ không tính theo token, nên các con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chúng là ước tính mức dùng, không phải hoá đơn."
|
||||||
},
|
},
|
||||||
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
|
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
|
||||||
"cacheWrite1h": "Ghi bộ nhớ đệm (1h)",
|
"cacheWrite1h": "Ghi bộ nhớ đệm (1h)",
|
||||||
"fastInput": "Fast Input",
|
"fastInput": "Fast Input",
|
||||||
"fastOutput": "Fast Output"
|
"fastOutput": "Fast Output",
|
||||||
|
"showAll": "Hiện mọi model (thêm {{count}})",
|
||||||
|
"empty": {
|
||||||
|
"claude": "Chưa có giá model Claude. Nút Khôi phục mặc định sẽ đưa chúng trở lại.",
|
||||||
|
"openai": "Chưa có giá model OpenAI. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Codex dùng model đó, hoặc bạn tự thêm.",
|
||||||
|
"google": "Chưa có giá model Gemini. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Antigravity dùng model đó, hoặc bạn tự thêm."
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"claude": {
|
||||||
|
"pattern": "claude-opus-4%",
|
||||||
|
"name": "Claude Opus 4"
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"pattern": "gpt-5%",
|
||||||
|
"name": "GPT 5"
|
||||||
|
},
|
||||||
|
"google": {
|
||||||
|
"pattern": "gemini-3.8-flash%",
|
||||||
|
"name": "Gemini 3.8 Flash"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"claudeHome": {
|
"claudeHome": {
|
||||||
"title": "CLAUDE_HOME",
|
"title": "CLAUDE_HOME",
|
||||||
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
|
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
|
||||||
"save": "Lưu",
|
|
||||||
"saving": "Đang lưu...",
|
|
||||||
"current": "Hiện tại:",
|
|
||||||
"placeholder": "/đường-dẫn/tới/claude-home",
|
"placeholder": "/đường-dẫn/tới/claude-home",
|
||||||
"saveFailed": "Cập nhật CLAUDE_HOME thất bại"
|
"routersMissing": "Đã lưu. Các lane này dùng một hồ sơ AI Router mà thư mục mới không có, nên sẽ chạy Native cho đến khi bạn thêm hồ sơ đó ở đây hoặc chọn lại router cho lane: {{lanes}}"
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"title": "Cấu hình hook",
|
"title": "Cấu hình hook",
|
||||||
@@ -450,6 +468,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"claudeCode": "Claude Code",
|
"claudeCode": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
|
"antigravity": "Antigravity",
|
||||||
"aiRouter": "AI Router",
|
"aiRouter": "AI Router",
|
||||||
"gitCredentials": "Git credentials"
|
"gitCredentials": "Git credentials"
|
||||||
},
|
},
|
||||||
@@ -556,5 +575,54 @@
|
|||||||
"colSaved": "Đã lưu",
|
"colSaved": "Đã lưu",
|
||||||
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
|
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
|
||||||
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
|
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
|
||||||
|
},
|
||||||
|
"homeDir": {
|
||||||
|
"save": "Lưu",
|
||||||
|
"saving": "Đang lưu...",
|
||||||
|
"current": "Hiện tại:",
|
||||||
|
"saveFailed": "Không lưu được thư mục",
|
||||||
|
"envOwned": "Biến này được đặt bởi môi trường lúc khởi động dashboard, hoặc bởi file .env của bản checkout, và nguồn đó luôn được ưu tiên mỗi lần khởi động: giá trị lưu ở đây chỉ giữ đến lần khởi động lại tiếp theo."
|
||||||
|
},
|
||||||
|
"codexHome": {
|
||||||
|
"title": "CODEX_HOME",
|
||||||
|
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi lane codex với thư mục này.",
|
||||||
|
"restartNote": "Lane codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
|
||||||
|
"placeholder": "/đường-dẫn/tới/codex-home",
|
||||||
|
"routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}"
|
||||||
|
},
|
||||||
|
"codexHooks": {
|
||||||
|
"title": "Hook của Codex",
|
||||||
|
"description": "Cho Codex báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, \"Codex cần bạn cho phép\" trên thẻ lane và qua thông báo, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ nhật ký của Codex dù có hook hay không.",
|
||||||
|
"notInstalled": "Chưa cài",
|
||||||
|
"untrusted": "Đã cài — đang chờ bạn tin cậy trong Codex",
|
||||||
|
"trusted": "Đã cài và được tin cậy",
|
||||||
|
"trustNote": "Codex chỉ chạy một hook sau khi bạn tin cậy nó. Lần tới Codex khởi động, nó sẽ hỏi bạn xem lại các hook mới: chọn Trust all and continue (hoặc gõ /hooks). Trước đó Codex bỏ qua chúng.",
|
||||||
|
"install": "Cài đặt",
|
||||||
|
"reinstall": "Cài lại",
|
||||||
|
"refresh": "Làm mới",
|
||||||
|
"remove": "Gỡ bỏ",
|
||||||
|
"eventTrusted": "Đã được tin cậy trong Codex",
|
||||||
|
"eventUntrusted": "Chưa được tin cậy trong Codex",
|
||||||
|
"eventMissing": "Chưa cài",
|
||||||
|
"unsupported": "Hook của Codex từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
|
||||||
|
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
|
||||||
|
"failed": "Không đổi được hook của Codex: {{message}}"
|
||||||
|
},
|
||||||
|
"antigravityHooks": {
|
||||||
|
"title": "Hook của Antigravity",
|
||||||
|
"description": "Cho CLI của Antigravity (agy) báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ file hội thoại của agy dù có hook hay không.",
|
||||||
|
"notInstalled": "Chưa cài",
|
||||||
|
"installed": "Đã cài",
|
||||||
|
"disabled": "Đã cài nhưng đang tắt trong hooks.json",
|
||||||
|
"note": "agy nạp hook ở lần khởi động tiếp theo và không hỏi tin cậy. File này dùng chung, nên Antigravity IDE cũng chạy hook; AgentHub bỏ qua những gì IDE gửi. agy không có hook cho lời hỏi quyền, nên lane Antigravity không thể hiện \"cần bạn cấp quyền\".",
|
||||||
|
"install": "Cài đặt",
|
||||||
|
"reinstall": "Cài lại",
|
||||||
|
"refresh": "Làm mới",
|
||||||
|
"remove": "Gỡ bỏ",
|
||||||
|
"eventInstalled": "Đã cài",
|
||||||
|
"eventMissing": "Chưa cài",
|
||||||
|
"unsupported": "Hook của Antigravity từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
|
||||||
|
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
|
||||||
|
"failed": "Không đổi được hook của Antigravity: {{message}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-8
@@ -393,6 +393,8 @@ import type {
|
|||||||
AlertEvent,
|
AlertEvent,
|
||||||
AlertRule,
|
AlertRule,
|
||||||
Analytics,
|
Analytics,
|
||||||
|
CodexHooksStatus,
|
||||||
|
AntigravityHooksStatus,
|
||||||
CostResult,
|
CostResult,
|
||||||
DashboardEvent,
|
DashboardEvent,
|
||||||
Lane,
|
Lane,
|
||||||
@@ -676,6 +678,8 @@ export const api = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
/** One CLI's sessions only (`claude`, `codex`, `antigravity`). */
|
||||||
|
tool?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_desc?: boolean;
|
sort_desc?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -686,6 +690,7 @@ export const api = {
|
|||||||
if (params?.status) qs.set("status", params.status);
|
if (params?.status) qs.set("status", params.status);
|
||||||
if (params?.q) qs.set("q", params.q);
|
if (params?.q) qs.set("q", params.q);
|
||||||
if (params?.cwd) qs.set("cwd", params.cwd);
|
if (params?.cwd) qs.set("cwd", params.cwd);
|
||||||
|
if (params?.tool) qs.set("tool", params.tool);
|
||||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||||
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
|
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
|
||||||
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
|
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
|
||||||
@@ -998,17 +1003,56 @@ export const api = {
|
|||||||
* GET /api/settings/claude-home - the currently configured Claude home path.
|
* GET /api/settings/claude-home - the currently configured Claude home path.
|
||||||
* @returns `{ claude_home }` — the absolute path the server reads config from.
|
* @returns `{ claude_home }` — the absolute path the server reads config from.
|
||||||
*/
|
*/
|
||||||
get: () => request<{ claude_home: string }>("/settings/claude-home"),
|
get: () => request<{ claude_home: string; env_owned?: boolean }>("/settings/claude-home"),
|
||||||
/**
|
/**
|
||||||
* PUT /api/settings/claude-home - repoint the server at a new Claude home.
|
* PUT /api/settings/claude-home - repoint the server at a new Claude home.
|
||||||
* @param path New absolute `~/.claude` root the server should read from.
|
* @param path New absolute `~/.claude` root the server should read from.
|
||||||
* @returns `{ ok, claude_home }` — success flag and the accepted path.
|
* @returns `{ ok, claude_home, routers }` — the accepted path, and the
|
||||||
|
* lanes whose AI Router profile the new home's store does not have.
|
||||||
*/
|
*/
|
||||||
set: (path: string) =>
|
set: (path: string) =>
|
||||||
request<{ ok: boolean; claude_home: string }>("/settings/claude-home", {
|
request<{
|
||||||
method: "PUT",
|
ok: boolean;
|
||||||
body: JSON.stringify({ path }),
|
claude_home: string;
|
||||||
}),
|
env_owned?: boolean;
|
||||||
|
routers: { missing: string[] };
|
||||||
|
}>("/settings/claude-home", { method: "PUT", body: JSON.stringify({ path }) }),
|
||||||
|
},
|
||||||
|
/** The Codex home: same shape as `claudeHome`, plus the AI Router layers
|
||||||
|
* a PUT re-wrote into the new home for codex lanes. */
|
||||||
|
codexHome: {
|
||||||
|
/** GET /api/settings/codex-home */
|
||||||
|
get: () => request<{ codex_home: string; env_owned: boolean }>("/settings/codex-home"),
|
||||||
|
/** PUT /api/settings/codex-home */
|
||||||
|
set: (path: string) =>
|
||||||
|
request<{
|
||||||
|
ok: boolean;
|
||||||
|
codex_home: string;
|
||||||
|
env_owned: boolean;
|
||||||
|
routers: { updated: number; failed: string[] };
|
||||||
|
}>("/settings/codex-home", { method: "PUT", body: JSON.stringify({ path }) }),
|
||||||
|
},
|
||||||
|
/** AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json` — changed only on a
|
||||||
|
* click; Codex asks the user to trust them before they run. */
|
||||||
|
codexHooks: {
|
||||||
|
/** GET /api/settings/codex-hooks */
|
||||||
|
get: () => request<CodexHooksStatus>("/settings/codex-hooks"),
|
||||||
|
/** POST /api/settings/codex-hooks — install, or reinstall */
|
||||||
|
install: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "POST" }),
|
||||||
|
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
|
||||||
|
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
|
||||||
|
},
|
||||||
|
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
|
||||||
|
* only on a click; agy runs it with no trust step. */
|
||||||
|
antigravityHooks: {
|
||||||
|
/** GET /api/settings/antigravity-hooks */
|
||||||
|
get: () => request<AntigravityHooksStatus>("/settings/antigravity-hooks"),
|
||||||
|
/** POST /api/settings/antigravity-hooks — install, or reinstall */
|
||||||
|
install: () =>
|
||||||
|
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "POST" }),
|
||||||
|
/** DELETE /api/settings/antigravity-hooks — AgentHub's named hook only */
|
||||||
|
remove: () =>
|
||||||
|
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "DELETE" }),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* POST /api/settings/clear-data - DESTRUCTIVE: wipes sessions/agents/
|
* POST /api/settings/clear-data - DESTRUCTIVE: wipes sessions/agents/
|
||||||
@@ -1063,11 +1107,13 @@ export const api = {
|
|||||||
* Wipes user-customized pricing rules and reseeds the shipped defaults;
|
* Wipes user-customized pricing rules and reseeds the shipped defaults;
|
||||||
* returns the resulting rule set so the Pricing UI can re-render.
|
* returns the resulting rule set so the Pricing UI can re-render.
|
||||||
*
|
*
|
||||||
* @returns `{ ok, pricing }` — the full default rule list now in effect.
|
* @param scope - `"claude"` resets only `claude-…` rows; omitted, every row.
|
||||||
|
* @returns `{ ok, pricing }` — the full rule list now in effect.
|
||||||
*/
|
*/
|
||||||
resetPricing: () =>
|
resetPricing: (scope?: "claude") =>
|
||||||
request<{ ok: boolean; pricing: ModelPricing[] }>("/settings/reset-pricing", {
|
request<{ ok: boolean; pricing: ModelPricing[] }>("/settings/reset-pricing", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
...(scope && { body: JSON.stringify({ scope }) }),
|
||||||
}),
|
}),
|
||||||
/**
|
/**
|
||||||
* Direct download URL for GET /api/settings/export (a full DB dump);
|
* Direct download URL for GET /api/settings/export (a full DB dump);
|
||||||
|
|||||||
@@ -629,6 +629,40 @@ export type EffectiveSessionStatus = SessionStatus | typeof AWAITING_STATUS;
|
|||||||
// `Agent` is one process within it (the main loop or a spawned subagent). Both
|
// `Agent` is one process within it (the main loop or a spawned subagent). Both
|
||||||
// are created lazily on their first hook event and mutated as hooks stream in.
|
// are created lazily on their first hook event and mutated as hooks stream in.
|
||||||
|
|
||||||
|
/** GET/POST/DELETE /api/settings/codex-hooks: AgentHub's hooks in Codex. */
|
||||||
|
export interface CodexHooksStatus {
|
||||||
|
/** False on Windows: the hook command is a POSIX shell script. */
|
||||||
|
supported: boolean;
|
||||||
|
/** False when hooks.json exists but is not a JSON object (see `error`). */
|
||||||
|
readable: boolean;
|
||||||
|
error?: string;
|
||||||
|
/** `$CODEX_HOME/hooks.json` and the forwarder script it points at. */
|
||||||
|
path: string;
|
||||||
|
script: string;
|
||||||
|
/** Every event has AgentHub's hook, and the script exists. */
|
||||||
|
installed: boolean;
|
||||||
|
/** Codex's config.toml holds a trust entry for every one of them. */
|
||||||
|
trusted: boolean;
|
||||||
|
events: Record<string, { installed: boolean; trusted: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/settings/antigravity-hooks — AgentHub's named hook for agy. */
|
||||||
|
export interface AntigravityHooksStatus {
|
||||||
|
/** False on Windows: the hook command is a POSIX shell script. */
|
||||||
|
supported: boolean;
|
||||||
|
/** False when hooks.json exists but is not a JSON object (see `error`). */
|
||||||
|
readable: boolean;
|
||||||
|
error?: string;
|
||||||
|
/** `~/.gemini/config/hooks.json` and the forwarder script it points at. */
|
||||||
|
path: string;
|
||||||
|
script: string;
|
||||||
|
/** Every event runs AgentHub's forwarder, and the script exists. */
|
||||||
|
installed: boolean;
|
||||||
|
/** Installed and not switched off (`"enabled": false`) in the file. */
|
||||||
|
enabled: boolean;
|
||||||
|
events: Record<string, { installed: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Claude Code CLI invocation tracked by the dashboard - one row per top-level
|
* A Claude Code CLI invocation tracked by the dashboard - one row per top-level
|
||||||
* `claude` process, created on its first hook event (or on import) and updated
|
* `claude` process, created on its first hook event (or on import) and updated
|
||||||
@@ -695,6 +729,11 @@ export interface Session {
|
|||||||
* dashboard's own machine; any other value is a remote source id (see the
|
* dashboard's own machine; any other value is a remote source id (see the
|
||||||
* Remote Data Sources feature / `remote_sources`). Maps to `sessions.source`. */
|
* Remote Data Sources feature / `remote_sources`). Maps to `sessions.source`. */
|
||||||
source?: string;
|
source?: string;
|
||||||
|
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||||
|
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||||
|
* journals and hooks) or `"antigravity"` (agy's conversation files and
|
||||||
|
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
||||||
|
tool?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @file AntigravityConfig.tsx
|
||||||
|
* @description Settings → Antigravity: AgentHub's own settings for the
|
||||||
|
* Antigravity CLI (`agy`) — Model Pricing for Gemini models (the shared
|
||||||
|
* `PricingSettings` table) and AgentHub's Antigravity hooks
|
||||||
|
* (`AntigravityHooksSettings`). agy has no config surface AgentHub edits, so
|
||||||
|
* there is no explorer and no sub-tab bar, only these cards (#40 part D).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
|
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
|
||||||
|
|
||||||
|
/** The Antigravity settings tab. */
|
||||||
|
export function AntigravityConfig() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<PricingSettings vendor="google" id="antigravity-pricing" />
|
||||||
|
<AntigravityHooksSettings />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
* low-risk text-file surfaces (skills, agents, commands, output styles,
|
* low-risk text-file surfaces (skills, agents, commands, output styles,
|
||||||
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
|
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
|
||||||
* hooks-in-settings, and settings.json files stay read-only - those have
|
* hooks-in-settings, and settings.json files stay read-only - those have
|
||||||
* concurrent-write races with the live CLI.
|
* concurrent-write races with the live CLI. Its AgentHub sub-tab holds the
|
||||||
|
* dashboard's own Claude Code settings (pricing, hooks, CLAUDE_HOME).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -58,6 +59,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Gauge,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -110,6 +112,7 @@ import { MdItemList } from "../components/config/MdItemList";
|
|||||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||||
|
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
@@ -178,7 +181,8 @@ type TabKey =
|
|||||||
| "hooks"
|
| "hooks"
|
||||||
| "keybindings"
|
| "keybindings"
|
||||||
| "settings"
|
| "settings"
|
||||||
| "memory";
|
| "memory"
|
||||||
|
| "agenthub";
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
key: TabKey;
|
key: TabKey;
|
||||||
@@ -199,6 +203,7 @@ const TABS: TabDef[] = [
|
|||||||
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
|
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
|
||||||
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
|
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
|
||||||
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
|
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
|
||||||
|
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface PageState {
|
interface PageState {
|
||||||
@@ -238,6 +243,8 @@ const EMPTY_STATE: PageState = {
|
|||||||
export function CcConfig() {
|
export function CcConfig() {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
const [tab, setTab] = useState<TabKey>("overview");
|
const [tab, setTab] = useState<TabKey>("overview");
|
||||||
|
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
|
||||||
|
const [agenthubKey, setAgenthubKey] = useState(0);
|
||||||
const [scope, setScope] = useState<CcScope>("all");
|
const [scope, setScope] = useState<CcScope>("all");
|
||||||
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -505,7 +512,10 @@ export function CcConfig() {
|
|||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onScopeChange={setScope}
|
onScopeChange={setScope}
|
||||||
onRefresh={fetchAll}
|
onRefresh={() => {
|
||||||
|
void fetchAll();
|
||||||
|
setAgenthubKey((k) => k + 1);
|
||||||
|
}}
|
||||||
onOpenBackups={() => setBackupsOpen(true)}
|
onOpenBackups={() => setBackupsOpen(true)}
|
||||||
onOpenProfiles={() => setProfilesOpen(true)}
|
onOpenProfiles={() => setProfilesOpen(true)}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
@@ -520,67 +530,73 @@ export function CcConfig() {
|
|||||||
|
|
||||||
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-surface-1">
|
{/* AgentHub's own Claude Code settings are cards of their own, not a
|
||||||
{tab !== "overview" && (
|
list over this tab's search box and scope. */}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
{tab === "agenthub" ? (
|
||||||
<Search className="w-4 h-4 text-fg-muted" />
|
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
|
||||||
<input
|
) : (
|
||||||
value={search}
|
<div className="rounded-xl border border-border bg-surface-1">
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
{tab !== "overview" && (
|
||||||
placeholder={t("common.search")}
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
<Search className="w-4 h-4 text-fg-muted" />
|
||||||
/>
|
<input
|
||||||
{search && (
|
value={search}
|
||||||
<button
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
type="button"
|
placeholder={t("common.search")}
|
||||||
onClick={() => setSearch("")}
|
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
||||||
title={t("common.clearSearch")}
|
/>
|
||||||
aria-label={t("common.clearSearch")}
|
{search && (
|
||||||
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
<button
|
||||||
>
|
type="button"
|
||||||
<X className="w-3.5 h-3.5" />
|
onClick={() => setSearch("")}
|
||||||
</button>
|
title={t("common.clearSearch")}
|
||||||
)}
|
aria-label={t("common.clearSearch")}
|
||||||
{isMutable(tab) && tab !== "memory" && (
|
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||||
<button
|
>
|
||||||
onClick={() => openCreate(tabToArtifactType(tab))}
|
<X className="w-3.5 h-3.5" />
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
</button>
|
||||||
>
|
)}
|
||||||
<Plus className="w-3 h-3" />
|
{isMutable(tab) && tab !== "memory" && (
|
||||||
{t("edit.newButton")}
|
<button
|
||||||
</button>
|
onClick={() => openCreate(tabToArtifactType(tab))}
|
||||||
)}
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||||
{/* The memory tab's own "new" is a different thing: the two
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
{t("edit.newButton")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* The memory tab's own "new" is a different thing: the two
|
||||||
CLAUDE.md cards below are this machine's user + current-project
|
CLAUDE.md cards below are this machine's user + current-project
|
||||||
files, while this creates one in ANOTHER project's directory. */}
|
files, while this creates one in ANOTHER project's directory. */}
|
||||||
{tab === "memory" && (
|
{tab === "memory" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setProjectMemoryOpen(true)}
|
onClick={() => setProjectMemoryOpen(true)}
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
{t("projectMemory.newButton")}
|
{t("projectMemory.newButton")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<TabPanel
|
||||||
|
tab={tab}
|
||||||
|
data={data}
|
||||||
|
search={search}
|
||||||
|
onOpenFile={openViewer}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={openDelete}
|
||||||
|
onCreateMemory={(s) => openCreate("memory", s)}
|
||||||
|
onEditAuto={openEditAuto}
|
||||||
|
onDeleteAuto={openDeleteAuto}
|
||||||
|
onCreateAuto={openCreateAuto}
|
||||||
|
onChanged={fetchAll}
|
||||||
|
onToast={setToast}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="p-4">
|
|
||||||
<TabPanel
|
|
||||||
tab={tab}
|
|
||||||
data={data}
|
|
||||||
search={search}
|
|
||||||
onOpenFile={openViewer}
|
|
||||||
onEdit={openEdit}
|
|
||||||
onDelete={openDelete}
|
|
||||||
onCreateMemory={(s) => openCreate("memory", s)}
|
|
||||||
onEditAuto={openEditAuto}
|
|
||||||
onDeleteAuto={openDeleteAuto}
|
|
||||||
onCreateAuto={openCreateAuto}
|
|
||||||
onChanged={fetchAll}
|
|
||||||
onToast={setToast}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
|
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
|
||||||
{editor && (
|
{editor && (
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
|
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
|
||||||
* split between editable artifacts and CLI-driven surfaces. Codex has no
|
* split between editable artifacts and CLI-driven surfaces. Codex has no
|
||||||
* "project-local" scope and no per-project file-based memory - its scope
|
* "project-local" scope and no per-project file-based memory - its scope
|
||||||
* union is exactly "user" | "project".
|
* union is exactly "user" | "project". Its **AgentHub** sub-tab holds the
|
||||||
|
* dashboard's own Codex settings: Model Pricing for OpenAI models (the shared
|
||||||
|
* `PricingSettings` table), AgentHub's Codex hooks (`CodexHooksSettings`)
|
||||||
|
* and CODEX_HOME.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Gauge,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -51,6 +55,9 @@ import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
|||||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||||
|
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
|
||||||
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
|
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
@@ -75,7 +82,16 @@ import type {
|
|||||||
CodexSkill,
|
CodexSkill,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
|
||||||
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
|
type TabKey =
|
||||||
|
| "overview"
|
||||||
|
| "skills"
|
||||||
|
| "agents"
|
||||||
|
| "rules"
|
||||||
|
| "memory"
|
||||||
|
| "config"
|
||||||
|
| "mcp"
|
||||||
|
| "plugins"
|
||||||
|
| "agenthub";
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
key: TabKey;
|
key: TabKey;
|
||||||
@@ -92,6 +108,7 @@ const TABS: TabDef[] = [
|
|||||||
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
|
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
|
||||||
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
|
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
|
||||||
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
|
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
|
||||||
|
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
|
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
|
||||||
@@ -141,7 +158,12 @@ interface NamedArtifactItem {
|
|||||||
|
|
||||||
export function CodexConfig() {
|
export function CodexConfig() {
|
||||||
const { t } = useTranslation("codexConfig");
|
const { t } = useTranslation("codexConfig");
|
||||||
|
const { t: tSettings } = useTranslation("settings");
|
||||||
const [tab, setTab] = useState<TabKey>("overview");
|
const [tab, setTab] = useState<TabKey>("overview");
|
||||||
|
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
|
||||||
|
const [agenthubKey, setAgenthubKey] = useState(0);
|
||||||
|
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
|
||||||
|
const [hooksKey, setHooksKey] = useState(0);
|
||||||
const [scope, setScope] = useState<CodexScope>("all");
|
const [scope, setScope] = useState<CodexScope>("all");
|
||||||
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -361,7 +383,10 @@ export function CodexConfig() {
|
|||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
onScopeChange={setScope}
|
onScopeChange={setScope}
|
||||||
onRefresh={fetchAll}
|
onRefresh={() => {
|
||||||
|
void fetchAll();
|
||||||
|
setAgenthubKey((k) => k + 1);
|
||||||
|
}}
|
||||||
onOpenBackups={() => setBackupsOpen(true)}
|
onOpenBackups={() => setBackupsOpen(true)}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
/>
|
/>
|
||||||
@@ -375,67 +400,101 @@ export function CodexConfig() {
|
|||||||
|
|
||||||
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-surface-1">
|
{/* AgentHub's own Codex settings are cards of their own, not a list. A
|
||||||
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
|
new CODEX_HOME re-reads every list, since they all live under it. */}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
{tab === "agenthub" ? (
|
||||||
<Search className="w-4 h-4 text-fg-muted" />
|
<div key={agenthubKey} className="space-y-8">
|
||||||
<input
|
<PricingSettings vendor="openai" id="codex-pricing" />
|
||||||
value={search}
|
<CodexHooksSettings key={hooksKey} />
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<HomeDirSetting
|
||||||
placeholder={t("common.search")}
|
id="codex-home"
|
||||||
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
title={tSettings("codexHome.title")}
|
||||||
/>
|
description={tSettings("codexHome.description")}
|
||||||
{search && (
|
note={tSettings("codexHome.restartNote")}
|
||||||
|
placeholder={tSettings("codexHome.placeholder")}
|
||||||
|
load={() =>
|
||||||
|
api.settings.codexHome
|
||||||
|
.get()
|
||||||
|
.then((r) => ({ value: r.codex_home, envOwned: r.env_owned }))
|
||||||
|
}
|
||||||
|
save={(p) =>
|
||||||
|
api.settings.codexHome.set(p).then((r) => {
|
||||||
|
void fetchAll();
|
||||||
|
setHooksKey((k) => k + 1);
|
||||||
|
return {
|
||||||
|
value: r.codex_home,
|
||||||
|
envOwned: r.env_owned,
|
||||||
|
warning: r.routers.failed.length
|
||||||
|
? tSettings("codexHome.routersFailed", { lanes: r.routers.failed.join(", ") })
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border bg-surface-1">
|
||||||
|
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
|
<Search className="w-4 h-4 text-fg-muted" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t("common.search")}
|
||||||
|
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
title={t("common.clearSearch")}
|
||||||
|
aria-label={t("common.clearSearch")}
|
||||||
|
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isNamedArtifact(tab) && (
|
||||||
|
<button
|
||||||
|
onClick={() => openCreate(tab)}
|
||||||
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
{t("list.newButton")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* The memory tab hides the search bar, but still needs its own
|
||||||
|
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
|
||||||
|
the user/project files listed below. */}
|
||||||
|
{tab === "memory" && (
|
||||||
|
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={() => setProjectMemoryOpen(true)}
|
||||||
onClick={() => setSearch("")}
|
|
||||||
title={t("common.clearSearch")}
|
|
||||||
aria-label={t("common.clearSearch")}
|
|
||||||
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isNamedArtifact(tab) && (
|
|
||||||
<button
|
|
||||||
onClick={() => openCreate(tab)}
|
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
{t("list.newButton")}
|
{t("projectMemory.newButton")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<TabPanel
|
||||||
|
tab={tab}
|
||||||
|
data={data}
|
||||||
|
search={search}
|
||||||
|
cliError={cliError}
|
||||||
|
onOpenFile={openViewer}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={openDelete}
|
||||||
|
onCreateScopeFile={openCreate}
|
||||||
|
onChanged={fetchAll}
|
||||||
|
onToast={setToast}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{/* The memory tab hides the search bar, but still needs its own
|
|
||||||
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
|
|
||||||
the user/project files listed below. */}
|
|
||||||
{tab === "memory" && (
|
|
||||||
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setProjectMemoryOpen(true)}
|
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
{t("projectMemory.newButton")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-4">
|
|
||||||
<TabPanel
|
|
||||||
tab={tab}
|
|
||||||
data={data}
|
|
||||||
search={search}
|
|
||||||
cliError={cliError}
|
|
||||||
onOpenFile={openViewer}
|
|
||||||
onEdit={openEdit}
|
|
||||||
onDelete={openDelete}
|
|
||||||
onCreateScopeFile={openCreate}
|
|
||||||
onChanged={fetchAll}
|
|
||||||
onToast={setToast}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{backupsOpen && (
|
{backupsOpen && (
|
||||||
<BackupsModal roots={data.overview?.roots} onClose={() => setBackupsOpen(false)} />
|
<BackupsModal roots={data.overview?.roots} onClose={() => setBackupsOpen(false)} />
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import { AgentCard } from "../components/AgentCard";
|
|||||||
import { SessionOverview } from "../components/SessionOverview";
|
import { SessionOverview } from "../components/SessionOverview";
|
||||||
import { ConversationView } from "../components/conversation/ConversationView";
|
import { ConversationView } from "../components/conversation/ConversationView";
|
||||||
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||||
|
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||||
import { CopyButton } from "../components/event-views/primitives";
|
import { CopyButton } from "../components/event-views/primitives";
|
||||||
import {
|
import {
|
||||||
effectiveSessionStatus,
|
effectiveSessionStatus,
|
||||||
@@ -589,6 +590,7 @@ export function SessionDetail() {
|
|||||||
<h2 className="text-xl font-semibold text-fg-primary">
|
<h2 className="text-xl font-semibold text-fg-primary">
|
||||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||||
</h2>
|
</h2>
|
||||||
|
<SessionToolBadge tool={session.tool} />
|
||||||
<SessionStatusBadge
|
<SessionStatusBadge
|
||||||
status={effectiveSessionStatus(session)}
|
status={effectiveSessionStatus(session)}
|
||||||
reason={sessionAwaitingReason(session)}
|
reason={sessionAwaitingReason(session)}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { eventBus } from "../lib/eventBus";
|
|||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||||
import { useDataScope } from "../lib/dataScope";
|
import { useDataScope } from "../lib/dataScope";
|
||||||
import { SessionStatusBadge } from "../components/StatusBadge";
|
import { SessionStatusBadge } from "../components/StatusBadge";
|
||||||
|
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { TableRowSkeleton } from "../components/Skeleton";
|
import { TableRowSkeleton } from "../components/Skeleton";
|
||||||
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
||||||
@@ -434,6 +435,7 @@ export function Sessions() {
|
|||||||
<p className="text-sm font-medium text-fg-secondary">
|
<p className="text-sm font-medium text-fg-secondary">
|
||||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||||
</p>
|
</p>
|
||||||
|
<SessionToolBadge tool={session.tool} />
|
||||||
{session.source && session.source !== "local" && (
|
{session.source && session.source !== "local" && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
|
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
|
||||||
|
|||||||
+12
-766
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Settings.tsx
|
* @file Settings.tsx
|
||||||
* @description Provides a settings page for managing model pricing rules, notification preferences, and system information with real-time updates and actionable controls for data management and hook configuration.
|
* @description Settings → Dashboard: the install-wide settings — total cost, appearance, AI Tools, lanes housekeeping, import, remote sources, Sagi, notifications, alerts, data management and system information. Per-tool settings live in that tool's own tab (Claude Code's pricing, hooks and CLAUDE_HOME: `components/settings/ClaudeAgentHubSettings.tsx`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -55,27 +55,20 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragment } from "react";
|
import { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Palette,
|
Palette,
|
||||||
Bot,
|
Bot,
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Database,
|
Database,
|
||||||
Plug,
|
|
||||||
HardDrive,
|
HardDrive,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
XCircle,
|
|
||||||
Server,
|
Server,
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
@@ -99,8 +92,6 @@ import {
|
|||||||
Coins,
|
Coins,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
FolderOpen,
|
|
||||||
Info,
|
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
History,
|
History,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -111,7 +102,7 @@ import { api } from "../lib/api";
|
|||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||||
import { sagiPrefs } from "../components/Sagi/prefs";
|
import { sagiPrefs } from "../components/Sagi/prefs";
|
||||||
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
|
import { fmt, fmtCost } from "../lib/format";
|
||||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||||
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
|
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
|
||||||
import { notify } from "../lib/notify";
|
import { notify } from "../lib/notify";
|
||||||
@@ -122,7 +113,7 @@ import { Skeleton } from "../components/Skeleton";
|
|||||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||||
import type { ModelPricing, WSMessage } from "../lib/types";
|
import type { WSMessage } from "../lib/types";
|
||||||
|
|
||||||
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
||||||
// `<section id>` rendered below; the TOC scroll-spies the active one.
|
// `<section id>` rendered below; the TOC scroll-spies the active one.
|
||||||
@@ -134,10 +125,7 @@ const SETTINGS_SECTIONS: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
|
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
|
||||||
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
|
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
|
||||||
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
|
|
||||||
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
|
|
||||||
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
||||||
{ id: "claude-home", labelKey: "claudeHome.title", Icon: FolderOpen },
|
|
||||||
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
|
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
|
||||||
{
|
{
|
||||||
id: "remote-sources",
|
id: "remote-sources",
|
||||||
@@ -154,44 +142,6 @@ const SETTINGS_SECTIONS: {
|
|||||||
|
|
||||||
// ─── Helpers ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
interface EditRow {
|
|
||||||
model_pattern: string;
|
|
||||||
display_name: string;
|
|
||||||
input_per_mtok: string;
|
|
||||||
output_per_mtok: string;
|
|
||||||
cache_read_per_mtok: string;
|
|
||||||
cache_write_per_mtok: string;
|
|
||||||
cache_write_1h_per_mtok: string;
|
|
||||||
fast_input_per_mtok: string;
|
|
||||||
fast_output_per_mtok: string;
|
|
||||||
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
|
|
||||||
// values are ignored). Generic: any model can carry a promo window.
|
|
||||||
intro_until: string;
|
|
||||||
intro_input_per_mtok: string;
|
|
||||||
intro_output_per_mtok: string;
|
|
||||||
intro_cache_read_per_mtok: string;
|
|
||||||
intro_cache_write_per_mtok: string;
|
|
||||||
intro_cache_write_1h_per_mtok: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyRow: EditRow = {
|
|
||||||
model_pattern: "",
|
|
||||||
display_name: "",
|
|
||||||
input_per_mtok: "0",
|
|
||||||
output_per_mtok: "0",
|
|
||||||
cache_read_per_mtok: "0",
|
|
||||||
cache_write_per_mtok: "0",
|
|
||||||
cache_write_1h_per_mtok: "0",
|
|
||||||
fast_input_per_mtok: "0",
|
|
||||||
fast_output_per_mtok: "0",
|
|
||||||
intro_until: "",
|
|
||||||
intro_input_per_mtok: "0",
|
|
||||||
intro_output_per_mtok: "0",
|
|
||||||
intro_cache_read_per_mtok: "0",
|
|
||||||
intro_cache_write_per_mtok: "0",
|
|
||||||
intro_cache_write_1h_per_mtok: "0",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SystemInfo {
|
interface SystemInfo {
|
||||||
db: { path: string; size: number; counts: Record<string, number> };
|
db: { path: string; size: number; counts: Record<string, number> };
|
||||||
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
|
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||||
@@ -204,19 +154,6 @@ interface SystemInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
|
||||||
const normalized =
|
|
||||||
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
|
|
||||||
const d = new Date(normalized);
|
|
||||||
return d.toLocaleString(getCurrentLocale(), {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
@@ -306,121 +243,11 @@ function Toggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Info popover for the Model Pricing section. Hover or focus the icon to see a
|
|
||||||
* three-section explanation: how prices are applied, how pattern matching
|
|
||||||
* works, and a reminder that prices must be edited manually when Anthropic
|
|
||||||
* publishes new rates. All copy is i18n-driven (settings.pricing.tooltip.*).
|
|
||||||
*
|
|
||||||
* The popover is fixed-positioned and clamped to the viewport so it never
|
|
||||||
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
|
|
||||||
* the Workflows stat tooltips.
|
|
||||||
*/
|
|
||||||
function PricingInfoTooltip() {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
|
||||||
|
|
||||||
const positionPopover = useCallback(() => {
|
|
||||||
const btn = buttonRef.current;
|
|
||||||
const pop = popoverRef.current;
|
|
||||||
if (!btn) return;
|
|
||||||
const r = btn.getBoundingClientRect();
|
|
||||||
const w = pop?.offsetWidth ?? 320;
|
|
||||||
const h = pop?.offsetHeight ?? 240;
|
|
||||||
const margin = 8;
|
|
||||||
|
|
||||||
let left = r.right - w; // right-align with the icon
|
|
||||||
if (left < margin) left = margin;
|
|
||||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
|
||||||
let top = r.bottom + 8;
|
|
||||||
if (top + h > window.innerHeight - margin) {
|
|
||||||
top = Math.max(margin, r.top - h - 8);
|
|
||||||
}
|
|
||||||
setPos({ left, top });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
positionPopover();
|
|
||||||
const onScroll = () => positionPopover();
|
|
||||||
const onResize = () => positionPopover();
|
|
||||||
window.addEventListener("scroll", onScroll, true);
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
const raf = requestAnimationFrame(positionPopover);
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
window.removeEventListener("scroll", onScroll, true);
|
|
||||||
window.removeEventListener("resize", onResize);
|
|
||||||
};
|
|
||||||
}, [open, positionPopover]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
|
||||||
aria-label={t("pricing.tooltip.title")}
|
|
||||||
onMouseEnter={() => setOpen(true)}
|
|
||||||
onMouseLeave={() => setOpen(false)}
|
|
||||||
onFocus={() => setOpen(true)}
|
|
||||||
onBlur={() => setOpen(false)}
|
|
||||||
className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
||||||
>
|
|
||||||
<Info className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
ref={popoverRef}
|
|
||||||
role="tooltip"
|
|
||||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
|
||||||
style={{ left: pos.left, top: pos.top, width: 320 }}
|
|
||||||
>
|
|
||||||
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
|
||||||
|
|
||||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
|
||||||
{t("pricing.tooltip.howItWorks")}
|
|
||||||
</p>
|
|
||||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
|
||||||
{t("pricing.tooltip.howItWorksBody")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
|
||||||
{t("pricing.tooltip.patternsTitle")}
|
|
||||||
</p>
|
|
||||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
|
||||||
{t("pricing.tooltip.patternsBody")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
|
||||||
{t("pricing.tooltip.manualUpdates")}
|
|
||||||
</p>
|
|
||||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
|
||||||
{t("pricing.tooltip.manualUpdatesBody")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
|
||||||
{t("pricing.tooltip.apiPricing")}
|
|
||||||
</p>
|
|
||||||
<p className="text-fg-secondary leading-snug">{t("pricing.tooltip.apiPricingBody")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main component ───
|
// ─── Main component ───
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [pricing, setPricing] = useState<ModelPricing[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingPattern, setEditingPattern] = useState<string | null>(null);
|
|
||||||
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
|
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [totalCost, setTotalCost] = useState<number | null>(null);
|
const [totalCost, setTotalCost] = useState<number | null>(null);
|
||||||
const [sysInfo, setSysInfo] = useState<SystemInfo | null>(null);
|
const [sysInfo, setSysInfo] = useState<SystemInfo | null>(null);
|
||||||
@@ -440,11 +267,7 @@ export function Settings() {
|
|||||||
const [abandonHours, setAbandonHours] = useState("24");
|
const [abandonHours, setAbandonHours] = useState("24");
|
||||||
const [purgeDays, setPurgeDays] = useState("90");
|
const [purgeDays, setPurgeDays] = useState("90");
|
||||||
const [gcDryRun, setGcDryRun] = useState(false);
|
const [gcDryRun, setGcDryRun] = useState(false);
|
||||||
const [claudeHome, setClaudeHomeState] = useState("");
|
const [activeSection, setActiveSection] = useState<string>("appearance");
|
||||||
const [claudeHomeInput, setClaudeHomeInput] = useState("");
|
|
||||||
const [claudeHomeSaving, setClaudeHomeSaving] = useState(false);
|
|
||||||
const [claudeHomeError, setClaudeHomeError] = useState<string | null>(null);
|
|
||||||
const [activeSection, setActiveSection] = useState<string>("pricing");
|
|
||||||
const tocRef = useRef<HTMLDivElement | null>(null);
|
const tocRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [tocOverflow, setTocOverflow] = useState({ left: false, right: false });
|
const [tocOverflow, setTocOverflow] = useState({ left: false, right: false });
|
||||||
|
|
||||||
@@ -504,17 +327,9 @@ export function Settings() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [pricingRes, costRes, infoRes, claudeHomeRes] = await Promise.all([
|
const [costRes, infoRes] = await Promise.all([api.pricing.totalCost(), api.settings.info()]);
|
||||||
api.pricing.list(),
|
|
||||||
api.pricing.totalCost(),
|
|
||||||
api.settings.info(),
|
|
||||||
api.settings.claudeHome.get(),
|
|
||||||
]);
|
|
||||||
setPricing(pricingRes.pricing);
|
|
||||||
setTotalCost(costRes.total_cost);
|
setTotalCost(costRes.total_cost);
|
||||||
setSysInfo(infoRes);
|
setSysInfo(infoRes);
|
||||||
setClaudeHomeState(claudeHomeRes.claude_home);
|
|
||||||
setClaudeHomeInput(claudeHomeRes.claude_home);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||||
@@ -583,91 +398,6 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (rule: ModelPricing) => {
|
|
||||||
setAdding(false);
|
|
||||||
setEditingPattern(rule.model_pattern);
|
|
||||||
setEditRow({
|
|
||||||
model_pattern: rule.model_pattern,
|
|
||||||
display_name: rule.display_name,
|
|
||||||
input_per_mtok: String(rule.input_per_mtok),
|
|
||||||
output_per_mtok: String(rule.output_per_mtok),
|
|
||||||
cache_read_per_mtok: String(rule.cache_read_per_mtok),
|
|
||||||
cache_write_per_mtok: String(rule.cache_write_per_mtok),
|
|
||||||
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
|
|
||||||
fast_input_per_mtok: String(rule.fast_input_per_mtok),
|
|
||||||
fast_output_per_mtok: String(rule.fast_output_per_mtok),
|
|
||||||
intro_until: rule.intro_until ?? "",
|
|
||||||
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
|
|
||||||
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
|
|
||||||
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
|
|
||||||
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
|
|
||||||
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const startAdd = () => {
|
|
||||||
setEditingPattern(null);
|
|
||||||
setAdding(true);
|
|
||||||
setEditRow({ ...emptyRow });
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setEditingPattern(null);
|
|
||||||
setAdding(false);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEdit = async () => {
|
|
||||||
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
|
|
||||||
setError(t("pricing.validationRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const introUntil = editRow.intro_until.trim();
|
|
||||||
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
|
|
||||||
setError(t("pricing.introUntilInvalid"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await api.pricing.upsert({
|
|
||||||
model_pattern: editRow.model_pattern.trim(),
|
|
||||||
display_name: editRow.display_name.trim(),
|
|
||||||
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
|
|
||||||
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
|
|
||||||
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
|
|
||||||
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
|
|
||||||
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
|
|
||||||
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
|
|
||||||
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
|
|
||||||
// Always send the intro block so the UI is authoritative for it: an
|
|
||||||
// empty date clears the promo, a valid date persists the intro rates.
|
|
||||||
intro_until: introUntil || null,
|
|
||||||
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
|
|
||||||
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
|
|
||||||
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
|
|
||||||
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
|
|
||||||
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
|
|
||||||
});
|
|
||||||
setEditingPattern(null);
|
|
||||||
setAdding(false);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteRule = async (pattern: string) => {
|
|
||||||
try {
|
|
||||||
await api.pricing.delete(pattern);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAction = async (key: string, fn: () => Promise<string>) => {
|
const runAction = async (key: string, fn: () => Promise<string>) => {
|
||||||
setActionLoading(key);
|
setActionLoading(key);
|
||||||
setActionResult(null);
|
setActionResult(null);
|
||||||
@@ -696,24 +426,12 @@ export function Settings() {
|
|||||||
return t("danger.clearedResult", { count: total });
|
return t("danger.clearedResult", { count: total });
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleReinstallHooks = () =>
|
|
||||||
runAction("hooks", async () => {
|
|
||||||
const res = await api.settings.reinstallHooks();
|
|
||||||
return res.ok ? t("hooks.success") : t("hooks.failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleGc = () =>
|
const handleGc = () =>
|
||||||
runAction("gc", async () => {
|
runAction("gc", async () => {
|
||||||
const res = await api.settings.gc({ dryRun: gcDryRun });
|
const res = await api.settings.gc({ dryRun: gcDryRun });
|
||||||
return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length });
|
return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleResetPricing = () =>
|
|
||||||
runAction("reset-pricing", async () => {
|
|
||||||
const res = await api.settings.resetPricing();
|
|
||||||
return t("pricing.resetResult", { count: res.pricing.length });
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCleanup = () =>
|
const handleCleanup = () =>
|
||||||
runAction("cleanup", async () => {
|
runAction("cleanup", async () => {
|
||||||
const params: { abandon_hours?: number; purge_days?: number } = {};
|
const params: { abandon_hours?: number; purge_days?: number } = {};
|
||||||
@@ -731,188 +449,6 @@ export function Settings() {
|
|||||||
return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean");
|
return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean");
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSaveClaudeHome = async () => {
|
|
||||||
if (claudeHomeInput === claudeHome) return;
|
|
||||||
setClaudeHomeSaving(true);
|
|
||||||
setClaudeHomeError(null);
|
|
||||||
try {
|
|
||||||
const res = await api.settings.claudeHome.set(claudeHomeInput);
|
|
||||||
setClaudeHomeState(res.claude_home);
|
|
||||||
setClaudeHomeInput(res.claude_home);
|
|
||||||
} catch (err) {
|
|
||||||
setClaudeHomeError(err instanceof Error ? err.message : t("claudeHome.saveFailed"));
|
|
||||||
} finally {
|
|
||||||
setClaudeHomeSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastUpdated =
|
|
||||||
pricing.length > 0
|
|
||||||
? pricing.reduce(
|
|
||||||
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
|
|
||||||
pricing[0]!.updated_at
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const isEditing = editingPattern !== null || adding;
|
|
||||||
|
|
||||||
const renderEditCells = () => (
|
|
||||||
<>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editRow.model_pattern}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
|
|
||||||
placeholder={t("pricing.patternPlaceholder")}
|
|
||||||
disabled={editingPattern !== null}
|
|
||||||
className="input w-full text-sm font-mono disabled:opacity-50"
|
|
||||||
autoFocus={adding}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editRow.display_name}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
|
|
||||||
placeholder={t("pricing.namePlaceholder")}
|
|
||||||
className="input w-full text-sm"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editRow.input_per_mtok}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
|
|
||||||
className="input w-full text-sm text-right font-mono"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editRow.output_per_mtok}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
|
|
||||||
className="input w-full text-sm text-right font-mono"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editRow.cache_read_per_mtok}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
|
|
||||||
className="input w-full text-sm text-right font-mono"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editRow.cache_write_per_mtok}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
|
|
||||||
className="input w-full text-sm text-right font-mono"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editRow.cache_write_1h_per_mtok}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
|
|
||||||
className="input w-full text-sm text-right font-mono"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editRow.fast_input_per_mtok}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
|
|
||||||
className="input w-full text-sm text-right font-mono"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editRow.fast_output_per_mtok}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
|
|
||||||
className="input w-full text-sm text-right font-mono"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={saveEdit}
|
|
||||||
disabled={saving}
|
|
||||||
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
|
|
||||||
title={t("common:save")}
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelEdit}
|
|
||||||
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
|
|
||||||
title={t("common:cancel")}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Second edit row: the time-limited introductory-rate block. Rendered under
|
|
||||||
// the standard-rate cells whenever a row is being edited/added. Leaving the
|
|
||||||
// date empty means "no promo" — the rate inputs are then ignored. This is the
|
|
||||||
// ONLY place intro rates are entered, and it works for any model pattern (not
|
|
||||||
// just Sonnet 5), so a future model with a launch promo needs no code change.
|
|
||||||
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
|
|
||||||
{t(labelKey)}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type={opts.date ? "text" : "number"}
|
|
||||||
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
|
|
||||||
value={editRow[key]}
|
|
||||||
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
|
|
||||||
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderIntroEditRow = () => (
|
|
||||||
<tr className="bg-surface-3">
|
|
||||||
<td colSpan={10} className="px-4 pb-3 pt-2">
|
|
||||||
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
|
||||||
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
|
|
||||||
{t("pricing.introRatesTitle")}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
|
||||||
{introField("intro_until", "pricing.introUntil", { date: true })}
|
|
||||||
{introField("intro_input_per_mtok", "common:token.input")}
|
|
||||||
{introField("intro_output_per_mtok", "common:token.output")}
|
|
||||||
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
|
|
||||||
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
|
|
||||||
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
|
|
||||||
const actionBanner = (keys: string[]) => {
|
const actionBanner = (keys: string[]) => {
|
||||||
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
@@ -1053,6 +589,12 @@ export function Settings() {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cost summary card */}
|
{/* Cost summary card */}
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
@@ -1086,264 +628,6 @@ export function Settings() {
|
|||||||
|
|
||||||
<AiToolsSettings />
|
<AiToolsSettings />
|
||||||
|
|
||||||
{/* ─── MODEL PRICING ─── */}
|
|
||||||
<section id="pricing" className="scroll-mt-24">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
|
||||||
<DollarSign className="w-4 h-4 text-fg-muted" />
|
|
||||||
{t("pricing.title")}
|
|
||||||
<PricingInfoTooltip />
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
confirmAction === "reset-pricing"
|
|
||||||
? handleResetPricing()
|
|
||||||
: setConfirmAction("reset-pricing")
|
|
||||||
}
|
|
||||||
disabled={isEditing || actionLoading !== null}
|
|
||||||
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
|
|
||||||
confirmAction === "reset-pricing"
|
|
||||||
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
|
|
||||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3 h-3" />
|
|
||||||
{confirmAction === "reset-pricing"
|
|
||||||
? t("pricing.resetConfirm")
|
|
||||||
: t("pricing.resetDefaults")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={startAdd}
|
|
||||||
disabled={isEditing}
|
|
||||||
className="btn-primary text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actionBanner(["reset-pricing"])}
|
|
||||||
|
|
||||||
<div className="card overflow-x-auto mt-4">
|
|
||||||
<table className="w-full min-w-[1000px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-left">
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
|
||||||
{t("pricing.pattern")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
|
||||||
{t("common:cost.model")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
||||||
{t("common:token.input")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
||||||
{t("common:token.output")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
||||||
{t("common:token.cacheRead")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
||||||
{t("pricing.cacheWrite5m")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
||||||
{t("pricing.cacheWrite1h")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
||||||
{t("pricing.fastInput")}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
||||||
{t("pricing.fastOutput")}
|
|
||||||
</th>
|
|
||||||
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
|
||||||
{t("common:actions")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{pricing.map((rule) =>
|
|
||||||
editingPattern === rule.model_pattern ? (
|
|
||||||
<Fragment key={rule.model_pattern}>
|
|
||||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
|
||||||
{renderIntroEditRow()}
|
|
||||||
</Fragment>
|
|
||||||
) : (
|
|
||||||
<tr
|
|
||||||
key={rule.model_pattern}
|
|
||||||
className="hover:bg-surface-4 transition-colors group"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
|
|
||||||
{rule.model_pattern}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary">
|
|
||||||
{rule.display_name}
|
|
||||||
{rule.intro_until && (
|
|
||||||
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
|
|
||||||
{t("pricing.introNote", {
|
|
||||||
input: rule.intro_input_per_mtok,
|
|
||||||
output: rule.intro_output_per_mtok,
|
|
||||||
until: rule.intro_until,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
||||||
${rule.input_per_mtok}
|
|
||||||
{rule.intro_until && (
|
|
||||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
|
||||||
${rule.intro_input_per_mtok}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
||||||
${rule.output_per_mtok}
|
|
||||||
{rule.intro_until && (
|
|
||||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
|
||||||
${rule.intro_output_per_mtok}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
||||||
${rule.cache_read_per_mtok}
|
|
||||||
{rule.intro_until && (
|
|
||||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
|
||||||
${rule.intro_cache_read_per_mtok}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
||||||
${rule.cache_write_per_mtok}
|
|
||||||
{rule.intro_until && (
|
|
||||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
|
||||||
${rule.intro_cache_write_per_mtok}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
||||||
${rule.cache_write_1h_per_mtok}
|
|
||||||
{rule.intro_until && (
|
|
||||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
|
||||||
${rule.intro_cache_write_1h_per_mtok}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
||||||
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
||||||
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => startEdit(rule)}
|
|
||||||
disabled={isEditing}
|
|
||||||
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
|
|
||||||
title={t("common:edit")}
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => deleteRule(rule.model_pattern)}
|
|
||||||
disabled={isEditing}
|
|
||||||
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
|
|
||||||
title={t("common:delete")}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{adding && (
|
|
||||||
<>
|
|
||||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
|
||||||
{renderIntroEditRow()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lastUpdated && (
|
|
||||||
<p className="text-xs text-fg-muted mt-3">
|
|
||||||
{t("pricing.lastUpdated")}
|
|
||||||
{formatTimestamp(lastUpdated)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ─── 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">
|
|
||||||
{sysInfo?.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"])}
|
|
||||||
|
|
||||||
{sysInfo && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
|
||||||
{Object.entries(sysInfo.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">{sysInfo.hooks.path}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ─── LANES ─── */}
|
{/* ─── LANES ─── */}
|
||||||
<section id="lanes" className="scroll-mt-24">
|
<section id="lanes" className="scroll-mt-24">
|
||||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||||
@@ -1379,44 +663,6 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ─── CLAUDE HOME ─── */}
|
|
||||||
<section id="claude-home" className="scroll-mt-24">
|
|
||||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
|
||||||
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
|
||||||
{t("claudeHome.title")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-fg-muted mb-1">{t("claudeHome.description")}</p>
|
|
||||||
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
|
|
||||||
|
|
||||||
<div className="card p-5 space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={claudeHomeInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
setClaudeHomeInput(e.target.value);
|
|
||||||
setClaudeHomeError(null);
|
|
||||||
}}
|
|
||||||
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
|
||||||
placeholder={t("claudeHome.placeholder")}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveClaudeHome}
|
|
||||||
disabled={claudeHomeSaving || claudeHomeInput === claudeHome}
|
|
||||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{claudeHomeSaving ? t("claudeHome.saving") : t("claudeHome.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{claudeHomeError && <p className="text-xs text-status-danger">{claudeHomeError}</p>}
|
|
||||||
{claudeHome && (
|
|
||||||
<p className="text-xs text-fg-muted">
|
|
||||||
{t("claudeHome.current")} <code className="text-fg-secondary">{claudeHome}</code>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ─── IMPORT HISTORY ─── */}
|
{/* ─── IMPORT HISTORY ─── */}
|
||||||
<section id="import" className="scroll-mt-24">
|
<section id="import" className="scroll-mt-24">
|
||||||
<ImportHistory />
|
<ImportHistory />
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* @file SettingsHub.tsx
|
* @file SettingsHub.tsx
|
||||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools.
|
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Boxes, KeyRound, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
import {
|
||||||
|
Boxes,
|
||||||
|
KeyRound,
|
||||||
|
Orbit,
|
||||||
|
Router,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
SquareTerminal,
|
||||||
|
} from "lucide-react";
|
||||||
import { PageTabs } from "../components/PageTabs";
|
import { PageTabs } from "../components/PageTabs";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { CcConfig } from "./CcConfig";
|
import { CcConfig } from "./CcConfig";
|
||||||
import { CodexConfig } from "./CodexConfig";
|
import { CodexConfig } from "./CodexConfig";
|
||||||
|
import { AntigravityConfig } from "./AntigravityConfig";
|
||||||
import { AiRouterConfig } from "./AiRouterConfig";
|
import { AiRouterConfig } from "./AiRouterConfig";
|
||||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
@@ -48,6 +56,13 @@ const TOOL_TABS: ToolTab[] = [
|
|||||||
Panel: CodexConfig,
|
Panel: CodexConfig,
|
||||||
aiTool: "codex",
|
aiTool: "codex",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "antigravity",
|
||||||
|
labelKey: "tools.antigravity",
|
||||||
|
Icon: Orbit,
|
||||||
|
Panel: AntigravityConfig,
|
||||||
|
aiTool: "antigravity",
|
||||||
|
},
|
||||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||||
{
|
{
|
||||||
id: "git-credentials",
|
id: "git-credentials",
|
||||||
|
|||||||
@@ -501,7 +501,9 @@ export function Workspace() {
|
|||||||
.then((r) => setActiveRuns(keepIfSame(r)))
|
.then((r) => setActiveRuns(keepIfSame(r)))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
|
// Attach/resume go through `claude`, so a Codex session is not offered
|
||||||
|
// (filtered by the server, before its limit).
|
||||||
|
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true, tool: "claude" })
|
||||||
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
|
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -46,6 +46,27 @@ const codexConfigMock = vi.hoisted(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const codexHomeMock = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const codexHooksMock = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
supported: true,
|
||||||
|
readable: true,
|
||||||
|
path: "/home/.codex/hooks.json",
|
||||||
|
script: "/home/.codex/agenthub-hook.sh",
|
||||||
|
installed: false,
|
||||||
|
trusted: false,
|
||||||
|
events: {},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
install: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +74,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
...(actual.api as Record<string, unknown>),
|
...(actual.api as Record<string, unknown>),
|
||||||
codexConfig: codexConfigMock,
|
codexConfig: codexConfigMock,
|
||||||
|
settings: {
|
||||||
|
...((actual.api as { settings: Record<string, unknown> }).settings ?? {}),
|
||||||
|
codexHome: codexHomeMock,
|
||||||
|
codexHooks: codexHooksMock,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -97,6 +123,33 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("CodexConfig", () => {
|
describe("CodexConfig", () => {
|
||||||
|
it("edits CODEX_HOME on the AgentHub sub-tab and reports lanes whose router layer failed", async () => {
|
||||||
|
stubCodexApi({});
|
||||||
|
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
|
||||||
|
codexHomeMock.set.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
codex_home: "/work/.codex",
|
||||||
|
env_owned: false,
|
||||||
|
routers: { updated: 1, failed: ["4 (api): EDIR"] },
|
||||||
|
});
|
||||||
|
render(<CodexConfig />);
|
||||||
|
await settle();
|
||||||
|
const overviewCalls = codexConfigMock.overview.mock.calls.length;
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: /agenthub/i }));
|
||||||
|
const input = await screen.findByDisplayValue("/home/.codex");
|
||||||
|
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "/work/.codex");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
expect(codexHomeMock.set).toHaveBeenCalledWith("/work/.codex");
|
||||||
|
expect(await screen.findByText(/4 \(api\): EDIR/)).toBeInTheDocument();
|
||||||
|
// Every list lives under CODEX_HOME, so a new one re-reads them.
|
||||||
|
expect(codexConfigMock.overview.mock.calls.length).toBeGreaterThan(overviewCalls);
|
||||||
|
});
|
||||||
|
|
||||||
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
|
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
|
||||||
stubCodexApi({ skills: [], mcp: { servers: [] } });
|
stubCodexApi({ skills: [], mcp: { servers: [] } });
|
||||||
render(<CodexConfig />);
|
render(<CodexConfig />);
|
||||||
|
|||||||
@@ -1474,6 +1474,35 @@ exports[`screen snapshots > Claude Config 1`] = `
|
|||||||
0
|
0
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
aria-selected="false"
|
||||||
|
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||||
|
data-testid="cc-tab-agenthub"
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-gauge w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m12 14 4-4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.34 19a10 10 0 1 1 17.32 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
AgentHub
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 opacity-0"
|
class="pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 opacity-0"
|
||||||
@@ -6867,7 +6896,7 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-xs text-fg-muted"
|
class="text-xs text-fg-muted"
|
||||||
>
|
>
|
||||||
Manage pricing, notifications, data, and hooks
|
Manage appearance, AI tools, notifications and data
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6951,7 +6980,7 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
class="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1"
|
class="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -7037,65 +7066,6 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
AI Tools
|
AI Tools
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-dollar-sign w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
x2="12"
|
|
||||||
y1="2"
|
|
||||||
y2="22"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Model Pricing
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-plug w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12 22v-5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M9 8V2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M15 8V2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Hook Configuration
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -7134,28 +7104,6 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
Lanes
|
Lanes
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-folder-open w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
CLAUDE_HOME
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -8265,308 +8213,6 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section
|
|
||||||
class="scroll-mt-24"
|
|
||||||
id="pricing"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center justify-between gap-3 mb-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-dollar-sign w-4 h-4 text-fg-muted"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
x2="12"
|
|
||||||
y1="2"
|
|
||||||
y2="22"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Model Pricing
|
|
||||||
<button
|
|
||||||
aria-label="How model pricing works"
|
|
||||||
class="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-info w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 16v-4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 8h.01"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
class="text-xs text-fg-muted mt-0.5"
|
|
||||||
>
|
|
||||||
Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-rotate-ccw w-3 h-3"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3 3v5h5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Reset Defaults
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-primary text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-plus w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5 12h14"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 5v14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
Add Model
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="card overflow-x-auto mt-4"
|
|
||||||
>
|
|
||||||
<table
|
|
||||||
class="w-full min-w-[1000px]"
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
class="border-b border-border text-left"
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Pattern
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Model
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
|
||||||
>
|
|
||||||
Input
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
|
||||||
>
|
|
||||||
Output
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
|
||||||
>
|
|
||||||
Cache Read
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
|
||||||
>
|
|
||||||
Cache Write (5m)
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
|
||||||
>
|
|
||||||
Cache Write (1h)
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
|
||||||
>
|
|
||||||
Fast Input
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
|
||||||
>
|
|
||||||
Fast Output
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
class="divide-y divide-border"
|
|
||||||
/>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section
|
|
||||||
class="scroll-mt-24"
|
|
||||||
id="hooks"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-plug w-4 h-4 text-fg-muted"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12 22v-5"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M9 8V2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M15 8V2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Hook Configuration
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
class="text-xs text-fg-muted mb-4"
|
|
||||||
>
|
|
||||||
Claude Code hooks forward events to the dashboard in real time.
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="card p-5 space-y-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between flex-wrap gap-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-circle-check-big w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21.801 10A10 10 0 1 1 17 3.335"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="m9 11 3 3L22 4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
All hooks installed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="btn-ghost text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-rotate-ccw w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3 3v5h5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Reinstall Hooks
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
class="text-[11px] text-fg-muted font-mono truncate"
|
|
||||||
>
|
|
||||||
/tmp/settings.json
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section
|
<section
|
||||||
class="scroll-mt-24"
|
class="scroll-mt-24"
|
||||||
id="lanes"
|
id="lanes"
|
||||||
@@ -8654,73 +8300,6 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section
|
|
||||||
class="scroll-mt-24"
|
|
||||||
id="claude-home"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-folder-open w-4 h-4 text-fg-muted"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
CLAUDE_HOME
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
class="text-xs text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-[11px] text-fg-muted italic mb-4 leading-snug"
|
|
||||||
>
|
|
||||||
Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="card p-5 space-y-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
|
||||||
placeholder="/path/to/claude-home"
|
|
||||||
type="text"
|
|
||||||
value="/home/test/.claude"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled=""
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-xs text-fg-muted"
|
|
||||||
>
|
|
||||||
Current:
|
|
||||||
|
|
||||||
<code
|
|
||||||
class="text-fg-secondary"
|
|
||||||
>
|
|
||||||
/home/test/.claude
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section
|
<section
|
||||||
class="scroll-mt-24"
|
class="scroll-mt-24"
|
||||||
id="import"
|
id="import"
|
||||||
|
|||||||
@@ -590,6 +590,36 @@ describe("screen snapshots", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||||
|
// they live behind the Claude Code tab's AgentHub sub-tab, not on Dashboard.
|
||||||
|
describe("Claude Code's AgentHub settings", () => {
|
||||||
|
it("shows pricing, hooks and CLAUDE_HOME on the AgentHub sub-tab only", async () => {
|
||||||
|
const dashboard = render(
|
||||||
|
<MemoryRouter initialEntries={["/settings"]}>
|
||||||
|
<Settings />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||||
|
expect(dashboard.container.querySelector(`section#${id}`)).toBeNull();
|
||||||
|
}
|
||||||
|
dashboard.unmount();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MemoryRouter initialEntries={["/cc-config"]}>
|
||||||
|
<CcConfig />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(container.querySelector("section#pricing")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId("cc-tab-agenthub"));
|
||||||
|
await settle();
|
||||||
|
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||||
|
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The page opens on Overview; the plugin cards live behind the Plugins tab.
|
// The page opens on Overview; the plugin cards live behind the Plugins tab.
|
||||||
async function openPluginsTab() {
|
async function openPluginsTab() {
|
||||||
render(
|
render(
|
||||||
|
|||||||
+43
-4
@@ -66,6 +66,7 @@ graph TB
|
|||||||
**Database Location:**
|
**Database Location:**
|
||||||
- **Canonical (default):** `~/.claude/agenthub/dashboard.db` — shared by `npm start`, `npm run dev`, and the desktop app when it uses the same data dir
|
- **Canonical (default):** `~/.claude/agenthub/dashboard.db` — shared by `npm start`, `npm run dev`, and the desktop app when it uses the same data dir
|
||||||
- **Override:** set `DASHBOARD_DATA_DIR` (directory) or `DASHBOARD_DB_PATH` (file path) for tests or custom deployments
|
- **Override:** set `DASHBOARD_DATA_DIR` (directory) or `DASHBOARD_DB_PATH` (file path) for tests or custom deployments
|
||||||
|
- **Pinned on a CLAUDE_HOME change:** the data dir defaults to `<CLAUDE_HOME>/agenthub`, so saving a new CLAUDE_HOME from Settings → Claude Code → AgentHub first writes the data dir in use as `DASHBOARD_DATA_DIR` to AgentHub's settings file (`~/.config/agenthub/settings.env`, or `$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`). The next start keeps the same database instead of opening an empty one under the new home. An explicit `DASHBOARD_DATA_DIR` is left alone
|
||||||
- **Legacy:** repo-local `./data/dashboard.db` is migrated into the canonical location on first launch (see `server/db.js`)
|
- **Legacy:** repo-local `./data/dashboard.db` is migrated into the canonical location on first launch (see `server/db.js`)
|
||||||
- **Pre-rebrand:** an install that predates AgentHub keeps its data in `~/.claude/agent-dashboard/`. Server startup renames that directory to `~/.claude/agenthub/` once, before anything opens the database (`migrateLegacyDataDir()` in `server/lib/claude-home.js`). It is a same-filesystem rename, so it is instant even at multiple gigabytes and never leaves two copies. If the rename fails, or `DASHBOARD_DATA_DIR` is set, nothing moves and the old directory keeps being used.
|
- **Pre-rebrand:** an install that predates AgentHub keeps its data in `~/.claude/agent-dashboard/`. Server startup renames that directory to `~/.claude/agenthub/` once, before anything opens the database (`migrateLegacyDataDir()` in `server/lib/claude-home.js`). It is a same-filesystem rename, so it is instant even at multiple gigabytes and never leaves two copies. If the rename fails, or `DASHBOARD_DATA_DIR` is set, nothing moves and the old directory keeps being used.
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ graph LR
|
|||||||
|
|
||||||
Tracks Claude Code sessions (one per CLI invocation or background task). Schema mirrors `server/db.js`.
|
Tracks Claude Code sessions (one per CLI invocation or background task). Schema mirrors `server/db.js`.
|
||||||
|
|
||||||
> **Cursor (informational):** Rows imported from `~/.claude` JSONL transcripts may also represent **Cursor** agent sessions — Cursor happens to use the same on-disk layout as Claude Code. The schema does not record which app created a session.
|
> **Cursor (informational):** Rows imported from `~/.claude` JSONL transcripts may also represent **Cursor** agent sessions — Cursor happens to use the same on-disk layout as Claude Code, so such a row still reads `tool = 'claude'`.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE sessions (
|
CREATE TABLE sessions (
|
||||||
@@ -188,7 +189,8 @@ CREATE TABLE sessions (
|
|||||||
awaiting_input_since TEXT, -- NULL unless Waiting
|
awaiting_input_since TEXT, -- NULL unless Waiting
|
||||||
awaiting_reason TEXT, -- notification|stop|session_start|interrupted, or NULL
|
awaiting_reason TEXT, -- notification|stop|session_start|interrupted, or NULL
|
||||||
transcript_path TEXT, -- absolute path to JSONL transcript
|
transcript_path TEXT, -- absolute path to JSONL transcript
|
||||||
source TEXT NOT NULL DEFAULT 'local' -- data source: 'local' or a remote_sources.id
|
source TEXT NOT NULL DEFAULT 'local', -- data source: 'local' or a remote_sources.id
|
||||||
|
tool TEXT NOT NULL DEFAULT 'claude' -- 'claude', 'codex' or 'antigravity'
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -200,7 +202,7 @@ CREATE TABLE sessions (
|
|||||||
| `name` | TEXT | YES | Human-readable label. Synced from the transcript title by `routes/hooks.js` (and the 15 s watchdog) on every event: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name, otherwise the session's first user prompt (60-char label) fills it. Falls back to `Session <id8>` |
|
| `name` | TEXT | YES | Human-readable label. Synced from the transcript title by `routes/hooks.js` (and the 15 s watchdog) on every event: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name, otherwise the session's first user prompt (60-char label) fills it. Falls back to `Session <id8>` |
|
||||||
| `status` | TEXT | NO | `active`, `completed`, `error`, or `abandoned` (CHECK-constrained). Besides the `SessionEnd` hook, the 15 s watchdog's **liveness reap** also lands `active` → `completed` when no running `claude` process has the session's `cwd` (a `SessionEnd` lost while the dashboard was down); gated by `DASHBOARD_LIVENESS_IDLE_SECONDS`, disabled via `DASHBOARD_LIVENESS_PROBE=0`. Sessions with a non-`local` `source` (Remote Data Sources) are exempt from the reap and both stale sweeps — their status is reconciled from the SSH mirror by `remote-sync.js` instead |
|
| `status` | TEXT | NO | `active`, `completed`, `error`, or `abandoned` (CHECK-constrained). Besides the `SessionEnd` hook, the 15 s watchdog's **liveness reap** also lands `active` → `completed` when no running `claude` process has the session's `cwd` (a `SessionEnd` lost while the dashboard was down); gated by `DASHBOARD_LIVENESS_IDLE_SECONDS`, disabled via `DASHBOARD_LIVENESS_PROBE=0`. Sessions with a non-`local` `source` (Remote Data Sources) are exempt from the reap and both stale sweeps — their status is reconciled from the SSH mirror by `remote-sync.js` instead |
|
||||||
| `cwd` | TEXT | YES | Working directory the CLI was launched from |
|
| `cwd` | TEXT | YES | Working directory the CLI was launched from |
|
||||||
| `model` | TEXT | YES | Claude model ID (e.g. `claude-opus-4-7`) |
|
| `model` | TEXT | YES | Model ID as the CLI reports it — a Claude id (e.g. `claude-opus-4-7`), an OpenAI one for a Codex session (`gpt-…`), a Gemini one for an Antigravity session (`gemini-…`) |
|
||||||
| `started_at` | TEXT | NO | ISO 8601 timestamp |
|
| `started_at` | TEXT | NO | ISO 8601 timestamp |
|
||||||
| `ended_at` | TEXT | YES | ISO 8601 timestamp on terminal transition |
|
| `ended_at` | TEXT | YES | ISO 8601 timestamp on terminal transition |
|
||||||
| `metadata` | TEXT | YES | JSON blob for extras (turn duration totals, thinking blocks, …) |
|
| `metadata` | TEXT | YES | JSON blob for extras (turn duration totals, thinking blocks, …) |
|
||||||
@@ -209,6 +211,7 @@ CREATE TABLE sessions (
|
|||||||
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
|
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
|
||||||
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
||||||
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
||||||
|
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a lane purge touch only the matching tool's rows (a purge takes the sessions of the lane's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||||
|
|
||||||
**Constraints:**
|
**Constraints:**
|
||||||
- `status` must be one of the four enum values
|
- `status` must be one of the four enum values
|
||||||
@@ -391,7 +394,7 @@ CREATE TABLE notifications (
|
|||||||
|
|
||||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
||||||
|
|
||||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`, the models Codex sessions use; Gemini means `gemini-…`, an Antigravity session's, taken whole) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A router's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom router row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE model_pricing (
|
CREATE TABLE model_pricing (
|
||||||
@@ -491,6 +494,42 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### codex_rollouts
|
||||||
|
|
||||||
|
How far `server/lib/codex-usage.js` has read each Codex rollout journal (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). A file whose `size` and `mtime_ms` are unchanged is not read again; a grown one is read from `offset` to its last newline, at most 16 MB per pass (`state.more` marks a file with more to read); a shrunken one from 0. There is one row per `thread_id`: the same journal met under a new path (CODEX_HOME moved, or reached through a symlink) replaces its old row instead of being counted twice. Because it lives in the DB, a Codex session the user deleted stays gone until Codex writes to that journal again, while a fresh database re-imports every journal. `POST /api/settings/clear-data` empties this table (and `antigravity_conversations`) with the sessions, so a cleared session is imported again whole instead of never, or half on its next write.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE codex_rollouts (
|
||||||
|
path TEXT PRIMARY KEY, -- absolute journal path
|
||||||
|
session_id TEXT NOT NULL, -- ROOT session (a sub-agent thread's parent)
|
||||||
|
thread_id TEXT NOT NULL, -- the journal's own thread
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
offset INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL DEFAULT '{}', -- JSON: meta, current model, last total, per-model usage sums
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_codex_rollouts_session ON codex_rollouts(session_id);
|
||||||
|
CREATE INDEX idx_codex_rollouts_thread ON codex_rollouts(thread_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
A session's `token_usage` rows are the sum of `state.usage` over every row with its `session_id`, rewritten whole on every change (delete, then `stmts.setTokenUsage`), bucketed `standard`/`global`/`standard`.
|
||||||
|
|
||||||
|
### antigravity_conversations
|
||||||
|
|
||||||
|
Which Antigravity CLI conversation files `server/lib/antigravity-usage.js` has read (`~/.gemini/antigravity-cli/conversations/<conversation id>.db`, agy's own SQLite), at what mtime — the newer of the file's and its non-empty `-wal`'s (an empty one is what AgentHub's own read-only open leaves). A file whose mtime is unchanged is not opened again. One that is not agy's (`SQLITE_NOTADB`, `SQLITE_CORRUPT`, `no such table`) is recorded with `session_id` `''`, so it is tried again only once it changes or at the next server start (`startAntigravityUsagePoll` deletes the `''` rows); any other read failure (a busy file, AgentHub's own error) is recorded nowhere and retried on the next pass; a changed one is re-read whole (they are small) and its session's `token_usage` rewritten whole, per model (`gen_metadata`'s `ModelUsageStats`: input without cache reads, output with thinking, cache read, cache write). As with `codex_rollouts`, a session the user deleted stays gone until agy writes to that conversation again.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE antigravity_conversations (
|
||||||
|
path TEXT PRIMARY KEY, -- absolute conversation file path
|
||||||
|
session_id TEXT NOT NULL, -- the file name's id = the hooks' conversationId; '' = not agy's file
|
||||||
|
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Indexes
|
## Indexes
|
||||||
|
|
||||||
### sessions Indexes
|
### sessions Indexes
|
||||||
|
|||||||
+9
-2
@@ -251,10 +251,17 @@ DASHBOARD_ALLOWED_HOSTS= # extra Host names (comma-sep) for a LAN
|
|||||||
# Database
|
# Database
|
||||||
DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
|
DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
|
||||||
|
|
||||||
# Ingestion polls (0 disables either one)
|
# Ingestion polls (0 disables any one of them)
|
||||||
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
|
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
|
||||||
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane running/idle
|
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane running/idle + Codex sessions/tokens
|
||||||
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
|
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
|
||||||
|
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens
|
||||||
|
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
||||||
|
|
||||||
|
# Pricing and saved settings
|
||||||
|
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
|
||||||
|
AGENTHUB_SETTINGS_FILE= # where dashboard-saved settings persist (default ~/.config/agenthub/settings.env);
|
||||||
|
# load order: real env > the checkout's .env > this file
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info # debug | info | warn | error
|
LOG_LEVEL=info # debug | info | warn | error
|
||||||
|
|||||||
+14
-27
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This guide documents how localization works in the Agent Dashboard, including architecture, resources, runtime behavior, testing, and rollout.
|
This guide documents how localization works in the Agent Dashboard, including architecture, resources, runtime behavior, testing, and rollout.
|
||||||
|
|
||||||
**Supported languages:** English (`en`), Chinese (`zh`), Vietnamese (`vi`), Korean (`ko`)
|
**Supported languages:** English (`en`), Vietnamese (`vi`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ flowchart TB
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key runtime facts**
|
**Key runtime facts**
|
||||||
- `supportedLngs`: `["en", "zh", "vi", "ko"]`
|
- `supportedLngs`: `["en", "vi"]`
|
||||||
- `fallbackLng`: `"en"`
|
- `fallbackLng`: `"en"`
|
||||||
- `nonExplicitSupportedLngs`: `true` (e.g. `vi-VN` resolves to `vi`)
|
- `nonExplicitSupportedLngs`: `true` (e.g. `vi-VN` resolves to `vi`)
|
||||||
- Detection order: `localStorage` → `navigator`
|
- Detection order: `localStorage` → `navigator`
|
||||||
@@ -48,9 +48,7 @@ flowchart TB
|
|||||||
Translation resources are stored per language and namespace:
|
Translation resources are stored per language and namespace:
|
||||||
|
|
||||||
- `client/src/i18n/locales/en/*.json`
|
- `client/src/i18n/locales/en/*.json`
|
||||||
- `client/src/i18n/locales/zh/*.json`
|
|
||||||
- `client/src/i18n/locales/vi/*.json`
|
- `client/src/i18n/locales/vi/*.json`
|
||||||
- `client/src/i18n/locales/ko/*.json`
|
|
||||||
|
|
||||||
Active namespaces:
|
Active namespaces:
|
||||||
- `common`
|
- `common`
|
||||||
@@ -71,8 +69,8 @@ erDiagram
|
|||||||
KEY ||--o{ TRANSLATION : maps_to
|
KEY ||--o{ TRANSLATION : maps_to
|
||||||
|
|
||||||
LANGUAGE {
|
LANGUAGE {
|
||||||
string code "en|zh|vi"
|
string code "en|vi"
|
||||||
string locale "en-US|zh-CN|vi-VN|ko-KR"
|
string locale "en-US|vi-VN"
|
||||||
}
|
}
|
||||||
NAMESPACE {
|
NAMESPACE {
|
||||||
string name "common|nav|dashboard|..."
|
string name "common|nav|dashboard|..."
|
||||||
@@ -89,7 +87,7 @@ erDiagram
|
|||||||
|
|
||||||
**Strategy notes**
|
**Strategy notes**
|
||||||
- Keep namespace boundaries page/domain focused.
|
- Keep namespace boundaries page/domain focused.
|
||||||
- Keep key parity across `en`, `zh`, `vi` files for the same namespace.
|
- Keep key parity across `en` and `vi` files for the same namespace.
|
||||||
- Keep fallback behavior deterministic by ensuring `en` is always complete.
|
- Keep fallback behavior deterministic by ensuring `en` is always complete.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -116,7 +114,7 @@ Use stable semantic keys, not English sentence literals.
|
|||||||
```mermaid
|
```mermaid
|
||||||
classDiagram
|
classDiagram
|
||||||
class I18nConfig {
|
class I18nConfig {
|
||||||
+supportedLngs: ["en","zh","vi"]
|
+supportedLngs: ["en","vi"]
|
||||||
+fallbackLng: "en"
|
+fallbackLng: "en"
|
||||||
+defaultNS: "common"
|
+defaultNS: "common"
|
||||||
+detectionOrder: ["localStorage","navigator"]
|
+detectionOrder: ["localStorage","navigator"]
|
||||||
@@ -184,19 +182,11 @@ sequenceDiagram
|
|||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> Detecting
|
[*] --> Detecting
|
||||||
Detecting --> Loaded_en: localStorage/navigator resolves en
|
Detecting --> Loaded_en: localStorage/navigator resolves en
|
||||||
Detecting --> Loaded_zh: localStorage/navigator resolves zh
|
|
||||||
Detecting --> Loaded_vi: localStorage/navigator resolves vi
|
Detecting --> Loaded_vi: localStorage/navigator resolves vi
|
||||||
Detecting --> Loaded_ko: localStorage/navigator resolves ko
|
|
||||||
Detecting --> Loaded_en: unsupported locale -> fallback en
|
Detecting --> Loaded_en: unsupported locale -> fallback en
|
||||||
|
|
||||||
Loaded_en --> Loaded_zh: user switches to zh
|
|
||||||
Loaded_en --> Loaded_vi: user switches to vi
|
Loaded_en --> Loaded_vi: user switches to vi
|
||||||
Loaded_zh --> Loaded_en: user switches to en
|
|
||||||
Loaded_zh --> Loaded_vi: user switches to vi
|
|
||||||
Loaded_vi --> Loaded_en: user switches to en
|
Loaded_vi --> Loaded_en: user switches to en
|
||||||
Loaded_vi --> Loaded_zh: user switches to zh
|
|
||||||
Loaded_en --> Loaded_ko: user switches to ko
|
|
||||||
Loaded_ko --> Loaded_en: user switches to en
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -205,14 +195,13 @@ stateDiagram-v2
|
|||||||
|
|
||||||
Formatting utilities are centralized in `client/src/lib/format.ts`.
|
Formatting utilities are centralized in `client/src/lib/format.ts`.
|
||||||
|
|
||||||
- `en` → `en-US`
|
- `en` → `en-US` (also the default for any other language)
|
||||||
- `zh` → `zh-CN`
|
|
||||||
- `vi` → `vi-VN`
|
- `vi` → `vi-VN`
|
||||||
|
|
||||||
`formatTime`, `formatDateTime`, and `fmtCostFull` use locale-aware `toLocale*` APIs.
|
`formatTime`, `formatDateTime`, and `fmtCostFull` use locale-aware `toLocale*` APIs.
|
||||||
Timestamp parsing normalizes timezone-less SQLite datetime strings to UTC before display formatting.
|
Timestamp parsing normalizes timezone-less SQLite datetime strings to UTC before display formatting.
|
||||||
|
|
||||||
`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Settings page (which shows raw patterns for pricing rule configuration).
|
`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Model Pricing table (Settings → Claude Code → AgentHub, Settings → Codex → AgentHub and Settings → Antigravity), which shows raw patterns for pricing rule configuration.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -220,10 +209,8 @@ flowchart LR
|
|||||||
B --> C["getCurrentLanguage()"]
|
B --> C["getCurrentLanguage()"]
|
||||||
C --> D{"Language"}
|
C --> D{"Language"}
|
||||||
D -->|en| E["Locale en-US"]
|
D -->|en| E["Locale en-US"]
|
||||||
D -->|zh| F["Locale zh-CN"]
|
|
||||||
D -->|vi| G["Locale vi-VN"]
|
D -->|vi| G["Locale vi-VN"]
|
||||||
E --> H["toLocaleTimeString / toLocaleString"]
|
E --> H["toLocaleTimeString / toLocaleString"]
|
||||||
F --> H
|
|
||||||
G --> H
|
G --> H
|
||||||
H --> I["Localized date/time/number output"]
|
H --> I["Localized date/time/number output"]
|
||||||
```
|
```
|
||||||
@@ -248,10 +235,10 @@ npm run test:client
|
|||||||
|
|
||||||
| Area | What to verify | Example |
|
| Area | What to verify | Example |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Resource parity | Same key coverage across `en/zh/vi/ko` | Missing key detection in CI |
|
| Resource parity | Same key coverage across `en/vi` | Missing key detection in CI |
|
||||||
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
|
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
|
||||||
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
|
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
|
||||||
| Date/number formatting | Locale-specific output shape | `zh-CN`, `vi-VN`, `ko-KR` formatting |
|
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
|
||||||
| Runtime switching | UI rerenders without reload | Sidebar language toggle |
|
| Runtime switching | UI rerenders without reload | Sidebar language toggle |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -261,7 +248,7 @@ npm run test:client
|
|||||||
| Symptom | Likely cause | Resolution |
|
| Symptom | Likely cause | Resolution |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| UI stays in old language after switch | Cached key or stale component state | Confirm `i18n.changeLanguage(...)` is called and component uses `useTranslation` |
|
| UI stays in old language after switch | Cached key or stale component state | Confirm `i18n.changeLanguage(...)` is called and component uses `useTranslation` |
|
||||||
| Unexpected fallback to English | Unsupported locale code | Ensure code normalizes to `en|zh|vi|ko` and key exists in target namespace |
|
| Unexpected fallback to English | Unsupported locale code | Ensure code normalizes to `en|vi` and key exists in target namespace |
|
||||||
| Missing text on one page | Namespace file key missing | Add key to all language files for that namespace |
|
| Missing text on one page | Namespace file key missing | Add key to all language files for that namespace |
|
||||||
| Date/time looks wrong | Locale mapping or timezone parse issue | Verify `getCurrentLocale()` and `parseDate()` behavior |
|
| Date/time looks wrong | Locale mapping or timezone parse issue | Verify `getCurrentLocale()` and `parseDate()` behavior |
|
||||||
| Inconsistent term translation | Manual translation drift | Enforce glossary and update locale tests |
|
| Inconsistent term translation | Manual translation drift | Enforce glossary and update locale tests |
|
||||||
@@ -278,7 +265,7 @@ gantt
|
|||||||
|
|
||||||
section Resource Preparation
|
section Resource Preparation
|
||||||
Lock key inventory :a1, 2026-01-01, 3d
|
Lock key inventory :a1, 2026-01-01, 3d
|
||||||
Fill en/zh/vi/ko namespace files :a2, after a1, 5d
|
Fill en/vi namespace files :a2, after a1, 5d
|
||||||
|
|
||||||
section Runtime Integration
|
section Runtime Integration
|
||||||
Wire detection + persistence :b1, after a2, 2d
|
Wire detection + persistence :b1, after a2, 2d
|
||||||
@@ -295,10 +282,10 @@ gantt
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Operational checklist
|
### Operational checklist
|
||||||
- [ ] Confirm all namespaces exist for `en`, `zh`, `vi`, `ko`
|
- [ ] Confirm all namespaces exist for `en` and `vi`
|
||||||
- [ ] Confirm key parity across all locale JSON files
|
- [ ] Confirm key parity across all locale JSON files
|
||||||
- [ ] Confirm language switching works in collapsed and expanded sidebar modes
|
- [ ] Confirm language switching works in collapsed and expanded sidebar modes
|
||||||
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `zh-CN`)
|
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
|
||||||
- [ ] Confirm date/time/currency formatting for all supported languages
|
- [ ] Confirm date/time/currency formatting for all supported languages
|
||||||
- [ ] Confirm client tests pass before release
|
- [ ] Confirm client tests pass before release
|
||||||
- [ ] Confirm docs references are updated (`README`, `ARCHITECTURE`, `docs/README`)
|
- [ ] Confirm docs references are updated (`README`, `ARCHITECTURE`, `docs/README`)
|
||||||
|
|||||||
+49
-6
@@ -164,7 +164,7 @@ These checks run unconditionally inside `resetWorktree`/`removeWorktree` themsel
|
|||||||
- **`clear`** (UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted.
|
- **`clear`** (UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted.
|
||||||
- **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
|
- **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
|
||||||
- **`remove`** — **deletes the dashboard's record of the lane and nothing else**, for every lane kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the lane's database (if any) is left in place; deleting any of them is the user's own call (`git worktree remove`, `git branch -d`, dropping the database). Stopping the lane's run and stack still happens first — those are processes, not data — and the runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/` (pid files, hook logs) is cleared, because it is AgentHub's own and would otherwise be inherited by the next lane to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit.
|
- **`remove`** — **deletes the dashboard's record of the lane and nothing else**, for every lane kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the lane's database (if any) is left in place; deleting any of them is the user's own call (`git worktree remove`, `git branch -d`, dropping the database). Stopping the lane's run and stack still happens first — those are processes, not data — and the runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/` (pid files, hook logs) is cleared, because it is AgentHub's own and would otherwise be inherited by the next lane to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit.
|
||||||
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories. No git operation, no worktree change; not gated on unpushed commits.
|
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories, recorded by the lane's own `tool` — purging a Claude lane leaves a Codex lane's sessions at the same directory alone. No git operation, no worktree change; not gated on unpushed commits.
|
||||||
|
|
||||||
### The preflight contract
|
### The preflight contract
|
||||||
|
|
||||||
@@ -1109,13 +1109,28 @@ pipeline map without requiring every skill to be rewritten.
|
|||||||
The matcher is fed by hooks — `touchLaneFromHook` in `server/routes/hooks.js`,
|
The matcher is fed by hooks — `touchLaneFromHook` in `server/routes/hooks.js`,
|
||||||
on every hook, live — so it only ever sees `claude` lanes.
|
on every hook, live — so it only ever sees `claude` lanes.
|
||||||
|
|
||||||
Codex has no hook mechanism at all, so `touchLaneFromHook` is pinned to
|
`touchLaneFromHook` resolves the lane by `(cwd, tool)` — a Claude hook never
|
||||||
`"claude"` — a hook must never touch a `codex` lane sharing the same directory.
|
touches a `codex` lane sharing the same directory, nor a Codex hook the
|
||||||
Codex lanes carry no pipeline, so nothing detects or advances their stage.
|
`claude` one — and runs detection for `claude` lanes only. Codex lanes carry no
|
||||||
|
pipeline, so nothing detects or advances their stage. Codex's own hooks are
|
||||||
|
opt-in (Settings → Codex → AgentHub → **Codex hooks**; Codex then asks you to
|
||||||
|
trust them): with them a codex lane gets `running`/`idle` as each turn starts
|
||||||
|
and ends, and "Codex needs your permission: …" as its `needs_action` while
|
||||||
|
Codex waits on an approval. A sub-agent thread's tool calls are folded onto
|
||||||
|
the root session but move nothing: they neither clear `needs_action` nor set
|
||||||
|
the lane `running` — see ARCHITECTURE.md, **Codex hooks**.
|
||||||
|
Codex sessions and their token usage are ingested from these same journals by
|
||||||
|
`server/lib/codex-usage.js` (see ARCHITECTURE.md); that never touches a lane.
|
||||||
`server/lib/codex-ingest.js` only mirrors a codex lane's `status` between
|
`server/lib/codex-ingest.js` only mirrors a codex lane's `status` between
|
||||||
`idle` and `running`: every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0`
|
`idle` and `running`, and skips a lane whose hooks are current (an event for
|
||||||
|
its session in the last two minutes — the hooks already report each turn's
|
||||||
|
start and end): every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0`
|
||||||
disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
|
disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
|
||||||
(`$CODEX_HOME` defaults to `~/.codex`), reads each thread's `cwd` from the
|
(`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the
|
||||||
|
dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this
|
||||||
|
reads; it is editable in Settings → Codex → AgentHub, and a save re-writes each
|
||||||
|
codex lane's AI Router layer into the new home — a pane already running keeps
|
||||||
|
the old one until it restarts), reads each thread's `cwd` from the
|
||||||
`session_meta` on the file's first line, and calls the owning lane `running`
|
`session_meta` on the file's first line, and calls the owning lane `running`
|
||||||
while that journal's mtime is under two minutes old — and `idle` once it is
|
while that journal's mtime is under two minutes old — and `idle` once it is
|
||||||
older **or in the future** (a clock stepped back makes a raw `now - mtime`
|
older **or in the future** (a clock stepped back makes a raw `now - mtime`
|
||||||
@@ -1124,6 +1139,34 @@ ingestion uses (only a lane with no `run_id`, and only from `idle`/`running` —
|
|||||||
never from `provisioning` or `failed`) and writes nothing else: not `stage`,
|
never from `provisioning` or `failed`) and writes nothing else: not `stage`,
|
||||||
not `notes`, not `slot`/`ports`.
|
not `notes`, not `slot`/`ports`.
|
||||||
|
|
||||||
|
An `antigravity` lane (Antigravity's `agy` CLI) also carries no pipeline.
|
||||||
|
|
||||||
|
**Antigravity hooks** are opt-in: Settings → Antigravity → **Antigravity
|
||||||
|
hooks**, and agy asks for no trust.
|
||||||
|
- **What they add:** the lane turns `running` when a run starts (a prompt, or a
|
||||||
|
PostToolUse) and `idle` on its Stop. A PreInvocation starts a turn (a `UserPromptSubmit`) when it is the
|
||||||
|
run's first, follows a Stop, has no event before it, lands on a session that
|
||||||
|
is not `active`, or comes more than two minutes after the last event; inside
|
||||||
|
a turn a model call follows a hook within seconds, and is dropped.
|
||||||
|
- **Stop:** one with `fullyIdle: false` (background work still running) is
|
||||||
|
dropped; one carrying a non-empty `error`, or a `terminationReason` matching
|
||||||
|
`error`/`fail`, ends the turn as an error.
|
||||||
|
- **No `needs_action`:** agy has no permission hook, so the lane never gets
|
||||||
|
one.
|
||||||
|
- **A session gone quiet:** agy fires no hook when it exits, nor when a run
|
||||||
|
ends on an API error or a permission refused in headless mode, so
|
||||||
|
`server/lib/antigravity-usage.js` marks the session completed (its agents
|
||||||
|
too, waiting flags cleared) and sets its lane `idle` once it has been quiet
|
||||||
|
for ten minutes when it has hook events — a long tool call writes nothing,
|
||||||
|
and after a Stop agy may still be waiting for the user — or two minutes when
|
||||||
|
it has none. Its activity is the newest of the conversation file (and a
|
||||||
|
non-empty `-wal`), its transcript and its last hook event. This uses the same
|
||||||
|
guard, so it happens only for a lane with no `run_id`, and only from
|
||||||
|
`running`.
|
||||||
|
- **Where sessions come from:** agy's own conversation files,
|
||||||
|
`~/.gemini/antigravity-cli/conversations/<id>.db`, with or without the hooks
|
||||||
|
(see ARCHITECTURE.md, **Antigravity Sessions and Hooks**).
|
||||||
|
|
||||||
### The evidence boundary
|
### The evidence boundary
|
||||||
|
|
||||||
**An inferred stage is never evidence and never renders as done.** Green
|
**An inferred stage is never evidence and never renders as done.** Green
|
||||||
|
|||||||
@@ -327,6 +327,17 @@ default keeps a plugin-run server and a developer's `npm run dev` server on one
|
|||||||
data directory, where `ingestGroupKey` already deduplicates hook ingest to a
|
data directory, where `ingestGroupKey` already deduplicates hook ingest to a
|
||||||
single port.
|
single port.
|
||||||
|
|
||||||
|
Settings the dashboard saves (CLAUDE_HOME, CODEX_HOME, and the data dir that a
|
||||||
|
CLAUDE_HOME change pins) go to `~/.config/agenthub/settings.env`
|
||||||
|
(`$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`), never
|
||||||
|
to a `.env` inside the plugin cache, which every update replaces. The server
|
||||||
|
reads it at startup. Precedence is real environment, then the checkout's own
|
||||||
|
`.env`, then that file — a dev checkout whose `.env` names its own
|
||||||
|
`DASHBOARD_DATA_DIR` must not be pointed at the live database by a pin saved
|
||||||
|
from the plugin dashboard. A variable the real environment or that `.env` sets
|
||||||
|
(a systemd unit's `Environment=`, say) wins at every start, and the settings
|
||||||
|
page warns about it.
|
||||||
|
|
||||||
### Uninstalling
|
### Uninstalling
|
||||||
|
|
||||||
`claude plugin uninstall agenthub@agenthub` only removes
|
`claude plugin uninstall agenthub@agenthub` only removes
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Antigravity parity, part D: sessions, tokens and hooks
|
||||||
|
|
||||||
|
Issue: #40 part D. Date: 2026-09-29. Status: self-reviewed (the user asked for
|
||||||
|
autonomous work with a self-review per part). Mirrors parts C1 and C2
|
||||||
|
(`2026-09-29-codex-usage-design.md`, `2026-09-29-codex-hooks-design.md`).
|
||||||
|
|
||||||
|
## Spike: what agy offers
|
||||||
|
|
||||||
|
Verified on agy 1.2.13 in an isolated `HOME`. agy keeps its login in the OS
|
||||||
|
keyring, so that run used the user's own account.
|
||||||
|
|
||||||
|
### Tokens
|
||||||
|
|
||||||
|
Not in the transcript, but in agy's own SQLite file per conversation:
|
||||||
|
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`.
|
||||||
|
|
||||||
|
- `gen_metadata` has one protobuf row per model call. Field 1 is a message
|
||||||
|
whose field 19 is the model name (`gemini-3.8-flash`) and whose field 4 is
|
||||||
|
`ModelUsageStats`.
|
||||||
|
- The field numbers were read from the proto descriptor embedded in the
|
||||||
|
binary: 2 input, 3 output, 4 cache write, 5 cache read, 9 thinking.
|
||||||
|
- Input does not include cache reads. Output includes thinking (171 = 73 + 98).
|
||||||
|
- `trajectory_meta.cascade_id` is the conversation id.
|
||||||
|
- `trajectory_metadata_blob` holds the workspace as a `file://` URI.
|
||||||
|
- agy 1.0.0 wrote a `.pb` file instead, and Google's service refuses 1.0.0.
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
Documented at antigravity.google/docs/hooks.
|
||||||
|
- `~/.gemini/config/hooks.json` maps hook **names** to their events:
|
||||||
|
`PreToolUse`/`PostToolUse` (`{matcher, hooks}` groups), and `PreInvocation`,
|
||||||
|
`PostInvocation`, `Stop` (flat handler lists). A handler is
|
||||||
|
`{type: "command", command, timeout (s)}`.
|
||||||
|
- agy asks for no trust.
|
||||||
|
- The payload carries `conversationId`, `workspacePaths`, `transcriptPath`
|
||||||
|
(`brain/<id>/.system_generated/logs/transcript_full.jsonl`) and `modelName`.
|
||||||
|
`PostToolUse` adds `toolCall {name, args}`, `stepIdx` and `error`. `Stop` adds
|
||||||
|
`terminationReason` and `fullyIdle`.
|
||||||
|
- The hook runs with its cwd in `~/.gemini/config`, and `AGENTHUB_LANE_ID`
|
||||||
|
does not reach it.
|
||||||
|
|
||||||
|
### Traps found in the spike
|
||||||
|
|
||||||
|
- **A `PreToolUse` hook that answers `{}` DENIES the tool** ("tool call denied
|
||||||
|
by pre-tool hook"). There is no neutral answer:
|
||||||
|
- `allow` would override the user's permission rules;
|
||||||
|
- `ask` would force a prompt.
|
||||||
|
- **No permission event, and no Stop when a run ends on an API error** or on a
|
||||||
|
permission that headless mode refused.
|
||||||
|
- `invocationNum` restarts at 0 for every run, including `--continue`.
|
||||||
|
- **Running `agy` self-updates `~/.local/bin/agy`**, even with another `HOME`.
|
||||||
|
The spike took the user's install from 1.0.0 to 1.2.13; the old binary is kept
|
||||||
|
as `agy.<n>.old`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **Sessions and tokens** (`server/lib/antigravity-usage.js`, every
|
||||||
|
`DASHBOARD_ANTIGRAVITY_POLL_MS`, default 15 s).
|
||||||
|
- Each conversation file is opened read-only when its mtime (or its
|
||||||
|
non-empty `-wal`'s) changes; the mtime lives in `antigravity_conversations`.
|
||||||
|
- It becomes a `tool = 'antigravity'` session: its name and start come from
|
||||||
|
the transcript, its cwd from the blob, its model from the last generation.
|
||||||
|
- Tokens are written whole, per model.
|
||||||
|
- The status reflects the session's last activity: the newest of its file
|
||||||
|
(a non-empty `-wal` included), its transcript and its last hook event. It
|
||||||
|
is quiet after ten minutes when it has any hook event — mid-turn a long
|
||||||
|
tool call writes nothing, and after a `Stop` agy may be waiting for the
|
||||||
|
user, with no exit hook to say otherwise — and after two minutes when it
|
||||||
|
has none.
|
||||||
|
- When a session settles to `completed`, its agents complete too, its
|
||||||
|
waiting flags are cleared, and its antigravity lane is set `idle`, but only
|
||||||
|
if the hooks left it `running` and it has no `run_id`. A `Stop` that marked
|
||||||
|
it `error` keeps that `error`.
|
||||||
|
- A file that is not agy's (`SQLITE_NOTADB`, `SQLITE_CORRUPT`, `no such
|
||||||
|
table`) is recorded with `session_id` `''` and retried only once it
|
||||||
|
changes or at the next server start; any other failure is retried on the
|
||||||
|
next pass.
|
||||||
|
- Only the CLI's directory is read.
|
||||||
|
- Gemini families are added to `pricing-sync.js`, taken from Vertex AI's or
|
||||||
|
the Gemini API's LiteLLM entries.
|
||||||
|
2. **Hooks** (`server/lib/antigravity-hooks.js`, Settings → Antigravity →
|
||||||
|
**Antigravity hooks**, `GET/POST/DELETE /api/settings/antigravity-hooks`,
|
||||||
|
`sameOriginGuard` on the writes).
|
||||||
|
- The named hook `agenthub` has `PreInvocation`, `PostToolUse` and `Stop`,
|
||||||
|
each `timeout: 5`. It never has `PreToolUse`.
|
||||||
|
- Every other named hook is kept, and a non-object file is refused
|
||||||
|
(`EBADJSON`).
|
||||||
|
- The command is the stable path `~/.gemini/config/agenthub-hook.sh`. The
|
||||||
|
script runs `scripts/hook-handler.js <Event> antigravity`, prints `{}`, and
|
||||||
|
is refreshed at every start when it is ours.
|
||||||
|
3. **Route** (`antigravityHookInput` in `server/routes/hooks.js`).
|
||||||
|
- A hook is accepted only with a UUID `conversationId` and a
|
||||||
|
`transcriptPath` under the CLI's directory. That keeps out the
|
||||||
|
Antigravity IDE, which loads the same global file.
|
||||||
|
- `session_id` is the `conversationId` and `cwd` is `workspacePaths[0]`.
|
||||||
|
- A `PreInvocation` becomes `UserPromptSubmit` when `invocationNum` is 0,
|
||||||
|
when the session has no event yet, when its last event is a `Stop`, when
|
||||||
|
the session is not `active`, or when its last event is more than two
|
||||||
|
minutes old; any other is dropped. `PostToolUse` carries
|
||||||
|
`tool_name`/`tool_input` and `step`. `Stop` stays `Stop` — as
|
||||||
|
`stop_reason: "error"` when it carries a non-empty `error` or a
|
||||||
|
`terminationReason` matching `/error|fail/i`, and dropped while
|
||||||
|
`fullyIdle` is false. Every other event is dropped.
|
||||||
|
4. **Settings → Antigravity** (`AntigravityConfig.tsx`) holds Model Pricing
|
||||||
|
(`vendor="google"`) and the hooks card. There is no sub-tab bar, because agy
|
||||||
|
has no config surface AgentHub edits.
|
||||||
|
|
||||||
|
## Limits (documented, not fixable from here)
|
||||||
|
|
||||||
|
- There is no "needs your permission" state for an Antigravity lane: agy has no
|
||||||
|
hook for it.
|
||||||
|
- A lane that a dashboard pane owns (`run_id`) keeps its run-owned status, as
|
||||||
|
it does for Codex.
|
||||||
|
- The Antigravity IDE's sessions are not read.
|
||||||
|
- Sub-agents: none were seen in a CLI run, so nothing folds them.
|
||||||
|
- The formats are undocumented: a future agy may move them. A generation that
|
||||||
|
no longer decodes is skipped, not guessed.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Server:**
|
||||||
|
- usage: the protobuf reader; the session and per-model tokens; no re-read;
|
||||||
|
quiet → completed; the stuck lane set idle, while a paned one is kept;
|
||||||
|
deleted stays deleted; another tool's row untouched; a hook-made cwd kept;
|
||||||
|
a non-SQLite file skipped;
|
||||||
|
- hooks: install with three events and no PreToolUse; the forwarder prints
|
||||||
|
`{}`; other named hooks kept; remove; EBADJSON; a hook switched off is
|
||||||
|
reported; the refresh is foreign-safe;
|
||||||
|
- route: a turn on the antigravity lane only; later PreInvocations dropped;
|
||||||
|
tool calls in the timeline; IDE and bad-id hooks ignored; the settings
|
||||||
|
routes, including the cross-origin refusal;
|
||||||
|
- handler: the `antigravity` marker, and an unknown marker dropped;
|
||||||
|
- pricing: Gemini from Vertex AI, and another vendor refused.
|
||||||
|
- **Client:** the hooks card (install, a hook switched off, Refresh, Remove, a
|
||||||
|
refusal).
|
||||||
|
- **End to end:** an isolated server on :4851 with `AGENTHUB_GEMINI_DIR` set.
|
||||||
|
The hook was installed through the API, then a real `agy -p` read a file.
|
||||||
|
Seen:
|
||||||
|
- the lane went `running`;
|
||||||
|
- the run ended with no Stop (a shell command refused in headless mode), and
|
||||||
|
the poll set the lane back to `idle`;
|
||||||
|
- the session had its name, cwd, model and tokens, and `view_file` in its
|
||||||
|
timeline;
|
||||||
|
- Settings → Antigravity showed the card as installed, with its three events.
|
||||||
|
|
||||||
|
## Found in the code review, fixed
|
||||||
|
|
||||||
|
- **Our own read-only open looked like activity.** On a file agy closed
|
||||||
|
cleanly, better-sqlite3's read-only open leaves an empty `-wal`, whose fresh
|
||||||
|
mtime flipped the session back to `active` with "Last active" set to the read
|
||||||
|
time. An empty `-wal` no longer counts.
|
||||||
|
- **The poll undid the hooks' `running` within 15 s.** Activity is now the
|
||||||
|
newest of the file, the transcript and the session's last hook event. The
|
||||||
|
quiet window is ten minutes, not two, when that event is not a `Stop`,
|
||||||
|
because a long tool call writes nothing until it ends.
|
||||||
|
- **A failed run showed as "ready for input".** A `Stop` carrying an `error`,
|
||||||
|
or a `terminationReason` naming one, now arrives as `stop_reason: "error"`,
|
||||||
|
and the poll keeps that `error`.
|
||||||
|
- **Stops and turns.**
|
||||||
|
- A `Stop` with `fullyIdle: false` is dropped, since background work is
|
||||||
|
still running.
|
||||||
|
- The first `PreInvocation` after a `Stop` is a new prompt, whatever its
|
||||||
|
`invocationNum`.
|
||||||
|
- **A script that is not ours** at the forwarder's path is never overwritten
|
||||||
|
or removed (`EFOREIGN`, 400), in `codex-hooks.js` too.
|
||||||
|
- **A rewritten `hooks.json` keeps its mode**, in `codex-hooks.js` too.
|
||||||
|
- **Session id and symlinks.**
|
||||||
|
- The session id is the file name's UUID; `cascade_id` is no longer read.
|
||||||
|
- A symlinked `~/.gemini` is matched under both paths (`agyCliDirs`).
|
||||||
|
- **Settling and bad files.**
|
||||||
|
- A file that is not agy's is recorded, so it is not re-read and warned
|
||||||
|
about every 15 s.
|
||||||
|
- Settling a session to `completed` also completes its agents and clears
|
||||||
|
their waiting flags.
|
||||||
|
- **Docs.** `.env.example` now lists `DASHBOARD_ANTIGRAVITY_POLL_MS` and
|
||||||
|
`AGENTHUB_GEMINI_DIR`.
|
||||||
|
|
||||||
|
## Found in the whole-change review, fixed
|
||||||
|
|
||||||
|
- **An open agy looked finished after a Stop.** The ten-minute window applied
|
||||||
|
only while the last event was not a `Stop`, so a session waiting at the
|
||||||
|
prompt settled to `completed` two minutes after each turn. agy fires no exit
|
||||||
|
hook, so an open agy and a closed one look the same: any session with hook
|
||||||
|
events now gets ten minutes, a hookless one two.
|
||||||
|
- **A turn after a missed Stop was lost.** A `PreInvocation` is now also a
|
||||||
|
prompt when the session has no event yet, is not `active` (a run that ended
|
||||||
|
with no Stop, a session the poll settled), or its last event is over two
|
||||||
|
minutes old — not only on `invocationNum: 0` or after a `Stop`.
|
||||||
|
- **Error Stops.** Only a non-empty `error` counts, and a `terminationReason`
|
||||||
|
counts when it matches `/error|fail/i`.
|
||||||
|
- **A bad file was never retried, a busy one was given up on.** A file that is
|
||||||
|
not agy's is recorded with `session_id` `''`, and those rows are deleted at
|
||||||
|
every server start (`startAntigravityUsagePoll`), so a newer AgentHub tries
|
||||||
|
it again; any other read failure (a busy file, our own error) is no longer
|
||||||
|
recorded, so the next pass retries it.
|
||||||
|
- **Settling left the session waiting.** It now clears the session's own
|
||||||
|
waiting flag as well as its agents'.
|
||||||
|
- **A forwarder path that is not a file of ours** (other content, an unreadable
|
||||||
|
file, a directory — `scriptIsOurs`) is never overwritten (`EFOREIGN`) or
|
||||||
|
removed, in `codex-hooks.js` too.
|
||||||
|
- **A symlinked `hooks.json`** (a dotfiles repo's) left with no hook is emptied
|
||||||
|
through the link instead of being unlinked, in both installers.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Codex parity, part C2: Codex hooks
|
||||||
|
|
||||||
|
Issue: #40 part C. Date: 2026-09-29. Status: self-reviewed (the user asked for
|
||||||
|
autonomous work with a self-review per part). Builds on C1
|
||||||
|
(`2026-09-29-codex-usage-design.md`).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
C1 reads Codex sessions and tokens from the rollout journals, every 15 s. That
|
||||||
|
leaves out what only a live signal gives:
|
||||||
|
- a Codex session's tool calls in its timeline;
|
||||||
|
- "Codex is waiting for you" when it asks permission, on the lane card and as a notification;
|
||||||
|
- a Codex lane's running/idle without a 2-minute lag after a turn ends.
|
||||||
|
|
||||||
|
## What Codex offers (verified on 0.155.1, isolated `CODEX_HOME` + a fake model server)
|
||||||
|
|
||||||
|
- **Where hooks live:** `$CODEX_HOME/hooks.json`, in the same shape as Claude Code's `hooks` block:
|
||||||
|
`{"hooks": {"<Event>": [{"matcher"?, "hooks": [{"type": "command", "command", "timeout"}]}]}}`.
|
||||||
|
- Events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `Stop`, `SessionEnd`, and others.
|
||||||
|
- Timeouts above 3 s are clamped (with a startup warning) for `SessionEnd`/`Interrupt`.
|
||||||
|
- **The stdin payload is Claude's shape:**
|
||||||
|
- `session_id` equals the rollout's thread id, so it matches C1's session row.
|
||||||
|
- Also: `transcript_path` (the rollout), `cwd`, `hook_event_name`, `model`, `permission_mode`, and `turn_id` on turn-scoped events.
|
||||||
|
- Tool events carry `tool_name` (`Bash`), `tool_input` and `tool_use_id`. `PermissionRequest` carries `tool_input.command`/`.description`.
|
||||||
|
- The hook inherits Codex's environment, including `AGENTHUB_LANE_ID`.
|
||||||
|
- **Trust:** a hook runs only once the user trusts its exact definition.
|
||||||
|
- Codex asks at startup ("Hooks need review → Trust all and continue"), or the user runs `/hooks`.
|
||||||
|
- Trust is stored in `$CODEX_HOME/config.toml` as `[hooks.state."<hooks.json path>:<event_snake>:<group>:<hook>"] trusted_hash = "sha256:…"`.
|
||||||
|
- An untrusted hook is silently skipped by `codex exec`.
|
||||||
|
- AgentHub never writes that trust and never passes `--dangerously-bypass-hook-trust`: skipping the user's review is not ours to do.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **A stable command:** `'<CODEX_HOME>/agenthub-hook.sh' <Event>`.
|
||||||
|
- Trust is keyed on the definition, so the command must not change when AgentHub updates. A plugin install moves to a new cache directory on every update.
|
||||||
|
- The script is AgentHub's own:
|
||||||
|
- it runs this build's `scripts/hook-handler.js <Event> codex`, with the server's `node` (falling back to `node` on PATH, like the CLI launcher);
|
||||||
|
- it sets the server's `CLAUDE_HOME`, so the handler finds the discovery file.
|
||||||
|
- Every server start rewrites the script's content when it is installed and stale. Only the content changes, so the trusted definition stays valid.
|
||||||
|
- Every value is single-quoted.
|
||||||
|
2. **Install / remove** (`server/lib/codex-hooks.js`), only on an explicit click in **Settings → Codex → AgentHub → Codex hooks**:
|
||||||
|
- `install`:
|
||||||
|
- Writes the script (0755).
|
||||||
|
- Merges one group per event into `hooks.json`: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `Stop`, `Interrupt`, `SessionEnd`, each with `timeout: 3`.
|
||||||
|
- Drops any earlier hook of ours first (matched by its exact command, never a group). Keeps every other entry.
|
||||||
|
- Refuses a `hooks.json` that is not a JSON object (`EBADJSON`, the router's rule for `settings.local.json`).
|
||||||
|
- Writes with temp + rename.
|
||||||
|
- `remove`:
|
||||||
|
- Takes our hooks out (a group left empty goes too) and deletes the script.
|
||||||
|
- Deletes `hooks.json` when nothing else is left in it.
|
||||||
|
- `status`:
|
||||||
|
- Per event: whether our hook is present, and whether `config.toml` holds a trust entry for its position.
|
||||||
|
- Whether the file is readable.
|
||||||
|
- Win32 is refused (`EUNSUPPORTED`): the command is a POSIX shell script.
|
||||||
|
- Routes: `GET/POST/DELETE /api/settings/codex-hooks`.
|
||||||
|
3. **Handler:** `scripts/hook-handler.js` sends `tool: "codex"` when its second argument is `codex`. The route reads `tool` through an allowlist (`codex`, else `claude`).
|
||||||
|
4. **Hook route, for `tool = codex`:**
|
||||||
|
- **Sub-agent threads.** A hook from one would describe a child thread, not the user's session. We cannot see whether Codex fires hooks there, so this is guarded, not assumed.
|
||||||
|
- When `transcript_path` is a rollout under `$CODEX_HOME/sessions` whose `session_meta.session_id` differs from its `id`, the hook is a sub-agent's.
|
||||||
|
- Tool and permission events are attributed to the root session.
|
||||||
|
- `SessionStart`/`SessionEnd`/`Stop`/`UserPromptSubmit` are dropped.
|
||||||
|
- The meta read is cached per path.
|
||||||
|
- **`PermissionRequest`** becomes the `Notification` Claude uses for a permission prompt, with the message `Codex needs your permission: <description or command>`. The awaiting flag, the lane's `needs_action`, the alert rules and the browser notification then work unchanged.
|
||||||
|
- **`ensureSession`** creates the row with `tool = 'codex'`, named `Codex <8>` (C1 renames it from the prompt or thread name). It stores no `transcript_path`.
|
||||||
|
- **The Claude transcript block** (tokens, titles, errors, compactions, turn durations), the SessionEnd transcript read, the sub-agent JSONL scan and the workflow ingest are skipped: they parse Claude's format, and C1 owns a Codex session's tokens.
|
||||||
|
- **`touchLaneFromHook`** resolves the lane by `(cwd, tool)`, so a Codex hook reaches the Codex lane and never a Claude lane at the same directory. Stage detection runs for `claude` only: a non-claude lane carries no pipeline.
|
||||||
|
5. **Who owns a Codex session's status.**
|
||||||
|
- A session that has hook events is **hook-fed**. While its hooks are current (see the whole-change review below), they own its status, `ended_at` and `updated_at`, as for Claude.
|
||||||
|
- C1's poll still writes its tokens, model and name, and `settleActive` leaves it alone while its hooks are current.
|
||||||
|
- A session with no events keeps C1's journal-activity status.
|
||||||
|
- A hook-fed session whose Codex process dies without `SessionEnd` stays active until the stale sweep (`STALE_MINUTES`, 3 h). The liveness reap probes `claude` processes only.
|
||||||
|
6. **The lane mirror** (`codex-ingest.js`) skips a lane whose bound session got a hook event in the last 2 minutes: the hooks are current, and the mtime mirror would call a lane that just stopped "running" for up to 2 minutes.
|
||||||
|
|
||||||
|
## Found in the end-to-end check, fixed
|
||||||
|
|
||||||
|
- **Declining a permission (or Esc) ends the turn with `Interrupt`, not `Stop`.** The lane stayed `running` with a stale "needs your permission" until the next prompt. `Interrupt` is now installed and becomes `Stop`, since Codex is back at its prompt either way; a sub-agent thread's `Interrupt` is dropped. Adding it to an install the user already trusted made Codex ask about that one hook only, and the other seven kept their trust.
|
||||||
|
- In the TUI, `SessionStart` fires on the first prompt, when Codex creates the thread, not when the TUI opens.
|
||||||
|
|
||||||
|
## Found in the code review, fixed
|
||||||
|
|
||||||
|
- **Remove/Reinstall deleted a user's hook along with ours** when it sat in our group, or when its command merely contained `agenthub-hook.sh`. Ours is now matched by its exact command shape, one hook at a time, and a group is dropped only when that leaves it empty.
|
||||||
|
- **Install/remove had no `sameOriginGuard`.** Any web page could POST to install the hooks (Codex's trust prompt was the only stop). Both write routes now carry it.
|
||||||
|
- **A symlinked `CODEX_HOME`** could show "untrusted" forever and turn the sub-agent guard off, if Codex reports the resolved path. Trust and `threadOf` now accept the home as given or resolved (`codexHomes` in `codex-home.js`).
|
||||||
|
- **A session the journal poll imported first had no main agent**, so its agent-level state stayed empty. `ensureSession` now creates the missing `<id>-main` for a non-claude session.
|
||||||
|
- Minor:
|
||||||
|
- `refreshScript` leaves a file that is not ours alone (`foreign`), restores a lost exec bit, and a failure is logged.
|
||||||
|
- Writes go through a symlinked `hooks.json` to its target, and a failed write leaves no temp file behind.
|
||||||
|
- The card has **Refresh**, shows Remove and the grid on a partial install, and gives each event screen-reader text.
|
||||||
|
- Left as is:
|
||||||
|
- Trust is matched by position, so a stale entry at the same position reads as trusted.
|
||||||
|
- The mirror takes over again after 2 minutes of hook silence during a long tool call.
|
||||||
|
- A sub-agent hook that arrives before its rollout's first line creates its own session.
|
||||||
|
- Whichever AgentHub server started last owns the forwarder.
|
||||||
|
|
||||||
|
## Found in the whole-change review, fixed
|
||||||
|
|
||||||
|
- **Hooks owned a session's status forever.** A session whose hooks were removed, or no longer trusted, stayed on its last hook-given status. Hooks now own it only while current (`hooksCurrent` in `codex-usage.js`: the newest event at or after the newest journal write minus 2 minutes); otherwise the journal decides again, in `settleActive` too.
|
||||||
|
- **A permission prompt could become a Compaction.** The Notification → `Compaction` tag matches `compact|compress|…` in the message, and a Codex `PermissionRequest` quoting a command such as `tar --compress` matched it. The tag now applies to `tool = 'claude'` only.
|
||||||
|
- **A sub-agent's tool call looked like the user resuming.** A child thread's `PreToolUse`/`PostToolUse`, folded onto the root session, is marked `_codexChild` and records work only: it no longer reactivates the session, clears its waiting flag, sets the main agent working, clears the lane's `needs_action` or moves the lane's status. Its own `Stop` is dropped, so anything it set would never be undone.
|
||||||
|
- **A forwarder path that is not a file of ours** — other content, an unreadable file, a directory (`scriptIsOurs`) — is never overwritten (install answers 400 `EFOREIGN`) or removed (remove skips it).
|
||||||
|
- **A rewritten `hooks.json` keeps the mode the user gave it**, and a symlinked one left with no hook is emptied through the link instead of being unlinked.
|
||||||
|
- The route's `tool` allowlist is now `codex`, `antigravity` (part D), else `claude`.
|
||||||
|
|
||||||
|
## Not changed
|
||||||
|
|
||||||
|
- Claude's hook path, apart from the parameter.
|
||||||
|
- Hook installs for Claude.
|
||||||
|
- `agenthub doctor`.
|
||||||
|
- The notification title, which still says "Claude needs your confirmation"; the body names Codex.
|
||||||
|
- No sub-agent rows for Codex.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Server:**
|
||||||
|
- `codex-hooks.js`: install into an empty home, and next to a user's own hooks (kept); reinstall replaces ours; remove leaves theirs and deletes a file of ours only; a non-JSON file refused; status with and without a trust entry; the script refreshed and its command unchanged.
|
||||||
|
- Route: a codex hook creates a `tool = codex` session with no `transcript_path` and no Claude transcript read; `PermissionRequest` stamps awaiting and the codex lane's `needs_action` while the claude lane at the same cwd is untouched; no stage detection on a codex lane; a sub-agent thread's `Stop` dropped and its `PreToolUse` attributed to the root.
|
||||||
|
- C1 does not overwrite a hook-fed session's status.
|
||||||
|
- The mirror skips a lane with a fresh hook.
|
||||||
|
- The handler sends `tool`.
|
||||||
|
- **Client:** the Codex hooks section (status, install, remove).
|
||||||
|
- **Browser:** the isolated server, a real `codex exec` against a fake model with the installed hooks trusted through the TUI prompt, then the session's timeline and a lane's `needs_action`.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Codex parity, part C1: sessions, token usage and pricing
|
||||||
|
|
||||||
|
Issue: #40 part C. Date: 2026-09-29. Status: self-reviewed (the user asked for
|
||||||
|
autonomous work with a self-review per part). Part C2 (Codex hooks) is separate.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Codex emits no hook AgentHub listens to, so a Codex session never reaches the
|
||||||
|
`sessions` table and its tokens cost nothing on any page. The pricing table
|
||||||
|
and the automatic pricing sync know Claude models only.
|
||||||
|
|
||||||
|
## What Codex writes (verified on 0.155, 178 real rollouts)
|
||||||
|
|
||||||
|
`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread id>.jsonl`, append-only:
|
||||||
|
|
||||||
|
- **Line 1** is `session_meta`, with these payload fields:
|
||||||
|
- `id`: the thread.
|
||||||
|
- `session_id`: the ROOT session. It is the same as `id` except in a sub-agent thread, whose `thread_source` is `subagent`. Older CLIs omit it.
|
||||||
|
- `cwd`, `timestamp`, `cli_version`.
|
||||||
|
- `forked_from_id`: set when the file is a fork.
|
||||||
|
- **`turn_context.payload.model`** is the model for the lines that follow. It can change mid-session, or be a router id such as `clinepass/cline-pass/deepseek-v4-pro`.
|
||||||
|
- **`event_msg` with `payload.type == "token_count"`** carries two things:
|
||||||
|
- `info.total_token_usage`: a cumulative total for the thread.
|
||||||
|
- `info.last_token_usage`: the delta.
|
||||||
|
|
||||||
|
`info` may be null. The same total is often emitted twice in a row.
|
||||||
|
- **Token fields:**
|
||||||
|
- `input_tokens` includes `cached_input_tokens`.
|
||||||
|
- `output_tokens` includes `reasoning_output_tokens`.
|
||||||
|
- `cache_write_input_tokens` is separate.
|
||||||
|
- **A fork COPIES its parent's whole history** into its own file, `token_count` events included, with rewritten timestamps. It then carries on from the parent's cumulative total. Summing the fork file naively counts the parent twice.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **`sessions.tool`** is a new column: `TEXT NOT NULL DEFAULT 'claude'`, added with the additive `ALTER TABLE` idiom the `source` column used. Every existing row and every hook-created row stays `claude`.
|
||||||
|
2. **`codex_rollouts`** is a new table recording how far each file has been read:
|
||||||
|
- Columns: `path` PK, `session_id` (root), `thread_id`, `size`, `mtime_ms`, `offset`, `state` JSON, `updated_at`.
|
||||||
|
- `state` holds the current model, the last total seen, the per-model usage sums, and the meta.
|
||||||
|
- A file whose size and mtime are unchanged is not read again.
|
||||||
|
- A grown file is read from `offset` up to its last newline.
|
||||||
|
- A shrunken file is re-read from 0.
|
||||||
|
- Because this table lives in the DB, a session the user deleted stays deleted until Codex writes to its file again, while wiping the DB re-imports everything.
|
||||||
|
3. **`server/lib/codex-usage.js`** does the ingest:
|
||||||
|
- **`parseRolloutChunk(text, state, skip)`** is pure. It JSON-parses only the lines it needs, picked by five needles (`NEEDLES`): `session_meta`, `turn_context`, `token_count`, and a prompt as `user_message` (older CLIs) or `UserMessage` (0.149+). Each new total adds its `last_token_usage` to the current model, mapped as:
|
||||||
|
- input = `input − cached`
|
||||||
|
- cache_read = `cached`
|
||||||
|
- cache_write = `cache_write_input_tokens`
|
||||||
|
- output = `output_tokens`
|
||||||
|
|
||||||
|
OpenAI bills reasoning as output.
|
||||||
|
- A total equal to the previous one is skipped.
|
||||||
|
- **Forks:** when a file with `forked_from_id` is read from offset 0, the parent's file is found by the `-<id>.jsonl` name. Every total vector in the parent is skipped in the child: the copied prefix matches the parent exactly, and the child's own totals never do. If the parent's file is gone, nothing is skipped and the overcount is logged.
|
||||||
|
- **`ingestCodexUsage({ broadcast })`:**
|
||||||
|
- Walks every rollout (no newest-40 cap) and yields to the event loop between files.
|
||||||
|
- For each root session touched, it sums its threads' usage per model and writes it with `stmts.setTokenUsage`. The totals are exact recomputes, so the high-water baseline of `replaceTokenUsage` would be wrong here.
|
||||||
|
- It then inserts or updates the `sessions` row:
|
||||||
|
- `tool='codex'`
|
||||||
|
- `name` = the `thread_name` from `session_index.jsonl`, else the first prompt (first line, 60 chars; `user_message` or the 0.149+ `item_completed` `UserMessage`), else `Codex <8>`
|
||||||
|
- `cwd` and `started_at` from the root meta
|
||||||
|
- `model` = the latest model
|
||||||
|
- `status` = `active` while any of the session's files was written in the last 2 minutes, else `completed`, with `ended_at` = the newest journal's mtime
|
||||||
|
- metadata `{source:"codex-rollout"}`
|
||||||
|
- It never touches a row whose `tool` is not `codex`, and never sets `transcript_path`, so the Claude transcript parsers and sweeps skip these rows.
|
||||||
|
- It broadcasts `session_created`/`session_updated`.
|
||||||
|
- One pass at a time. It runs at startup (in the background) and then every `DASHBOARD_CODEX_POLL_MS`, the codex lane poll's own setting (`0` turns both off).
|
||||||
|
4. **Pricing sync learns OpenAI.**
|
||||||
|
- `familyOf` also accepts `gpt-…`, `o<digit>…` and `codex-…` ids, dropping a `-YYYY-MM-DD` suffix.
|
||||||
|
- `toRow` takes `litellm_provider == "openai"` entries. A cache write costs `cache_creation_input_token_cost` or 0, since OpenAI has no cache-write charge by default. The fast rate is 0. The display name comes from `displayName` (`gpt-5.6-sol` → `GPT-5.6 Sol`).
|
||||||
|
- Long-context (>272k) and priority/flex tiers are not modelled. Codex's context window stays under 272k.
|
||||||
|
- `POST /api/settings/reset-pricing` starts a sync right after reseeding, so OpenAI rows wiped by a reset return at once.
|
||||||
|
5. **Model Pricing in Codex → AgentHub.**
|
||||||
|
- The pricing section of `ClaudeAgentHubSettings.tsx` becomes `components/settings/PricingSettings.tsx` with a `vendor` prop. The Claude tab shows `claude-…` rows; the Codex tab shows OpenAI rows.
|
||||||
|
- A **Show all models** switch shows every row, since one table prices every tool's sessions (a router model may serve either). A row added outside the filter turns the switch on, so it never vanishes from view.
|
||||||
|
- Reset Defaults stays on the Claude tab only and sends `{scope: "claude"}`: it deletes and reseeds only `claude-…` rows, so OpenAI rows (and a custom router row) survive it; a reset with no body wipes every row, and OpenAI rows come back from the sync.
|
||||||
|
6. **Claude-only surfaces exclude Codex sessions.**
|
||||||
|
- The Workspace's external sessions, which offer attach and `claude --resume`, list `tool === 'claude'` only.
|
||||||
|
- The Sessions list and session detail show a **Codex** badge.
|
||||||
|
|
||||||
|
## Found in the browser check, fixed
|
||||||
|
|
||||||
|
- The Claude liveness reap and the transcript watchdog (`routes/hooks.js`) probed Codex sessions for a `claude` process and reaped them: both now select `tool = 'claude'` only.
|
||||||
|
- The pricing sync had already run when the first ingest wrote new models; a pass that writes sessions now triggers a sync.
|
||||||
|
|
||||||
|
## Found in the code review, fixed
|
||||||
|
|
||||||
|
- **A journal met under a new path counted twice** (CODEX_HOME moved, or a symlinked alias): there is now one `codex_rollouts` row per thread, and reading a file drops the thread's rows under other paths.
|
||||||
|
- **The Claude resume picker offered Codex sessions** (`RunSetup.tsx`), which `claude --resume` cannot open: it lists `tool === 'claude'` only.
|
||||||
|
- **A failed price-list fetch was retried on every Codex pass** (every 15 s): a failure now waits for the sync's own 15-minute check.
|
||||||
|
- **A fork's copied history arriving after the first read was counted**: the parent's totals are skipped until the fork's first own total (`state.pastFork`), on every read, not just the one from offset 0.
|
||||||
|
- **A journal written in the future** (a clock running ahead) kept its session `active`: it no longer counts as activity.
|
||||||
|
- **A large journal blocked the event loop**: one pass reads at most 16 MB of a file, and a fork's parent is streamed.
|
||||||
|
- **A model gone from a re-read journal kept its old usage row**: a Codex session's usage is rewritten whole.
|
||||||
|
- **A sub-agent file read before its root fixed the session's cwd, start and model**: the root thread's file sets them once it is read.
|
||||||
|
- **The startup cleanup completed active Codex sessions** and **a Claude lane's purge deleted a Codex lane's sessions** at the same directory: both are scoped by tool.
|
||||||
|
- **The codex lane mirror flapped** (`codex-ingest.js`, predates C1): a lane with an old journal beside the live one went running → idle → running on alternate passes, and a journal written in the current millisecond read as future. One verdict per lane now, with a second of slack.
|
||||||
|
|
||||||
|
## Found in the whole-change review, fixed
|
||||||
|
|
||||||
|
- **Hooks kept a session's status forever.** A Codex session with any hook event kept its hook-given status even after the hooks were removed or no longer trusted, so it could stay `active` for good. Hooks now own the status only while current — `hooksCurrent`: the session's newest event is at or after its newest journal write minus 2 minutes — and otherwise the journal decides again, in `settleActive` too.
|
||||||
|
- **Clear data stopped Codex (and Antigravity) sessions ever coming back.** `POST /api/settings/clear-data` kept `codex_rollouts`, so a cleared session was never re-imported, or came back half on its next write: it now empties `codex_rollouts` and `antigravity_conversations` too.
|
||||||
|
- **The per-thread lookup scanned the table**: `idx_codex_rollouts_thread` indexes `codex_rollouts(thread_id)`.
|
||||||
|
- **A busy Codex lane pushed Claude rows out of a page**: `GET /api/sessions` takes `?tool=`, applied before the limit, and the Workspace attach list and the run form's resume picker ask for `tool: "claude"`.
|
||||||
|
- **Reset Defaults wiped the OpenAI rows** a user had edited: the Claude tab's reset is scoped to `claude-…` rows (above).
|
||||||
|
|
||||||
|
Left as is: a journal rewritten to the same or a larger size is not detected (Codex only appends), and a fork without a Codex thread name is named after the prompt it copied from its parent.
|
||||||
|
|
||||||
|
## Not changed
|
||||||
|
|
||||||
|
Hook ingestion, `transcript-cache.js`, `import-history.js` and remote sync still handle Claude Code only. Codex lanes keep their idle/running mirror in `codex-ingest.js`. Session↔lane binding is untouched.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Server:**
|
||||||
|
- Chunk parser: mapping, duplicate totals, model switch, null info, partial last line.
|
||||||
|
- Fork skip, with and without the parent file.
|
||||||
|
- Sub-agent thread folded into the root session.
|
||||||
|
- Incremental read after an append; a shrunken file re-read.
|
||||||
|
- An unchanged file not re-read; a deleted session not resurrected.
|
||||||
|
- A claude row never touched.
|
||||||
|
- Pricing sync for an OpenAI family.
|
||||||
|
- Reset triggers a sync.
|
||||||
|
- **Client:** `PricingSettings` vendor filter and the show-all switch; the resume picker excluding codex sessions; the Codex badge.
|
||||||
|
- **Browser:** an isolated server on a temp `CODEX_HOME` with copied real rollouts.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Settings persistence + editable CODEX_HOME
|
||||||
|
|
||||||
|
Issue: #40 follow-up to part B. Date: 2026-09-29. Status: self-reviewed (user
|
||||||
|
asked for autonomous work with self-review per part).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`setClaudeHome` persists through `writeEnvFile`, which writes `<repo>/.env`.
|
||||||
|
For a plugin install that directory is the plugin CACHE, replaced on every
|
||||||
|
update, so a saved CLAUDE_HOME silently reverts. Two more faults ride along:
|
||||||
|
|
||||||
|
- The data dir (database, runtime, `ai-tools.json`) is `getDataDir()` =
|
||||||
|
`<CLAUDE_HOME>/agenthub`, recomputed on every call. Changing CLAUDE_HOME at
|
||||||
|
runtime moves every later `getDataDir()` caller mid-process, and the next
|
||||||
|
start opens an EMPTY database under the new home — indistinguishable from
|
||||||
|
data loss.
|
||||||
|
- `writeEnvFile` appends a blank line on every save.
|
||||||
|
|
||||||
|
An editable CODEX_HOME (deferred from part B) has the same persistence problem,
|
||||||
|
plus one of its own: the AI Router layer `<name>.config.toml` lives in
|
||||||
|
`$CODEX_HOME`, so after a change a codex lane on a router would start with
|
||||||
|
`-p <name>` against a home that has no such layer.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **A settings file outside both the repo and CLAUDE_HOME:**
|
||||||
|
`$XDG_CONFIG_HOME/agenthub/settings.env` (default `~/.config/agenthub/`),
|
||||||
|
overridable with `AGENTHUB_SETTINGS_FILE` (the server test runner points it
|
||||||
|
at a temp path). Dir 0700, file 0600, atomic write, one `KEY=value` per
|
||||||
|
line, no blank-line growth. New module `server/lib/settings-env.js`.
|
||||||
|
2. **Load order at startup** (`server/index.js`, replacing `loadDotEnv`): the
|
||||||
|
real environment wins, then `<repo>/.env`, then the settings file
|
||||||
|
(`loadEnvFiles({owned: [.env], files: [settings]})`). The checkout's `.env`
|
||||||
|
ranks above the per-user file because it is more specific: a dev checkout
|
||||||
|
whose `.env` names its own `DASHBOARD_DATA_DIR` must not be pointed at the
|
||||||
|
live database by a pin saved from the plugin dashboard. A key is only set
|
||||||
|
when still unset, exactly as before. The keys the real environment or `.env`
|
||||||
|
supplied are remembered (`envOwned`), so a route can say a saved value will
|
||||||
|
be overridden at the next start. Keys that came from a file are named in
|
||||||
|
`AGENTHUB_FILE_KEYS`: a self-restart spawns the successor with this
|
||||||
|
process's env, and without the marker the successor would count them as the
|
||||||
|
real environment's and warn falsely. A saved path may not contain a line
|
||||||
|
break (it would inject a second `KEY=`).
|
||||||
|
3. **Pin the data dir on the first CLAUDE_HOME change.** Before switching,
|
||||||
|
`setClaudeHome` writes `DASHBOARD_DATA_DIR=<the data dir in use>` to the
|
||||||
|
settings file and to `process.env` when neither already sets it. The
|
||||||
|
database and runtime dir stay where the plugin bootstrap and the CLI (which
|
||||||
|
never read the settings file) look for them. The discovery file
|
||||||
|
(`<CLAUDE_HOME>/.agenthub.json`) is NOT pinned and follows CLAUDE_HOME, as it
|
||||||
|
did before this change; hooks under a different CLAUDE_HOME then fall back to
|
||||||
|
port 4820.
|
||||||
|
4. **`validateHomeDir(label, input)`** in `settings-env.js` is the one path
|
||||||
|
check both homes use: `~/` expansion, absolute, `path.resolve`, exists, is
|
||||||
|
a directory, and neither IS nor CONTAINS the OS home (`EBADHOME`) — `/`,
|
||||||
|
`/home`, `~` itself, or a symlink, bind mount or differently-cased path to
|
||||||
|
any of them, compared by device and inode against the home and each of its
|
||||||
|
ancestors.
|
||||||
|
5. **`setCodexHome`** (`server/lib/codex-home.js`) and
|
||||||
|
`GET/PUT /api/settings/codex-home`. PUT re-applies every AI Router profile a
|
||||||
|
codex lane uses (`reapplyCodexLayers` in `router-apply.js`), so the layer
|
||||||
|
exists in the new home, and answers
|
||||||
|
`{ ok, codex_home, env_owned, routers: { updated, failed } }`.
|
||||||
|
`GET/PUT /api/settings/claude-home` gain `env_owned` (additive).
|
||||||
|
6. **UI:** a shared `HomeDirSetting` component renders CLAUDE_HOME (Claude Code
|
||||||
|
→ AgentHub) and CODEX_HOME (new Codex → AgentHub sub-tab). It shows the
|
||||||
|
env-owned warning, the router re-apply failures, and — for CODEX_HOME —
|
||||||
|
that a running codex pane keeps its old home until restarted.
|
||||||
|
|
||||||
|
## Not changed
|
||||||
|
|
||||||
|
Existing `<repo>/.env` files keep working, and rank above the settings file: a
|
||||||
|
key a `.env` sets is `env_owned`, so a value saved from the UI for it is
|
||||||
|
overridden at the next start, and the UI says so. The file is per OS user, so every AgentHub server
|
||||||
|
of that user (a plugin install and a dev checkout) reads the same one. Stores that live
|
||||||
|
under CLAUDE_HOME by design (`agenthub-routers/`, `agenthub-profiles/`,
|
||||||
|
`agenthub-git-credentials/`) still follow it. The CLI and bootstrap do not read
|
||||||
|
the settings file; pinning the data dir is what keeps them consistent.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Server: settings-env parse/load order/`envOwned`/write (no blank lines, 0600),
|
||||||
|
`validateHomeDir` refusals, `setClaudeHome` pinning `DASHBOARD_DATA_DIR`,
|
||||||
|
`setCodexHome` + the route (refusal 400, success, router re-apply called),
|
||||||
|
OpenAPI entry. Client: `HomeDirSetting` save/error/env warning, the Codex
|
||||||
|
sub-tab. Browser check on an isolated server.
|
||||||
|
|
||||||
|
## Found in the whole-change review, fixed
|
||||||
|
|
||||||
|
- **A dev checkout's `.env` lost to the plugin dashboard's pin.** The settings
|
||||||
|
file ranked above `<repo>/.env`, so a `DASHBOARD_DATA_DIR` pinned from the
|
||||||
|
plugin dashboard pointed a dev checkout's server at the live database. The
|
||||||
|
order is now real environment > `.env` > settings file, and a `.env` key
|
||||||
|
counts as `env_owned`.
|
||||||
|
- **An old line could win.** `writeSetting` replaced only the first line for a
|
||||||
|
key; it now drops every earlier one, `KEY = value` included, and writes
|
||||||
|
through a symlinked settings file to its target.
|
||||||
|
- **A case-insensitive or bind-mounted path to the home passed.**
|
||||||
|
`validateHomeDir` now compares device and inode with the home and each
|
||||||
|
ancestor.
|
||||||
|
- **Another site could set a home.** `PUT /api/settings/claude-home` and
|
||||||
|
`/codex-home` carry `sameOriginGuard` (403 `EBADORIGIN`): a home decides
|
||||||
|
which config a pane runs.
|
||||||
|
- **Lanes silently lost their router.** The router store is
|
||||||
|
`<CLAUDE_HOME>/agenthub-routers/`, so a new home may lack a lane's profile;
|
||||||
|
the claude-home PUT answers `routers: {missing: [...]}`
|
||||||
|
(`lanesWithMissingRouter`) and the UI warns that those lanes start on native.
|
||||||
|
- **Stale paths after a change.** The Claude hook status (`settings.js`),
|
||||||
|
`scripts/install-hooks.js` and `scripts/import-history.js` read their
|
||||||
|
CLAUDE_HOME-derived paths per call, not at load. `cc-watcher.js` still
|
||||||
|
watches the startup home until a restart.
|
||||||
+522
-8
@@ -228,6 +228,9 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
tool:
|
||||||
|
type: string
|
||||||
|
description: 'The AI coding CLI that produced the session: `claude` (Claude Code''s hooks and imported transcripts), `codex` (Codex''s rollout journals and its opt-in hooks) or `antigravity` (agy''s conversation files and its opt-in hooks).'
|
||||||
agent_count:
|
agent_count:
|
||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -1017,11 +1020,17 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
hook_type:
|
hook_type:
|
||||||
type: string
|
type: string
|
||||||
description: 'Hook type from Claude Code (common values: PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionStart, SessionEnd)'
|
description: 'Hook type from the CLI that sent it (Claude Code: PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionStart, SessionEnd; Codex adds PermissionRequest and Interrupt; agy sends PreInvocation, PostToolUse and Stop)'
|
||||||
|
tool:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claude
|
||||||
|
- codex
|
||||||
|
- antigravity
|
||||||
|
description: 'Which CLI sent the hook (`scripts/hook-handler.js <Event> [codex|antigravity]`). An allowlist: anything else is `claude`.'
|
||||||
data:
|
data:
|
||||||
type: object
|
type: object
|
||||||
required:
|
description: The raw hook payload. Claude Code and Codex name the session `session_id`; agy names it `conversationId` and is accepted only with a `transcriptPath` under its own CLI directory.
|
||||||
- session_id
|
|
||||||
properties:
|
properties:
|
||||||
session_id:
|
session_id:
|
||||||
type: string
|
type: string
|
||||||
@@ -1034,7 +1043,6 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- ok
|
- ok
|
||||||
- event
|
|
||||||
properties:
|
properties:
|
||||||
ok:
|
ok:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -1042,6 +1050,12 @@ components:
|
|||||||
- true
|
- true
|
||||||
event:
|
event:
|
||||||
$ref: '#/components/schemas/DashboardEvent'
|
$ref: '#/components/schemas/DashboardEvent'
|
||||||
|
ignored:
|
||||||
|
type: boolean
|
||||||
|
description: The hook maps to nothing AgentHub records (a Codex sub-agent's lifecycle hook, an agy event other than a turn's first PreInvocation, a PostToolUse or a Stop, or a hook from the Antigravity IDE).
|
||||||
|
duplicate:
|
||||||
|
type: boolean
|
||||||
|
description: The same hook delivered twice within 10 seconds; stored once.
|
||||||
PricingRule:
|
PricingRule:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -4495,6 +4509,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Absolute path to the active Claude Code home directory (CLAUDE_HOME, or `<homedir>/.claude` when unset).
|
description: Absolute path to the active Claude Code home directory (CLAUDE_HOME, or `<homedir>/.claude` when unset).
|
||||||
example: /Users/son/.claude
|
example: /Users/son/.claude
|
||||||
|
env_owned:
|
||||||
|
type: boolean
|
||||||
|
description: 'True when the real environment, or the checkout''s own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.'
|
||||||
SettingsClaudeHomeUpdateRequest:
|
SettingsClaudeHomeUpdateRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Request body for changing the Claude Code home directory. A leading `~` is expanded to the user's home directory; the resolved path must be absolute and point to an existing directory.
|
description: Request body for changing the Claude Code home directory. A leading `~` is expanded to the user's home directory; the resolved path must be absolute and point to an existing directory.
|
||||||
@@ -4507,7 +4524,7 @@ components:
|
|||||||
example: ~/.codefuse/engine/cc
|
example: ~/.codefuse/engine/cc
|
||||||
SettingsClaudeHomeUpdateResponse:
|
SettingsClaudeHomeUpdateResponse:
|
||||||
type: object
|
type: object
|
||||||
description: Confirmation that CLAUDE_HOME was updated. The new value is applied to process.env immediately and persisted to the project `.env` file.
|
description: Confirmation that CLAUDE_HOME was updated. The new value is applied to process.env immediately and persisted to the settings file (`$XDG_CONFIG_HOME/agenthub/settings.env`, default `~/.config/agenthub/`, or AGENTHUB_SETTINGS_FILE).
|
||||||
required:
|
required:
|
||||||
- ok
|
- ok
|
||||||
- claude_home
|
- claude_home
|
||||||
@@ -4520,6 +4537,17 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: The resolved absolute path now in effect (after `~` expansion).
|
description: The resolved absolute path now in effect (after `~` expansion).
|
||||||
example: /Users/son/.codefuse/engine/cc
|
example: /Users/son/.codefuse/engine/cc
|
||||||
|
env_owned:
|
||||||
|
type: boolean
|
||||||
|
description: 'True when the real environment, or the checkout''s own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.'
|
||||||
|
routers:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
missing:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: '`<lane id> (<title>): <profile>` for every lane naming an AI Router profile the new home''s store (`<CLAUDE_HOME>/agenthub-routers/`) does not have — such a lane starts on its base configuration.'
|
||||||
WorkflowToolRun:
|
WorkflowToolRun:
|
||||||
type: object
|
type: object
|
||||||
description: 'A Claude Code Workflow-tool run (issue #167): a fleet of sub-agents spawned by the ''Workflow'' tool (or self-paced /loop). These emit no hooks; the source of truth is the on-disk run journal, ingested into the `workflows` table (see server/lib/workflow-ingest.js). Keyed by `run_id` and parented to the launching session. The JSON-blob columns `phases` and `progress` are parsed into arrays before serialization.'
|
description: 'A Claude Code Workflow-tool run (issue #167): a fleet of sub-agents spawned by the ''Workflow'' tool (or self-paced /loop). These emit no hooks; the source of truth is the on-disk run journal, ingested into the `workflows` table (see server/lib/workflow-ingest.js). Keyed by `run_id` and parented to the launching session. The JSON-blob columns `phases` and `progress` are parsed into arrays before serialization.'
|
||||||
@@ -4727,6 +4755,16 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
description: Case-insensitive search across `id` / `name` / `cwd`. Composes with the status filter when both are present.
|
description: Case-insensitive search across `id` / `name` / `cwd`. Composes with the status filter when both are present.
|
||||||
example: pricing
|
example: pricing
|
||||||
|
- name: tool
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claude
|
||||||
|
- codex
|
||||||
|
- antigravity
|
||||||
|
description: Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.
|
||||||
|
example: claude
|
||||||
- $ref: '#/components/parameters/SourcesQuery'
|
- $ref: '#/components/parameters/SourcesQuery'
|
||||||
example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11
|
example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11
|
||||||
- $ref: '#/components/parameters/LimitQuery'
|
- $ref: '#/components/parameters/LimitQuery'
|
||||||
@@ -5739,6 +5777,8 @@ paths:
|
|||||||
|
|
||||||
On success the response is `{ ok: true, event: { ... } }`, where `event` echoes the normalized row that was just inserted (`session_id`, `agent_id`, `event_type`, `tool_name`, `summary`, `created_at`). Ingestion is designed to be fail-safe and non-blocking for the hook caller.
|
On success the response is `{ ok: true, event: { ... } }`, where `event` echoes the normalized row that was just inserted (`session_id`, `agent_id`, `event_type`, `tool_name`, `summary`, `created_at`). Ingestion is designed to be fail-safe and non-blocking for the hook caller.
|
||||||
|
|
||||||
|
The optional `tool` (`codex`, `antigravity`; anything else is `claude`) names the CLI that sent the hook. A Codex or agy hook is mapped onto the same hook types first (a Codex PermissionRequest becomes a Notification, an Interrupt a Stop; an agy turn's first PreInvocation a UserPromptSubmit), and one that maps to nothing is answered `{ ok: true, ignored: true }`. A hook delivered twice is answered `{ ok: true, duplicate: true }` and stored once.
|
||||||
|
|
||||||
Validation failures return HTTP 400 with an `ErrorResponse` body (`{ error: { code, message } }`): `INVALID_INPUT` when `hook_type` or `data` is missing, and `MISSING_SESSION` when `data.session_id` is absent.
|
Validation failures return HTTP 400 with an `ErrorResponse` body (`{ error: { code, message } }`): `INVALID_INPUT` when `hook_type` or `data` is missing, and `MISSING_SESSION` when `data.session_id` is absent.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -6472,8 +6512,20 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Settings
|
- Settings
|
||||||
summary: Reset pricing table to defaults
|
summary: Reset pricing table to defaults
|
||||||
description: ⚠ DESTRUCTIVE to pricing customizations. Deletes EVERY row in the model_pricing table and re-seeds it from the dashboard's built-in DEFAULT_PRICING list. Any custom rates or custom model patterns you added are permanently lost — there is no undo. Captured session/token data is untouched (only the pricing rules used to *compute* cost change). The response returns the full freshly-seeded pricing table.
|
description: '⚠ DESTRUCTIVE to pricing customizations. Deletes EVERY row in the model_pricing table and re-seeds it from the dashboard''s built-in DEFAULT_PRICING list. Any custom rates or custom model patterns you added are permanently lost — there is no undo. Captured session/token data is untouched (only the pricing rules used to *compute* cost change). The response returns the full freshly-seeded pricing table. With `{"scope": "claude"}` (the Claude Code tab''s button) only `claude-…` rows are deleted and re-seeded; every other vendor''s rows and custom patterns are kept.'
|
||||||
operationId: resetPricing
|
operationId: resetPricing
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claude
|
||||||
|
description: 'Reset only `claude-…` rows. Omitted: every row.'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Pricing defaults restored
|
description: Pricing defaults restored
|
||||||
@@ -12256,6 +12308,462 @@ paths:
|
|||||||
results:
|
results:
|
||||||
- id: src_a
|
- id: src_a
|
||||||
ok: true
|
ok: true
|
||||||
|
/api/settings/codex-hooks:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Status of AgentHub's hooks in Codex
|
||||||
|
description: 'Per event (SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd): whether AgentHub''s group is in `$CODEX_HOME/hooks.json`, and whether Codex''s `config.toml` holds a trust entry for it. Codex runs a hook only once the user trusts it; AgentHub only reads that trust.'
|
||||||
|
operationId: getCodexHooks
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Where AgentHub's Codex hooks stand
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- supported
|
||||||
|
- readable
|
||||||
|
- path
|
||||||
|
- script
|
||||||
|
- installed
|
||||||
|
- trusted
|
||||||
|
- events
|
||||||
|
properties:
|
||||||
|
supported:
|
||||||
|
type: boolean
|
||||||
|
description: False on Windows (the command is a POSIX script)
|
||||||
|
readable:
|
||||||
|
type: boolean
|
||||||
|
description: False when hooks.json is not a JSON object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: '`$CODEX_HOME/hooks.json`'
|
||||||
|
script:
|
||||||
|
type: string
|
||||||
|
description: '`$CODEX_HOME/agenthub-hook.sh`, the forwarder'
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
trusted:
|
||||||
|
type: boolean
|
||||||
|
description: Codex's config.toml holds a trust entry for every one of AgentHub's hooks
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
trusted:
|
||||||
|
type: boolean
|
||||||
|
'500':
|
||||||
|
description: 'ESAVE: the file system refused the write'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Install (or reinstall) AgentHub's hooks in Codex
|
||||||
|
description: Refuses a cross-origin request (403 EBADORIGIN). Writes the forwarder script and adds one hook of AgentHub's per event to `$CODEX_HOME/hooks.json`, keeping every other entry. The command is the script's stable path, so Codex's trust survives AgentHub updates; the server rewrites the script's content at every start. Codex asks the user to trust the new hooks the next time it starts.
|
||||||
|
operationId: installCodexHooks
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Where AgentHub's Codex hooks stand
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- supported
|
||||||
|
- readable
|
||||||
|
- path
|
||||||
|
- script
|
||||||
|
- installed
|
||||||
|
- trusted
|
||||||
|
- events
|
||||||
|
properties:
|
||||||
|
supported:
|
||||||
|
type: boolean
|
||||||
|
description: False on Windows (the command is a POSIX script)
|
||||||
|
readable:
|
||||||
|
type: boolean
|
||||||
|
description: False when hooks.json is not a JSON object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: '`$CODEX_HOME/hooks.json`'
|
||||||
|
script:
|
||||||
|
type: string
|
||||||
|
description: '`$CODEX_HOME/agenthub-hook.sh`, the forwarder'
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
trusted:
|
||||||
|
type: boolean
|
||||||
|
description: Codex's config.toml holds a trust entry for every one of AgentHub's hooks
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
trusted:
|
||||||
|
type: boolean
|
||||||
|
'400':
|
||||||
|
description: EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 'ESAVE: the file system refused the write'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Remove AgentHub's hooks from Codex
|
||||||
|
description: Refuses a cross-origin request (403 EBADORIGIN). Takes AgentHub's own hooks (matched by their exact command) out of `$CODEX_HOME/hooks.json` (deleting the file when nothing else is left, or emptying it through the link when it is a symlink) and deletes the forwarder script when it is AgentHub's. 400 EBADJSON when hooks.json is not a JSON object.
|
||||||
|
operationId: removeCodexHooks
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Where AgentHub's Codex hooks stand
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- supported
|
||||||
|
- readable
|
||||||
|
- path
|
||||||
|
- script
|
||||||
|
- installed
|
||||||
|
- trusted
|
||||||
|
- events
|
||||||
|
properties:
|
||||||
|
supported:
|
||||||
|
type: boolean
|
||||||
|
description: False on Windows (the command is a POSIX script)
|
||||||
|
readable:
|
||||||
|
type: boolean
|
||||||
|
description: False when hooks.json is not a JSON object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: '`$CODEX_HOME/hooks.json`'
|
||||||
|
script:
|
||||||
|
type: string
|
||||||
|
description: '`$CODEX_HOME/agenthub-hook.sh`, the forwarder'
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
trusted:
|
||||||
|
type: boolean
|
||||||
|
description: Codex's config.toml holds a trust entry for every one of AgentHub's hooks
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
trusted:
|
||||||
|
type: boolean
|
||||||
|
'400':
|
||||||
|
description: EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 'ESAVE: the file system refused the write'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/settings/antigravity-hooks:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Status of AgentHub's hook in Antigravity
|
||||||
|
description: 'Per event (PreInvocation, PostToolUse, Stop): whether AgentHub''s named hook `agenthub` in `~/.gemini/config/hooks.json` runs its forwarder. PreToolUse is never installed: agy treats its answer as a permission decision.'
|
||||||
|
operationId: getAntigravityHooks
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Where AgentHub's Antigravity hook stands
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- supported
|
||||||
|
- readable
|
||||||
|
- path
|
||||||
|
- script
|
||||||
|
- installed
|
||||||
|
- enabled
|
||||||
|
- events
|
||||||
|
properties:
|
||||||
|
supported:
|
||||||
|
type: boolean
|
||||||
|
description: False on Windows (the command is a POSIX script)
|
||||||
|
readable:
|
||||||
|
type: boolean
|
||||||
|
description: False when hooks.json is not a JSON object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: '`~/.gemini/config/hooks.json`'
|
||||||
|
script:
|
||||||
|
type: string
|
||||||
|
description: '`~/.gemini/config/agenthub-hook.sh`, the forwarder'
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
description: 'Installed and not switched off (`"enabled": false`) in the file'
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
'500':
|
||||||
|
description: 'ESAVE: the file system refused the write'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Install (or reinstall) AgentHub's hook in Antigravity
|
||||||
|
description: Refuses a cross-origin request (403 EBADORIGIN). Writes the forwarder script and sets the named hook `agenthub` in `~/.gemini/config/hooks.json`, keeping every other named hook. agy loads it at its next start, with no trust step.
|
||||||
|
operationId: installAntigravityHooks
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Where AgentHub's Antigravity hook stands
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- supported
|
||||||
|
- readable
|
||||||
|
- path
|
||||||
|
- script
|
||||||
|
- installed
|
||||||
|
- enabled
|
||||||
|
- events
|
||||||
|
properties:
|
||||||
|
supported:
|
||||||
|
type: boolean
|
||||||
|
description: False on Windows (the command is a POSIX script)
|
||||||
|
readable:
|
||||||
|
type: boolean
|
||||||
|
description: False when hooks.json is not a JSON object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: '`~/.gemini/config/hooks.json`'
|
||||||
|
script:
|
||||||
|
type: string
|
||||||
|
description: '`~/.gemini/config/agenthub-hook.sh`, the forwarder'
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
description: 'Installed and not switched off (`"enabled": false`) in the file'
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
'400':
|
||||||
|
description: EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 'ESAVE: the file system refused the write'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Remove AgentHub's hook from Antigravity
|
||||||
|
description: Refuses a cross-origin request (403 EBADORIGIN). Deletes the named hook `agenthub` (and the file when nothing else is left, unless it is a symlink, which is emptied through the link) and the forwarder script when it is AgentHub's. 400 EBADJSON when hooks.json is not a JSON object.
|
||||||
|
operationId: removeAntigravityHooks
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Where AgentHub's Antigravity hook stands
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- supported
|
||||||
|
- readable
|
||||||
|
- path
|
||||||
|
- script
|
||||||
|
- installed
|
||||||
|
- enabled
|
||||||
|
- events
|
||||||
|
properties:
|
||||||
|
supported:
|
||||||
|
type: boolean
|
||||||
|
description: False on Windows (the command is a POSIX script)
|
||||||
|
readable:
|
||||||
|
type: boolean
|
||||||
|
description: False when hooks.json is not a JSON object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: '`~/.gemini/config/hooks.json`'
|
||||||
|
script:
|
||||||
|
type: string
|
||||||
|
description: '`~/.gemini/config/agenthub-hook.sh`, the forwarder'
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
description: 'Installed and not switched off (`"enabled": false`) in the file'
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
'400':
|
||||||
|
description: EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 'ESAVE: the file system refused the write'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/settings/codex-home:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Get the active Codex home directory
|
||||||
|
description: Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Router layers into, and hands every codex lane pane through tmux `-e`.
|
||||||
|
operationId: getCodexHome
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current Codex home directory
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- codex_home
|
||||||
|
- env_owned
|
||||||
|
properties:
|
||||||
|
codex_home:
|
||||||
|
type: string
|
||||||
|
env_owned:
|
||||||
|
type: boolean
|
||||||
|
example:
|
||||||
|
codex_home: /Users/son/.codex
|
||||||
|
env_owned: false
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Update the Codex home directory
|
||||||
|
description: Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). On success it re-writes the AI Router layer (`<name>.config.toml`) of every codex lane on a router into the new home, reported as `routers`, because `codex -p <name>` loads it from there. A codex pane already running keeps its old home until restarted.
|
||||||
|
operationId: updateCodexHome
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- path
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
path: ~/.codex-work
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Codex home updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- ok
|
||||||
|
- codex_home
|
||||||
|
- env_owned
|
||||||
|
- routers
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
enum:
|
||||||
|
- true
|
||||||
|
codex_home:
|
||||||
|
type: string
|
||||||
|
env_owned:
|
||||||
|
type: boolean
|
||||||
|
routers:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- updated
|
||||||
|
- failed
|
||||||
|
properties:
|
||||||
|
updated:
|
||||||
|
type: integer
|
||||||
|
failed:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
ok: true
|
||||||
|
codex_home: /Users/son/.codex-work
|
||||||
|
env_owned: false
|
||||||
|
routers:
|
||||||
|
updated: 1
|
||||||
|
failed: []
|
||||||
|
'400':
|
||||||
|
description: Invalid path (INVALID_PATH) or too broad (EBADHOME).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
example:
|
||||||
|
error:
|
||||||
|
code: EBADHOME
|
||||||
|
message: 'Too broad for CODEX_HOME: / contains your home directory'
|
||||||
|
'500':
|
||||||
|
description: The settings file could not be written (ESAVE).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/settings/claude-home:
|
/api/settings/claude-home:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -12276,7 +12784,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Settings
|
- Settings
|
||||||
summary: Update the Claude Code home directory
|
summary: Update the Claude Code home directory
|
||||||
description: Changes the Claude Code home directory used for transcript/settings discovery. A leading `~/` in `path` is expanded to the OS home directory; the resolved value must be an absolute path that exists and is a directory. On success the new value is applied to process.env immediately (so subsequent reads use it) and persisted to the project `.env` file. Returns 400 INVALID_PATH when `path` is missing/not a string, or when the resolved path is not absolute, does not exist, or is not a directory.
|
description: Changes the Claude Code home directory used for transcript/settings discovery. A leading `~/` in `path` is expanded to the OS home directory; the resolved value must be an absolute path that exists and is a directory. On success the new value is applied to process.env immediately (so subsequent reads use it) and persisted to the settings file (`$XDG_CONFIG_HOME/agenthub/settings.env`, default `~/.config/agenthub/`, or AGENTHUB_SETTINGS_FILE — never the repo `.env`, which a plugin update replaces). The data dir (database, runtime) is derived from CLAUDE_HOME, so the first change also pins the one in use as DASHBOARD_DATA_DIR unless the environment already sets it. Refuses a cross-origin request (403 EBADORIGIN). Returns 400 EBADHOME when the path is, or contains (symlinks resolved), the OS user's home directory — `/`, `/home`, `~` — because everything under CLAUDE_HOME becomes readable through the config panel; 400 INVALID_PATH when `path` is missing/not a string, or the resolved path is not absolute, does not exist, or is not a directory.
|
||||||
operationId: updateClaudeHome
|
operationId: updateClaudeHome
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -12297,7 +12805,7 @@ paths:
|
|||||||
ok: true
|
ok: true
|
||||||
claude_home: /Users/son/.codefuse/engine/cc
|
claude_home: /Users/son/.codefuse/engine/cc
|
||||||
'400':
|
'400':
|
||||||
description: Invalid path — `path` missing or not a string, or the resolved path is not absolute / does not exist / is not a directory (code INVALID_PATH).
|
description: Invalid path — `path` missing or not a string, or the resolved path is not absolute / does not exist / is not a directory (code INVALID_PATH), or it is or contains the OS home directory (code EBADHOME).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -12306,6 +12814,12 @@ paths:
|
|||||||
error:
|
error:
|
||||||
code: INVALID_PATH
|
code: INVALID_PATH
|
||||||
message: 'Directory does not exist: /Users/son/.codefuse/engine/cc'
|
message: 'Directory does not exist: /Users/son/.codefuse/engine/cc'
|
||||||
|
'500':
|
||||||
|
description: The settings file could not be written (ESAVE).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/workflows/runs:
|
/api/workflows/runs:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Code hook handler.
|
* Claude Code (and Codex, and Antigravity) hook handler.
|
||||||
* Receives hook event JSON on stdin and forwards it to every live Agent
|
* Receives hook event JSON on stdin and forwards it to every live Agent
|
||||||
* Dashboard server. Designed to fail silently so it never blocks Claude
|
* Dashboard server. Designed to fail silently so it never blocks Claude
|
||||||
* Code, and to fan out across multiple dashboards that use **different**
|
* Code, and to fan out across multiple dashboards that use **different**
|
||||||
@@ -22,6 +22,11 @@
|
|||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
|
||||||
const hookType = process.argv[2] || "unknown";
|
const hookType = process.argv[2] || "unknown";
|
||||||
|
// Codex runs this through `<CODEX_HOME>/agenthub-hook.sh <Event>`, which adds
|
||||||
|
// `codex` (server/lib/codex-hooks.js), Antigravity through
|
||||||
|
// `~/.gemini/config/agenthub-hook.sh`, which adds `antigravity`
|
||||||
|
// (server/lib/antigravity-hooks.js); Claude Code passes no second argument.
|
||||||
|
const tool = ["codex", "antigravity"].includes(process.argv[3]) ? process.argv[3] : undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve every live dashboard server's port via the discovery file. Falls
|
* Resolve every live dashboard server's port via the discovery file. Falls
|
||||||
@@ -54,6 +59,7 @@ process.stdin.on("end", () => {
|
|||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
hook_type: hookType,
|
hook_type: hookType,
|
||||||
data: parsedData,
|
data: parsedData,
|
||||||
|
...(tool && { tool }),
|
||||||
});
|
});
|
||||||
const contentLength = Buffer.byteLength(payload);
|
const contentLength = Buffer.byteLength(payload);
|
||||||
|
|
||||||
|
|||||||
+19
-18
@@ -31,7 +31,8 @@ const {
|
|||||||
} = require("../server/lib/claude-home");
|
} = require("../server/lib/claude-home");
|
||||||
const { extractFirstUserText } = require("../server/lib/transcript-cache");
|
const { extractFirstUserText } = require("../server/lib/transcript-cache");
|
||||||
const CLAUDE_DIR = getClaudeHome();
|
const CLAUDE_DIR = getClaudeHome();
|
||||||
const PROJECTS_DIR = getProjectsDir();
|
// The projects dir is read per call, not at load: CLAUDE_HOME can change while
|
||||||
|
// the dashboard runs (Settings → Claude Code → AgentHub).
|
||||||
|
|
||||||
// Max session files a directory sweep scans synchronously before it yields to
|
// Max session files a directory sweep scans synchronously before it yields to
|
||||||
// the event loop. A long synchronous scan of a large ~/.claude/projects tree —
|
// the event loop. A long synchronous scan of a large ~/.claude/projects tree —
|
||||||
@@ -1699,18 +1700,18 @@ function importSession(dbModule, session) {
|
|||||||
* agents + events that are missing. Safe to run repeatedly (deduplicated).
|
* agents + events that are missing. Safe to run repeatedly (deduplicated).
|
||||||
*/
|
*/
|
||||||
async function backfillCompactions(dbModule) {
|
async function backfillCompactions(dbModule) {
|
||||||
if (!fs.existsSync(PROJECTS_DIR)) return { backfilled: 0 };
|
if (!fs.existsSync(getProjectsDir())) return { backfilled: 0 };
|
||||||
const { stmts } = dbModule;
|
const { stmts } = dbModule;
|
||||||
|
|
||||||
const projectDirs = fs
|
const projectDirs = fs
|
||||||
.readdirSync(PROJECTS_DIR, { withFileTypes: true })
|
.readdirSync(getProjectsDir(), { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory())
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
|
|
||||||
let backfilled = 0;
|
let backfilled = 0;
|
||||||
|
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
const projPath = path.join(PROJECTS_DIR, projDir);
|
const projPath = path.join(getProjectsDir(), projDir);
|
||||||
const files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
const files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -1782,10 +1783,10 @@ async function parseSessionForImport(projPath, sourcePath) {
|
|||||||
* Designed to be fast on repeat runs (skips existing sessions).
|
* Designed to be fast on repeat runs (skips existing sessions).
|
||||||
*/
|
*/
|
||||||
async function importAllSessions(dbModule) {
|
async function importAllSessions(dbModule) {
|
||||||
if (!fs.existsSync(PROJECTS_DIR)) return { imported: 0, skipped: 0, errors: 0 };
|
if (!fs.existsSync(getProjectsDir())) return { imported: 0, skipped: 0, errors: 0 };
|
||||||
|
|
||||||
const projectDirs = fs
|
const projectDirs = fs
|
||||||
.readdirSync(PROJECTS_DIR, { withFileTypes: true })
|
.readdirSync(getProjectsDir(), { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory())
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
|
|
||||||
@@ -1802,7 +1803,7 @@ async function importAllSessions(dbModule) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
const projPath = path.join(PROJECTS_DIR, projDir);
|
const projPath = path.join(getProjectsDir(), projDir);
|
||||||
const files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
const files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
||||||
if (files.length === 0) continue;
|
if (files.length === 0) continue;
|
||||||
|
|
||||||
@@ -1884,12 +1885,12 @@ async function ingestWorkflowsForBatch(dbModule, sessions) {
|
|||||||
async function syncDefaultProjects(dbModule, options = {}) {
|
async function syncDefaultProjects(dbModule, options = {}) {
|
||||||
const mtimeCache = options.mtimeCache instanceof Map ? options.mtimeCache : new Map();
|
const mtimeCache = options.mtimeCache instanceof Map ? options.mtimeCache : new Map();
|
||||||
const changed = [];
|
const changed = [];
|
||||||
if (!fs.existsSync(PROJECTS_DIR)) return { changed };
|
if (!fs.existsSync(getProjectsDir())) return { changed };
|
||||||
|
|
||||||
let projectDirs;
|
let projectDirs;
|
||||||
try {
|
try {
|
||||||
projectDirs = fs
|
projectDirs = fs
|
||||||
.readdirSync(PROJECTS_DIR, { withFileTypes: true })
|
.readdirSync(getProjectsDir(), { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory())
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1901,7 +1902,7 @@ async function syncDefaultProjects(dbModule, options = {}) {
|
|||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
|
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
const projPath = path.join(PROJECTS_DIR, projDir);
|
const projPath = path.join(getProjectsDir(), projDir);
|
||||||
let files;
|
let files;
|
||||||
try {
|
try {
|
||||||
files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
||||||
@@ -1992,10 +1993,10 @@ async function syncDefaultProjects(dbModule, options = {}) {
|
|||||||
async function reconcileTokens(dbModule, options = {}) {
|
async function reconcileTokens(dbModule, options = {}) {
|
||||||
const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
|
const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
|
||||||
const counters = { reconciled: 0, sessionsTouched: 0, modelsWritten: 0, missingFiles: 0 };
|
const counters = { reconciled: 0, sessionsTouched: 0, modelsWritten: 0, missingFiles: 0 };
|
||||||
if (!fs.existsSync(PROJECTS_DIR)) return counters;
|
if (!fs.existsSync(getProjectsDir())) return counters;
|
||||||
|
|
||||||
const projectDirs = fs
|
const projectDirs = fs
|
||||||
.readdirSync(PROJECTS_DIR, { withFileTypes: true })
|
.readdirSync(getProjectsDir(), { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory())
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
|
|
||||||
@@ -2003,7 +2004,7 @@ async function reconcileTokens(dbModule, options = {}) {
|
|||||||
// already present in the DB.
|
// already present in the DB.
|
||||||
const sessionPaths = new Map();
|
const sessionPaths = new Map();
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
const projPath = path.join(PROJECTS_DIR, projDir);
|
const projPath = path.join(getProjectsDir(), projDir);
|
||||||
let files;
|
let files;
|
||||||
try {
|
try {
|
||||||
files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
||||||
@@ -2105,8 +2106,8 @@ if (require.main === module) {
|
|||||||
console.log("RECONCILE — refreshing token totals for already-imported sessions\n");
|
console.log("RECONCILE — refreshing token totals for already-imported sessions\n");
|
||||||
if (projectFilter) console.log(`Filtering to project: ${projectFilter}\n`);
|
if (projectFilter) console.log(`Filtering to project: ${projectFilter}\n`);
|
||||||
|
|
||||||
if (!fs.existsSync(PROJECTS_DIR)) {
|
if (!fs.existsSync(getProjectsDir())) {
|
||||||
console.error(`Projects directory not found: ${PROJECTS_DIR}`);
|
console.error(`Projects directory not found: ${getProjectsDir()}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2166,14 +2167,14 @@ if (require.main === module) {
|
|||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
const projectDirs = fs
|
const projectDirs = fs
|
||||||
.readdirSync(PROJECTS_DIR, { withFileTypes: true })
|
.readdirSync(getProjectsDir(), { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory())
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const projDir of projectDirs) {
|
for (const projDir of projectDirs) {
|
||||||
if (projectFilter && !projDir.includes(projectFilter)) continue;
|
if (projectFilter && !projDir.includes(projectFilter)) continue;
|
||||||
const projPath = path.join(PROJECTS_DIR, projDir);
|
const projPath = path.join(getProjectsDir(), projDir);
|
||||||
const files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
const files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
||||||
if (files.length === 0) continue;
|
if (files.length === 0) continue;
|
||||||
|
|
||||||
@@ -2690,7 +2691,7 @@ async function backfillSubagentTokenMetadata(dbModule) {
|
|||||||
slug = null;
|
slug = null;
|
||||||
}
|
}
|
||||||
if (slug) {
|
if (slug) {
|
||||||
const candidate = path.join(PROJECTS_DIR, slug, `${s.session_id}.jsonl`);
|
const candidate = path.join(getProjectsDir(), slug, `${s.session_id}.jsonl`);
|
||||||
// The file itself may be gone, but findSessionSubagents only needs the
|
// The file itself may be gone, but findSessionSubagents only needs the
|
||||||
// path to derive the sibling subagents dir — so accept the candidate as
|
// path to derive the sibling subagents dir — so accept the candidate as
|
||||||
// long as its directory exists.
|
// long as its directory exists.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const { getSettingsPath } = require("../server/lib/claude-home");
|
const { getSettingsPath } = require("../server/lib/claude-home");
|
||||||
const SETTINGS_PATH = getSettingsPath();
|
// Read per call, not at load: the dashboard's CLAUDE_HOME can change while it runs.
|
||||||
const HOOK_HANDLER = path.resolve(__dirname, "hook-handler.js").replace(/\\/g, "/");
|
const HOOK_HANDLER = path.resolve(__dirname, "hook-handler.js").replace(/\\/g, "/");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +81,7 @@ function containerRefusalMessage() {
|
|||||||
` The hook command would embed this handler path:`,
|
` The hook command would embed this handler path:`,
|
||||||
` ${HOOK_HANDLER}`,
|
` ${HOOK_HANDLER}`,
|
||||||
` written into:`,
|
` written into:`,
|
||||||
` ${SETTINGS_PATH}`,
|
` ${getSettingsPath()}`,
|
||||||
"",
|
"",
|
||||||
" Claude Code runs on the HOST. When ~/.claude is bind-mounted, a",
|
" Claude Code runs on the HOST. When ~/.claude is bind-mounted, a",
|
||||||
" container-internal handler path does not exist on the host, so every host",
|
" container-internal handler path does not exist on the host, so every host",
|
||||||
@@ -155,12 +155,12 @@ function installHooks(silent = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let settings = {};
|
let settings = {};
|
||||||
if (fs.existsSync(SETTINGS_PATH)) {
|
if (fs.existsSync(getSettingsPath())) {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(SETTINGS_PATH, "utf8");
|
const raw = fs.readFileSync(getSettingsPath(), "utf8");
|
||||||
settings = JSON.parse(raw);
|
settings = JSON.parse(raw);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!silent) console.error(`Failed to parse ${SETTINGS_PATH}:`, err.message);
|
if (!silent) console.error(`Failed to parse ${getSettingsPath()}:`, err.message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,13 +185,13 @@ function installHooks(silent = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = path.dirname(SETTINGS_PATH);
|
const dir = path.dirname(getSettingsPath());
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
console.log(`Hook handler: ${HOOK_HANDLER}`);
|
console.log(`Hook handler: ${HOOK_HANDLER}`);
|
||||||
console.log(`Settings file: ${SETTINGS_PATH}`);
|
console.log(`Settings file: ${getSettingsPath()}`);
|
||||||
console.log(`Installed: ${installed} new, updated: ${updated} existing`);
|
console.log(`Installed: ${installed} new, updated: ${updated} existing`);
|
||||||
console.log("Claude Code hooks configured. Start a new Claude Code session to begin tracking.");
|
console.log("Claude Code hooks configured. Start a new Claude Code session to begin tracking.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
* It also points `DASHBOARD_PIPELINES_DIR` at `server/__tests__/fixtures/
|
* It also points `DASHBOARD_PIPELINES_DIR` at `server/__tests__/fixtures/
|
||||||
* pipelines/`: AgentHub ships no pipeline template any more, and the two old
|
* pipelines/`: AgentHub ships no pipeline template any more, and the two old
|
||||||
* ones kept there are what the dormant stage/detection code is tested against.
|
* ones kept there are what the dormant stage/detection code is tested against.
|
||||||
|
* `AGENTHUB_SETTINGS_FILE` goes to the same temp dir, so a suite that saves a
|
||||||
|
* setting never writes the developer's real `~/.config/agenthub/settings.env`,
|
||||||
|
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -32,6 +35,11 @@ const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
|
|||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
TMUX_TMPDIR: tmuxDir,
|
TMUX_TMPDIR: tmuxDir,
|
||||||
|
// Never the developer's real ~/.config/agenthub/settings.env: it can pin
|
||||||
|
// DASHBOARD_DATA_DIR at their live database.
|
||||||
|
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",
|
||||||
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
||||||
};
|
};
|
||||||
delete env.TMUX;
|
delete env.TMUX;
|
||||||
|
|||||||
+40
-5
@@ -448,7 +448,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------- | ------------------- | ------------------------------------------------ |
|
| ------- | ------------------- | ------------------------------------------------ |
|
||||||
| `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) |
|
| `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) |
|
||||||
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`) |
|
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity` filters by CLI before the limit) |
|
||||||
| `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) |
|
| `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) |
|
||||||
| `POST` | `/api/sessions` | Create session (idempotent by `id`) |
|
| `POST` | `/api/sessions` | Create session (idempotent by `id`) |
|
||||||
| `PATCH` | `/api/sessions/:id` | Update session |
|
| `PATCH` | `/api/sessions/:id` | Update session |
|
||||||
@@ -593,12 +593,37 @@ pipeline's last node, where the pipeline is finished and a later detection is
|
|||||||
new work — and, the one rule that matters, never counts as evidence and never
|
new work — and, the one rule that matters, never counts as evidence and never
|
||||||
renders a node as `done` (see `docs/LANES.md#stage-detection`).
|
renders a node as `done` (see `docs/LANES.md#stage-detection`).
|
||||||
|
|
||||||
A `codex` lane has no hooks to read and carries no pipeline, so nothing detects
|
A `codex` lane carries no pipeline, so nothing detects or advances its stage.
|
||||||
or advances its stage. `server/lib/codex-ingest.js` only mirrors its `status`
|
Codex's hooks are opt-in (`server/lib/codex-hooks.js`, installed from Settings →
|
||||||
|
Codex → AgentHub and trusted by the user inside Codex); a hook posted with
|
||||||
|
`tool: "codex"` reaches only the codex lane at its cwd and creates a
|
||||||
|
`tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise
|
||||||
|
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose
|
||||||
|
hooks are current (an event for its session in the last two minutes) —
|
||||||
between `idle` and `running` from the mtime of Codex's own rollout journals
|
between `idle` and `running` from the mtime of Codex's own rollout journals
|
||||||
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||||
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook
|
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook
|
||||||
ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else.
|
ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else.
|
||||||
|
`server/lib/codex-usage.js` reads the same journals for every Codex session
|
||||||
|
(lane or not) into `sessions` (`tool = 'codex'`) and `token_usage`, tracking
|
||||||
|
each file's progress in `codex_rollouts` (see ARCHITECTURE.md).
|
||||||
|
|
||||||
|
An `antigravity` lane carries no pipeline either.
|
||||||
|
- **Sessions and tokens:** `server/lib/antigravity-usage.js` reads agy's
|
||||||
|
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
|
||||||
|
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
|
||||||
|
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
|
||||||
|
`antigravity_conversations` holds the mtime read for each file.
|
||||||
|
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
||||||
|
minutes (with hook events) or two (without) is settled to `completed`, its
|
||||||
|
agents too, and sets its lane idle. That lane must be one the hooks left
|
||||||
|
running with no dashboard pane.
|
||||||
|
- **Hooks:** opt-in. `server/lib/antigravity-hooks.js` installs them from
|
||||||
|
Settings → Antigravity: the named hook `agenthub` in
|
||||||
|
`~/.gemini/config/hooks.json`, with PreInvocation, PostToolUse and Stop,
|
||||||
|
never PreToolUse. A hook posted with `tool: "antigravity"` reaches only the
|
||||||
|
antigravity lane at its workspace (see ARCHITECTURE.md, **Antigravity
|
||||||
|
Sessions and Hooks**).
|
||||||
|
|
||||||
### Remote Data Sources
|
### Remote Data Sources
|
||||||
|
|
||||||
@@ -647,10 +672,20 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------------ | ------------------------------------------------ |
|
| ------ | ------------------------------ | ------------------------------------------------ |
|
||||||
| `GET` | `/api/settings/info` | System info, DB stats, hooks status, cache stats. Also powers the Dashboard Health tab (server uptime, memory, CPU, DB record counts, WAL/journal mode, transcript cache hit/miss rates) |
|
| `GET` | `/api/settings/info` | System info, DB stats, hooks status, cache stats. Also powers the Dashboard Health tab (server uptime, memory, CPU, DB record counts, WAL/journal mode, transcript cache hit/miss rates) |
|
||||||
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage |
|
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage, and empty `codex_rollouts`/`antigravity_conversations` so those sessions import again |
|
||||||
| `POST` | `/api/settings/reimport` | Re-import legacy sessions from `~/.claude/` |
|
| `POST` | `/api/settings/reimport` | Re-import legacy sessions from `~/.claude/` |
|
||||||
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
|
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
|
||||||
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults |
|
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
|
||||||
|
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
|
||||||
|
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`). Answers `routers: {missing: [...]}` — lanes naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) |
|
||||||
|
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
|
||||||
|
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; answers `routers: {updated, failed}` after re-writing every codex lane's AI Router layer into the new home (`reapplyCodexLayers`) |
|
||||||
|
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
|
||||||
|
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
|
||||||
|
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
|
||||||
|
| `GET` | `/api/settings/antigravity-hooks` | Status of the named hook `agenthub` in `~/.gemini/config/hooks.json` |
|
||||||
|
| `POST` | `/api/settings/antigravity-hooks` | Install or reinstall it — PreInvocation, PostToolUse, Stop, never PreToolUse (`sameOriginGuard`; same 400 codes) |
|
||||||
|
| `DELETE` | `/api/settings/antigravity-hooks` | Remove the `agenthub` hook only (`sameOriginGuard`) |
|
||||||
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
|
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
|
||||||
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file`, or JSON `{ path }` (server reads it). Idempotent + non-destructive: sessions already present are skipped whole |
|
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file`, or JSON `{ path }` (server reads it). Idempotent + non-destructive: sessions already present are skipped whole |
|
||||||
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data |
|
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data |
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests POST /api/hooks/event for hooks marked `tool: "antigravity"`
|
||||||
|
* (sent by agy through server/lib/antigravity-hooks.js): the conversation
|
||||||
|
* becomes an Antigravity session with the workspace as its cwd, a run's first
|
||||||
|
* PreInvocation is the prompt (the lane goes running) and later ones are
|
||||||
|
* dropped, PostToolUse lands in the timeline, Stop sets the lane idle, a
|
||||||
|
* claude lane at the same directory is untouched, and a hook from the
|
||||||
|
* Antigravity IDE (transcript outside the CLI's directory) is ignored; a turn
|
||||||
|
* after a Stop, a Stop with background work (ignored), a failed run (error),
|
||||||
|
* and a symlinked Gemini dir. Plus the settings routes. The Gemini dir and the DB live in a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-hook-route-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db, stmts } = require("../db");
|
||||||
|
const lanes = require("../lib/lanes");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
function request(method, urlPath, body, headers = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(urlPath, BASE);
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname,
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let text = "";
|
||||||
|
res.on("data", (c) => (text += c));
|
||||||
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text || "{}") }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const CLI = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity-cli");
|
||||||
|
/** A hook exactly as agy 1.2.13 sends it. */
|
||||||
|
function agyHook(hook_type, id, cwd, extra = {}, dir = CLI) {
|
||||||
|
return request("POST", "/api/hooks/event", {
|
||||||
|
hook_type,
|
||||||
|
tool: "antigravity",
|
||||||
|
data: {
|
||||||
|
conversationId: id,
|
||||||
|
workspacePaths: [cwd],
|
||||||
|
transcriptPath: path.join(dir, "brain", id, ".system_generated/logs/transcript_full.jsonl"),
|
||||||
|
artifactDirectoryPath: path.join(dir, "brain", id),
|
||||||
|
modelName: "gemini-3.8-flash-high",
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const eventsOf = (sid) =>
|
||||||
|
db.prepare("SELECT event_type, tool_name FROM events WHERE session_id = ? ORDER BY id").all(sid);
|
||||||
|
|
||||||
|
describe("antigravity hooks through /api/hooks/event", () => {
|
||||||
|
it("runs a turn on the antigravity lane only, and records its tool calls", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const agyLane = lanes.createLane({ title: "a", cwd, tool: "antigravity" });
|
||||||
|
const claudeLane = lanes.createLane({ title: "c", cwd, tool: "claude" });
|
||||||
|
const id = "64389dfc-a91a-47d1-92f5-e153001026c7";
|
||||||
|
|
||||||
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0, initialNumSteps: 1 });
|
||||||
|
const s = stmts.getSession.get(id);
|
||||||
|
assert.equal(s.tool, "antigravity");
|
||||||
|
assert.equal(s.cwd, cwd);
|
||||||
|
assert.equal(s.name, `Antigravity ${id.slice(0, 8)}`);
|
||||||
|
assert.equal(s.transcript_path, null);
|
||||||
|
assert.equal(lanes.getLane(agyLane.id).status, "running");
|
||||||
|
assert.equal(lanes.getLane(agyLane.id).session_id, id);
|
||||||
|
|
||||||
|
const later = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 });
|
||||||
|
assert.equal(later.body.ignored, true);
|
||||||
|
const call = { name: "view_file", args: { AbsolutePath: `${cwd}/note.txt` } };
|
||||||
|
await agyHook("PostToolUse", id, cwd, { stepIdx: 2, error: "", toolCall: call });
|
||||||
|
await agyHook("PostToolUse", id, cwd, { stepIdx: 3, error: "", toolCall: call });
|
||||||
|
await agyHook("PostInvocation", id, cwd, { invocationNum: 0 });
|
||||||
|
await agyHook("Stop", id, cwd, {
|
||||||
|
executionNum: 0,
|
||||||
|
terminationReason: "NO_TOOL_CALL",
|
||||||
|
fullyIdle: true,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
eventsOf(id).map((e) => [e.event_type, e.tool_name]),
|
||||||
|
[
|
||||||
|
["UserPromptSubmit", null],
|
||||||
|
["PostToolUse", "view_file"],
|
||||||
|
["PostToolUse", "view_file"],
|
||||||
|
["Stop", null],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(lanes.getLane(agyLane.id).status, "idle");
|
||||||
|
const c = lanes.getLane(claudeLane.id);
|
||||||
|
assert.equal(c.session_id, null);
|
||||||
|
assert.equal(c.status, claudeLane.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the Antigravity IDE's hooks and a malformed conversation id", async () => {
|
||||||
|
const ide = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity");
|
||||||
|
const id = "11111111-2222-3333-4444-555555555555";
|
||||||
|
const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, ide);
|
||||||
|
assert.equal(r.body.ignored, true);
|
||||||
|
assert.equal(stmts.getSession.get(id), undefined);
|
||||||
|
const bad = await agyHook("Stop", "../../x", "/w");
|
||||||
|
assert.equal(bad.body.ignored, true);
|
||||||
|
const noData = await request("POST", "/api/hooks/event", {
|
||||||
|
hook_type: "Stop",
|
||||||
|
tool: "antigravity",
|
||||||
|
data: "x",
|
||||||
|
});
|
||||||
|
assert.equal(noData.body.ignored, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("antigravity turns, errors and a symlinked Gemini dir", () => {
|
||||||
|
it("starts a later turn after a Stop, whatever its invocationNum", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "t", cwd, tool: "antigravity" });
|
||||||
|
const id = "22222222-2222-4222-8222-222222222222";
|
||||||
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
|
||||||
|
await agyHook("Stop", id, cwd, { fullyIdle: true, error: "" });
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||||
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 4 });
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||||
|
assert.deepEqual(
|
||||||
|
eventsOf(id).map((e) => e.event_type),
|
||||||
|
["UserPromptSubmit", "Stop", "UserPromptSubmit"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a turn's own model calls out, but not the prompt after a turn with no Stop", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
lanes.createLane({ title: "n", cwd, tool: "antigravity" });
|
||||||
|
const id = "55555555-5555-4555-8555-555555555555";
|
||||||
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
|
||||||
|
const call = { name: "view_file", args: {} };
|
||||||
|
await agyHook("PostToolUse", id, cwd, { stepIdx: 1, error: "", toolCall: call });
|
||||||
|
const inTurn = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 });
|
||||||
|
assert.equal(inTurn.body.ignored, true);
|
||||||
|
// The run died on an API error (no Stop); the poll settled the session.
|
||||||
|
db.prepare("UPDATE sessions SET status = 'completed' WHERE id = ?").run(id);
|
||||||
|
const next = await agyHook("PreInvocation", id, cwd, { invocationNum: 2 });
|
||||||
|
assert.notEqual(next.body.ignored, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
eventsOf(id).map((e) => e.event_type),
|
||||||
|
["UserPromptSubmit", "PostToolUse", "UserPromptSubmit"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a Stop while background work runs, and lands a failed run as an error", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "e", cwd, tool: "antigravity" });
|
||||||
|
const id = "33333333-3333-4333-8333-333333333333";
|
||||||
|
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
|
||||||
|
const busy = await agyHook("Stop", id, cwd, { fullyIdle: false, error: "" });
|
||||||
|
assert.equal(busy.body.ignored, true);
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||||
|
await agyHook("Stop", id, cwd, {
|
||||||
|
fullyIdle: true,
|
||||||
|
terminationReason: "ERROR",
|
||||||
|
error: "quota exceeded",
|
||||||
|
});
|
||||||
|
assert.equal(stmts.getSession.get(id).status, "error");
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a CLI hook reported under the real path of a symlinked Gemini dir", async () => {
|
||||||
|
const real = process.env.AGENTHUB_GEMINI_DIR;
|
||||||
|
const link = path.join(ROOT, "gemini-link");
|
||||||
|
fs.mkdirSync(real, { recursive: true });
|
||||||
|
fs.symlinkSync(real, link);
|
||||||
|
process.env.AGENTHUB_GEMINI_DIR = link;
|
||||||
|
try {
|
||||||
|
const id = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, CLI);
|
||||||
|
assert.notEqual(r.body.ignored, true);
|
||||||
|
assert.equal(stmts.getSession.get(id).tool, "antigravity");
|
||||||
|
} finally {
|
||||||
|
process.env.AGENTHUB_GEMINI_DIR = real;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET/POST/DELETE /api/settings/antigravity-hooks", () => {
|
||||||
|
it("installs, reports and removes AgentHub's named hook, and refuses another site", async () => {
|
||||||
|
const file = path.join(process.env.AGENTHUB_GEMINI_DIR, "config", "hooks.json");
|
||||||
|
let r = await request("POST", "/api/settings/antigravity-hooks", null, {
|
||||||
|
Origin: "https://evil.example",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
assert.equal(fs.existsSync(file), false);
|
||||||
|
r = await request("POST", "/api/settings/antigravity-hooks");
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.installed, true);
|
||||||
|
assert.ok(JSON.parse(fs.readFileSync(file, "utf8")).agenthub);
|
||||||
|
r = await request("GET", "/api/settings/antigravity-hooks");
|
||||||
|
assert.equal(r.body.installed, true);
|
||||||
|
r = await request("DELETE", "/api/settings/antigravity-hooks");
|
||||||
|
assert.equal(r.body.installed, false);
|
||||||
|
assert.equal(fs.existsSync(file), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests server/lib/antigravity-hooks.js — AgentHub's named hook in
|
||||||
|
* `~/.gemini/config/hooks.json`: installed as `agenthub` with only
|
||||||
|
* PreInvocation/PostToolUse/Stop (never PreToolUse, whose `{}` answer makes
|
||||||
|
* agy deny the tool), every other named hook kept, remove taking out only
|
||||||
|
* ours, a non-JSON file refused, a hook switched off reported, the forwarder
|
||||||
|
* answering `{}` whatever the dashboard does, and refreshed only when it is
|
||||||
|
* ours. The Gemini dir is a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-hooks-"));
|
||||||
|
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||||
|
|
||||||
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const hooks = require("../lib/antigravity-hooks");
|
||||||
|
|
||||||
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const CONFIG = path.join(process.env.AGENTHUB_GEMINI_DIR, "config");
|
||||||
|
const FILE = path.join(CONFIG, "hooks.json");
|
||||||
|
const SCRIPT = path.join(CONFIG, "agenthub-hook.sh");
|
||||||
|
const read = () => JSON.parse(fs.readFileSync(FILE, "utf8"));
|
||||||
|
const theirs = {
|
||||||
|
enabled: true,
|
||||||
|
PreToolUse: [{ matcher: "run_command", hooks: [{ type: "command", command: "guard.sh" }] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(process.env.AGENTHUB_GEMINI_DIR, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(CONFIG, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("antigravity hooks", () => {
|
||||||
|
it("installs one named hook with the three observational events, never PreToolUse", () => {
|
||||||
|
const st = hooks.install();
|
||||||
|
assert.equal(st.installed, true);
|
||||||
|
assert.equal(st.enabled, true);
|
||||||
|
const ours = read().agenthub;
|
||||||
|
assert.deepEqual(Object.keys(ours).sort(), ["PostToolUse", "PreInvocation", "Stop", "enabled"]);
|
||||||
|
assert.equal(ours.PreToolUse, undefined);
|
||||||
|
const cmd = (e) => `'${SCRIPT}' ${e}`;
|
||||||
|
assert.deepEqual(ours.PreInvocation, [
|
||||||
|
{ type: "command", command: cmd("PreInvocation"), timeout: 5 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(ours.PostToolUse, [
|
||||||
|
{ matcher: ".*", hooks: [{ type: "command", command: cmd("PostToolUse"), timeout: 5 }] },
|
||||||
|
]);
|
||||||
|
assert.equal(fs.statSync(SCRIPT).mode & 0o111, 0o111);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers {} on stdout and nothing else, with no dashboard to reach", () => {
|
||||||
|
hooks.install();
|
||||||
|
const out = execFileSync("sh", [SCRIPT, "Stop"], {
|
||||||
|
input: JSON.stringify({ conversationId: "x" }),
|
||||||
|
env: { PATH: process.env.PATH, CLAUDE_DASHBOARD_PORT: "1" },
|
||||||
|
timeout: 10_000,
|
||||||
|
}).toString();
|
||||||
|
assert.equal(out, "{}\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every other named hook, and remove takes out only ours", () => {
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ "my-guard": theirs }));
|
||||||
|
hooks.install();
|
||||||
|
hooks.install();
|
||||||
|
assert.deepEqual(Object.keys(read()).sort(), ["agenthub", "my-guard"]);
|
||||||
|
assert.deepEqual(read()["my-guard"], theirs);
|
||||||
|
const st = hooks.remove();
|
||||||
|
assert.equal(st.installed, false);
|
||||||
|
assert.deepEqual(read(), { "my-guard": theirs });
|
||||||
|
assert.equal(fs.existsSync(SCRIPT), false);
|
||||||
|
|
||||||
|
hooks.install();
|
||||||
|
hooks.remove();
|
||||||
|
assert.equal(fs.existsSync(FILE), true); // theirs is still in it
|
||||||
|
fs.rmSync(FILE);
|
||||||
|
hooks.install();
|
||||||
|
hooks.remove();
|
||||||
|
assert.equal(fs.existsSync(FILE), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a hooks.json that is not a JSON object, and changes nothing", () => {
|
||||||
|
fs.writeFileSync(FILE, "[]");
|
||||||
|
assert.throws(() => hooks.install(), { code: "EBADJSON" });
|
||||||
|
assert.equal(fs.readFileSync(FILE, "utf8"), "[]");
|
||||||
|
assert.equal(hooks.status().readable, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a hook the user switched off", () => {
|
||||||
|
hooks.install();
|
||||||
|
const file = read();
|
||||||
|
file.agenthub.enabled = false;
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify(file));
|
||||||
|
const st = hooks.status();
|
||||||
|
assert.equal(st.installed, true);
|
||||||
|
assert.equal(st.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes a stale forwarder, and leaves a file that is not ours alone", () => {
|
||||||
|
assert.equal(hooks.refreshScript(), "absent");
|
||||||
|
hooks.install();
|
||||||
|
const stale = fs.readFileSync(SCRIPT, "utf8").replace(/'[^']*hook-handler\.js'/, "'/old/h.js'");
|
||||||
|
fs.writeFileSync(SCRIPT, stale);
|
||||||
|
assert.equal(hooks.refreshScript(), "written");
|
||||||
|
assert.equal(hooks.refreshScript(), "exists");
|
||||||
|
fs.writeFileSync(SCRIPT, "#!/bin/sh\necho mine\n");
|
||||||
|
assert.equal(hooks.refreshScript(), "foreign");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never overwrites or removes a script that is not ours, and keeps hooks.json's mode", () => {
|
||||||
|
fs.writeFileSync(SCRIPT, "#!/bin/sh\necho mine\n");
|
||||||
|
assert.throws(() => hooks.install(), { code: "EFOREIGN" });
|
||||||
|
assert.equal(fs.existsSync(FILE), false);
|
||||||
|
hooks.remove();
|
||||||
|
assert.equal(fs.readFileSync(SCRIPT, "utf8"), "#!/bin/sh\necho mine\n");
|
||||||
|
fs.rmSync(SCRIPT);
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ theirs }), { mode: 0o600 });
|
||||||
|
hooks.install();
|
||||||
|
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
||||||
|
hooks.remove();
|
||||||
|
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a directory at the forwarder's path as not ours, and still removes the hook", () => {
|
||||||
|
hooks.install();
|
||||||
|
fs.rmSync(SCRIPT);
|
||||||
|
fs.mkdirSync(SCRIPT);
|
||||||
|
assert.throws(() => hooks.install(), { code: "EFOREIGN" });
|
||||||
|
const st = hooks.remove();
|
||||||
|
assert.equal(st.events.Stop.installed, false);
|
||||||
|
assert.ok(fs.statSync(SCRIPT).isDirectory());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests server/lib/antigravity-usage.js — Antigravity CLI sessions and
|
||||||
|
* tokens from agy's per-conversation SQLite files: the protobuf reader, a
|
||||||
|
* session created as `tool = 'antigravity'` with its name, cwd, model and
|
||||||
|
* per-model tokens (input excluding cache reads, output including thinking),
|
||||||
|
* an unchanged file never re-read, a quiet one settled to completed (and the
|
||||||
|
* lane a run with no Stop left running set idle, unless a pane owns it), a
|
||||||
|
* session the user deleted staying deleted, a non-antigravity row with the
|
||||||
|
* same id never touched, the empty `-wal` of our own read-only open not being
|
||||||
|
* activity, a hook-reported turn kept running through a long quiet tool call,
|
||||||
|
* an error Stop's status kept, and a file that is not agy's read only once. The Gemini dir and the DB are temp dirs; the fixture
|
||||||
|
* files are built in the shape agy 1.2.13 writes.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-usage-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
||||||
|
|
||||||
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const agy = require("../lib/antigravity-usage");
|
||||||
|
const lanes = require("../lib/lanes");
|
||||||
|
|
||||||
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const CLI = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity-cli");
|
||||||
|
const CONV = path.join(CLI, "conversations");
|
||||||
|
|
||||||
|
// --- a minimal protobuf writer, enough for the fixtures ---
|
||||||
|
function varint(n) {
|
||||||
|
const out = [];
|
||||||
|
while (n >= 0x80) {
|
||||||
|
out.push((n % 128) | 0x80);
|
||||||
|
n = Math.floor(n / 128);
|
||||||
|
}
|
||||||
|
out.push(n);
|
||||||
|
return Buffer.from(out);
|
||||||
|
}
|
||||||
|
const pb = (num, v) =>
|
||||||
|
typeof v === "number"
|
||||||
|
? Buffer.concat([varint(num * 8), varint(v)])
|
||||||
|
: Buffer.concat([varint(num * 8 + 2), varint(v.length), Buffer.from(v)]);
|
||||||
|
const msg = (...parts) => Buffer.concat(parts);
|
||||||
|
|
||||||
|
function generation({ model, input, output, cacheRead = 0, cacheWrite = 0 }) {
|
||||||
|
const stats = msg(
|
||||||
|
pb(1, 1318),
|
||||||
|
pb(2, input),
|
||||||
|
pb(3, output),
|
||||||
|
...(cacheWrite ? [pb(4, cacheWrite)] : []),
|
||||||
|
pb(5, cacheRead),
|
||||||
|
pb(7, "bot-1"),
|
||||||
|
pb(9, Math.floor(output / 2))
|
||||||
|
);
|
||||||
|
return msg(pb(2, ""), pb(4, "req"), pb(1, msg(pb(3, 1318), pb(4, stats), pb(19, model))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversation(
|
||||||
|
id,
|
||||||
|
{ cwd = "/work/app", gens = [], prompt = "Fix the login bug\nplease", wal = false } = {}
|
||||||
|
) {
|
||||||
|
fs.mkdirSync(CONV, { recursive: true });
|
||||||
|
const file = path.join(CONV, `${id}.db`);
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
const c = new Database(file);
|
||||||
|
c.exec(`CREATE TABLE trajectory_meta (trajectory_id text, cascade_id text, trajectory_type integer, source integer, PRIMARY KEY (trajectory_id));
|
||||||
|
CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx));
|
||||||
|
CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id));`);
|
||||||
|
c.prepare("INSERT INTO trajectory_meta VALUES ('t1', ?, 4, 17)").run(id);
|
||||||
|
c.prepare("INSERT INTO trajectory_metadata_blob VALUES ('main', ?)").run(
|
||||||
|
msg(pb(1, msg(pb(2, `file://${cwd.replace(/ /g, "%20")}`))))
|
||||||
|
);
|
||||||
|
gens.forEach((g, i) =>
|
||||||
|
c.prepare("INSERT INTO gen_metadata VALUES (?, ?, 0)").run(i, generation(g))
|
||||||
|
);
|
||||||
|
// agy's files are WAL; a clean close deletes the -wal, as agy's exit does.
|
||||||
|
if (wal) c.pragma("journal_mode = WAL");
|
||||||
|
c.close();
|
||||||
|
const logs = path.join(CLI, "brain", id, ".system_generated", "logs");
|
||||||
|
fs.mkdirSync(logs, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(logs, "transcript_full.jsonl"),
|
||||||
|
JSON.stringify({
|
||||||
|
step_index: 0,
|
||||||
|
source: "USER_EXPLICIT",
|
||||||
|
type: "USER_INPUT",
|
||||||
|
status: "DONE",
|
||||||
|
created_at: "2026-09-29T04:38:44Z",
|
||||||
|
content: `<USER_REQUEST>\n${prompt}\n</USER_REQUEST>\n<ADDITIONAL_METADATA>x</ADDITIONAL_METADATA>`,
|
||||||
|
}) + "\n"
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function age(id, ms) {
|
||||||
|
const t = new Date(Date.now() - ms);
|
||||||
|
fs.utimesSync(path.join(CONV, `${id}.db`), t, t);
|
||||||
|
fs.utimesSync(
|
||||||
|
path.join(CLI, "brain", id, ".system_generated", "logs", "transcript_full.jsonl"),
|
||||||
|
t,
|
||||||
|
t
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||||
|
const usage = (id) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
|
||||||
|
)
|
||||||
|
.all(id);
|
||||||
|
|
||||||
|
const ID = "57f3e409-9608-422b-8040-503f1bc7b5b8";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(process.env.AGENTHUB_GEMINI_DIR, { recursive: true, force: true });
|
||||||
|
db.exec(
|
||||||
|
"DELETE FROM events; DELETE FROM agents; DELETE FROM token_usage; DELETE FROM sessions; DELETE FROM antigravity_conversations;"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hookEvent = (id, type, msAgo) =>
|
||||||
|
db
|
||||||
|
.prepare("INSERT INTO events (session_id, event_type, created_at) VALUES (?, ?, ?)")
|
||||||
|
.run(id, type, new Date(Date.now() - msAgo).toISOString());
|
||||||
|
|
||||||
|
describe("antigravity usage", () => {
|
||||||
|
it("reads a generation's usage and model, and refuses a malformed buffer", () => {
|
||||||
|
const u = agy.generationUsage(
|
||||||
|
generation({ model: "gemini-3.8-flash", input: 12271, output: 153, cacheRead: 40 })
|
||||||
|
);
|
||||||
|
assert.deepEqual(u, {
|
||||||
|
model: "gemini-3.8-flash",
|
||||||
|
input: 12271,
|
||||||
|
output: 153,
|
||||||
|
cacheWrite: 0,
|
||||||
|
cacheRead: 40,
|
||||||
|
});
|
||||||
|
assert.equal(agy.generationUsage(Buffer.from([0x0a, 0x7f, 1])), null);
|
||||||
|
assert.equal(agy.generationUsage(msg(pb(4, "x"))), null);
|
||||||
|
assert.equal(agy.firstFileUri(msg(pb(1, msg(pb(2, "file:///a%20b/c"))))), "/a b/c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the session with its name, cwd, model and per-model tokens", async () => {
|
||||||
|
conversation(ID, {
|
||||||
|
cwd: "/work/my app",
|
||||||
|
gens: [
|
||||||
|
{ model: "gemini-3.8-flash", input: 100, output: 10, cacheRead: 1000 },
|
||||||
|
{ model: "gemini-3.8-flash", input: 50, output: 5, cacheWrite: 7 },
|
||||||
|
{ model: "gemini-3.1-pro", input: 1, output: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = await agy.ingestAntigravityUsage();
|
||||||
|
assert.deepEqual(r, { files: 1, sessions: 1 });
|
||||||
|
const s = session(ID);
|
||||||
|
assert.equal(s.tool, "antigravity");
|
||||||
|
assert.equal(s.name, "Fix the login bug");
|
||||||
|
assert.equal(s.cwd, "/work/my app");
|
||||||
|
assert.equal(s.model, "gemini-3.1-pro");
|
||||||
|
assert.equal(s.started_at, "2026-09-29T04:38:44Z");
|
||||||
|
assert.equal(s.status, "active");
|
||||||
|
assert.equal(s.transcript_path, null);
|
||||||
|
assert.deepEqual(usage(ID), [
|
||||||
|
{
|
||||||
|
model: "gemini-3.1-pro",
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 2,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: "gemini-3.8-flash",
|
||||||
|
input_tokens: 150,
|
||||||
|
output_tokens: 15,
|
||||||
|
cache_read_tokens: 1000,
|
||||||
|
cache_write_tokens: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles a session to completed once its file has gone quiet", async () => {
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).status, "active");
|
||||||
|
// Unchanged since the last read, just older: no re-read, only the status.
|
||||||
|
const seen = db.prepare("SELECT mtime_ms FROM antigravity_conversations").get().mtime_ms;
|
||||||
|
const r = await agy.ingestAntigravityUsage({ now: seen + 5 * 60_000 });
|
||||||
|
assert.deepEqual(r, { files: 0, sessions: 1 });
|
||||||
|
assert.equal(session(ID).status, "completed");
|
||||||
|
// Read fresh when already old: completed straight away, ended at its last write.
|
||||||
|
db.exec("DELETE FROM sessions; DELETE FROM antigravity_conversations;");
|
||||||
|
age(ID, 10 * 60_000);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).status, "completed");
|
||||||
|
assert.ok(session(ID).ended_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets idle the lane a run with no Stop left running, once the session is quiet", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "a", cwd, tool: "antigravity" });
|
||||||
|
lanes.updateLane(lane.id, { status: "running", session_id: ID });
|
||||||
|
const other = "11111111-2222-3333-4444-555555555555";
|
||||||
|
const cwd2 = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const paned = lanes.createLane({ title: "b", cwd: cwd2, tool: "antigravity" });
|
||||||
|
lanes.updateLane(paned.id, { status: "running", session_id: other, run_id: "agenthub-lane-9" });
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
conversation(other, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
const released = [];
|
||||||
|
const broadcastLane = (id) => released.push(id);
|
||||||
|
await agy.ingestAntigravityUsage({ broadcastLane });
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running"); // still being written
|
||||||
|
const seen = db.prepare("SELECT max(mtime_ms) m FROM antigravity_conversations").get().m;
|
||||||
|
await agy.ingestAntigravityUsage({ broadcastLane, now: seen + 5 * 60_000 });
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||||
|
assert.equal(lanes.getLane(paned.id).status, "running");
|
||||||
|
assert.deepEqual(released, [lane.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a deleted session deleted until agy writes to it again", async () => {
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
age(ID, 10 * 60_000);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID), undefined);
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 2, output: 1 }] });
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).tool, "antigravity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches a session of another tool with the same id", async () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES (?, 'mine', 'active', 'claude', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||||
|
).run(ID);
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).name, "mine");
|
||||||
|
assert.deepEqual(usage(ID), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a hook-made session's cwd and a name the user saw, over a placeholder", async () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, tool, cwd, started_at, updated_at) VALUES (?, ?, 'active', 'antigravity', '/from/hook', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||||
|
).run(ID, `Antigravity ${ID.slice(0, 8)}`);
|
||||||
|
conversation(ID, {
|
||||||
|
cwd: "/from/db",
|
||||||
|
gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }],
|
||||||
|
});
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).cwd, "/from/hook");
|
||||||
|
assert.equal(session(ID).name, "Fix the login bug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a file that is not a conversation database, and carries on", async () => {
|
||||||
|
fs.mkdirSync(CONV, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(CONV, "00000000-0000-0000-0000-000000000000.db"), "not sqlite");
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
const r = await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(r.sessions, 1);
|
||||||
|
assert.ok(session(ID));
|
||||||
|
// Recorded, so it is not re-read (nor warned about) every pass.
|
||||||
|
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not take the empty -wal its own read-only open leaves for activity", async () => {
|
||||||
|
conversation(ID, { wal: true, gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
age(ID, 10 * 60_000);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
const first = session(ID);
|
||||||
|
assert.equal(first.status, "completed");
|
||||||
|
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
|
||||||
|
assert.equal(session(ID).status, "completed");
|
||||||
|
assert.equal(session(ID).updated_at, first.updated_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a turn the hooks report running through a long quiet tool call", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "h", cwd, tool: "antigravity" });
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
age(ID, 10 * 60_000);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
// A hook three minutes ago: agy is mid-turn, its file untouched for ten.
|
||||||
|
db.prepare("UPDATE sessions SET status = 'active', ended_at = NULL WHERE id = ?").run(ID);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES (?, ?, 'Main', 'main', 'working', '2026-09-29T00:00:00Z')"
|
||||||
|
).run(`${ID}-main`, ID);
|
||||||
|
lanes.updateLane(lane.id, { status: "running", session_id: ID });
|
||||||
|
hookEvent(ID, "PostToolUse", 3 * 60_000);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).status, "active");
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||||
|
// Ten minutes after that hook, it is over.
|
||||||
|
await agy.ingestAntigravityUsage({ now: Date.now() + 8 * 60_000 });
|
||||||
|
assert.equal(session(ID).status, "completed");
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT status FROM agents WHERE id = ?").get(`${ID}-main`).status,
|
||||||
|
"completed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a session waiting after a Stop active for ten quiet minutes", async () => {
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
age(ID, 10 * 60_000);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
db.prepare("UPDATE sessions SET status = 'active', ended_at = NULL WHERE id = ?").run(ID);
|
||||||
|
hookEvent(ID, "Stop", 3 * 60_000);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).status, "active");
|
||||||
|
await agy.ingestAntigravityUsage({ now: Date.now() + 8 * 60_000 });
|
||||||
|
assert.equal(session(ID).status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a file it could not read another try after a restart", async () => {
|
||||||
|
fs.mkdirSync(CONV, { recursive: true });
|
||||||
|
const bad = path.join(CONV, "00000000-0000-0000-0000-000000000000.db");
|
||||||
|
fs.writeFileSync(bad, "not sqlite");
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
const seen = () => db.prepare("SELECT session_id FROM antigravity_conversations").all();
|
||||||
|
assert.deepEqual(seen(), [{ session_id: "" }]);
|
||||||
|
const saved = process.env.DASHBOARD_ANTIGRAVITY_POLL_MS;
|
||||||
|
process.env.DASHBOARD_ANTIGRAVITY_POLL_MS = "3600000";
|
||||||
|
try {
|
||||||
|
agy.startAntigravityUsagePoll(
|
||||||
|
() => {},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
// Cleared before the first pass reads it again.
|
||||||
|
assert.ok(seen().length <= 1);
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete process.env.DASHBOARD_ANTIGRAVITY_POLL_MS;
|
||||||
|
else process.env.DASHBOARD_ANTIGRAVITY_POLL_MS = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the error a failed run's Stop gave the session", async () => {
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
db.prepare("UPDATE sessions SET status = 'error' WHERE id = ?").run(ID);
|
||||||
|
hookEvent(ID, "Stop", 1000);
|
||||||
|
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 2, output: 1 }] });
|
||||||
|
await agy.ingestAntigravityUsage();
|
||||||
|
assert.equal(session(ID).status, "error");
|
||||||
|
assert.equal(usage(ID)[0].input_tokens, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,7 @@ const EXPECTED_API_PATHS = [
|
|||||||
"/api/settings/export",
|
"/api/settings/export",
|
||||||
"/api/settings/cleanup",
|
"/api/settings/cleanup",
|
||||||
"/api/settings/claude-home",
|
"/api/settings/claude-home",
|
||||||
|
"/api/settings/codex-home",
|
||||||
"/api/import/guide",
|
"/api/import/guide",
|
||||||
"/api/import/rescan",
|
"/api/import/rescan",
|
||||||
"/api/import/scan-path",
|
"/api/import/scan-path",
|
||||||
@@ -277,6 +278,20 @@ describe("Sessions API", () => {
|
|||||||
res.body.sessions.forEach((s) => assert.equal(s.status, "active"));
|
res.body.sessions.forEach((s) => assert.equal(s.status, "active"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters sessions by tool, before the limit", async () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES ('sess-cx', 'cx', 'active', 'codex', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||||
|
).run();
|
||||||
|
const codex = await fetch("/api/sessions?tool=codex&limit=1");
|
||||||
|
assert.deepEqual(
|
||||||
|
codex.body.sessions.map((s) => s.id),
|
||||||
|
["sess-cx"]
|
||||||
|
);
|
||||||
|
const claude = await fetch("/api/sessions?tool=claude");
|
||||||
|
assert.ok(claude.body.sessions.length >= 1);
|
||||||
|
assert.ok(claude.body.sessions.every((s) => s.tool === "claude"));
|
||||||
|
});
|
||||||
|
|
||||||
it("should paginate sessions", async () => {
|
it("should paginate sessions", async () => {
|
||||||
const res = await fetch("/api/sessions?limit=1&offset=0");
|
const res = await fetch("/api/sessions?limit=1&offset=0");
|
||||||
assert.equal(res.body.sessions.length, 1);
|
assert.equal(res.body.sessions.length, 1);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests setClaudeHome: the shared home-dir guard (a directory that is or
|
||||||
|
* contains the user's home is refused), persistence to the settings file
|
||||||
|
* (never the repo `.env`, which a plugin update wipes), and pinning the data
|
||||||
|
* dir, so a new CLAUDE_HOME can never open an empty database at the next start.
|
||||||
|
* The settings file is a temp path (AGENTHUB_SETTINGS_FILE).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-claude-home-set-"));
|
||||||
|
const SETTINGS = path.join(ROOT, "settings.env");
|
||||||
|
const REPO_ENV = path.resolve(__dirname, "..", "..", ".env");
|
||||||
|
const SAVED = {
|
||||||
|
AGENTHUB_SETTINGS_FILE: process.env.AGENTHUB_SETTINGS_FILE,
|
||||||
|
CLAUDE_HOME: process.env.CLAUDE_HOME,
|
||||||
|
DASHBOARD_DATA_DIR: process.env.DASHBOARD_DATA_DIR,
|
||||||
|
};
|
||||||
|
process.env.AGENTHUB_SETTINGS_FILE = SETTINGS;
|
||||||
|
|
||||||
|
const { setClaudeHome, getClaudeHome, getDataDir } = require("../lib/claude-home");
|
||||||
|
|
||||||
|
describe("setClaudeHome", () => {
|
||||||
|
let repoEnvBefore;
|
||||||
|
before(() => {
|
||||||
|
repoEnvBefore = fs.existsSync(REPO_ENV) ? fs.readFileSync(REPO_ENV, "utf8") : null;
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(SETTINGS, { force: true });
|
||||||
|
process.env.CLAUDE_HOME = fs.mkdtempSync(path.join(ROOT, "start-"));
|
||||||
|
delete process.env.DASHBOARD_DATA_DIR;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
for (const [k, v] of Object.entries(SAVED)) {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
}
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const bad of ["/", os.homedir(), path.dirname(os.homedir()), "~/"]) {
|
||||||
|
it(`refuses ${JSON.stringify(bad)} as too broad`, () => {
|
||||||
|
const current = getClaudeHome();
|
||||||
|
assert.throws(() => setClaudeHome(bad), { code: "EBADHOME" });
|
||||||
|
assert.equal(getClaudeHome(), current);
|
||||||
|
assert.ok(!fs.existsSync(SETTINGS));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts a real directory, persisting it to the settings file only", () => {
|
||||||
|
const next = fs.mkdtempSync(path.join(ROOT, "next-"));
|
||||||
|
assert.equal(setClaudeHome(next), next);
|
||||||
|
assert.equal(getClaudeHome(), next);
|
||||||
|
assert.match(fs.readFileSync(SETTINGS, "utf8"), new RegExp(`^CLAUDE_HOME=${next}$`, "m"));
|
||||||
|
const repoEnvNow = fs.existsSync(REPO_ENV) ? fs.readFileSync(REPO_ENV, "utf8") : null;
|
||||||
|
assert.equal(repoEnvNow, repoEnvBefore, "the repo .env is never written");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the data dir in use before CLAUDE_HOME moves, so the database stays put", () => {
|
||||||
|
const dataDirBefore = getDataDir();
|
||||||
|
setClaudeHome(fs.mkdtempSync(path.join(ROOT, "next-")));
|
||||||
|
assert.equal(getDataDir(), dataDirBefore);
|
||||||
|
assert.equal(process.env.DASHBOARD_DATA_DIR, dataDirBefore);
|
||||||
|
assert.match(
|
||||||
|
fs.readFileSync(SETTINGS, "utf8"),
|
||||||
|
new RegExp(`^DASHBOARD_DATA_DIR=${dataDirBefore}$`, "m")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an explicit DASHBOARD_DATA_DIR alone", () => {
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "explicit-data");
|
||||||
|
setClaudeHome(fs.mkdtempSync(path.join(ROOT, "next-")));
|
||||||
|
assert.equal(process.env.DASHBOARD_DATA_DIR, path.join(ROOT, "explicit-data"));
|
||||||
|
assert.doesNotMatch(fs.readFileSync(SETTINGS, "utf8"), /DASHBOARD_DATA_DIR/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests POST /api/hooks/event for hooks marked `tool: "codex"` (sent by
|
||||||
|
* Codex through server/lib/codex-hooks.js): the session is created as a Codex
|
||||||
|
* one with no transcript path, a PermissionRequest becomes the permission
|
||||||
|
* Notification (awaiting flag + the codex lane's needs_action, never the claude
|
||||||
|
* lane sharing the directory), no stage detection on a codex lane, and a
|
||||||
|
* sub-agent thread's lifecycle hooks dropped while its tool calls land on the
|
||||||
|
* root session, a main agent made for a session the journal poll imported first,
|
||||||
|
* and the install/remove routes refusing another site. CODEX_HOME and the DB
|
||||||
|
* live in a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-hook-route-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db, stmts } = require("../db");
|
||||||
|
const lanes = require("../lib/lanes");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
function request(method, urlPath, body, headers = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(urlPath, BASE);
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname,
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let text = "";
|
||||||
|
res.on("data", (c) => (text += c));
|
||||||
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text || "{}") }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const codexHook = (hook_type, data) =>
|
||||||
|
request("POST", "/api/hooks/event", { hook_type, data, tool: "codex" });
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const DAY = path.join(process.env.CODEX_HOME, "sessions", "2026", "09", "29");
|
||||||
|
function rollout(thread, root) {
|
||||||
|
fs.mkdirSync(DAY, { recursive: true });
|
||||||
|
const file = path.join(DAY, `rollout-2026-09-29T01-00-00-${thread}.jsonl`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
JSON.stringify({ type: "session_meta", payload: { id: thread, session_id: root, cwd: "/w" } }) +
|
||||||
|
"\n"
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
const eventsOf = (sid) =>
|
||||||
|
db.prepare("SELECT event_type, summary FROM events WHERE session_id = ? ORDER BY id").all(sid);
|
||||||
|
|
||||||
|
describe("codex hooks through /api/hooks/event", () => {
|
||||||
|
it("files a permission request as waiting even when its command says compress", async () => {
|
||||||
|
await codexHook("UserPromptSubmit", { session_id: "cx-zip", cwd: "/w", prompt: "pack" });
|
||||||
|
await codexHook("PermissionRequest", {
|
||||||
|
session_id: "cx-zip",
|
||||||
|
cwd: "/w",
|
||||||
|
tool_name: "Bash",
|
||||||
|
tool_input: { command: "tar --compress -cf out.tgz dist" },
|
||||||
|
});
|
||||||
|
assert.equal(eventsOf("cx-zip").at(-1).event_type, "Notification");
|
||||||
|
assert.ok(stmts.getSession.get("cx-zip").awaiting_input_since);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a sub-agent's tool call after the root's Stop resume the root", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "k", cwd, tool: "codex" });
|
||||||
|
rollout("cx-root2", "cx-root2");
|
||||||
|
const kid = rollout("cx-kid2", "cx-root2");
|
||||||
|
await codexHook("UserPromptSubmit", { session_id: "cx-root2", cwd, prompt: "go" });
|
||||||
|
await codexHook("Stop", { session_id: "cx-root2", cwd });
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||||
|
const waiting = stmts.getSession.get("cx-root2").awaiting_input_since;
|
||||||
|
assert.ok(waiting);
|
||||||
|
const call = { session_id: "cx-kid2", transcript_path: kid, cwd, tool_name: "Bash" };
|
||||||
|
await codexHook("PreToolUse", { ...call, tool_input: { command: "ls" } });
|
||||||
|
await codexHook("PostToolUse", { ...call, tool_input: { command: "ls" } });
|
||||||
|
assert.equal(stmts.getSession.get("cx-root2").awaiting_input_since, waiting);
|
||||||
|
assert.notEqual(
|
||||||
|
db.prepare("SELECT status FROM agents WHERE id = ?").get("cx-root2-main").status,
|
||||||
|
"working"
|
||||||
|
);
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||||
|
assert.deepEqual(
|
||||||
|
eventsOf("cx-root2").map((e) => e.event_type),
|
||||||
|
["UserPromptSubmit", "Stop", "PreToolUse", "PostToolUse"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a Codex session with no transcript path, and never reads the rollout as a Claude transcript", async () => {
|
||||||
|
const file = rollout("cx-1", "cx-1");
|
||||||
|
const r = await codexHook("SessionStart", {
|
||||||
|
session_id: "cx-1",
|
||||||
|
transcript_path: file,
|
||||||
|
cwd: "/w",
|
||||||
|
model: "gpt-5.6-sol",
|
||||||
|
source: "startup",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const row = stmts.getSession.get("cx-1");
|
||||||
|
assert.equal(row.tool, "codex");
|
||||||
|
assert.equal(row.name, "Codex cx-1");
|
||||||
|
assert.equal(row.transcript_path, null);
|
||||||
|
assert.equal(row.status, "active");
|
||||||
|
assert.ok(row.awaiting_input_since, "a fresh Codex session sits at its prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unknown tool value as Claude", async () => {
|
||||||
|
await request("POST", "/api/hooks/event", {
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { session_id: "cc-odd", cwd: "/w" },
|
||||||
|
tool: "evil",
|
||||||
|
});
|
||||||
|
assert.equal(stmts.getSession.get("cc-odd").tool, "claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a PermissionRequest into the permission Notification, on the codex lane only", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const claudeLane = lanes.createLane({ title: "c", cwd, tool: "claude" });
|
||||||
|
const codexLane = lanes.createLane({ title: "x", cwd, tool: "codex" });
|
||||||
|
await codexHook("UserPromptSubmit", { session_id: "cx-2", cwd, prompt: "go" });
|
||||||
|
await codexHook("PermissionRequest", {
|
||||||
|
session_id: "cx-2",
|
||||||
|
cwd,
|
||||||
|
tool_name: "Bash",
|
||||||
|
tool_input: { command: "touch /etc/x", description: "write outside the sandbox" },
|
||||||
|
});
|
||||||
|
assert.ok(stmts.getSession.get("cx-2").awaiting_input_since);
|
||||||
|
const last = eventsOf("cx-2").at(-1);
|
||||||
|
assert.equal(last.event_type, "Notification");
|
||||||
|
assert.equal(last.summary, "Codex needs your permission: write outside the sandbox");
|
||||||
|
assert.equal(lanes.getLane(codexLane.id).needs_action, last.summary);
|
||||||
|
assert.equal(lanes.getLane(codexLane.id).session_id, "cx-2");
|
||||||
|
assert.equal(lanes.getLane(claudeLane.id).needs_action, null);
|
||||||
|
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
|
||||||
|
|
||||||
|
await codexHook("PostToolUse", { session_id: "cx-2", cwd, tool_name: "Bash" });
|
||||||
|
assert.equal(lanes.getLane(codexLane.id).needs_action, null);
|
||||||
|
assert.equal(stmts.getSession.get("cx-2").awaiting_input_since, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends the turn on an Interrupt (a declined permission, or Esc)", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "x", cwd, tool: "codex" });
|
||||||
|
await codexHook("UserPromptSubmit", { session_id: "cx-int", cwd, prompt: "go" });
|
||||||
|
await codexHook("PermissionRequest", {
|
||||||
|
session_id: "cx-int",
|
||||||
|
cwd,
|
||||||
|
tool_name: "Bash",
|
||||||
|
tool_input: { command: "rm -rf build" },
|
||||||
|
});
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||||
|
await codexHook("Interrupt", { session_id: "cx-int", cwd });
|
||||||
|
const after = lanes.getLane(lane.id);
|
||||||
|
assert.equal(after.status, "idle");
|
||||||
|
assert.equal(after.needs_action, null);
|
||||||
|
assert.equal(stmts.getSession.get("cx-int").awaiting_reason, "stop");
|
||||||
|
assert.equal(eventsOf("cx-int").at(-1).event_type, "Stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never infers a stage on a codex lane", async () => {
|
||||||
|
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
|
||||||
|
const lane = lanes.createLane({ title: "x", cwd, tool: "codex" });
|
||||||
|
await codexHook("PreToolUse", {
|
||||||
|
session_id: "cx-3",
|
||||||
|
cwd,
|
||||||
|
tool_name: "Bash",
|
||||||
|
tool_input: { command: "npm run test:server" },
|
||||||
|
});
|
||||||
|
const after = lanes.getLane(lane.id);
|
||||||
|
assert.equal(after.detected_stage, null);
|
||||||
|
assert.equal(after.status, "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a sub-agent thread's lifecycle hooks and puts its tool calls on the root session", async () => {
|
||||||
|
const child = rollout("cx-child", "cx-root");
|
||||||
|
await codexHook("SessionStart", { session_id: "cx-root", cwd: "/w" });
|
||||||
|
const stop = await codexHook("Stop", {
|
||||||
|
session_id: "cx-child",
|
||||||
|
transcript_path: child,
|
||||||
|
cwd: "/w",
|
||||||
|
});
|
||||||
|
assert.equal(stop.status, 200);
|
||||||
|
assert.equal(stop.body.ignored, true);
|
||||||
|
await codexHook("PreToolUse", {
|
||||||
|
session_id: "cx-child",
|
||||||
|
transcript_path: child,
|
||||||
|
cwd: "/w",
|
||||||
|
tool_name: "Bash",
|
||||||
|
tool_input: { command: "ls" },
|
||||||
|
});
|
||||||
|
assert.equal(stmts.getSession.get("cx-child"), undefined);
|
||||||
|
assert.equal(eventsOf("cx-root").at(-1).event_type, "PreToolUse");
|
||||||
|
assert.equal(
|
||||||
|
eventsOf("cx-root").some((e) => e.event_type === "Stop"),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a session the journal poll imported first its main agent", async () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES ('cx-polled', 'p', 'completed', 'codex', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
|
||||||
|
).run();
|
||||||
|
await codexHook("PreToolUse", { session_id: "cx-polled", cwd: "/w", tool_name: "Bash" });
|
||||||
|
const agent = stmts.getAgent.get("cx-polled-main");
|
||||||
|
assert.ok(agent);
|
||||||
|
assert.equal(agent.type, "main");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trust a transcript_path outside CODEX_HOME to name a root session", async () => {
|
||||||
|
const outside = path.join(ROOT, "rollout-2026-09-29T01-00-00-cx-x.jsonl");
|
||||||
|
fs.writeFileSync(
|
||||||
|
outside,
|
||||||
|
JSON.stringify({ type: "session_meta", payload: { id: "cx-x", session_id: "victim" } }) + "\n"
|
||||||
|
);
|
||||||
|
await codexHook("PreToolUse", {
|
||||||
|
session_id: "cx-x",
|
||||||
|
transcript_path: outside,
|
||||||
|
cwd: "/w",
|
||||||
|
tool_name: "Bash",
|
||||||
|
});
|
||||||
|
assert.ok(stmts.getSession.get("cx-x"));
|
||||||
|
assert.equal(stmts.getSession.get("victim"), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET/POST/DELETE /api/settings/codex-hooks", () => {
|
||||||
|
it("installs, reports and removes AgentHub's Codex hooks", async () => {
|
||||||
|
let r = await request("GET", "/api/settings/codex-hooks");
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.installed, false);
|
||||||
|
r = await request("POST", "/api/settings/codex-hooks");
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.installed, true);
|
||||||
|
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, "hooks.json")));
|
||||||
|
r = await request("DELETE", "/api/settings/codex-hooks");
|
||||||
|
assert.equal(r.body.installed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an install or remove asked by another site", async () => {
|
||||||
|
const evil = { Origin: "https://evil.example" };
|
||||||
|
let r = await request("POST", "/api/settings/codex-hooks", null, evil);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "hooks.json")), false);
|
||||||
|
r = await request("DELETE", "/api/settings/codex-hooks", null, evil);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 400 EBADJSON for a hooks.json it cannot merge into", async () => {
|
||||||
|
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(process.env.CODEX_HOME, "hooks.json"), "[1]");
|
||||||
|
const r = await request("POST", "/api/settings/codex-hooks");
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "EBADJSON");
|
||||||
|
fs.rmSync(path.join(process.env.CODEX_HOME, "hooks.json"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests server/lib/codex-hooks.js — installing AgentHub's hooks into
|
||||||
|
* Codex's `$CODEX_HOME/hooks.json`: a user's own hooks kept, a reinstall
|
||||||
|
* replacing only ours, remove leaving theirs, a non-JSON file refused, trust
|
||||||
|
* read from `config.toml` (also under a symlinked home), a user's hook in our
|
||||||
|
* group or with a similar name never removed with ours, and the forwarding
|
||||||
|
* script refreshed — only when it is ours — without its command (which Codex's
|
||||||
|
* trust is keyed on) changing. CODEX_HOME is a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-hooks-"));
|
||||||
|
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||||
|
|
||||||
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const hooks = require("../lib/codex-hooks");
|
||||||
|
|
||||||
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const HOME = process.env.CODEX_HOME;
|
||||||
|
const FILE = path.join(HOME, "hooks.json");
|
||||||
|
const read = () => JSON.parse(fs.readFileSync(FILE, "utf8"));
|
||||||
|
const theirs = { hooks: [{ type: "command", command: "my-audit.sh", timeout: 10 }] };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(HOME, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(HOME, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("codex hooks", () => {
|
||||||
|
it("installs one group per event, all through the stable script", () => {
|
||||||
|
const st = hooks.install();
|
||||||
|
assert.equal(st.installed, true);
|
||||||
|
const file = read();
|
||||||
|
assert.deepEqual(Object.keys(file.hooks).sort(), [...hooks.HOOK_EVENTS].sort());
|
||||||
|
for (const e of hooks.HOOK_EVENTS) {
|
||||||
|
assert.deepEqual(file.hooks[e], [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: "command",
|
||||||
|
command: `'${path.join(HOME, "agenthub-hook.sh")}' ${e}`,
|
||||||
|
timeout: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const script = fs.readFileSync(path.join(HOME, "agenthub-hook.sh"), "utf8");
|
||||||
|
assert.match(script, /^#!\/bin\/sh\n/);
|
||||||
|
assert.ok(script.includes(path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js")));
|
||||||
|
assert.ok(script.includes(`CLAUDE_HOME='${process.env.CLAUDE_HOME}'`));
|
||||||
|
assert.ok(script.includes('"$1" codex'));
|
||||||
|
assert.equal(fs.statSync(path.join(HOME, "agenthub-hook.sh")).mode & 0o111, 0o111);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the user's own hooks, and a reinstall replaces only ours", () => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
FILE,
|
||||||
|
JSON.stringify({ description: "mine", hooks: { PreToolUse: [theirs], Notify: [theirs] } })
|
||||||
|
);
|
||||||
|
hooks.install();
|
||||||
|
hooks.install();
|
||||||
|
const file = read();
|
||||||
|
assert.equal(file.description, "mine");
|
||||||
|
assert.deepEqual(file.hooks.Notify, [theirs]);
|
||||||
|
assert.equal(file.hooks.PreToolUse.length, 2);
|
||||||
|
assert.deepEqual(file.hooks.PreToolUse[0], theirs);
|
||||||
|
assert.equal(file.hooks.Stop.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove takes out only ours, and deletes a file that held nothing else", () => {
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
|
||||||
|
hooks.install();
|
||||||
|
let st = hooks.remove();
|
||||||
|
assert.equal(st.installed, false);
|
||||||
|
assert.deepEqual(read(), { hooks: { PreToolUse: [theirs] } });
|
||||||
|
assert.equal(fs.existsSync(path.join(HOME, "agenthub-hook.sh")), false);
|
||||||
|
|
||||||
|
fs.rmSync(FILE);
|
||||||
|
hooks.install();
|
||||||
|
st = hooks.remove();
|
||||||
|
assert.equal(fs.existsSync(FILE), false);
|
||||||
|
assert.equal(st.installed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a hooks.json that is not a JSON object, and changes nothing", () => {
|
||||||
|
fs.writeFileSync(FILE, "{ not json");
|
||||||
|
assert.throws(() => hooks.install(), { code: "EBADJSON" });
|
||||||
|
assert.equal(fs.readFileSync(FILE, "utf8"), "{ not json");
|
||||||
|
assert.equal(hooks.status().readable, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports trust from Codex's own hooks.state entries", () => {
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
|
||||||
|
hooks.install();
|
||||||
|
let st = hooks.status();
|
||||||
|
assert.equal(st.events.PreToolUse.trusted, false);
|
||||||
|
// Ours is the second PreToolUse group (index 1); Stop's is the first.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(HOME, "config.toml"),
|
||||||
|
[
|
||||||
|
'model = "x"',
|
||||||
|
"",
|
||||||
|
`[hooks.state."${FILE}:pre_tool_use:1:0"]`,
|
||||||
|
'trusted_hash = "sha256:aa"',
|
||||||
|
`[hooks.state."${FILE}:stop:0:0"]`,
|
||||||
|
'trusted_hash = "sha256:bb"',
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
st = hooks.status();
|
||||||
|
assert.equal(st.events.PreToolUse.trusted, true);
|
||||||
|
assert.equal(st.events.Stop.trusted, true);
|
||||||
|
assert.equal(st.events.SessionStart.trusted, false);
|
||||||
|
assert.equal(st.trusted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes a stale script, never touches an absent one, and keeps the command", () => {
|
||||||
|
assert.equal(hooks.refreshScript(), "absent");
|
||||||
|
hooks.install();
|
||||||
|
const before = read();
|
||||||
|
const script = path.join(HOME, "agenthub-hook.sh");
|
||||||
|
const stale = fs.readFileSync(script, "utf8").replace(/'[^']*hook-handler\.js'/, "'/old/h.js'");
|
||||||
|
fs.writeFileSync(script, stale);
|
||||||
|
assert.equal(hooks.refreshScript(), "written");
|
||||||
|
assert.ok(fs.readFileSync(script, "utf8").includes(path.resolve(__dirname, "..", "..")));
|
||||||
|
assert.equal(hooks.refreshScript(), "exists");
|
||||||
|
fs.chmodSync(script, 0o644);
|
||||||
|
assert.equal(hooks.refreshScript(), "written");
|
||||||
|
assert.equal(fs.statSync(script).mode & 0o111, 0o111);
|
||||||
|
assert.deepEqual(read(), before);
|
||||||
|
// A file by that name that is not ours is left alone.
|
||||||
|
fs.writeFileSync(script, "#!/bin/sh\necho mine\n");
|
||||||
|
assert.equal(hooks.refreshScript(), "foreign");
|
||||||
|
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never removes a user's hook that shares our group or has a similar name", () => {
|
||||||
|
hooks.install();
|
||||||
|
const file = read();
|
||||||
|
const mine = { type: "command", command: "my-notify.sh" };
|
||||||
|
file.hooks.Stop[0].hooks.push(mine);
|
||||||
|
const lookalike = {
|
||||||
|
hooks: [{ type: "command", command: "/home/me/bin/not-agenthub-hook.sh" }],
|
||||||
|
};
|
||||||
|
file.hooks.PreToolUse.unshift(lookalike);
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify(file));
|
||||||
|
assert.equal(hooks.status().events.PreToolUse.installed, true);
|
||||||
|
hooks.install();
|
||||||
|
let after = read();
|
||||||
|
assert.deepEqual(after.hooks.PreToolUse[0], lookalike);
|
||||||
|
assert.deepEqual(after.hooks.Stop[0].hooks, [mine]);
|
||||||
|
assert.equal(after.hooks.Stop.length, 2);
|
||||||
|
hooks.remove();
|
||||||
|
after = read();
|
||||||
|
assert.deepEqual(after.hooks, { PreToolUse: [lookalike], Stop: [{ hooks: [mine] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads trust keyed on the resolved home when CODEX_HOME is a symlink", () => {
|
||||||
|
const link = path.join(ROOT, "codex-link");
|
||||||
|
fs.rmSync(link, { force: true });
|
||||||
|
fs.symlinkSync(HOME, link);
|
||||||
|
process.env.CODEX_HOME = link;
|
||||||
|
try {
|
||||||
|
hooks.install();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(HOME, "config.toml"),
|
||||||
|
`[hooks.state."${FILE}:stop:0:0"]\ntrusted_hash = "sha256:bb"\n`
|
||||||
|
);
|
||||||
|
assert.equal(hooks.status().events.Stop.trusted, true);
|
||||||
|
} finally {
|
||||||
|
process.env.CODEX_HOME = HOME;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes through a symlinked hooks.json instead of replacing the link", () => {
|
||||||
|
const real = path.join(ROOT, "dotfiles-hooks.json");
|
||||||
|
fs.writeFileSync(real, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
|
||||||
|
fs.symlinkSync(real, FILE);
|
||||||
|
hooks.install();
|
||||||
|
assert.equal(fs.lstatSync(FILE).isSymbolicLink(), true);
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(real, "utf8")).hooks.Stop.length, 1);
|
||||||
|
fs.rmSync(real);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single-quotes every value it writes into the script", () => {
|
||||||
|
assert.equal(hooks.shq("/a b/it's"), `'/a b/it'\\''s'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never overwrites or removes a script that is not ours, and keeps hooks.json's mode", () => {
|
||||||
|
const script = path.join(HOME, "agenthub-hook.sh");
|
||||||
|
fs.writeFileSync(script, "#!/bin/sh\necho mine\n");
|
||||||
|
assert.throws(() => hooks.install(), { code: "EFOREIGN" });
|
||||||
|
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
|
||||||
|
assert.equal(fs.existsSync(FILE), false);
|
||||||
|
hooks.remove();
|
||||||
|
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
|
||||||
|
fs.rmSync(script);
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ hooks: { Stop: [theirs] } }), { mode: 0o600 });
|
||||||
|
hooks.install();
|
||||||
|
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
||||||
|
hooks.remove();
|
||||||
|
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empties a symlinked hooks.json through the link instead of unlinking it", () => {
|
||||||
|
const real = path.join(ROOT, "dotfiles-hooks.json");
|
||||||
|
fs.writeFileSync(real, "{}\n");
|
||||||
|
fs.symlinkSync(real, FILE);
|
||||||
|
hooks.install();
|
||||||
|
hooks.remove();
|
||||||
|
assert.ok(fs.lstatSync(FILE).isSymbolicLink());
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(real, "utf8")), { hooks: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,6 +95,40 @@ describe("codex status mirror", () => {
|
|||||||
lanes.deleteLane(lane.id);
|
lanes.deleteLane(lane.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a lane running while an older journal of the same lane sits beside the live one", () => {
|
||||||
|
const lane = codexLane();
|
||||||
|
writeRollout(lane.cwd, { mtime: Date.now() - 6 * 60 * 60 * 1000 });
|
||||||
|
writeRollout(lane.cwd);
|
||||||
|
|
||||||
|
codex.ingestCodexOnce({});
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||||
|
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||||
|
lanes.deleteLane(lane.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a journal whose mtime is a fraction of a second ahead as fresh", () => {
|
||||||
|
const lane = codexLane();
|
||||||
|
writeRollout(lane.cwd, { mtime: Date.now() + 400 });
|
||||||
|
|
||||||
|
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||||
|
lanes.deleteLane(lane.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a lane alone while its session's hooks are current", () => {
|
||||||
|
const { stmts } = require("../db");
|
||||||
|
const lane = codexLane();
|
||||||
|
lanes.updateLane(lane.id, { session_id: "hooked" });
|
||||||
|
stmts.insertSession.run("hooked", "x", "active", lane.cwd, null, null);
|
||||||
|
stmts.insertEvent.run("hooked", null, "Stop", null, "ready", "{}");
|
||||||
|
writeRollout(lane.cwd); // fresh journal: the mirror alone would say running
|
||||||
|
|
||||||
|
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||||
|
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||||
|
lanes.deleteLane(lane.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("a second pass with nothing new changes nothing", () => {
|
it("a second pass with nothing new changes nothing", () => {
|
||||||
const lane = codexLane();
|
const lane = codexLane();
|
||||||
writeRollout(lane.cwd);
|
writeRollout(lane.cwd);
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests server/lib/codex-usage.js — Codex sessions and token usage read
|
||||||
|
* from rollout journals: the token mapping (input includes cached, output
|
||||||
|
* includes reasoning), duplicate totals, a mid-session model switch, a fork's
|
||||||
|
* copied history skipped, a sub-agent thread folded into its root session,
|
||||||
|
* incremental reads, a deleted session not resurrected, and a Claude row never
|
||||||
|
* touched. CODEX_HOME and the DB live in a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-usage-"));
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||||
|
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
|
||||||
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const { db, stmts } = require("../db");
|
||||||
|
const { parseRolloutChunk, ingestCodexUsage, settleActive } = require("../lib/codex-usage");
|
||||||
|
|
||||||
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const DAY = path.join(process.env.CODEX_HOME, "sessions", "2026", "09", "29");
|
||||||
|
|
||||||
|
function meta(id, extra = {}) {
|
||||||
|
return {
|
||||||
|
timestamp: "2026-09-29T01:00:00.000Z",
|
||||||
|
type: "session_meta",
|
||||||
|
payload: { id, session_id: id, timestamp: "2026-09-29T01:00:00.000Z", cwd: "/w/p", ...extra },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ctx = (model) => ({
|
||||||
|
timestamp: "2026-09-29T01:00:01.000Z",
|
||||||
|
type: "turn_context",
|
||||||
|
payload: { model },
|
||||||
|
});
|
||||||
|
function tc(total, last, ts = "2026-09-29T01:00:02.000Z") {
|
||||||
|
const v = (t) => ({
|
||||||
|
input_tokens: t[0],
|
||||||
|
cached_input_tokens: t[1],
|
||||||
|
cache_write_input_tokens: 0,
|
||||||
|
output_tokens: t[2],
|
||||||
|
reasoning_output_tokens: 0,
|
||||||
|
total_tokens: t[0] + t[2],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
timestamp: ts,
|
||||||
|
type: "event_msg",
|
||||||
|
payload: {
|
||||||
|
type: "token_count",
|
||||||
|
info: { total_token_usage: v(total), last_token_usage: v(last) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const jsonl = (lines) => lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
||||||
|
function writeRollout(id, lines, mtime = new Date("2026-09-29T02:00:00Z")) {
|
||||||
|
fs.mkdirSync(DAY, { recursive: true });
|
||||||
|
const file = path.join(DAY, `rollout-2026-09-29T01-00-00-${id}.jsonl`);
|
||||||
|
fs.writeFileSync(file, jsonl(lines));
|
||||||
|
fs.utimesSync(file, mtime, mtime);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
const usage = (sid) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT model, input_tokens AS i, cache_read_tokens AS r, output_tokens AS o FROM token_usage WHERE session_id = ? ORDER BY model"
|
||||||
|
)
|
||||||
|
.all(sid);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
||||||
|
db.exec(
|
||||||
|
"DELETE FROM codex_rollouts; DELETE FROM token_usage; DELETE FROM events; DELETE FROM sessions;"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseRolloutChunk", () => {
|
||||||
|
it("maps tokens, skips a repeated total and a null info, and follows the model", () => {
|
||||||
|
const text = jsonl([
|
||||||
|
meta("t1"),
|
||||||
|
ctx("gpt-a"),
|
||||||
|
tc([100, 60, 10], [100, 60, 10]),
|
||||||
|
tc([100, 60, 10], [100, 60, 10]), // emitted twice
|
||||||
|
{ type: "event_msg", payload: { type: "token_count", info: null } },
|
||||||
|
ctx("gpt-b"),
|
||||||
|
tc([150, 90, 15], [50, 30, 5], "2026-09-29T01:05:00.000Z"),
|
||||||
|
]);
|
||||||
|
const s = parseRolloutChunk(text, {});
|
||||||
|
assert.equal(s.threadId, "t1");
|
||||||
|
assert.equal(s.sessionId, "t1");
|
||||||
|
assert.equal(s.cwd, "/w/p");
|
||||||
|
assert.equal(s.model, "gpt-b");
|
||||||
|
assert.deepEqual(s.usage, {
|
||||||
|
"gpt-a": { input: 40, cacheRead: 60, cacheWrite: 0, output: 10 },
|
||||||
|
"gpt-b": { input: 20, cacheRead: 30, cacheWrite: 0, output: 5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues from saved state across chunks", () => {
|
||||||
|
let s = parseRolloutChunk(jsonl([meta("t1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]), {});
|
||||||
|
s = parseRolloutChunk(jsonl([tc([10, 0, 1], [10, 0, 1]), tc([30, 0, 3], [20, 0, 2])]), s);
|
||||||
|
assert.deepEqual(s.usage["gpt-a"], { input: 30, cacheRead: 0, cacheWrite: 0, output: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ingestCodexUsage", () => {
|
||||||
|
it("creates a codex session with its usage, named from session_index.jsonl", async () => {
|
||||||
|
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([100, 60, 10], [100, 60, 10])]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(process.env.CODEX_HOME, "session_index.jsonl"),
|
||||||
|
JSON.stringify({ id: "s1", thread_name: "Fix the thing" }) + "\n"
|
||||||
|
);
|
||||||
|
const events = [];
|
||||||
|
await ingestCodexUsage({
|
||||||
|
broadcast: (t, d) => events.push([t, d.id]),
|
||||||
|
now: Date.parse("2026-09-29T03:00:00Z"),
|
||||||
|
});
|
||||||
|
const row = stmts.getSession.get("s1");
|
||||||
|
assert.equal(row.tool, "codex");
|
||||||
|
assert.equal(row.name, "Fix the thing");
|
||||||
|
assert.equal(row.cwd, "/w/p");
|
||||||
|
assert.equal(row.model, "gpt-a");
|
||||||
|
assert.equal(row.status, "completed");
|
||||||
|
assert.equal(row.started_at, "2026-09-29T01:00:00.000Z");
|
||||||
|
assert.equal(row.ended_at, "2026-09-29T02:00:00.000Z"); // the journal's mtime
|
||||||
|
assert.equal(row.updated_at, "2026-09-29T02:00:00.000Z"); // last activity, not ingestion
|
||||||
|
assert.equal(row.transcript_path, null);
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 40, r: 60, o: 10 }]);
|
||||||
|
assert.deepEqual(events, [["session_created", "s1"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a session with no thread name by its first prompt", async () => {
|
||||||
|
writeRollout("s2", [
|
||||||
|
meta("s2"),
|
||||||
|
{
|
||||||
|
type: "event_msg",
|
||||||
|
payload: { type: "user_message", message: " Fix the login bug\nthen ship" },
|
||||||
|
},
|
||||||
|
{ type: "event_msg", payload: { type: "user_message", message: "second prompt" } },
|
||||||
|
ctx("gpt-a"),
|
||||||
|
tc([1, 0, 1], [1, 0, 1]),
|
||||||
|
]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.equal(stmts.getSession.get("s2").name, "Fix the login bug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a prompt from the item_completed UserMessage shape too", async () => {
|
||||||
|
writeRollout("s3", [
|
||||||
|
meta("s3"),
|
||||||
|
{
|
||||||
|
type: "event_msg",
|
||||||
|
payload: {
|
||||||
|
type: "item_completed",
|
||||||
|
item: { type: "UserMessage", content: [{ type: "text", text: "Continue the task" }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.equal(stmts.getSession.get("s3").name, "Continue the task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is active while a journal was written in the last two minutes", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
writeRollout(
|
||||||
|
"s1",
|
||||||
|
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||||
|
new Date(now - 10_000)
|
||||||
|
);
|
||||||
|
await ingestCodexUsage({ now });
|
||||||
|
assert.equal(stmts.getSession.get("s1").status, "active");
|
||||||
|
assert.equal(stmts.getSession.get("s1").ended_at, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles an active session to completed once its journals go quiet", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
writeRollout(
|
||||||
|
"s1",
|
||||||
|
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||||
|
new Date(now - 10_000)
|
||||||
|
);
|
||||||
|
await ingestCodexUsage({ now });
|
||||||
|
const events = [];
|
||||||
|
settleActive((t, d) => events.push([t, d.status]), now + 5 * 60_000);
|
||||||
|
assert.equal(stmts.getSession.get("s1").status, "completed");
|
||||||
|
assert.equal(
|
||||||
|
stmts.getSession.get("s1").ended_at,
|
||||||
|
new Date(Math.floor(now - 10_000)).toISOString()
|
||||||
|
);
|
||||||
|
assert.deepEqual(events, [["session_updated", "completed"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the history a fork copied from its parent", async () => {
|
||||||
|
const parent = [
|
||||||
|
meta("p1"),
|
||||||
|
ctx("gpt-a"),
|
||||||
|
tc([100, 0, 10], [100, 0, 10]),
|
||||||
|
tc([300, 0, 30], [200, 0, 20]),
|
||||||
|
];
|
||||||
|
writeRollout("p1", parent);
|
||||||
|
writeRollout("f1", [
|
||||||
|
meta("f1", { forked_from_id: "p1" }),
|
||||||
|
meta("p1"),
|
||||||
|
...parent.slice(1),
|
||||||
|
tc([350, 0, 35], [50, 0, 5]),
|
||||||
|
]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.deepEqual(usage("p1"), [{ model: "gpt-a", i: 300, r: 0, o: 30 }]);
|
||||||
|
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds a sub-agent thread into its root session", async () => {
|
||||||
|
writeRollout("r1", [meta("r1"), ctx("gpt-a"), tc([100, 0, 10], [100, 0, 10])]);
|
||||||
|
writeRollout("c1", [
|
||||||
|
meta("c1", { session_id: "r1", thread_source: "subagent" }),
|
||||||
|
ctx("gpt-b"),
|
||||||
|
tc([40, 0, 4], [40, 0, 4]),
|
||||||
|
]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.equal(stmts.getSession.get("c1"), undefined);
|
||||||
|
assert.deepEqual(usage("r1"), [
|
||||||
|
{ model: "gpt-a", i: 100, r: 0, o: 10 },
|
||||||
|
{ model: "gpt-b", i: 40, r: 0, o: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads only what was appended, and re-reads a file that shrank", async () => {
|
||||||
|
const lines = [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])];
|
||||||
|
const file = writeRollout("s1", lines);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
fs.appendFileSync(file, jsonl([tc([30, 0, 3], [20, 0, 2])]) + '{"partial":');
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, jsonl(lines)); // rewritten shorter
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bring back a deleted session while its journal is unchanged", async () => {
|
||||||
|
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
db.prepare("DELETE FROM sessions WHERE id = 's1'").run();
|
||||||
|
db.prepare("DELETE FROM token_usage WHERE session_id = 's1'").run();
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.equal(stmts.getSession.get("s1"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches a Claude session with the same id", async () => {
|
||||||
|
stmts.insertSession.run("s1", "mine", "active", "/c", "claude-x", null);
|
||||||
|
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
const row = stmts.getSession.get("s1");
|
||||||
|
assert.equal(row.tool, "claude");
|
||||||
|
assert.equal(row.name, "mine");
|
||||||
|
assert.deepEqual(usage("s1"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps skipping a fork's copied history that arrives after the first read", async () => {
|
||||||
|
const parent = [
|
||||||
|
meta("p1"),
|
||||||
|
ctx("gpt-a"),
|
||||||
|
tc([100, 0, 10], [100, 0, 10]),
|
||||||
|
tc([300, 0, 30], [200, 0, 20]),
|
||||||
|
];
|
||||||
|
writeRollout("p1", parent);
|
||||||
|
const fork = writeRollout("f1", [meta("f1", { forked_from_id: "p1" }), meta("p1"), parent[1]]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
fs.appendFileSync(fork, jsonl([...parent.slice(2), tc([350, 0, 35], [50, 0, 5])]));
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a fork whole, and says so, when its parent's journal is gone", async () => {
|
||||||
|
const warn = console.warn;
|
||||||
|
const warned = [];
|
||||||
|
console.warn = (m) => warned.push(String(m));
|
||||||
|
try {
|
||||||
|
writeRollout("f1", [
|
||||||
|
meta("f1", { forked_from_id: "gone" }),
|
||||||
|
ctx("gpt-a"),
|
||||||
|
tc([100, 0, 10], [100, 0, 10]),
|
||||||
|
]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
}
|
||||||
|
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 100, r: 0, o: 10 }]);
|
||||||
|
assert.ok(warned.some((m) => m.includes("fork parent gone")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count a journal twice when CODEX_HOME moves", async () => {
|
||||||
|
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
const home = process.env.CODEX_HOME;
|
||||||
|
fs.renameSync(home, `${home}-moved`);
|
||||||
|
process.env.CODEX_HOME = `${home}-moved`;
|
||||||
|
try {
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
} finally {
|
||||||
|
fs.renameSync(`${home}-moved`, home);
|
||||||
|
process.env.CODEX_HOME = home;
|
||||||
|
}
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM codex_rollouts").get().n, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call a journal written in the future active", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
writeRollout(
|
||||||
|
"s1",
|
||||||
|
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||||
|
new Date(now + 60_000)
|
||||||
|
);
|
||||||
|
await ingestCodexUsage({ now });
|
||||||
|
assert.equal(stmts.getSession.get("s1").status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the usage of a model a re-read journal no longer has", async () => {
|
||||||
|
const file = writeRollout("s1", [
|
||||||
|
meta("s1"),
|
||||||
|
ctx("gpt-a"),
|
||||||
|
tc([10, 0, 1], [10, 0, 1]),
|
||||||
|
ctx("gpt-b"),
|
||||||
|
tc([30, 0, 3], [20, 0, 2]),
|
||||||
|
]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.equal(usage("s1").length, 2);
|
||||||
|
fs.writeFileSync(file, jsonl([meta("s1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]));
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes cwd, start and model from the root thread even when a sub-agent is read first", async () => {
|
||||||
|
writeRollout("c1", [
|
||||||
|
meta("c1", { session_id: "r1", cwd: "/w/child", timestamp: "2026-09-29T01:30:00.000Z" }),
|
||||||
|
ctx("gpt-b"),
|
||||||
|
tc([40, 0, 4], [40, 0, 4]),
|
||||||
|
]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.equal(stmts.getSession.get("r1").cwd, "/w/child");
|
||||||
|
writeRollout(
|
||||||
|
"r1",
|
||||||
|
[meta("r1"), ctx("gpt-a"), tc([100, 0, 10], [100, 0, 10])],
|
||||||
|
new Date("2026-09-29T01:40:00Z")
|
||||||
|
);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
const row = stmts.getSession.get("r1");
|
||||||
|
assert.equal(row.cwd, "/w/p");
|
||||||
|
assert.equal(row.started_at, "2026-09-29T01:00:00.000Z");
|
||||||
|
assert.equal(row.model, "gpt-a"); // the root's, though the child's file is newer
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a journal with a line longer than one read in bounded steps", async () => {
|
||||||
|
const file = writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||||
|
fs.appendFileSync(file, `{"type":"response_item","x":"${"y".repeat(17 * 1024 * 1024)}"}\n`);
|
||||||
|
fs.appendFileSync(file, jsonl([tc([30, 0, 3], [20, 0, 2])]));
|
||||||
|
for (let i = 0; i < 4; i++) await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
|
||||||
|
const row = db.prepare("SELECT offset, size FROM codex_rollouts").get();
|
||||||
|
assert.equal(row.offset, row.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a hook-fed session's status to its hooks, and still writes its usage", async () => {
|
||||||
|
stmts.insertSession.run("s1", "Codex s1", "active", "/w/p", null, null);
|
||||||
|
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = 's1'").run();
|
||||||
|
stmts.insertEvent.run("s1", null, "Stop", null, "ready", "{}");
|
||||||
|
const before = stmts.getSession.get("s1");
|
||||||
|
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
settleActive(() => {}, Date.parse("2026-09-29T09:00:00Z"));
|
||||||
|
const row = stmts.getSession.get("s1");
|
||||||
|
assert.equal(row.status, "active");
|
||||||
|
assert.equal(row.ended_at, null);
|
||||||
|
assert.equal(row.updated_at, before.updated_at);
|
||||||
|
assert.equal(row.model, "gpt-a");
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the status back to the journal once the hooks stop reporting", async () => {
|
||||||
|
// Hooked once (an hour before), then the hooks were removed; the journal
|
||||||
|
// kept being written.
|
||||||
|
stmts.insertSession.run("s1", "Codex s1", "abandoned", "/w/p", null, null);
|
||||||
|
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = 's1'").run();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO events (session_id, event_type, created_at) VALUES ('s1', 'Stop', '2026-09-29T01:00:00.000Z')"
|
||||||
|
).run();
|
||||||
|
const now = Date.parse("2026-09-29T02:00:30Z");
|
||||||
|
writeRollout(
|
||||||
|
"s1",
|
||||||
|
[meta("s1"), ctx("gpt-a"), tc([30, 0, 3], [30, 0, 3])],
|
||||||
|
new Date(now - 30_000)
|
||||||
|
);
|
||||||
|
await ingestCodexUsage({ now });
|
||||||
|
assert.equal(stmts.getSession.get("s1").status, "active");
|
||||||
|
settleActive(() => {}, now + 5 * 60_000);
|
||||||
|
assert.equal(stmts.getSession.get("s1").status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports its journals again after Clear data", async () => {
|
||||||
|
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
const express = require("express");
|
||||||
|
const http = require("node:http");
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/settings", require("../routes/settings"));
|
||||||
|
const server = http.createServer(app);
|
||||||
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${server.address().port}/api/settings/clear-data`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
assert.equal(stmts.getSession.get("s1"), undefined);
|
||||||
|
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||||
|
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers quietly when CODEX_HOME has no sessions", async () => {
|
||||||
|
const r = await ingestCodexUsage({ now: Date.now() });
|
||||||
|
assert.deepEqual(r, { files: 0, sessions: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
* local server to process the buffered request on its own schedule. These tests
|
* local server to process the buffered request on its own schedule. These tests
|
||||||
* lock in that non-blocking contract so a future refactor can't reintroduce the
|
* lock in that non-blocking contract so a future refactor can't reintroduce the
|
||||||
* "stuck running hooks" stall (handler waiting up to the per-request timeout for
|
* "stuck running hooks" stall (handler waiting up to the per-request timeout for
|
||||||
* a slow/busy/wedged dashboard to reply).
|
* a slow/busy/wedged dashboard to reply). Also: a Codex hook (second argument
|
||||||
|
* `codex`) is marked `tool: "codex"`, an Antigravity one `tool: "antigravity"`,
|
||||||
|
* and any other second argument is not passed on.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -46,10 +48,10 @@ function startMockServer({ responseDelayMs }) {
|
|||||||
|
|
||||||
// Spawn the real handler, pipe a hook payload to stdin, and time how long it
|
// Spawn the real handler, pipe a hook payload to stdin, and time how long it
|
||||||
// takes to exit.
|
// takes to exit.
|
||||||
function runHandler({ port, hookType = "Stop", payload }) {
|
function runHandler({ port, hookType = "Stop", payload, extra = [] }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const start = process.hrtime.bigint();
|
const start = process.hrtime.bigint();
|
||||||
const child = spawn(process.execPath, [HANDLER, hookType], {
|
const child = spawn(process.execPath, [HANDLER, hookType, ...extra], {
|
||||||
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(port) },
|
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(port) },
|
||||||
stdio: ["pipe", "ignore", "ignore"],
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
});
|
});
|
||||||
@@ -90,6 +92,34 @@ describe("hook-handler non-blocking delivery", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks a Codex hook with tool: codex, and a Claude hook with no tool at all", async () => {
|
||||||
|
const { server, port, received } = await startMockServer({ responseDelayMs: 0 });
|
||||||
|
try {
|
||||||
|
await runHandler({
|
||||||
|
port,
|
||||||
|
hookType: "PreToolUse",
|
||||||
|
payload: { session_id: "cx" },
|
||||||
|
extra: ["codex"],
|
||||||
|
});
|
||||||
|
await runHandler({ port, hookType: "PreToolUse", payload: { session_id: "cc" } });
|
||||||
|
await runHandler({
|
||||||
|
port,
|
||||||
|
hookType: "Stop",
|
||||||
|
payload: { session_id: "ag" },
|
||||||
|
extra: ["antigravity"],
|
||||||
|
});
|
||||||
|
await runHandler({ port, hookType: "Stop", payload: { session_id: "zz" }, extra: ["evil"] });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const bodies = received.map((b) => JSON.parse(b));
|
||||||
|
assert.equal(bodies.find((b) => b.data.session_id === "cx").tool, "codex");
|
||||||
|
assert.equal(bodies.find((b) => b.data.session_id === "ag").tool, "antigravity");
|
||||||
|
assert.equal("tool" in bodies.find((b) => b.data.session_id === "zz"), false);
|
||||||
|
assert.equal("tool" in bodies.find((b) => b.data.session_id === "cc"), false);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("exits promptly when no dashboard is listening (connection refused)", async () => {
|
it("exits promptly when no dashboard is listening (connection refused)", async () => {
|
||||||
// Grab a port then close it so nothing is listening there.
|
// Grab a port then close it so nothing is listening there.
|
||||||
const { server, port } = await startMockServer({ responseDelayMs: 0 });
|
const { server, port } = await startMockServer({ responseDelayMs: 0 });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for the automatic pricing sync: a model seen in token_usage with
|
* @file Tests for the automatic pricing sync: a model seen in token_usage with
|
||||||
* no row of its own gets one from the price list, an edited row is never
|
* no row of its own gets one from the price list, an edited row is never
|
||||||
* overwritten, and a bad list or a failed fetch changes nothing.
|
* overwritten, a bad list or a failed fetch changes nothing, and an OpenAI
|
||||||
|
* model (a Codex session's) is priced from its `openai` entry too.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -15,7 +16,14 @@ process.env.DASHBOARD_DB_PATH = path.join(
|
|||||||
`pricing-sync-${Date.now()}-${process.pid}.db`
|
`pricing-sync-${Date.now()}-${process.pid}.db`
|
||||||
);
|
);
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
|
const {
|
||||||
|
syncPricing,
|
||||||
|
familyOf,
|
||||||
|
displayName,
|
||||||
|
toRow,
|
||||||
|
_resetCache,
|
||||||
|
_setCache,
|
||||||
|
} = require("../lib/pricing-sync");
|
||||||
|
|
||||||
// The real LiteLLM entry for Opus 5.5, trimmed.
|
// The real LiteLLM entry for Opus 5.5, trimmed.
|
||||||
const LIST = {
|
const LIST = {
|
||||||
@@ -43,15 +51,49 @@ const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern =
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetCache();
|
_resetCache();
|
||||||
db.prepare("DELETE FROM token_usage").run();
|
db.prepare("DELETE FROM token_usage").run();
|
||||||
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
|
db.prepare(
|
||||||
|
"DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%' OR model_pattern LIKE 'gpt-9%'"
|
||||||
|
).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pricing sync", () => {
|
describe("pricing sync", () => {
|
||||||
it("reduces an id to its family and names it", () => {
|
it("reduces an id to its family and names it", () => {
|
||||||
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
|
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
|
||||||
assert.equal(familyOf("gpt-4"), null);
|
assert.equal(familyOf("gpt-5.6-sol"), "gpt-5.6-sol");
|
||||||
|
assert.equal(familyOf("gpt-5.4-2026-03-05"), "gpt-5.4");
|
||||||
|
assert.equal(familyOf("o3-mini"), "o3-mini");
|
||||||
|
assert.equal(familyOf("clinepass/cline-pass/deepseek-v4-pro"), null);
|
||||||
|
assert.equal(familyOf("Highest"), null);
|
||||||
assert.equal(familyOf("claude-x%"), null);
|
assert.equal(familyOf("claude-x%"), null);
|
||||||
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
|
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
|
||||||
|
assert.equal(displayName("gpt-5.6-sol"), "GPT-5.6 Sol");
|
||||||
|
assert.equal(displayName("o3-mini"), "o3 Mini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices a Gemini model from Vertex AI's entry, never another vendor's", async () => {
|
||||||
|
assert.equal(familyOf("gemini-3.8-flash"), "gemini-3.8-flash");
|
||||||
|
assert.equal(displayName("gemini-3.8-flash"), "Gemini 3.8 Flash");
|
||||||
|
useModel("gemini-3.8-flash");
|
||||||
|
const list = {
|
||||||
|
"gemini-3.8-flash": {
|
||||||
|
litellm_provider: "vertex_ai-language-models",
|
||||||
|
input_cost_per_token: 7.5e-7,
|
||||||
|
output_cost_per_token: 3.75e-6,
|
||||||
|
cache_read_input_token_cost: 7.5e-8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), ["gemini-3.8-flash%"]);
|
||||||
|
const r = row("gemini-3.8-flash%");
|
||||||
|
assert.equal(r.display_name, "Gemini 3.8 Flash");
|
||||||
|
assert.deepEqual(
|
||||||
|
[r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok],
|
||||||
|
[0.75, 3.75, 0.075]
|
||||||
|
);
|
||||||
|
assert.equal(r.cache_write_per_mtok, 0);
|
||||||
|
const other = {
|
||||||
|
"gemini-3.8-flash": { ...list["gemini-3.8-flash"], litellm_provider: "openrouter" },
|
||||||
|
};
|
||||||
|
assert.equal(toRow("gemini-3.8-flash", other["gemini-3.8-flash"]), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds a row for a used model that only a broader pattern matched", async () => {
|
it("adds a row for a used model that only a broader pattern matched", async () => {
|
||||||
@@ -96,5 +138,113 @@ describe("pricing sync", () => {
|
|||||||
});
|
});
|
||||||
assert.deepEqual(failed, []);
|
assert.deepEqual(failed, []);
|
||||||
assert.equal(row("claude-opus-9-9%"), undefined);
|
assert.equal(row("claude-opus-9-9%"), undefined);
|
||||||
|
// A failure is not retried by the next caller, only after the check interval.
|
||||||
|
let calls = 0;
|
||||||
|
const counting = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return LIST;
|
||||||
|
};
|
||||||
|
await syncPricing(db, { fetchList: counting });
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
await syncPricing(db, { fetchList: counting, now: Date.now() + 16 * 60_000 });
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices an OpenAI model from its openai entry, with no cache-write charge", async () => {
|
||||||
|
useModel("gpt-9.1-sol");
|
||||||
|
const list = {
|
||||||
|
"gpt-9.1-sol": {
|
||||||
|
litellm_provider: "openai",
|
||||||
|
input_cost_per_token: 0.000004,
|
||||||
|
output_cost_per_token: 0.00002,
|
||||||
|
cache_read_input_token_cost: 4e-7,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), ["gpt-9.1-sol%"]);
|
||||||
|
const r = row("gpt-9.1-sol%");
|
||||||
|
assert.equal(r.display_name, "GPT-9.1 Sol");
|
||||||
|
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.4]);
|
||||||
|
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [0, 0]);
|
||||||
|
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an entry whose provider does not match the model's vendor", async () => {
|
||||||
|
useModel("gpt-9.2");
|
||||||
|
const list = {
|
||||||
|
"gpt-9.2": {
|
||||||
|
litellm_provider: "azure",
|
||||||
|
input_cost_per_token: 1e-6,
|
||||||
|
output_cost_per_token: 1e-6,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/settings/reset-pricing brings a synced model's row straight back", async () => {
|
||||||
|
const express = require("express");
|
||||||
|
const http = require("node:http");
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/settings", require("../routes/settings"));
|
||||||
|
const server = http.createServer(app);
|
||||||
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
const saved = process.env.AGENTHUB_PRICING_SYNC;
|
||||||
|
process.env.AGENTHUB_PRICING_SYNC = "1";
|
||||||
|
try {
|
||||||
|
useModel("gpt-9.3");
|
||||||
|
_setCache({
|
||||||
|
"gpt-9.3": {
|
||||||
|
litellm_provider: "openai",
|
||||||
|
input_cost_per_token: 1e-6,
|
||||||
|
output_cost_per_token: 8e-6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await fetch(
|
||||||
|
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.ok(body.pricing.some((p) => p.model_pattern === "gpt-9.3%"));
|
||||||
|
assert.ok(body.pricing.some((p) => p.model_pattern.startsWith("claude-")));
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete process.env.AGENTHUB_PRICING_SYNC;
|
||||||
|
else process.env.AGENTHUB_PRICING_SYNC = saved;
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/settings/reset-pricing with scope claude keeps every other row", async () => {
|
||||||
|
const express = require("express");
|
||||||
|
const http = require("node:http");
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/settings", require("../routes/settings"));
|
||||||
|
const server = http.createServer(app);
|
||||||
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
try {
|
||||||
|
const add = db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok) VALUES (?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
add.run("glm-4%", "GLM 4 (router)", 1, 2);
|
||||||
|
add.run("claude-opus-5-5%", "edited", 999, 999);
|
||||||
|
const res = await fetch(
|
||||||
|
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scope: "claude" }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { pricing } = await res.json();
|
||||||
|
assert.equal(pricing.find((p) => p.model_pattern === "glm-4%")?.input_per_mtok, 1);
|
||||||
|
assert.notEqual(
|
||||||
|
pricing.find((p) => p.model_pattern === "claude-opus-5-5%")?.input_per_mtok,
|
||||||
|
999
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -398,6 +398,23 @@ describe("pty-run", () => {
|
|||||||
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands a codex pane the dashboard's own CODEX_HOME, and a claude pane none", (t) => {
|
||||||
|
const saved = process.env.CODEX_HOME;
|
||||||
|
process.env.CODEX_HOME = "/tmp/custom-codex-home";
|
||||||
|
t.after(() => {
|
||||||
|
if (saved === undefined) delete process.env.CODEX_HOME;
|
||||||
|
else process.env.CODEX_HOME = saved;
|
||||||
|
});
|
||||||
|
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
|
||||||
|
const codex = captureNewSession(() =>
|
||||||
|
pty.spawnRun({ laneId: 40, cwd: "/tmp/repo", tool: "codex" })
|
||||||
|
);
|
||||||
|
assert.ok(envFlags(codex).includes("CODEX_HOME=/tmp/custom-codex-home"));
|
||||||
|
tmux.__reset();
|
||||||
|
const claude = captureNewSession(() => pty.spawnRun({ laneId: 41, cwd: "/tmp/repo" }));
|
||||||
|
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
|
||||||
|
});
|
||||||
|
|
||||||
describe("catalog tools", () => {
|
describe("catalog tools", () => {
|
||||||
const aiTools = require("../lib/ai-tools");
|
const aiTools = require("../lib/ai-tools");
|
||||||
const BIN = path.join(TMP, "bin");
|
const BIN = path.join(TMP, "bin");
|
||||||
|
|||||||
@@ -351,6 +351,20 @@ describe("watchdog liveness reap", () => {
|
|||||||
assert.equal(stmts.getSession.get(sid).status, "active", "remote session must not be reaped");
|
assert.equal(stmts.getSession.get(sid).status, "active", "remote session must not be reaped");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("spares a Codex session, whose liveness codex-usage.js owns", async () => {
|
||||||
|
// A `claude` process probe proves nothing about a Codex session: it is
|
||||||
|
// settled from its own rollout journals' mtime instead.
|
||||||
|
const sid = "cdx00000-0000-0000-0000-00000000000f";
|
||||||
|
const tpath = await seedSession(sid, "/tmp/liveness-codex");
|
||||||
|
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = ?").run(sid);
|
||||||
|
backdate(sid, tpath);
|
||||||
|
|
||||||
|
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
|
||||||
|
hooksRouter.livenessReap();
|
||||||
|
|
||||||
|
assert.equal(stmts.getSession.get(sid).status, "active", "codex session must not be reaped");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not touch error sessions", async () => {
|
it("does not touch error sessions", async () => {
|
||||||
const sid = "errr0000-0000-0000-0000-000000000006";
|
const sid = "errr0000-0000-0000-0000-000000000006";
|
||||||
const cwd = "/tmp/liveness-error";
|
const cwd = "/tmp/liveness-error";
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests GET/PUT /api/settings/codex-home and setCodexHome: the shared
|
||||||
|
* home-dir guard, persistence to the settings file, the `env_owned` flag, and
|
||||||
|
* the AI Router layer being re-written into the NEW home for every codex lane
|
||||||
|
* on a router (a `codex -p <name>` against a home without `<name>.config.toml`
|
||||||
|
* would fail at start); a home change from another site refused, and a
|
||||||
|
* CLAUDE_HOME change naming the lanes whose router profile the new home's
|
||||||
|
* store lacks. Everything lives in a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-home-"));
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||||
|
process.env.CODEX_HOME = path.join(ROOT, "codex-a");
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
|
||||||
|
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const express = require("express");
|
||||||
|
|
||||||
|
const routers = require("../lib/ai-routers");
|
||||||
|
const lanesLib = require("../lib/lanes");
|
||||||
|
const { applyRouterToLane } = require("../lib/router-apply");
|
||||||
|
const { envOwned } = require("../lib/settings-env");
|
||||||
|
const settingsRouter = require("../routes/settings");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let base;
|
||||||
|
|
||||||
|
function request(method, url, body, extra = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
||||||
|
const req = http.request(
|
||||||
|
`${base}${url}`,
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(data ? { "content-type": "application/json", "content-length": data.length } : {}),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let text = "";
|
||||||
|
res.on("data", (c) => (text += c));
|
||||||
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
if (data) req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/settings", settingsRouter);
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/settings/codex-home", () => {
|
||||||
|
it("GET answers the current home and whether the environment owns it", async () => {
|
||||||
|
const res = await request("GET", "/api/settings/codex-home");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.codex_home, process.env.CODEX_HOME);
|
||||||
|
assert.equal(res.body.env_owned, envOwned.has("CODEX_HOME"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a path that contains the user's home, changing nothing", async () => {
|
||||||
|
const before = process.env.CODEX_HOME;
|
||||||
|
const res = await request("PUT", "/api/settings/codex-home", { path: "/" });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(res.body.error.code, "EBADHOME");
|
||||||
|
assert.equal(process.env.CODEX_HOME, before);
|
||||||
|
assert.ok(!fs.existsSync(process.env.AGENTHUB_SETTINGS_FILE));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a missing path and a non-string body", async () => {
|
||||||
|
for (const body of [{ path: path.join(ROOT, "nope") }, { path: 7 }, {}]) {
|
||||||
|
const res = await request("PUT", "/api/settings/codex-home", body);
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(res.body.error.code, "INVALID_PATH");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches, persists, and re-writes every codex lane's router layer into the new home", async () => {
|
||||||
|
const cwd = path.join(ROOT, "lane");
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
const { name } = routers.createRouter({
|
||||||
|
label: "9Router",
|
||||||
|
baseUrl: "http://localhost:20128/v1",
|
||||||
|
apiKey: "sk-secret-value",
|
||||||
|
model: "Highest",
|
||||||
|
});
|
||||||
|
const lane = lanesLib.createLane({ title: "c", cwd, tool: "codex", router_profile: name });
|
||||||
|
applyRouterToLane(lane, name);
|
||||||
|
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`)));
|
||||||
|
|
||||||
|
const next = path.join(ROOT, "codex-b");
|
||||||
|
fs.mkdirSync(next);
|
||||||
|
const res = await request("PUT", "/api/settings/codex-home", { path: next });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.codex_home, next);
|
||||||
|
assert.deepEqual(res.body.routers, { updated: 1, failed: [] });
|
||||||
|
assert.equal(process.env.CODEX_HOME, next);
|
||||||
|
assert.ok(fs.existsSync(path.join(next, `${name}.config.toml`)));
|
||||||
|
assert.match(
|
||||||
|
fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"),
|
||||||
|
new RegExp(`^CODEX_HOME=${next}$`, "m")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a home change asked by another site", async () => {
|
||||||
|
const next = path.join(ROOT, "codex-evil");
|
||||||
|
fs.mkdirSync(next);
|
||||||
|
const res = await request(
|
||||||
|
"PUT",
|
||||||
|
"/api/settings/codex-home",
|
||||||
|
{ path: next },
|
||||||
|
{ Origin: "https://evil.example" }
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.notEqual(process.env.CODEX_HOME, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a CLAUDE_HOME change names the lanes whose router the new store lacks", async () => {
|
||||||
|
const next = path.join(ROOT, "claude-b");
|
||||||
|
fs.mkdirSync(next);
|
||||||
|
const res = await request("PUT", "/api/settings/claude-home", { path: next });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.routers.missing.length, 1);
|
||||||
|
assert.match(res.body.routers.missing[0], /\(c\): 9router/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests server/lib/settings-env.js — where a setting saved from the
|
||||||
|
* dashboard persists (a file outside the repo and outside CLAUDE_HOME, so a
|
||||||
|
* plugin update cannot wipe it), the startup load order (real env, then the
|
||||||
|
* checkout's `.env`, both owned, then the settings file), a save replacing
|
||||||
|
* every earlier line for its key and writing through a symlink, and the one
|
||||||
|
* path check both editable homes share. Every file lives in a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { describe, it, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-settings-env-"));
|
||||||
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const envSettings = require("../lib/settings-env");
|
||||||
|
|
||||||
|
describe("settings-env", () => {
|
||||||
|
let n = 0;
|
||||||
|
let file;
|
||||||
|
let repoEnv;
|
||||||
|
let env;
|
||||||
|
beforeEach(() => {
|
||||||
|
n += 1;
|
||||||
|
file = path.join(ROOT, `cfg${n}`, "agenthub", "settings.env");
|
||||||
|
repoEnv = path.join(ROOT, `repo${n}.env`);
|
||||||
|
env = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves its file from AGENTHUB_SETTINGS_FILE, else XDG_CONFIG_HOME, else ~/.config", () => {
|
||||||
|
assert.equal(envSettings.settingsFilePath({ AGENTHUB_SETTINGS_FILE: "/x/s.env" }), "/x/s.env");
|
||||||
|
assert.equal(
|
||||||
|
envSettings.settingsFilePath({ XDG_CONFIG_HOME: "/cfg" }),
|
||||||
|
path.join("/cfg", "agenthub", "settings.env")
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
envSettings.settingsFilePath({}),
|
||||||
|
path.join(os.homedir(), ".config", "agenthub", "settings.env")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes one line per key, replacing in place, with no blank-line growth, at 0600", () => {
|
||||||
|
envSettings.writeSetting("CLAUDE_HOME", "/a", file);
|
||||||
|
envSettings.writeSetting("CODEX_HOME", "/b", file);
|
||||||
|
envSettings.writeSetting("CLAUDE_HOME", "/c", file);
|
||||||
|
envSettings.writeSetting("CLAUDE_HOME", "/d", file);
|
||||||
|
assert.equal(fs.readFileSync(file, "utf8"), "CLAUDE_HOME=/d\nCODEX_HOME=/b\n");
|
||||||
|
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||||
|
assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the real env first, then the checkout's .env (owned), then the settings file", () => {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
"CLAUDE_HOME=/from-settings\nCODEX_HOME=~/codex\nDASHBOARD_DATA_DIR=/live\n# note\n"
|
||||||
|
);
|
||||||
|
fs.writeFileSync(repoEnv, 'DASHBOARD_DATA_DIR=/dev-data\nOTHER="quoted"\nCODEX_HOME=/repo\n');
|
||||||
|
env.CODEX_HOME = "/from-env";
|
||||||
|
const owned = envSettings.loadEnvFiles({ env, owned: [repoEnv], files: [file] });
|
||||||
|
assert.equal(env.CODEX_HOME, "/from-env");
|
||||||
|
// A dev checkout's own data dir wins over the one the dashboard pinned.
|
||||||
|
assert.equal(env.DASHBOARD_DATA_DIR, "/dev-data");
|
||||||
|
assert.equal(env.CLAUDE_HOME, "/from-settings");
|
||||||
|
assert.equal(env.OTHER, "quoted");
|
||||||
|
assert.ok(owned.has("CODEX_HOME"));
|
||||||
|
assert.ok(owned.has("DASHBOARD_DATA_DIR"), "a .env key is the environment's");
|
||||||
|
assert.ok(!owned.has("CLAUDE_HOME"));
|
||||||
|
assert.equal(env.AGENTHUB_FILE_KEYS, "CLAUDE_HOME");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the settings file a .env names in AGENTHUB_SETTINGS_FILE", () => {
|
||||||
|
const other = path.join(ROOT, "other", "settings.env");
|
||||||
|
fs.mkdirSync(path.dirname(other), { recursive: true });
|
||||||
|
fs.writeFileSync(other, "CLAUDE_HOME=/from-other\n");
|
||||||
|
fs.writeFileSync(repoEnv, `AGENTHUB_SETTINGS_FILE=${other}\n`);
|
||||||
|
delete env.AGENTHUB_SETTINGS_FILE;
|
||||||
|
envSettings.loadEnvFiles({ env, owned: [repoEnv] });
|
||||||
|
assert.equal(env.CLAUDE_HOME, "/from-other");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a hand-written `KEY = value` line, and writes through a symlink", () => {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, "# mine\nCODEX_HOME = /old\nCODEX_HOME=/older\nX=1\n");
|
||||||
|
envSettings.writeSetting("CODEX_HOME", "/new", file);
|
||||||
|
assert.equal(fs.readFileSync(file, "utf8"), "# mine\nCODEX_HOME=/new\nX=1\n");
|
||||||
|
const real = path.join(ROOT, `dotfiles${n}.env`);
|
||||||
|
fs.renameSync(file, real);
|
||||||
|
fs.symlinkSync(real, file);
|
||||||
|
envSettings.writeSetting("CODEX_HOME", "/newer", file);
|
||||||
|
assert.ok(fs.lstatSync(file).isSymbolicLink());
|
||||||
|
assert.match(fs.readFileSync(real, "utf8"), /CODEX_HOME=\/newer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands ~/ in a loaded value and skips missing files", () => {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, "CODEX_HOME=~/codex\n");
|
||||||
|
envSettings.loadEnvFiles({ env, files: [path.join(ROOT, "missing.env"), file] });
|
||||||
|
assert.equal(env.CODEX_HOME, path.join(os.homedir(), "codex"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the file-loaded keys, and a successor inheriting them re-reads the file", () => {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, "CLAUDE_HOME=/saved\n");
|
||||||
|
env.CODEX_HOME = "/from-env";
|
||||||
|
envSettings.loadEnvFiles({ env, files: [file] });
|
||||||
|
assert.equal(env.AGENTHUB_FILE_KEYS, "CLAUDE_HOME");
|
||||||
|
// A self-restart hands this env on; the file changed meanwhile.
|
||||||
|
fs.writeFileSync(file, "CLAUDE_HOME=/saved-later\n");
|
||||||
|
const owned = envSettings.loadEnvFiles({ env, files: [file] });
|
||||||
|
assert.ok(!owned.has("CLAUDE_HOME"), "a file-loaded key is not the real env's");
|
||||||
|
assert.ok(owned.has("CODEX_HOME"));
|
||||||
|
assert.equal(env.CLAUDE_HOME, "/saved-later");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a value with a line break, which would inject a second key", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => envSettings.writeSetting("CLAUDE_HOME", "/a\nNODE_OPTIONS=x", file),
|
||||||
|
/line break/
|
||||||
|
);
|
||||||
|
assert.ok(!fs.existsSync(file));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateHomeDir", () => {
|
||||||
|
const dir = () => fs.mkdtempSync(path.join(ROOT, "home-"));
|
||||||
|
|
||||||
|
for (const bad of ["/", "/.", os.homedir(), `${os.homedir()}/`, "~/"]) {
|
||||||
|
it(`refuses ${JSON.stringify(bad)} as too broad`, () => {
|
||||||
|
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", bad), { code: "EBADHOME" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("refuses a parent of the home dir, and a symlink resolving to one", () => {
|
||||||
|
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", path.dirname(os.homedir())), {
|
||||||
|
code: "EBADHOME",
|
||||||
|
});
|
||||||
|
const link = path.join(ROOT, "link-to-root");
|
||||||
|
fs.symlinkSync("/", link);
|
||||||
|
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", link), { code: "EBADHOME" });
|
||||||
|
const homeLink = path.join(ROOT, "link-to-home");
|
||||||
|
fs.symlinkSync(os.homedir(), homeLink);
|
||||||
|
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", homeLink), {
|
||||||
|
code: "EBADHOME",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a relative path, a missing path and a file", () => {
|
||||||
|
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", "rel"), /absolute path/);
|
||||||
|
assert.throws(
|
||||||
|
() => envSettings.validateHomeDir("CODEX_HOME", path.join(ROOT, "nope")),
|
||||||
|
/does not exist/
|
||||||
|
);
|
||||||
|
const f = path.join(ROOT, "a-file");
|
||||||
|
fs.writeFileSync(f, "");
|
||||||
|
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", f), /Not a directory/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the normalized absolute path, trimmed", () => {
|
||||||
|
const d = dir();
|
||||||
|
assert.equal(envSettings.validateHomeDir("CODEX_HOME", ` ${d}/./ `), d);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses control characters with INVALID_PATH", () => {
|
||||||
|
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", `${dir()}\nX=1`), {
|
||||||
|
code: "INVALID_PATH",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1081,6 +1081,46 @@ try {
|
|||||||
}
|
}
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source)`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source)`);
|
||||||
|
|
||||||
|
// Migrate: add `tool` to sessions — the AI coding CLI that produced it. Every
|
||||||
|
// hook-created and imported row is Claude Code's ('claude', the default); a
|
||||||
|
// Codex session is ingested from its rollout journals (server/lib/codex-usage.js)
|
||||||
|
// with 'codex'. Surfaces that act on a session through the `claude` CLI
|
||||||
|
// (attach, resume) list only 'claude' rows.
|
||||||
|
try {
|
||||||
|
db.prepare("SELECT tool FROM sessions LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
db.prepare("ALTER TABLE sessions ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
||||||
|
// In the DB, not a side file, so a session the user deleted stays deleted
|
||||||
|
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS codex_rollouts (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
thread_id TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
offset INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_codex_rollouts_session ON codex_rollouts(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_codex_rollouts_thread ON codex_rollouts(thread_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Which Antigravity CLI conversation databases have been read, at what mtime
|
||||||
|
// (server/lib/antigravity-usage.js) — the same "deleted stays deleted" rule.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS antigravity_conversations (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// Remote data sources: other machines whose Claude Code history this dashboard
|
// Remote data sources: other machines whose Claude Code history this dashboard
|
||||||
// pulls in over SSH. Config only — NO secrets are stored here: authentication
|
// pulls in over SSH. Config only — NO secrets are stored here: authentication
|
||||||
// always defers to the host's own SSH stack (~/.ssh/config, ssh-agent, keys,
|
// always defers to the host's own SSH stack (~/.ssh/config, ssh-agent, keys,
|
||||||
@@ -1286,12 +1326,14 @@ try {
|
|||||||
// Remote-source sessions (source != 'local') are exempt: their "last event" is bounded by
|
// Remote-source sessions (source != 'local') are exempt: their "last event" is bounded by
|
||||||
// the rsync cadence, not the remote CLI's actual activity, so a busy remote session could be
|
// the rsync cadence, not the remote CLI's actual activity, so a busy remote session could be
|
||||||
// wrongly completed here. server/lib/remote-sync.js owns their status via mirror reconciliation.
|
// wrongly completed here. server/lib/remote-sync.js owns their status via mirror reconciliation.
|
||||||
|
// Codex sessions are exempt too: server/lib/codex-usage.js derives theirs from the journal.
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE sessions SET
|
UPDATE sessions SET
|
||||||
status = 'completed',
|
status = 'completed',
|
||||||
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
|
AND tool = 'claude'
|
||||||
AND (source = 'local' OR source IS NULL)
|
AND (source = 'local' OR source IS NULL)
|
||||||
AND started_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
AND started_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -1493,6 +1535,10 @@ const stmts = {
|
|||||||
AND updated_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' minutes')`
|
AND updated_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' minutes')`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// A session's newest event (Antigravity: a turn after a Stop, a quiet session).
|
||||||
|
lastEvent: db.prepare(
|
||||||
|
"SELECT event_type, created_at FROM events WHERE session_id = ? ORDER BY id DESC LIMIT 1"
|
||||||
|
),
|
||||||
// A second delivery of one hook: the session's LATEST event, a few seconds
|
// A second delivery of one hook: the session's LATEST event, a few seconds
|
||||||
// old, with the same type and a byte-identical payload (the duplicate guard
|
// old, with the same type and a byte-identical payload (the duplicate guard
|
||||||
// in routes/hooks.js). Latest only, never "any recent": two turns can end in
|
// in routes/hooks.js). Latest only, never "any recent": two turns can end in
|
||||||
|
|||||||
+42
-22
@@ -20,28 +20,19 @@ for (const k of Object.keys(process.env)) {
|
|||||||
// real editor setting is anything else and stays.
|
// real editor setting is anything else and stays.
|
||||||
if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
|
if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
|
||||||
|
|
||||||
// Load .env file (simple key=value, no external dependency needed)
|
// Load the repo `.env` (simple key=value), then persisted settings. The real
|
||||||
(function loadDotEnv() {
|
// environment always wins, then a checkout's own `.env`; a dashboard-saved
|
||||||
const fs = require("fs");
|
// value (CLAUDE_HOME, CODEX_HOME, the pinned DASHBOARD_DATA_DIR) lives in the
|
||||||
const os = require("os");
|
// settings file, outside the plugin cache an update replaces — see
|
||||||
const envPath = require("path").resolve(__dirname, "..", ".env");
|
// server/lib/settings-env.js. Only when this
|
||||||
if (!fs.existsSync(envPath)) return;
|
// file IS the process: the test suite requires it dozens of times, and a
|
||||||
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
// developer's real settings file pins DASHBOARD_DATA_DIR at their live database.
|
||||||
const trimmed = line.trim();
|
if (require.main === module) {
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
const settingsEnv = require("./lib/settings-env");
|
||||||
const eqIdx = trimmed.indexOf("=");
|
settingsEnv.loadEnvFiles({
|
||||||
if (eqIdx === -1) continue;
|
owned: [require("path").resolve(__dirname, "..", ".env")],
|
||||||
const key = trimmed.slice(0, eqIdx).trim();
|
});
|
||||||
let val = trimmed.slice(eqIdx + 1).trim();
|
}
|
||||||
// Strip surrounding quotes (single or double)
|
|
||||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
||||||
val = val.slice(1, -1);
|
|
||||||
}
|
|
||||||
if (!process.env[key]) {
|
|
||||||
process.env[key] = val.replace(/^~(?=\/)/, os.homedir());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Carry a pre-rebrand `~/.claude/agent-dashboard` over to `~/.claude/agenthub`.
|
// Carry a pre-rebrand `~/.claude/agent-dashboard` over to `~/.claude/agenthub`.
|
||||||
//
|
//
|
||||||
@@ -513,6 +504,35 @@ function startBackgroundServices() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("codex poll failed to start:", err.message);
|
console.warn("codex poll failed to start:", err.message);
|
||||||
}
|
}
|
||||||
|
// Codex sessions and token usage, from the same journals (codex-usage.js):
|
||||||
|
// a first pass over every rollout in the background, then the same beat.
|
||||||
|
try {
|
||||||
|
require("./lib/codex-usage").startCodexUsagePoll(broadcast);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("codex usage poll failed to start:", err.message);
|
||||||
|
}
|
||||||
|
// Antigravity CLI sessions and token usage, from agy's conversation files.
|
||||||
|
try {
|
||||||
|
const { broadcastLane } = require("./routes/lanes");
|
||||||
|
require("./lib/antigravity-usage").startAntigravityUsagePoll(broadcast, broadcastLane);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("antigravity usage poll failed to start:", err.message);
|
||||||
|
}
|
||||||
|
// Codex/Antigravity hooks the user installed forward through a script whose
|
||||||
|
// content names this build; point it here (the command in the tool's
|
||||||
|
// hooks.json, which Codex trusts, stays the same).
|
||||||
|
for (const [name, mod] of [
|
||||||
|
["codex-hooks", "./lib/codex-hooks"],
|
||||||
|
["antigravity-hooks", "./lib/antigravity-hooks"],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
const refreshed = require(mod).refreshScript();
|
||||||
|
if (refreshed === "written") console.log(`[${name}] forwarder updated to this build`);
|
||||||
|
if (refreshed === "failed") console.warn(`[${name}] could not update the forwarder`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${name} forwarder refresh failed:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
// A codex/antigravity pane that exits fires no hook, so release its lane on
|
// A codex/antigravity pane that exits fires no hook, so release its lane on
|
||||||
// a beat instead (one `tmux has-session` per lane with a run). Off with
|
// a beat instead (one `tmux has-session` per lane with a run). Off with
|
||||||
// DASHBOARD_RUN_HEAL_MS=0.
|
// DASHBOARD_RUN_HEAL_MS=0.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @file Where Antigravity's CLI (`agy`) keeps its data. `agy` has no home
|
||||||
|
* override of its own — everything is under `~/.gemini`: its conversations and
|
||||||
|
* transcripts in `antigravity-cli/`, and the user's global hooks in
|
||||||
|
* `config/hooks.json`, shared with the Antigravity IDE. `AGENTHUB_GEMINI_DIR`
|
||||||
|
* points AgentHub elsewhere (tests; a pane whose HOME differs). `agyCliDirs`
|
||||||
|
* also names the resolved path when a symlink leads elsewhere, since agy may
|
||||||
|
* report either in a hook.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
/** `~/.gemini`, or `AGENTHUB_GEMINI_DIR`; read at call time. */
|
||||||
|
function getGeminiDir() {
|
||||||
|
return process.env.AGENTHUB_GEMINI_DIR || path.join(os.homedir(), ".gemini");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CLI's own data: `conversations/<id>.db`, `brain/<id>/…`. */
|
||||||
|
function getAgyCliDir() {
|
||||||
|
return path.join(getGeminiDir(), "antigravity-cli");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CLI dir as configured and, when a symlink leads elsewhere, as resolved. */
|
||||||
|
function agyCliDirs() {
|
||||||
|
const raw = path.resolve(getAgyCliDir());
|
||||||
|
let real = raw;
|
||||||
|
try {
|
||||||
|
real = fs.realpathSync(raw);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
real = path.join(fs.realpathSync(getGeminiDir()), "antigravity-cli");
|
||||||
|
} catch {
|
||||||
|
/* neither exists yet */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return real === raw ? [raw] : [raw, real];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getGeminiDir, getAgyCliDir, agyCliDirs };
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* @file AgentHub's hooks in Antigravity (`~/.gemini/config/hooks.json`, the
|
||||||
|
* global file `agy` and the Antigravity IDE both load), installed and removed
|
||||||
|
* only on the user's click. The file maps hook NAMES to their events; ours is
|
||||||
|
* the one named `agenthub`, and every other name is left as it is.
|
||||||
|
*
|
||||||
|
* Only three events, all observational: `PreInvocation` (a model call starts
|
||||||
|
* — the first of a run is the user's prompt), `PostToolUse` and `Stop`.
|
||||||
|
* NEVER `PreToolUse`: its answer is a permission decision, and agy DENIES the
|
||||||
|
* tool when a PreToolUse hook answers `{}` (verified on 1.2.13) — `allow`
|
||||||
|
* would skip the user's own permission rules and `ask` would force a prompt,
|
||||||
|
* so there is no answer that leaves agy's behaviour alone. agy has no hook
|
||||||
|
* for a permission prompt either, so AgentHub cannot show one.
|
||||||
|
*
|
||||||
|
* The command is a stable path, `'<gemini>/config/agenthub-hook.sh' <Event>`;
|
||||||
|
* the script runs this build's `scripts/hook-handler.js <Event> antigravity`
|
||||||
|
* with the server's node and CLAUDE_HOME, prints the `{}` agy reads, and every
|
||||||
|
* server start rewrites it when stale — same scheme as codex-hooks.js, though
|
||||||
|
* agy asks for no trust. A script there that is not ours is never overwritten
|
||||||
|
* or removed (`EFOREIGN`), and hooks.json keeps its mode.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { getGeminiDir } = require("./antigravity-home");
|
||||||
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
const { shq, writeAtomic, scriptIsOurs, isLink } = require("./codex-hooks");
|
||||||
|
|
||||||
|
const HOOK_NAME = "agenthub";
|
||||||
|
const HOOK_EVENTS = ["PreInvocation", "PostToolUse", "Stop"];
|
||||||
|
const TOOL_EVENTS = new Set(["PostToolUse"]); // take {matcher, hooks} groups
|
||||||
|
const TIMEOUT_S = 5; // the handler gives up after 2.5 s
|
||||||
|
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
||||||
|
const MARKER = "# AgentHub's Antigravity hook forwarder.";
|
||||||
|
|
||||||
|
const configDir = () => path.join(getGeminiDir(), "config");
|
||||||
|
const hooksPath = () => path.join(configDir(), "hooks.json");
|
||||||
|
const scriptPath = () => path.join(configDir(), "agenthub-hook.sh");
|
||||||
|
const commandFor = (event) => `${shq(scriptPath())} ${event}`;
|
||||||
|
|
||||||
|
function scriptBody() {
|
||||||
|
return [
|
||||||
|
"#!/bin/sh",
|
||||||
|
`${MARKER} Rewritten by AgentHub on every start.`,
|
||||||
|
`N=${shq(process.execPath)}; [ -x "$N" ] || N=node`,
|
||||||
|
`CLAUDE_HOME=${shq(getClaudeHome())} "$N" ${shq(HANDLER)} "$1" antigravity >/dev/null 2>&1`,
|
||||||
|
"# agy reads a JSON answer; an empty one changes nothing for these events.",
|
||||||
|
"echo '{}'",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(code, message) {
|
||||||
|
return Object.assign(new Error(message), { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hooks.json as an object, `{}` when absent; throws EBADJSON otherwise. */
|
||||||
|
function readHooks() {
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(hooksPath(), "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOENT") return {};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
file = null;
|
||||||
|
}
|
||||||
|
if (!file || typeof file !== "object" || Array.isArray(file)) {
|
||||||
|
throw fail("EBADJSON", `${hooksPath()} is not a JSON object; fix or move it first`);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupported() {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
throw fail("EUNSUPPORTED", "Antigravity hooks from AgentHub need a POSIX shell");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ourDefinition() {
|
||||||
|
const def = { enabled: true };
|
||||||
|
for (const event of HOOK_EVENTS) {
|
||||||
|
const handler = { type: "command", command: commandFor(event), timeout: TIMEOUT_S };
|
||||||
|
def[event] = TOOL_EVENTS.has(event) ? [{ matcher: ".*", hooks: [handler] }] : [handler];
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install (or reinstall) AgentHub's named hook. @returns the new status. */
|
||||||
|
function install() {
|
||||||
|
assertSupported();
|
||||||
|
const file = readHooks();
|
||||||
|
if (!scriptIsOurs(scriptPath(), MARKER)) {
|
||||||
|
throw fail("EFOREIGN", `${scriptPath()} is not AgentHub's; move it first`);
|
||||||
|
}
|
||||||
|
file[HOOK_NAME] = ourDefinition();
|
||||||
|
fs.mkdirSync(configDir(), { recursive: true });
|
||||||
|
writeAtomic(scriptPath(), scriptBody(), 0o755);
|
||||||
|
writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||||
|
return status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take AgentHub's named hook out; every other one stays. @returns the new status. */
|
||||||
|
function remove() {
|
||||||
|
const file = readHooks();
|
||||||
|
if (HOOK_NAME in file) {
|
||||||
|
delete file[HOOK_NAME];
|
||||||
|
if (Object.keys(file).length === 0 && !isLink(hooksPath()))
|
||||||
|
fs.rmSync(hooksPath(), { force: true });
|
||||||
|
else writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||||
|
}
|
||||||
|
if (scriptIsOurs(scriptPath(), MARKER)) fs.rmSync(scriptPath(), { force: true });
|
||||||
|
return status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `list` (a flat handler list, or {matcher, hooks} groups) runs our command. */
|
||||||
|
function runsOurs(list, event) {
|
||||||
|
if (!Array.isArray(list)) return false;
|
||||||
|
return list.some((h) =>
|
||||||
|
Array.isArray(h?.hooks)
|
||||||
|
? h.hooks.some((x) => x?.command === commandFor(event))
|
||||||
|
: h?.command === commandFor(event)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per event: is our handler there; plus whether our hook is switched off. */
|
||||||
|
function status() {
|
||||||
|
const base = {
|
||||||
|
supported: process.platform !== "win32",
|
||||||
|
path: hooksPath(),
|
||||||
|
script: scriptPath(),
|
||||||
|
};
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = readHooks();
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
readable: false,
|
||||||
|
error: err.message,
|
||||||
|
installed: false,
|
||||||
|
enabled: false,
|
||||||
|
events: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ours = file[HOOK_NAME] && typeof file[HOOK_NAME] === "object" ? file[HOOK_NAME] : {};
|
||||||
|
const events = {};
|
||||||
|
for (const event of HOOK_EVENTS) events[event] = { installed: runsOurs(ours[event], event) };
|
||||||
|
const installed = HOOK_EVENTS.every((e) => events[e].installed) && fs.existsSync(scriptPath());
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
readable: true,
|
||||||
|
installed,
|
||||||
|
enabled: installed && ours.enabled !== false,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite the forwarder when it is installed and stale or lost its exec bit.
|
||||||
|
* Never creates it, and never touches a file by that name that is not ours.
|
||||||
|
* @returns {"absent"|"foreign"|"exists"|"written"|"failed"}
|
||||||
|
*/
|
||||||
|
function refreshScript() {
|
||||||
|
try {
|
||||||
|
const current = fs.readFileSync(scriptPath(), "utf8");
|
||||||
|
if (!current.split("\n", 2)[1]?.startsWith(MARKER)) return "foreign";
|
||||||
|
const executable = (fs.statSync(scriptPath()).mode & 0o111) !== 0;
|
||||||
|
if (current === scriptBody() && executable) return "exists";
|
||||||
|
writeAtomic(scriptPath(), scriptBody(), 0o755);
|
||||||
|
return "written";
|
||||||
|
} catch (err) {
|
||||||
|
return err.code === "ENOENT" ? "absent" : "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { HOOK_NAME, HOOK_EVENTS, commandFor, install, remove, status, refreshScript };
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* @file Antigravity CLI sessions and token usage, read from `agy`'s own
|
||||||
|
* per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/
|
||||||
|
* <conversation id>.db`). This is how an Antigravity session reaches
|
||||||
|
* `sessions` (as `tool = 'antigravity'`) and its tokens reach `token_usage`;
|
||||||
|
* AgentHub's Antigravity hooks, when the user installs them, add its events and
|
||||||
|
* a lane's live status but no tokens.
|
||||||
|
*
|
||||||
|
* What a conversation file holds (agy 1.2.x, verified against the binary's own
|
||||||
|
* proto descriptors — nothing here is a documented format):
|
||||||
|
* - its file name is the conversation id the hooks report;
|
||||||
|
* - `trajectory_metadata_blob` — protobuf, whose first `file://` URI is the
|
||||||
|
* workspace the conversation ran in;
|
||||||
|
* - one `gen_metadata` row per model call — protobuf, field 1 a message whose
|
||||||
|
* field 19 is the model name and field 4 `ModelUsageStats`: 2 input (NOT
|
||||||
|
* including cache reads), 3 output (INCLUDING thinking), 4 cache write,
|
||||||
|
* 5 cache read.
|
||||||
|
* The session's name and start come from its transcript
|
||||||
|
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
|
||||||
|
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
|
||||||
|
* `-wal`'s) changes — they are small — and the mtime per file lives in
|
||||||
|
* `antigravity_conversations`, so a session the user deleted stays deleted
|
||||||
|
* until agy writes to it again. Opening read-only still lets SQLite leave an
|
||||||
|
* EMPTY `-wal` and a `-shm` beside a cleanly closed file, which is why an empty
|
||||||
|
* `-wal` is not activity. A file that is not agy's is recorded too (with no
|
||||||
|
* session), and tried again only once it changes or the server restarts.
|
||||||
|
*
|
||||||
|
* The status is the session's last activity — its file, its transcript and its
|
||||||
|
* newest hook event: `agy` fires no hook when it exits, nor when a run ends on
|
||||||
|
* an API error or a permission headless mode refused, so a session gone quiet
|
||||||
|
* settles to completed (its agents too) and sets its lane idle, when the hooks
|
||||||
|
* left it running and no dashboard pane owns its status. Quiet is 10 minutes
|
||||||
|
* for a session the hooks report on (a long tool call writes nothing until it
|
||||||
|
* ends; after a Stop agy may be waiting for the user, and it fires no hook on
|
||||||
|
* exit), else 2 minutes of file activity. An error Stop's `error` is kept.
|
||||||
|
* Only the CLI's directory is read, never the Antigravity IDE's.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const { db, stmts } = require("../db");
|
||||||
|
const { getAgyCliDir } = require("./antigravity-home");
|
||||||
|
const pricingSync = require("./pricing-sync");
|
||||||
|
|
||||||
|
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||||
|
const HOOK_QUIET_MS = 600_000; // with hooks: a tool call may run long, a prompt may wait
|
||||||
|
const FUTURE_SLACK_MS = 1000;
|
||||||
|
const FILE_NAME = /^([0-9a-f-]{36})\.db$/;
|
||||||
|
const NOT_AGYS = /SQLITE_NOTADB|SQLITE_CORRUPT|no such table/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A protobuf message's fields as `[number, wireType, value]` — a varint as a
|
||||||
|
* Number (exact below 2^53, which every count is), a length-delimited field as
|
||||||
|
* a Buffer. Throws on a malformed buffer.
|
||||||
|
*/
|
||||||
|
function pbFields(buf) {
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
const varint = () => {
|
||||||
|
let r = 0;
|
||||||
|
for (let s = 0; ; s += 7) {
|
||||||
|
if (i >= buf.length || s > 63) throw new Error("bad varint");
|
||||||
|
const b = buf[i++];
|
||||||
|
r += (b & 0x7f) * 2 ** s;
|
||||||
|
if (b < 0x80) return r;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while (i < buf.length) {
|
||||||
|
const key = varint();
|
||||||
|
const field = Math.floor(key / 8);
|
||||||
|
const wire = key % 8;
|
||||||
|
if (field === 0) throw new Error("field 0");
|
||||||
|
if (wire === 0) out.push([field, 0, varint()]);
|
||||||
|
else if (wire === 1) i += 8;
|
||||||
|
else if (wire === 5) i += 4;
|
||||||
|
else if (wire === 2) {
|
||||||
|
const n = varint();
|
||||||
|
if (i + n > buf.length) throw new Error("truncated");
|
||||||
|
out.push([field, 2, buf.subarray(i, i + n)]);
|
||||||
|
i += n;
|
||||||
|
} else throw new Error(`wire type ${wire}`);
|
||||||
|
}
|
||||||
|
if (i > buf.length) throw new Error("truncated");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = (fields, n, wire) => fields.find((f) => f[0] === n && f[1] === wire)?.[2];
|
||||||
|
|
||||||
|
/** One `gen_metadata` row → `{model, input, output, cacheRead, cacheWrite}`, or null. */
|
||||||
|
function generationUsage(buf) {
|
||||||
|
try {
|
||||||
|
const gen = field(pbFields(buf), 1, 2);
|
||||||
|
if (!gen) return null;
|
||||||
|
const g = pbFields(gen);
|
||||||
|
const stats = field(g, 4, 2);
|
||||||
|
if (!stats) return null;
|
||||||
|
const s = pbFields(stats);
|
||||||
|
const n = (k) => field(s, k, 0) || 0;
|
||||||
|
if (field(s, 2, 0) === undefined && field(s, 3, 0) === undefined) return null;
|
||||||
|
const name = field(g, 19, 2)?.toString("utf8");
|
||||||
|
return {
|
||||||
|
model: name && /^[\w.:/-]{1,100}$/.test(name) ? name : "unknown",
|
||||||
|
input: n(2),
|
||||||
|
output: n(3),
|
||||||
|
cacheWrite: n(4),
|
||||||
|
cacheRead: n(5),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first `file://` URI anywhere in a protobuf blob, as a path; null otherwise. */
|
||||||
|
function firstFileUri(buf, depth = 0) {
|
||||||
|
let fields;
|
||||||
|
try {
|
||||||
|
fields = pbFields(buf);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const [, wire, v] of fields) {
|
||||||
|
if (wire !== 2) continue;
|
||||||
|
if (v.subarray(0, 7).toString("latin1") === "file://") {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(new URL(v.toString("utf8")).pathname) || null;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth < 6) {
|
||||||
|
const found = firstFileUri(v, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptFiles = (id) => {
|
||||||
|
const dir = path.join(getAgyCliDir(), "brain", id, ".system_generated", "logs");
|
||||||
|
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The transcript's last write, 0 when it has none. */
|
||||||
|
function transcriptMtime(id) {
|
||||||
|
for (const file of transcriptFiles(id)) {
|
||||||
|
try {
|
||||||
|
return Math.floor(fs.statSync(file).mtimeMs);
|
||||||
|
} catch {
|
||||||
|
/* try the next name */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name (first prompt line, 60 chars) and start from the transcript's first user step. */
|
||||||
|
function transcriptFacts(id) {
|
||||||
|
for (const file of transcriptFiles(id)) {
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(file, "r");
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 1024 * 1024));
|
||||||
|
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||||
|
text = buf.toString("utf8");
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const facts = { startedAt: null, firstPrompt: null };
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
let o;
|
||||||
|
try {
|
||||||
|
o = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
facts.startedAt ||= typeof o.created_at === "string" ? o.created_at : null;
|
||||||
|
if (o.type === "USER_INPUT" && typeof o.content === "string") {
|
||||||
|
const req = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/.exec(o.content);
|
||||||
|
const prompt = (req ? req[1] : o.content).trim();
|
||||||
|
if (prompt) {
|
||||||
|
facts.firstPrompt = prompt.split("\n")[0].slice(0, 60);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return facts;
|
||||||
|
}
|
||||||
|
return { startedAt: null, firstPrompt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything AgentHub reads from one conversation file. */
|
||||||
|
function readConversation(file) {
|
||||||
|
const conn = new Database(file, { readonly: true, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
|
||||||
|
const usage = {};
|
||||||
|
let model = null;
|
||||||
|
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
|
||||||
|
const u = data && generationUsage(data);
|
||||||
|
if (!u) continue;
|
||||||
|
model = u.model;
|
||||||
|
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||||
|
for (const k of Object.keys(t)) t[k] += u[k];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cwd: blob?.data ? firstFileUri(blob.data) : null,
|
||||||
|
usage,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The conversation files, each with the newer mtime of the file and its non-empty `-wal`. */
|
||||||
|
function conversationFiles() {
|
||||||
|
const dir = path.join(getAgyCliDir(), "conversations");
|
||||||
|
let names;
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const m = FILE_NAME.exec(name);
|
||||||
|
if (!m) continue;
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
try {
|
||||||
|
let mtime = fs.statSync(file).mtimeMs;
|
||||||
|
try {
|
||||||
|
// Our own read-only open leaves an empty one behind; it holds nothing.
|
||||||
|
const wal = fs.statSync(`${file}-wal`);
|
||||||
|
if (wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
|
||||||
|
} catch {
|
||||||
|
/* no WAL right now */
|
||||||
|
}
|
||||||
|
out.push({ path: file, id: m[1], mtime: Math.floor(mtime) });
|
||||||
|
} catch {
|
||||||
|
/* gone between readdir and stat */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeen = db.prepare("SELECT * FROM antigravity_conversations WHERE path = ?");
|
||||||
|
const upsertSeen = db.prepare(
|
||||||
|
`INSERT INTO antigravity_conversations (path, session_id, mtime_ms, updated_at)
|
||||||
|
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id,
|
||||||
|
mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
|
||||||
|
);
|
||||||
|
const insertSession = db.prepare(
|
||||||
|
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'antigravity')`
|
||||||
|
);
|
||||||
|
const updateSession = db.prepare(
|
||||||
|
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
|
||||||
|
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
|
||||||
|
WHERE id = ? AND tool = 'antigravity'`
|
||||||
|
);
|
||||||
|
const settle = db.prepare(
|
||||||
|
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'antigravity'"
|
||||||
|
);
|
||||||
|
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||||
|
const clearAwaiting = db.prepare(
|
||||||
|
"UPDATE sessions SET awaiting_input_since = NULL, awaiting_reason = NULL WHERE id = ? AND tool = 'antigravity'"
|
||||||
|
);
|
||||||
|
const stuckLanes = db.prepare(
|
||||||
|
`SELECT id FROM lanes WHERE tool = 'antigravity' AND session_id = ? AND status = 'running'
|
||||||
|
AND (run_id IS NULL OR run_id = '')`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The lanes a quiet session left `running` (no Stop came), set idle. */
|
||||||
|
function releaseLanes(sessionId, broadcastLane) {
|
||||||
|
for (const { id } of stuckLanes.all(sessionId)) {
|
||||||
|
try {
|
||||||
|
require("./lanes").updateLane(id, { status: "idle" });
|
||||||
|
broadcastLane(id);
|
||||||
|
} catch {
|
||||||
|
/* a lane is never worth failing the pass for */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session's last activity (file, transcript, newest hook) and its quiet
|
||||||
|
* window: 10 minutes for a session the hooks report on — mid-turn a long tool
|
||||||
|
* call writes nothing, and after a Stop agy may be waiting for the user (it
|
||||||
|
* fires no hook on exit, so an open agy and a closed one look the same) — else
|
||||||
|
* 2 minutes of file activity.
|
||||||
|
*/
|
||||||
|
function activity(id, fileMtime) {
|
||||||
|
const ev = stmts.lastEvent.get(id);
|
||||||
|
return {
|
||||||
|
last: Math.max(fileMtime, transcriptMtime(id), ev ? Date.parse(ev.created_at) || 0 : 0),
|
||||||
|
window: ev ? HOOK_QUIET_MS : ACTIVE_MS,
|
||||||
|
stopped: ev?.event_type === "Stop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write "in the future" (a clock that runs ahead) is not proof of activity.
|
||||||
|
const fresh = (a, now) => a.last - now < FUTURE_SLACK_MS && now - a.last < a.window;
|
||||||
|
|
||||||
|
/** A quiet session's agents, completed like a SessionEnd would. */
|
||||||
|
function completeAgents(sessionId, at, broadcast) {
|
||||||
|
for (const agent of stmts.listAgentsBySession.all(sessionId)) {
|
||||||
|
if (agent.status === "completed" || agent.status === "error") continue;
|
||||||
|
stmts.updateAgent.run(null, "completed", null, null, at, null, agent.id);
|
||||||
|
broadcast("agent_updated", stmts.getAgent.get(agent.id));
|
||||||
|
}
|
||||||
|
stmts.clearSessionAgentsAwaitingInput.run(sessionId);
|
||||||
|
clearAwaiting.run(sessionId); // not stmts' own: that one stamps updated_at = now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write one conversation's session row and usage. @returns the broadcast type, or null. */
|
||||||
|
function writeSession(f, conv, now) {
|
||||||
|
const id = f.id;
|
||||||
|
const existing = stmts.getSession.get(id);
|
||||||
|
if (existing && existing.tool !== "antigravity") return null;
|
||||||
|
const facts = transcriptFacts(id);
|
||||||
|
const a = activity(id, f.mtime);
|
||||||
|
const lastWrite = new Date(a.last).toISOString();
|
||||||
|
const active = fresh(a, now);
|
||||||
|
let status = active ? "active" : "completed";
|
||||||
|
let endedAt = active ? null : lastWrite;
|
||||||
|
// A run the hooks saw fail stays an error until the next turn.
|
||||||
|
if (existing?.status === "error" && a.stopped) ({ status, ended_at: endedAt } = existing);
|
||||||
|
const name =
|
||||||
|
facts.firstPrompt ||
|
||||||
|
(existing && !/^Antigravity [0-9a-f]{8}$/.test(existing.name) ? existing.name : null) ||
|
||||||
|
`Antigravity ${id.slice(0, 8)}`;
|
||||||
|
db.transaction(() => {
|
||||||
|
if (existing)
|
||||||
|
updateSession.run(
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
conv.model,
|
||||||
|
endedAt,
|
||||||
|
lastWrite,
|
||||||
|
conv.cwd,
|
||||||
|
facts.startedAt,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
else
|
||||||
|
insertSession.run(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
conv.cwd,
|
||||||
|
conv.model,
|
||||||
|
facts.startedAt || lastWrite,
|
||||||
|
endedAt,
|
||||||
|
lastWrite,
|
||||||
|
JSON.stringify({ source: "antigravity-conversation" })
|
||||||
|
);
|
||||||
|
// Every usage row of an Antigravity session is this module's: rewritten whole.
|
||||||
|
clearUsage.run(id);
|
||||||
|
for (const [m, t] of Object.entries(conv.usage)) {
|
||||||
|
stmts.setTokenUsage.run(
|
||||||
|
id,
|
||||||
|
m,
|
||||||
|
"standard",
|
||||||
|
"global",
|
||||||
|
"standard",
|
||||||
|
t.input,
|
||||||
|
t.output,
|
||||||
|
t.cacheRead,
|
||||||
|
t.cacheWrite,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return existing ? "session_updated" : "session_created";
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass over every conversation file: a changed one is re-read, an
|
||||||
|
* unchanged one only has an `active` session settled once it went quiet.
|
||||||
|
* @returns {Promise<{files: number, sessions: number}>}
|
||||||
|
*/
|
||||||
|
function ingestAntigravityUsage({
|
||||||
|
broadcast = () => {},
|
||||||
|
broadcastLane = () => {},
|
||||||
|
now = Date.now(),
|
||||||
|
} = {}) {
|
||||||
|
if (running) return running;
|
||||||
|
running = (async () => {
|
||||||
|
let read = 0;
|
||||||
|
let written = 0;
|
||||||
|
for (const f of conversationFiles()) {
|
||||||
|
try {
|
||||||
|
const seen = getSeen.get(f.path);
|
||||||
|
if (seen && seen.mtime_ms === f.mtime) {
|
||||||
|
const a = activity(f.id, f.mtime);
|
||||||
|
if (fresh(a, now)) continue;
|
||||||
|
const s = stmts.getSession.get(f.id);
|
||||||
|
if (s && s.tool === "antigravity" && s.status === "active") {
|
||||||
|
const at = new Date(a.last).toISOString();
|
||||||
|
settle.run(at, s.id);
|
||||||
|
completeAgents(s.id, at, broadcast);
|
||||||
|
broadcast("session_updated", stmts.getSession.get(s.id));
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
releaseLanes(f.id, broadcastLane);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let conv;
|
||||||
|
try {
|
||||||
|
conv = readConversation(f.path);
|
||||||
|
} catch (err) {
|
||||||
|
// Not agy's (or not yet): tried again once it changes, or after a
|
||||||
|
// restart (a newer AgentHub may read it). Anything else — a busy
|
||||||
|
// file, our own bug — is retried on the next pass.
|
||||||
|
if (NOT_AGYS.test(`${err.code} ${err.message}`)) upsertSeen.run(f.path, "", f.mtime);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
read += 1;
|
||||||
|
const type = writeSession(f, conv, now);
|
||||||
|
upsertSeen.run(f.path, f.id, f.mtime);
|
||||||
|
if (type) {
|
||||||
|
written += 1;
|
||||||
|
const s = stmts.getSession.get(f.id);
|
||||||
|
if (s?.status === "completed") {
|
||||||
|
completeAgents(f.id, s.ended_at, broadcast);
|
||||||
|
releaseLanes(f.id, broadcastLane);
|
||||||
|
}
|
||||||
|
broadcast(type, stmts.getSession.get(f.id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (getSeen.get(f.path)?.mtime_ms !== f.mtime) {
|
||||||
|
console.warn(`[antigravity-usage] skipped ${f.path}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
}
|
||||||
|
return { files: read, sessions: written };
|
||||||
|
})().finally(() => {
|
||||||
|
running = null;
|
||||||
|
});
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First pass at once (in the background), then every
|
||||||
|
* DASHBOARD_ANTIGRAVITY_POLL_MS (default 15 s; 0 disables).
|
||||||
|
*/
|
||||||
|
function startAntigravityUsagePoll(broadcast, broadcastLane) {
|
||||||
|
const POLL_MS = process.env.DASHBOARD_ANTIGRAVITY_POLL_MS
|
||||||
|
? Number(process.env.DASHBOARD_ANTIGRAVITY_POLL_MS)
|
||||||
|
: 15_000;
|
||||||
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||||
|
// Files an older build could not read get another try.
|
||||||
|
db.prepare("DELETE FROM antigravity_conversations WHERE session_id = ''").run();
|
||||||
|
const tick = () => {
|
||||||
|
ingestAntigravityUsage({ broadcast, broadcastLane })
|
||||||
|
.then((r) => {
|
||||||
|
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const timer = setInterval(tick, POLL_MS);
|
||||||
|
if (timer.unref) timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
pbFields,
|
||||||
|
generationUsage,
|
||||||
|
firstFileUri,
|
||||||
|
ingestAntigravityUsage,
|
||||||
|
startAntigravityUsagePoll,
|
||||||
|
};
|
||||||
+20
-45
@@ -273,55 +273,31 @@ function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CLAUDE_HOME at runtime. Updates process.env so getClaudeHome()
|
* Update CLAUDE_HOME at runtime: `process.env` changes at once and the value
|
||||||
* immediately returns the new value, and persists to .env file.
|
* persists to the settings file (`settings-env.js` — outside the repo, which a
|
||||||
* Returns the resolved absolute path.
|
* plugin update replaces, and outside CLAUDE_HOME). The path passes the shared
|
||||||
|
* `validateHomeDir` guard, which refuses anything that is or contains the
|
||||||
|
* user's home (`EBADHOME`).
|
||||||
|
*
|
||||||
|
* The data dir is derived from CLAUDE_HOME, so the first change pins the one
|
||||||
|
* in use as DASHBOARD_DATA_DIR (process + settings file) unless something
|
||||||
|
* already sets it: otherwise the next start would open an EMPTY database under
|
||||||
|
* the new home, and the plugin bootstrap and CLI — which never read the
|
||||||
|
* settings file — would disagree with the server about where its runtime is.
|
||||||
|
* The discovery file (`.agenthub.json`, server-info.js) is NOT pinned: it
|
||||||
|
* follows CLAUDE_HOME, so hooks and the CLI running under the default home
|
||||||
|
* find a dashboard on a non-default port only if they share its CLAUDE_HOME.
|
||||||
|
*
|
||||||
|
* @returns {string} the resolved absolute path.
|
||||||
*/
|
*/
|
||||||
function setClaudeHome(newPath) {
|
function setClaudeHome(newPath) {
|
||||||
const resolved = newPath.replace(/^~(?=\/)/, os.homedir());
|
const { validateHomeDir, applySetting } = require("./settings-env");
|
||||||
if (!path.isAbsolute(resolved)) {
|
const resolved = validateHomeDir("CLAUDE_HOME", newPath);
|
||||||
throw new Error("CLAUDE_HOME must be an absolute path");
|
if (!process.env.DASHBOARD_DATA_DIR) applySetting("DASHBOARD_DATA_DIR", getDataDir());
|
||||||
}
|
applySetting("CLAUDE_HOME", resolved);
|
||||||
if (!fs.existsSync(resolved)) {
|
|
||||||
throw new Error(`Directory does not exist: ${resolved}`);
|
|
||||||
}
|
|
||||||
const stat = fs.statSync(resolved);
|
|
||||||
if (!stat.isDirectory()) {
|
|
||||||
throw new Error(`Not a directory: ${resolved}`);
|
|
||||||
}
|
|
||||||
process.env.CLAUDE_HOME = resolved;
|
|
||||||
writeEnvFile("CLAUDE_HOME", resolved);
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Write or update a key=value line in the .env file.
|
|
||||||
* Creates the file if it doesn't exist.
|
|
||||||
*/
|
|
||||||
function writeEnvFile(key, value) {
|
|
||||||
const envPath = path.resolve(__dirname, "..", "..", ".env");
|
|
||||||
let lines = [];
|
|
||||||
if (fs.existsSync(envPath)) {
|
|
||||||
lines = fs.readFileSync(envPath, "utf8").split("\n");
|
|
||||||
}
|
|
||||||
let found = false;
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const trimmed = lines[i].trim();
|
|
||||||
if (trimmed.startsWith(`${key}=`)) {
|
|
||||||
lines[i] = `${key}=${value}`;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
lines.push(`${key}=${value}`);
|
|
||||||
}
|
|
||||||
// Write atomically: write to temp file then rename to prevent corruption
|
|
||||||
const tempPath = envPath + ".tmp";
|
|
||||||
fs.writeFileSync(tempPath, lines.join("\n") + "\n", "utf8");
|
|
||||||
fs.renameSync(tempPath, envPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getClaudeHome,
|
getClaudeHome,
|
||||||
getProjectsDir,
|
getProjectsDir,
|
||||||
@@ -337,5 +313,4 @@ module.exports = {
|
|||||||
findTranscriptPath,
|
findTranscriptPath,
|
||||||
findSubagentTranscriptPath,
|
findSubagentTranscriptPath,
|
||||||
setClaudeHome,
|
setClaudeHome,
|
||||||
writeEnvFile,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
* @file Centralized Codex home directory path management. Resolves the Codex
|
* @file Centralized Codex home directory path management. Resolves the Codex
|
||||||
* home directory, project-level Codex surfaces, and project roots. Supports a
|
* home directory, project-level Codex surfaces, and project roots. Supports a
|
||||||
* custom root via the CODEX_HOME environment variable so the dashboard can
|
* custom root via the CODEX_HOME environment variable so the dashboard can
|
||||||
* track non-standard Codex installations.
|
* track non-standard Codex installations, and changes it at runtime
|
||||||
|
* (`setCodexHome`, persisted to the settings file).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
|
|
||||||
@@ -19,6 +21,22 @@ function getCodexHome() {
|
|||||||
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CODEX_HOME as given and, when a symlink makes it differ, as resolved — so a
|
||||||
|
* path Codex reports (it may canonicalize) matches either.
|
||||||
|
*
|
||||||
|
* @returns {string[]} one or two absolute paths.
|
||||||
|
*/
|
||||||
|
function codexHomes() {
|
||||||
|
const raw = path.resolve(getCodexHome());
|
||||||
|
try {
|
||||||
|
const real = fs.realpathSync(raw);
|
||||||
|
return real === raw ? [raw] : [raw, real];
|
||||||
|
} catch {
|
||||||
|
return [raw];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The directory where Codex stores per-project surfaces (rollout journals,
|
* The directory where Codex stores per-project surfaces (rollout journals,
|
||||||
* SQLite mirrors, etc.) for a project at the given working directory.
|
* SQLite mirrors, etc.) for a project at the given working directory.
|
||||||
@@ -42,8 +60,27 @@ function getProjectRoot(cwd) {
|
|||||||
return cwd;
|
return cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update CODEX_HOME at runtime: `process.env` changes at once and the value
|
||||||
|
* persists to the settings file (see `settings-env.js`). The path passes the
|
||||||
|
* shared guard that refuses anything that is or contains the user's home
|
||||||
|
* (`EBADHOME`). A codex pane gets this value through tmux `-e` at its next
|
||||||
|
* start; one already running keeps the home it started with.
|
||||||
|
*
|
||||||
|
* @param {string} newPath absolute path, `~/` expanded.
|
||||||
|
* @returns {string} the resolved absolute path.
|
||||||
|
*/
|
||||||
|
function setCodexHome(newPath) {
|
||||||
|
const { validateHomeDir, applySetting } = require("./settings-env");
|
||||||
|
const resolved = validateHomeDir("CODEX_HOME", newPath);
|
||||||
|
applySetting("CODEX_HOME", resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
codexHomes,
|
||||||
getCodexHome,
|
getCodexHome,
|
||||||
|
setCodexHome,
|
||||||
getProjectCodexDir,
|
getProjectCodexDir,
|
||||||
getProjectRoot,
|
getProjectRoot,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* @file AgentHub's hooks in Codex (`$CODEX_HOME/hooks.json`), installed and
|
||||||
|
* removed only on the user's click. Codex runs a hook only once the user
|
||||||
|
* trusts its exact definition (Codex asks at startup, or `/hooks`), keyed on
|
||||||
|
* that definition's hash — so the command is a STABLE path,
|
||||||
|
* `'<CODEX_HOME>/agenthub-hook.sh' <Event>`, and what changes between AgentHub
|
||||||
|
* builds is only that script's content: it runs this build's
|
||||||
|
* `scripts/hook-handler.js <Event> codex` with the server's node and
|
||||||
|
* CLAUDE_HOME, and every server start rewrites it when stale. AgentHub never
|
||||||
|
* writes Codex's trust (`[hooks.state]` in `config.toml`) — it only reads it
|
||||||
|
* to report — and never passes `--dangerously-bypass-hook-trust`. A script
|
||||||
|
* at that path that is not AgentHub's is never overwritten or removed
|
||||||
|
* (`EFOREIGN`), and a rewritten hooks.json keeps the mode the user gave it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { atomicWriteFile } = require("./config-write");
|
||||||
|
const { getCodexHome, codexHomes } = require("./codex-home");
|
||||||
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
|
const HOOK_EVENTS = [
|
||||||
|
"SessionStart",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"Interrupt", // a declined permission or Esc ends the turn without a Stop
|
||||||
|
"SessionEnd",
|
||||||
|
];
|
||||||
|
const SCRIPT = "agenthub-hook.sh";
|
||||||
|
// Codex clamps SessionEnd/Interrupt to 3 s with a startup warning; the handler
|
||||||
|
// gives up after 2.5 s anyway.
|
||||||
|
const TIMEOUT_S = 3;
|
||||||
|
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
||||||
|
|
||||||
|
const scriptPath = () => path.join(getCodexHome(), SCRIPT);
|
||||||
|
const hooksPath = () => path.join(getCodexHome(), "hooks.json");
|
||||||
|
const MARKER = "# AgentHub's Codex hook forwarder.";
|
||||||
|
|
||||||
|
/** POSIX single-quoting: safe inside a shell command whatever the value. */
|
||||||
|
function shq(s) {
|
||||||
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandFor = (event) => `${shq(scriptPath())} ${event}`;
|
||||||
|
|
||||||
|
function scriptBody() {
|
||||||
|
return [
|
||||||
|
"#!/bin/sh",
|
||||||
|
`${MARKER} Rewritten by AgentHub on every start; the`,
|
||||||
|
"# hooks.json command pointing here stays the same, so Codex's trust holds.",
|
||||||
|
`N=${shq(process.execPath)}; [ -x "$N" ] || N=node`,
|
||||||
|
`CLAUDE_HOME=${shq(getClaudeHome())} exec "$N" ${shq(HANDLER)} "$1" codex`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ours: exactly `'<dir>/agenthub-hook.sh' <Event>` as commandFor quotes it
|
||||||
|
// (any CODEX_HOME, earlier ones included) — never a user's hook that merely
|
||||||
|
// mentions the name, and never a user's hook sharing a group with ours.
|
||||||
|
const OURS = /^'(?:[^']|'\\'')*\/agenthub-hook\.sh' [A-Za-z]+$/;
|
||||||
|
const isOurHook = (h) => typeof h?.command === "string" && OURS.test(h.command);
|
||||||
|
|
||||||
|
function fail(code, message) {
|
||||||
|
return Object.assign(new Error(message), { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hooks.json as an object, `{hooks:{}}` when absent; throws EBADJSON otherwise. */
|
||||||
|
function readHooks() {
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(hooksPath(), "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOENT") return { hooks: {} };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
file = null;
|
||||||
|
}
|
||||||
|
if (!file || typeof file !== "object" || Array.isArray(file)) {
|
||||||
|
throw fail("EBADJSON", `${hooksPath()} is not a JSON object; fix or move it first`);
|
||||||
|
}
|
||||||
|
if (file.hooks === undefined) file.hooks = {};
|
||||||
|
if (!file.hooks || typeof file.hooks !== "object" || Array.isArray(file.hooks)) {
|
||||||
|
throw fail("EBADJSON", `${hooksPath()}: "hooks" is not an object`);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Temp + rename, through a symlinked file to its target, keeping `mode`. */
|
||||||
|
function writeAtomic(file, text, mode, { keepMode = false } = {}) {
|
||||||
|
let target = file;
|
||||||
|
let existing = null;
|
||||||
|
try {
|
||||||
|
target = fs.realpathSync(file);
|
||||||
|
existing = fs.statSync(target).mode & 0o777;
|
||||||
|
} catch {
|
||||||
|
/* not there yet */
|
||||||
|
}
|
||||||
|
atomicWriteFile(target, text);
|
||||||
|
// A user's own file keeps its mode (a 0600 hooks.json stays private).
|
||||||
|
fs.chmodSync(target, keepMode && existing !== null ? existing : mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A symlink (a dotfiles repo's file): emptied through the link, never unlinked. */
|
||||||
|
function isLink(file) {
|
||||||
|
try {
|
||||||
|
return fs.lstatSync(file).isSymbolicLink();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `file` is absent or is AgentHub's forwarder (line 2 starts with
|
||||||
|
* `marker`). Unreadable, a directory, anything else: not ours, so it is left
|
||||||
|
* alone (install answers EFOREIGN, remove skips it).
|
||||||
|
*/
|
||||||
|
function scriptIsOurs(file, marker) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(file, "utf8").split("\n", 2)[1]?.startsWith(marker) ?? false;
|
||||||
|
} catch (err) {
|
||||||
|
return err.code === "ENOENT";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file with every hook of ours taken out; a group left empty by that is
|
||||||
|
* dropped, and so is an event left with no group.
|
||||||
|
*/
|
||||||
|
function withoutOurs(file) {
|
||||||
|
for (const [event, groups] of Object.entries(file.hooks)) {
|
||||||
|
if (!Array.isArray(groups)) continue;
|
||||||
|
const kept = [];
|
||||||
|
for (const g of groups) {
|
||||||
|
if (!Array.isArray(g?.hooks) || !g.hooks.some(isOurHook)) {
|
||||||
|
kept.push(g);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hooks = g.hooks.filter((h) => !isOurHook(h));
|
||||||
|
if (hooks.length) kept.push({ ...g, hooks });
|
||||||
|
}
|
||||||
|
if (kept.length) file.hooks[event] = kept;
|
||||||
|
else delete file.hooks[event];
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupported() {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
throw fail("EUNSUPPORTED", "Codex hooks from AgentHub need a POSIX shell");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install (or reinstall) AgentHub's hooks. @returns the new status. */
|
||||||
|
function install() {
|
||||||
|
assertSupported();
|
||||||
|
const file = withoutOurs(readHooks());
|
||||||
|
for (const event of HOOK_EVENTS) {
|
||||||
|
const groups = Array.isArray(file.hooks[event]) ? file.hooks[event] : [];
|
||||||
|
groups.push({ hooks: [{ type: "command", command: commandFor(event), timeout: TIMEOUT_S }] });
|
||||||
|
file.hooks[event] = groups;
|
||||||
|
}
|
||||||
|
if (!scriptIsOurs(scriptPath(), MARKER)) {
|
||||||
|
throw fail("EFOREIGN", `${scriptPath()} is not AgentHub's; move it first`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(getCodexHome(), { recursive: true });
|
||||||
|
writeAtomic(scriptPath(), scriptBody(), 0o755);
|
||||||
|
writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||||
|
return status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take AgentHub's hooks out; the user's own stay. @returns the new status. */
|
||||||
|
function remove() {
|
||||||
|
const file = withoutOurs(readHooks());
|
||||||
|
const others = Object.keys(file).filter((k) => k !== "hooks");
|
||||||
|
if (Object.keys(file.hooks).length === 0 && others.length === 0 && !isLink(hooksPath())) {
|
||||||
|
fs.rmSync(hooksPath(), { force: true });
|
||||||
|
} else if (fs.existsSync(hooksPath())) {
|
||||||
|
writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||||
|
}
|
||||||
|
if (scriptIsOurs(scriptPath(), MARKER)) fs.rmSync(scriptPath(), { force: true });
|
||||||
|
return status();
|
||||||
|
}
|
||||||
|
|
||||||
|
const snake = (event) => event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per event: is our hook there, and does Codex's `config.toml` hold a trust
|
||||||
|
* entry for its position (`"<hooks.json>:<event>:<group>:<hook>"`, under the
|
||||||
|
* home as given or resolved). A trust entry for an older definition would read
|
||||||
|
* as trusted too; ours never changes.
|
||||||
|
*/
|
||||||
|
function status() {
|
||||||
|
const base = {
|
||||||
|
supported: process.platform !== "win32",
|
||||||
|
path: hooksPath(),
|
||||||
|
script: scriptPath(),
|
||||||
|
};
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = readHooks();
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
readable: false,
|
||||||
|
error: err.message,
|
||||||
|
installed: false,
|
||||||
|
trusted: false,
|
||||||
|
events: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let toml = "";
|
||||||
|
try {
|
||||||
|
toml = fs.readFileSync(path.join(getCodexHome(), "config.toml"), "utf8");
|
||||||
|
} catch {
|
||||||
|
/* no config.toml: nothing trusted */
|
||||||
|
}
|
||||||
|
const files = codexHomes().map((h) => path.join(h, "hooks.json"));
|
||||||
|
const events = {};
|
||||||
|
for (const event of HOOK_EVENTS) {
|
||||||
|
const groups = Array.isArray(file.hooks[event]) ? file.hooks[event] : [];
|
||||||
|
let at = null;
|
||||||
|
groups.forEach((g, i) => {
|
||||||
|
const j = Array.isArray(g?.hooks)
|
||||||
|
? g.hooks.findIndex((h) => h?.command === commandFor(event))
|
||||||
|
: -1;
|
||||||
|
if (j >= 0 && !at) at = `${snake(event)}:${i}:${j}`;
|
||||||
|
});
|
||||||
|
events[event] = {
|
||||||
|
installed: at !== null,
|
||||||
|
trusted: at !== null && files.some((f) => toml.includes(`"${f}:${at}"`)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const all = (k) => HOOK_EVENTS.every((e) => events[e][k]);
|
||||||
|
const installed = all("installed") && fs.existsSync(scriptPath());
|
||||||
|
return { ...base, readable: true, installed, trusted: installed && all("trusted"), events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite the forwarder when it is installed and stale (a new build, node or
|
||||||
|
* CLAUDE_HOME) or lost its exec bit. Never creates it — installing is the
|
||||||
|
* user's call — and never touches a file by that name that is not ours.
|
||||||
|
* @returns {"absent"|"foreign"|"exists"|"written"|"failed"}
|
||||||
|
*/
|
||||||
|
function refreshScript() {
|
||||||
|
try {
|
||||||
|
const current = fs.readFileSync(scriptPath(), "utf8");
|
||||||
|
if (!current.split("\n", 2)[1]?.startsWith(MARKER)) return "foreign";
|
||||||
|
const executable = (fs.statSync(scriptPath()).mode & 0o111) !== 0;
|
||||||
|
if (current === scriptBody() && executable) return "exists";
|
||||||
|
writeAtomic(scriptPath(), scriptBody(), 0o755);
|
||||||
|
return "written";
|
||||||
|
} catch (err) {
|
||||||
|
return err.code === "ENOENT" ? "absent" : "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
HOOK_EVENTS,
|
||||||
|
shq,
|
||||||
|
writeAtomic,
|
||||||
|
scriptIsOurs,
|
||||||
|
isLink,
|
||||||
|
commandFor,
|
||||||
|
install,
|
||||||
|
remove,
|
||||||
|
status,
|
||||||
|
refreshScript,
|
||||||
|
};
|
||||||
+44
-10
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file Codex lane status mirror. Codex emits no hooks, so nothing else can
|
* @file Codex lane status mirror. Without AgentHub's Codex hooks installed
|
||||||
* tell the dashboard that a `codex` lane's agent is working. Its on-disk
|
* (codex-hooks.js, opt-in and trusted by the user in Codex) nothing else tells
|
||||||
* signal is the rollout journal it appends live, one file per thread:
|
* the dashboard that a `codex` lane's agent is working; while a lane's hooks
|
||||||
|
* are current (an event for its session in the last 2 minutes) this mirror
|
||||||
|
* leaves the lane to them. Its on-disk signal is the rollout journal Codex
|
||||||
|
* appends live, one file per thread:
|
||||||
*
|
*
|
||||||
* $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl
|
* $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl
|
||||||
* line 1: {"type":"session_meta","payload":{"cwd":…,"session_id":…}}
|
* line 1: {"type":"session_meta","payload":{"cwd":…,"session_id":…}}
|
||||||
@@ -15,8 +18,9 @@
|
|||||||
* Codex 0.149 also mirrors threads into version-suffixed SQLite files
|
* Codex 0.149 also mirrors threads into version-suffixed SQLite files
|
||||||
* (`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still
|
* (`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still
|
||||||
* points at a live rollout file, so reading the JSONL costs no coupling to a
|
* points at a live rollout file, so reading the JSONL costs no coupling to a
|
||||||
* schema the next Codex release renames. `rolloutFiles()` is the one function
|
* schema the next Codex release renames. `allRolloutFiles()` is the one
|
||||||
* to re-point if that ever stops being true.
|
* function to re-point if that ever stops being true (codex-usage.js walks
|
||||||
|
* the same files for sessions and token usage).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,12 +29,13 @@ const path = require("node:path");
|
|||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const { getCodexHome } = require("./codex-home");
|
const { getCodexHome } = require("./codex-home");
|
||||||
|
|
||||||
|
const FUTURE_SLACK_MS = 1000;
|
||||||
const CODEX_ACTIVE_MS = 120_000; // a journal written within 2 min means the agent is working
|
const CODEX_ACTIVE_MS = 120_000; // a journal written within 2 min means the agent is working
|
||||||
const MAX_FILES = 40; // newest-first cap so a years-deep sessions/ tree can't stall a pass
|
const MAX_FILES = 40; // newest-first cap so a years-deep sessions/ tree can't stall a pass
|
||||||
const MAX_DEPTH = 4; // sessions/<YYYY>/<MM>/<DD>/<file>
|
const MAX_DEPTH = 4; // sessions/<YYYY>/<MM>/<DD>/<file>
|
||||||
|
|
||||||
/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */
|
/** Every rollout journal under sessions/ ({path, mtime, size}), unsorted. */
|
||||||
function rolloutFiles() {
|
function allRolloutFiles() {
|
||||||
const out = [];
|
const out = [];
|
||||||
const walk = (dir, depth) => {
|
const walk = (dir, depth) => {
|
||||||
let entries = [];
|
let entries = [];
|
||||||
@@ -54,7 +59,14 @@ function rolloutFiles() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(path.join(getCodexHome(), "sessions"), 0);
|
walk(path.join(getCodexHome(), "sessions"), 0);
|
||||||
return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest MAX_FILES rollout journals, newest mtime first. */
|
||||||
|
function rolloutFiles() {
|
||||||
|
return allRolloutFiles()
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
.slice(0, MAX_FILES);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwdCache = new Map(); // rollout path → thread cwd (written once, at thread creation)
|
const cwdCache = new Map(); // rollout path → thread cwd (written once, at thread creation)
|
||||||
@@ -114,6 +126,10 @@ function ingestCodexOnce({ broadcast } = {}) {
|
|||||||
if (!codexLanes.length) return 0;
|
if (!codexLanes.length) return 0;
|
||||||
|
|
||||||
const touched = new Set();
|
const touched = new Set();
|
||||||
|
// One verdict per lane — running when ANY of its journals is fresh. Judging
|
||||||
|
// file by file against the same status snapshot flipped a lane with an old
|
||||||
|
// journal beside the live one between running and idle on every pass.
|
||||||
|
const activeByLane = new Map();
|
||||||
const files = rolloutFiles();
|
const files = rolloutFiles();
|
||||||
pruneTracking(new Set(files.map((f) => f.path)));
|
pruneTracking(new Set(files.map((f) => f.path)));
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -127,10 +143,26 @@ function ingestCodexOnce({ broadcast } = {}) {
|
|||||||
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
|
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
|
||||||
// preserved timestamps) makes the raw difference negative, which reads as
|
// preserved timestamps) makes the raw difference negative, which reads as
|
||||||
// "written milliseconds ago". A file that claims to be from the future is
|
// "written milliseconds ago". A file that claims to be from the future is
|
||||||
// not evidence of an agent working now, so treat it as idle.
|
// not evidence of an agent working now, so treat it as idle. The second of
|
||||||
|
// slack is for the file's nanosecond mtime against Date.now()'s truncated
|
||||||
|
// millisecond: a journal written this very millisecond reads as future.
|
||||||
const age = Date.now() - file.mtime;
|
const age = Date.now() - file.mtime;
|
||||||
const active = age >= 0 && age < CODEX_ACTIVE_MS;
|
const active = age > -FUTURE_SLACK_MS && age < CODEX_ACTIVE_MS;
|
||||||
|
const seen = activeByLane.get(lane.id);
|
||||||
|
activeByLane.set(lane.id, { lane, active: active || (seen ? seen.active : false) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex's hooks (codex-hooks.js) report a turn's start and end as they
|
||||||
|
// happen; while they are current, a journal written a minute ago would call
|
||||||
|
// a lane that just stopped "running".
|
||||||
|
const { db } = require("../db");
|
||||||
|
const hookedSince = new Date(Date.now() - CODEX_ACTIVE_MS).toISOString();
|
||||||
|
const recentHook = db.prepare(
|
||||||
|
"SELECT 1 FROM events WHERE session_id = ? AND created_at > ? LIMIT 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { lane, active } of activeByLane.values()) {
|
||||||
|
if (lane.session_id && recentHook.get(lane.session_id, hookedSince)) continue;
|
||||||
// Status mirror, under the same guard hook ingestion uses: only idle↔running,
|
// Status mirror, under the same guard hook ingestion uses: only idle↔running,
|
||||||
// and only for a lane the dashboard did not launch — provisioning and failed
|
// and only for a lane the dashboard did not launch — provisioning and failed
|
||||||
// are lifecycle states this path must never stomp on. `updateLane` filters
|
// are lifecycle states this path must never stomp on. `updateLane` filters
|
||||||
@@ -181,7 +213,9 @@ function startCodexPoll(broadcast) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
allRolloutFiles,
|
||||||
rolloutCwd,
|
rolloutCwd,
|
||||||
|
FUTURE_SLACK_MS,
|
||||||
ingestCodexOnce,
|
ingestCodexOnce,
|
||||||
startCodexPoll,
|
startCodexPoll,
|
||||||
__resetCache,
|
__resetCache,
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* @file Codex sessions and token usage, read from Codex's rollout journals
|
||||||
|
* (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). This is how
|
||||||
|
* a Codex session reaches `sessions` (as `tool = 'codex'`) and its tokens reach
|
||||||
|
* `token_usage` — Codex's hooks, when the user installs them, add its events
|
||||||
|
* and live status but no tokens.
|
||||||
|
*
|
||||||
|
* What a journal holds, and the two traps:
|
||||||
|
* - line 1 `session_meta`: `id` = the thread, `session_id` = the ROOT session
|
||||||
|
* (a sub-agent thread's parent; absent on old CLIs, then `id`), `cwd`,
|
||||||
|
* `timestamp`, `forked_from_id` on a fork;
|
||||||
|
* - `turn_context.model` is the model for the lines that follow;
|
||||||
|
* - `token_count` events carry a cumulative `total_token_usage` and its delta
|
||||||
|
* `last_token_usage` — `input_tokens` INCLUDES `cached_input_tokens`,
|
||||||
|
* `output_tokens` includes reasoning. The same total is often sent twice.
|
||||||
|
* - a FORK copies its parent's whole history into its own file, token events
|
||||||
|
* included, then carries on from the parent's total: every total vector the
|
||||||
|
* parent's file holds is skipped in the child, or the parent counts twice.
|
||||||
|
* - a sub-agent thread has its own file; its usage is summed into the root.
|
||||||
|
*
|
||||||
|
* Progress per file lives in `codex_rollouts` (offset + parse state), so an
|
||||||
|
* unchanged file is never re-read, a grown one is read from where it stopped,
|
||||||
|
* and a session the user deleted stays deleted until its journal changes.
|
||||||
|
* Totals are exact recomputes, written with `setTokenUsage`. A session is named
|
||||||
|
* by Codex's own thread name (`session_index.jsonl`), else its first prompt. Rows are never
|
||||||
|
* given a `transcript_path`, so the Claude transcript parsers skip them, and a
|
||||||
|
* session row whose tool is not `codex` is never touched. A session Codex's
|
||||||
|
* hooks report on (an event no older than its newest journal write, less two
|
||||||
|
* minutes) keeps the status, end and last-active time the hooks gave it; this
|
||||||
|
* module still writes its usage, model and name. Hooks that stopped reporting
|
||||||
|
* (removed, no longer trusted) hand the status back to the journal.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const readline = require("node:readline");
|
||||||
|
const { db, stmts } = require("../db");
|
||||||
|
const { getCodexHome, codexHomes } = require("./codex-home");
|
||||||
|
const { allRolloutFiles, FUTURE_SLACK_MS } = require("./codex-ingest");
|
||||||
|
const pricingSync = require("./pricing-sync");
|
||||||
|
|
||||||
|
const ACTIVE_MS = 120_000; // same window codex-ingest.js calls a lane running
|
||||||
|
// Read at most this much of one journal per pass: a sync read of a 100 MB
|
||||||
|
// journal would block the event loop for about a second.
|
||||||
|
const MAX_CHUNK = 16 * 1024 * 1024;
|
||||||
|
const NEEDLES = [
|
||||||
|
'"session_meta"',
|
||||||
|
'"turn_context"',
|
||||||
|
'"token_count"',
|
||||||
|
'"user_message"', // a prompt, older CLIs
|
||||||
|
'"UserMessage"', // a prompt, as an item_completed event (0.149+)
|
||||||
|
];
|
||||||
|
const FILE_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
|
||||||
|
|
||||||
|
function totalKey(t) {
|
||||||
|
return [
|
||||||
|
t.input_tokens,
|
||||||
|
t.cached_input_tokens,
|
||||||
|
t.cache_write_input_tokens,
|
||||||
|
t.output_tokens,
|
||||||
|
t.reasoning_output_tokens,
|
||||||
|
].join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text a user typed, from either prompt event shape; null otherwise. */
|
||||||
|
function promptOf(p) {
|
||||||
|
let text = null;
|
||||||
|
if (p.type === "user_message") text = p.message;
|
||||||
|
else if (p.type === "item_completed" && p.item?.type === "UserMessage") {
|
||||||
|
text = (p.item.content || [])
|
||||||
|
.filter((c) => c && c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
return typeof text === "string" && text.trim() ? text.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold one chunk of journal text into `state` (mutated and returned). `skip`
|
||||||
|
* holds total vectors that belong to a fork's parent.
|
||||||
|
*/
|
||||||
|
function parseRolloutChunk(text, state = {}, skip = null) {
|
||||||
|
const s = state;
|
||||||
|
s.usage ||= {};
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!line || !NEEDLES.some((n) => line.includes(n))) continue;
|
||||||
|
let o;
|
||||||
|
try {
|
||||||
|
o = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const p = o.payload || {};
|
||||||
|
if (o.type === "session_meta") {
|
||||||
|
if (s.threadId) continue; // a fork repeats its ancestors' metas
|
||||||
|
s.threadId = p.id || null;
|
||||||
|
s.sessionId = p.session_id || p.id || null;
|
||||||
|
s.cwd = p.cwd || null;
|
||||||
|
s.startedAt = p.timestamp || o.timestamp || null;
|
||||||
|
s.forkedFrom = p.forked_from_id || null;
|
||||||
|
} else if (o.type === "turn_context") {
|
||||||
|
if (p.model) s.model = p.model;
|
||||||
|
} else if (o.type === "event_msg" && !s.firstPrompt && promptOf(p)) {
|
||||||
|
// The session's label when Codex has no thread name for it — the same
|
||||||
|
// first-line, 60-character rule hooks.js applies to a Claude prompt.
|
||||||
|
s.firstPrompt = promptOf(p).split("\n")[0].slice(0, 60);
|
||||||
|
} else if (o.type === "event_msg" && p.type === "token_count" && p.info?.total_token_usage) {
|
||||||
|
const key = totalKey(p.info.total_token_usage);
|
||||||
|
if (key === s.lastTotal) continue;
|
||||||
|
s.lastTotal = key;
|
||||||
|
// A fork's copied history comes first; its first total the parent never
|
||||||
|
// wrote ends it, so later totals are never matched against the parent.
|
||||||
|
if (s.forkedFrom && !s.pastFork) {
|
||||||
|
if (skip?.has(key)) continue;
|
||||||
|
if (skip) s.pastFork = true;
|
||||||
|
}
|
||||||
|
const l = p.info.last_token_usage || {};
|
||||||
|
const u = (s.usage[s.model || "unknown"] ||= {
|
||||||
|
input: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
output: 0,
|
||||||
|
});
|
||||||
|
const cached = l.cached_input_tokens || 0;
|
||||||
|
u.input += Math.max(0, (l.input_tokens || 0) - cached);
|
||||||
|
u.cacheRead += cached;
|
||||||
|
u.cacheWrite += l.cache_write_input_tokens || 0;
|
||||||
|
u.output += l.output_tokens || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes [offset, size), at most MAX_CHUNK, up to the last newline, as text +
|
||||||
|
* bytes consumed + whether more is left. A window with no newline at all is
|
||||||
|
* one line longer than MAX_CHUNK — never a token event — and is skipped whole.
|
||||||
|
*/
|
||||||
|
function readChunk(file, offset, size) {
|
||||||
|
const buf = Buffer.alloc(Math.max(0, Math.min(size - offset, MAX_CHUNK)));
|
||||||
|
const fd = fs.openSync(file, "r");
|
||||||
|
try {
|
||||||
|
fs.readSync(fd, buf, 0, buf.length, offset);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
let end = buf.lastIndexOf(0x0a) + 1; // 0 when no full line yet
|
||||||
|
if (end === 0 && buf.length === MAX_CHUNK) end = MAX_CHUNK;
|
||||||
|
return { text: buf.toString("utf8", 0, end), bytes: end, more: offset + end < size && end > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
|
||||||
|
async function parentTotals(file) {
|
||||||
|
const keys = new Set();
|
||||||
|
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
||||||
|
for await (const line of lines) {
|
||||||
|
if (!line.includes('"token_count"')) continue;
|
||||||
|
try {
|
||||||
|
const t = JSON.parse(line).payload?.info?.total_token_usage;
|
||||||
|
if (t) keys.add(totalKey(t));
|
||||||
|
} catch {
|
||||||
|
/* torn line */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLineMeta(file) {
|
||||||
|
try {
|
||||||
|
const { text } = readChunk(file, 0, Math.min(fs.statSync(file).size, 1024 * 1024));
|
||||||
|
const o = JSON.parse(text.split("\n")[0]);
|
||||||
|
return o.type === "session_meta" ? o.payload || {} : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadCache = new Map(); // rollout path → {id, sessionId}; a first line never changes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thread a rollout journal belongs to — `{id, sessionId}` from its
|
||||||
|
* session_meta, `sessionId` being the ROOT session — or null. Only a
|
||||||
|
* `rollout-*.jsonl` under `$CODEX_HOME/sessions` (as given or resolved) is read: the path comes from a
|
||||||
|
* hook payload, and a file elsewhere could name any session it likes.
|
||||||
|
*/
|
||||||
|
function threadOf(file) {
|
||||||
|
if (typeof file !== "string" || !path.isAbsolute(file)) return null;
|
||||||
|
const resolved = path.resolve(file);
|
||||||
|
const under = codexHomes().some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
|
||||||
|
if (!under || !FILE_NAME.test(path.basename(resolved))) return null;
|
||||||
|
if (threadCache.has(resolved)) return threadCache.get(resolved);
|
||||||
|
const meta = firstLineMeta(resolved);
|
||||||
|
const thread = meta.id ? { id: meta.id, sessionId: meta.session_id || meta.id } : null;
|
||||||
|
if (!thread) return null; // not written yet: ask again next time
|
||||||
|
if (threadCache.size >= 500) threadCache.clear();
|
||||||
|
threadCache.set(resolved, thread);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `thread_name` per id from `$CODEX_HOME/session_index.jsonl` (last wins). */
|
||||||
|
function threadNames() {
|
||||||
|
const names = new Map();
|
||||||
|
let text = "";
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(path.join(getCodexHome(), "session_index.jsonl"), "utf8");
|
||||||
|
} catch {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(line);
|
||||||
|
if (o.id && o.thread_name) names.set(o.id, String(o.thread_name));
|
||||||
|
} catch {
|
||||||
|
/* blank or torn */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRollout = db.prepare("SELECT * FROM codex_rollouts WHERE path = ?");
|
||||||
|
const upsertRollout = db.prepare(
|
||||||
|
`INSERT INTO codex_rollouts (path, session_id, thread_id, size, mtime_ms, offset, state, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id, thread_id = excluded.thread_id,
|
||||||
|
size = excluded.size, mtime_ms = excluded.mtime_ms, offset = excluded.offset,
|
||||||
|
state = excluded.state, updated_at = excluded.updated_at`
|
||||||
|
);
|
||||||
|
// One row per thread: the same journal met under a new path (CODEX_HOME moved,
|
||||||
|
// or reached through a symlink) replaces its old row instead of counting twice.
|
||||||
|
const dropOtherPaths = db.prepare("DELETE FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
|
||||||
|
const rolloutsOf = db.prepare(
|
||||||
|
"SELECT thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
|
||||||
|
);
|
||||||
|
const insertCodexSession = db.prepare(
|
||||||
|
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'codex')`
|
||||||
|
);
|
||||||
|
const updateCodexSession = db.prepare(
|
||||||
|
`UPDATE sessions SET name = ?, status = ?, model = ?, ended_at = ?, updated_at = ?,
|
||||||
|
cwd = COALESCE(?, cwd), started_at = COALESCE(?, started_at)
|
||||||
|
WHERE id = ? AND tool = 'codex'`
|
||||||
|
);
|
||||||
|
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||||
|
// A session Codex's hooks report on (server/lib/codex-hooks.js) has events;
|
||||||
|
// its status is theirs, as a Claude session's is — while they still report:
|
||||||
|
// an event no older than the journal's newest write (less ACTIVE_MS). Hooks
|
||||||
|
// removed, or no longer trusted, hand the status back to the journal.
|
||||||
|
const lastEventAt = db.prepare("SELECT MAX(created_at) AS t FROM events WHERE session_id = ?");
|
||||||
|
const newestRollout = db.prepare(
|
||||||
|
"SELECT MAX(mtime_ms) AS m FROM codex_rollouts WHERE session_id = ?"
|
||||||
|
);
|
||||||
|
function hooksCurrent(sessionId, newestMtime) {
|
||||||
|
const t = Date.parse(lastEventAt.get(sessionId)?.t || "");
|
||||||
|
return Number.isFinite(t) && t >= newestMtime - ACTIVE_MS;
|
||||||
|
}
|
||||||
|
// A journal written "in the future" (a clock that runs ahead) is not proof of
|
||||||
|
// activity — codex-ingest.js treats it the same way.
|
||||||
|
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
|
||||||
|
|
||||||
|
/** Re-derive one root session's row and usage from all its threads. */
|
||||||
|
function writeSession(sessionId, names, now) {
|
||||||
|
const rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const existing = stmts.getSession.get(sessionId);
|
||||||
|
if (existing && existing.tool !== "codex") return null;
|
||||||
|
|
||||||
|
// The root thread's own file, when it has been read: a sub-agent file read
|
||||||
|
// first must not fix the session's cwd, start or model.
|
||||||
|
const ownRoot = rows.find((r) => r.thread_id === sessionId);
|
||||||
|
const root = ownRoot || rows[0];
|
||||||
|
const newest = rows.reduce((a, b) => (b.mtime_ms > a.mtime_ms ? b : a));
|
||||||
|
const active = rows.some((r) => fresh(r.mtime_ms, now));
|
||||||
|
const name =
|
||||||
|
names.get(sessionId) ||
|
||||||
|
root.state.firstPrompt ||
|
||||||
|
existing?.name ||
|
||||||
|
`Codex ${sessionId.slice(0, 8)}`;
|
||||||
|
const model = (ownRoot && ownRoot.state.model) || newest.state.model || null;
|
||||||
|
// A journal is appended to the end, so its mtime is when the session last
|
||||||
|
// did anything — later than its last token event after an abort.
|
||||||
|
let lastWrite = new Date(newest.mtime_ms).toISOString();
|
||||||
|
let status = active ? "active" : "completed";
|
||||||
|
let endedAt = active ? null : lastWrite;
|
||||||
|
if (existing && hooksCurrent(sessionId, newest.mtime_ms)) {
|
||||||
|
({ status, ended_at: endedAt, updated_at: lastWrite } = existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
for (const [m, u] of Object.entries(r.state.usage || {})) {
|
||||||
|
const t = (totals[m] ||= { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
|
||||||
|
for (const k of Object.keys(t)) t[k] += u[k] || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
// `updated_at` is the session's last activity (the list's "Last active"
|
||||||
|
// and its sort), so it is the journal's last write, not ingestion time.
|
||||||
|
if (existing)
|
||||||
|
updateCodexSession.run(
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
model,
|
||||||
|
endedAt,
|
||||||
|
lastWrite,
|
||||||
|
ownRoot ? ownRoot.state.cwd : null,
|
||||||
|
ownRoot ? ownRoot.state.startedAt : null,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
else
|
||||||
|
insertCodexSession.run(
|
||||||
|
sessionId,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
root.state.cwd,
|
||||||
|
model,
|
||||||
|
root.state.startedAt || new Date(now).toISOString(),
|
||||||
|
endedAt,
|
||||||
|
lastWrite,
|
||||||
|
JSON.stringify({ source: "codex-rollout" })
|
||||||
|
);
|
||||||
|
// Every usage row of a Codex session is this module's, so it is rewritten
|
||||||
|
// whole: a model gone from a re-read journal leaves no stale row behind.
|
||||||
|
clearUsage.run(sessionId);
|
||||||
|
for (const [m, t] of Object.entries(totals)) {
|
||||||
|
stmts.setTokenUsage.run(
|
||||||
|
sessionId,
|
||||||
|
m,
|
||||||
|
"standard",
|
||||||
|
"global",
|
||||||
|
"standard",
|
||||||
|
t.input,
|
||||||
|
t.output,
|
||||||
|
t.cacheRead,
|
||||||
|
t.cacheWrite,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return existing ? "session_updated" : "session_created";
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass over every journal. Yields to the event loop between files; a
|
||||||
|
* second call while one runs joins it.
|
||||||
|
* @returns {Promise<{files: number, sessions: number}>} files read, sessions written
|
||||||
|
*/
|
||||||
|
function ingestCodexUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||||
|
if (running) return running;
|
||||||
|
running = (async () => {
|
||||||
|
const files = allRolloutFiles();
|
||||||
|
const byThread = new Map();
|
||||||
|
for (const f of files) {
|
||||||
|
const m = FILE_NAME.exec(path.basename(f.path));
|
||||||
|
if (m) byThread.set(m[1], f.path);
|
||||||
|
}
|
||||||
|
const touched = new Set();
|
||||||
|
let read = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const mtime = Math.floor(f.mtime);
|
||||||
|
const row = getRollout.get(f.path);
|
||||||
|
let offset = row ? row.offset : 0;
|
||||||
|
let state = row ? JSON.parse(row.state) : {};
|
||||||
|
if (row && row.size === f.size && row.mtime_ms === mtime && !state.more) continue;
|
||||||
|
if (f.size < offset) {
|
||||||
|
offset = 0;
|
||||||
|
state = {};
|
||||||
|
}
|
||||||
|
let skip = null;
|
||||||
|
const parent = offset === 0 ? firstLineMeta(f.path).forked_from_id : state.forkedFrom;
|
||||||
|
if (parent && !state.pastFork) {
|
||||||
|
if (byThread.has(parent)) skip = await parentTotals(byThread.get(parent));
|
||||||
|
else if (offset === 0)
|
||||||
|
console.warn(`[codex-usage] fork parent ${parent} not found; ${f.path} may overcount`);
|
||||||
|
}
|
||||||
|
const { text, bytes, more } = readChunk(f.path, offset, f.size);
|
||||||
|
state = parseRolloutChunk(text, state, skip);
|
||||||
|
state.more = more;
|
||||||
|
upsertRollout.run(
|
||||||
|
f.path,
|
||||||
|
state.sessionId || "",
|
||||||
|
state.threadId || "",
|
||||||
|
f.size,
|
||||||
|
mtime,
|
||||||
|
offset + bytes,
|
||||||
|
JSON.stringify(state)
|
||||||
|
);
|
||||||
|
if (state.threadId) dropOtherPaths.run(state.threadId, f.path);
|
||||||
|
read += 1;
|
||||||
|
if (state.sessionId) touched.add(state.sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[codex-usage] skipped ${f.path}: ${err.message}`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
}
|
||||||
|
let written = 0;
|
||||||
|
const names = touched.size ? threadNames() : new Map();
|
||||||
|
for (const sid of touched) {
|
||||||
|
try {
|
||||||
|
const type = writeSession(sid, names, now);
|
||||||
|
if (!type) continue;
|
||||||
|
written += 1;
|
||||||
|
broadcast(type, stmts.getSession.get(sid));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[codex-usage] session ${sid}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files: read, sessions: written };
|
||||||
|
})().finally(() => {
|
||||||
|
running = null;
|
||||||
|
});
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First pass at once (in the background), then every DASHBOARD_CODEX_POLL_MS
|
||||||
|
* (default 15 s; 0 disables, like the codex lane poll it shares the knob with).
|
||||||
|
* A pass also re-derives `active` → `completed`, so a session whose journal
|
||||||
|
* went quiet is re-written once its files are older than ACTIVE_MS.
|
||||||
|
*/
|
||||||
|
function startCodexUsagePoll(broadcast) {
|
||||||
|
const POLL_MS = process.env.DASHBOARD_CODEX_POLL_MS
|
||||||
|
? Number(process.env.DASHBOARD_CODEX_POLL_MS)
|
||||||
|
: 15_000;
|
||||||
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||||
|
const tick = () => {
|
||||||
|
ingestCodexUsage({ broadcast })
|
||||||
|
.then((r) => {
|
||||||
|
settleActive(broadcast);
|
||||||
|
// A pass that wrote sessions may have brought a model with no rate
|
||||||
|
// row; price it now rather than at the sync's next 15-minute check.
|
||||||
|
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const timer = setInterval(tick, POLL_MS);
|
||||||
|
if (timer.unref) timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codex sessions still `active` whose every journal has gone quiet. */
|
||||||
|
function settleActive(broadcast, now = Date.now()) {
|
||||||
|
const quiet = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT s.id FROM sessions s WHERE s.tool = 'codex' AND s.status = 'active'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM codex_rollouts r WHERE r.session_id = s.id
|
||||||
|
AND r.mtime_ms > ? AND r.mtime_ms < ?)`
|
||||||
|
)
|
||||||
|
.all(now - ACTIVE_MS, now + FUTURE_SLACK_MS);
|
||||||
|
if (quiet.length === 0) return;
|
||||||
|
const names = threadNames();
|
||||||
|
for (const { id } of quiet) {
|
||||||
|
if (hooksCurrent(id, newestRollout.get(id)?.m || 0)) continue;
|
||||||
|
if (writeSession(id, names, now)) broadcast("session_updated", stmts.getSession.get(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseRolloutChunk,
|
||||||
|
ingestCodexUsage,
|
||||||
|
settleActive,
|
||||||
|
startCodexUsagePoll,
|
||||||
|
threadOf,
|
||||||
|
};
|
||||||
+7
-3
@@ -790,7 +790,9 @@ function subdirLikePattern(cwd) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find sessions that belong to a lane and may be purged: exact or subdirectory,
|
* Find sessions that belong to a lane and may be purged: exact or subdirectory,
|
||||||
* excluding the lane's bound session and any active sessions. Shared between
|
* recorded by the lane's own tool (a Claude and a Codex lane can share one
|
||||||
|
* directory, and purging one must not take the other's history), excluding
|
||||||
|
* the lane's bound session and any active sessions. Shared between
|
||||||
* purgeLaneSessions (the deleter) and preflight counting, so the confirmation
|
* purgeLaneSessions (the deleter) and preflight counting, so the confirmation
|
||||||
* dialog's numbers match what actually gets deleted.
|
* dialog's numbers match what actually gets deleted.
|
||||||
*/
|
*/
|
||||||
@@ -799,10 +801,11 @@ function purgeCandidateSessions(lane) {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id FROM sessions
|
`SELECT id FROM sessions
|
||||||
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
|
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
|
||||||
|
AND tool = ?
|
||||||
AND id != ?
|
AND id != ?
|
||||||
AND status != 'active'`
|
AND status != 'active'`
|
||||||
)
|
)
|
||||||
.all(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
|
.all(lane.cwd, subdirLikePattern(lane.cwd), lane.tool || "claude", lane.session_id || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -816,10 +819,11 @@ function hasActiveLaneSession(lane) {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) AS count FROM sessions
|
`SELECT COUNT(*) AS count FROM sessions
|
||||||
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
|
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
|
||||||
|
AND tool = ?
|
||||||
AND id != ?
|
AND id != ?
|
||||||
AND status = 'active'`
|
AND status = 'active'`
|
||||||
)
|
)
|
||||||
.get(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
|
.get(lane.cwd, subdirLikePattern(lane.cwd), lane.tool || "claude", lane.session_id || "");
|
||||||
return Boolean(row && row.count > 0);
|
return Boolean(row && row.count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+96
-17
@@ -1,14 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* @file Automatic pricing for Claude models the dashboard has seen but has no
|
* @file Automatic pricing for Claude, OpenAI (a Codex session's — see
|
||||||
* rate row of its own for. Every model id in `token_usage` is reduced to its
|
* codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js)
|
||||||
* family (a `-YYYYMMDD` snapshot suffix dropped); a family with no
|
* models the dashboard has seen but has no rate row of its own
|
||||||
|
* for. Every model id in `token_usage` is reduced to its family (a
|
||||||
|
* `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix dropped); a family with no
|
||||||
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
|
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
|
||||||
* never updated, so a rate the user edited in Settings always wins. "Its own
|
* never updated, so a rate the user edited in Settings always wins. "Its own
|
||||||
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
|
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
|
||||||
* Opus 5 by the broader `claude-opus-5%` pattern.
|
* Opus 5 by the broader `claude-opus-5%` pattern.
|
||||||
*
|
*
|
||||||
* The list is fetched only when something is missing, at most once per
|
* The list is fetched only when something is missing, at most once per
|
||||||
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off.
|
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken
|
||||||
|
* only from its own vendor (`anthropic` for claude-…, `openai` for gpt-…,
|
||||||
|
* o<n>…, codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id (`clinepass/…`, `Highest`) is left to
|
||||||
|
* the user.
|
||||||
|
*
|
||||||
|
* ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not
|
||||||
|
* modelled — Codex's context window stays under 272k and runs on the default
|
||||||
|
* tier; add a service_tier bucket if that changes. Gemini's >200k-token rate
|
||||||
|
* is not modelled either (the base rate is used).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -20,38 +30,78 @@ const CACHE_MS = 24 * 60 * 60_000;
|
|||||||
const MAX_PER_MTOK = 1000;
|
const MAX_PER_MTOK = 1000;
|
||||||
|
|
||||||
let cached = null; // { at, list }
|
let cached = null; // { at, list }
|
||||||
|
let failedAt = -Infinity; // last failed fetch
|
||||||
|
|
||||||
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
|
const OPENAI = /^(gpt-|o\d|codex-)[a-z0-9.-]*$/;
|
||||||
|
const GEMINI = /^gemini-[a-z0-9.-]+$/;
|
||||||
|
const PROVIDERS = {
|
||||||
|
claude: ["anthropic"],
|
||||||
|
gemini: ["vertex_ai-language-models", "gemini"],
|
||||||
|
openai: ["openai"],
|
||||||
|
};
|
||||||
|
const vendorOf = (fam) =>
|
||||||
|
fam.startsWith("claude-") ? "claude" : fam.startsWith("gemini-") ? "gemini" : "openai";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `claude-opus-5-5-20260101` → `claude-opus-5-5`, `gpt-5.4-2026-03-05` →
|
||||||
|
* `gpt-5.4`; null for anything else.
|
||||||
|
*/
|
||||||
function familyOf(model) {
|
function familyOf(model) {
|
||||||
if (typeof model !== "string") return null;
|
if (typeof model !== "string") return null;
|
||||||
const fam = model.replace(/-\d{8}$/, "");
|
if (model.startsWith("claude-")) {
|
||||||
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
const fam = model.replace(/-\d{8}$/, "");
|
||||||
|
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
||||||
|
}
|
||||||
|
if (model.startsWith("gemini-")) return GEMINI.test(model) ? model : null;
|
||||||
|
const fam = model.replace(/-\d{4}-\d{2}-\d{2}$/, "");
|
||||||
|
return OPENAI.test(fam) ? fam : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
|
const cap = (w) => w[0].toUpperCase() + w.slice(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `claude-opus-5-5` → `Claude Opus 5.5`, `gpt-5.6-sol` → `GPT-5.6 Sol`,
|
||||||
|
* `gemini-3.8-flash` → `Gemini 3.8 Flash`.
|
||||||
|
*/
|
||||||
function displayName(fam) {
|
function displayName(fam) {
|
||||||
|
if (fam.startsWith("gemini-")) return fam.split("-").map(cap).join(" ");
|
||||||
|
if (!fam.startsWith("claude-")) {
|
||||||
|
const [head, ...rest] = fam.split("-");
|
||||||
|
if (head === "gpt" && rest.length) {
|
||||||
|
const [ver, ...words] = rest;
|
||||||
|
return [`GPT-${ver}`, ...words.map(cap)].join(" ");
|
||||||
|
}
|
||||||
|
return [head === "codex" ? "Codex" : head, ...rest.map(cap)].join(" ");
|
||||||
|
}
|
||||||
const [, ...parts] = fam.split("-");
|
const [, ...parts] = fam.split("-");
|
||||||
const words = [];
|
const words = [];
|
||||||
const nums = [];
|
const nums = [];
|
||||||
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
||||||
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
const name = words.map(cap).join(" ");
|
||||||
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
||||||
function toRow(fam, e) {
|
function toRow(fam, e) {
|
||||||
if (!e || e.litellm_provider !== "anthropic") return null;
|
const claude = fam.startsWith("claude-");
|
||||||
|
if (!e || !PROVIDERS[vendorOf(fam)].includes(e.litellm_provider)) return null;
|
||||||
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
||||||
const input = per(e.input_cost_per_token);
|
const input = per(e.input_cost_per_token);
|
||||||
const output = per(e.output_cost_per_token);
|
const output = per(e.output_cost_per_token);
|
||||||
// Anthropic's published multipliers stand in for a field the list omits.
|
// Published multipliers stand in for a field the list omits: Anthropic
|
||||||
|
// charges cache writes (1.25x / 2x), OpenAI does not.
|
||||||
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
||||||
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
|
const w5m = per(
|
||||||
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
|
e.cache_creation_input_token_cost ?? (claude ? e.input_cost_per_token * 1.25 : 0)
|
||||||
|
);
|
||||||
|
const w1h = per(
|
||||||
|
e.cache_creation_input_token_cost_above_1hr ??
|
||||||
|
(claude ? e.input_cost_per_token * 2 : (e.cache_creation_input_token_cost ?? 0))
|
||||||
|
);
|
||||||
const rates = [input, output, read, w5m, w1h];
|
const rates = [input, output, read, w5m, w1h];
|
||||||
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
||||||
if (input === 0 && output === 0) return null;
|
if (input === 0 && output === 0) return null;
|
||||||
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
const fast = claude && e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
||||||
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
||||||
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
||||||
const r = (n) => Math.round(n * 1e6) / 1e6;
|
const r = (n) => Math.round(n * 1e6) / 1e6;
|
||||||
@@ -98,7 +148,17 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
|
|||||||
try {
|
try {
|
||||||
const missing = missingFamilies(db);
|
const missing = missingFamilies(db);
|
||||||
if (missing.length === 0) return [];
|
if (missing.length === 0) return [];
|
||||||
if (!cached || now - cached.at > CACHE_MS) cached = { at: now, list: await fetchList() };
|
if (!cached || now - cached.at > CACHE_MS) {
|
||||||
|
// A failed fetch is retried at the sync's own cadence, not on every
|
||||||
|
// caller (the Codex poll asks every 15 s while a session is active).
|
||||||
|
if (now - failedAt < CHECK_MS) return [];
|
||||||
|
try {
|
||||||
|
cached = { at: now, list: await fetchList() };
|
||||||
|
} catch (err) {
|
||||||
|
failedAt = now;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
|
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
|
||||||
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
|
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
|
||||||
@@ -129,14 +189,33 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncEnabled() {
|
||||||
|
return !/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "");
|
||||||
|
}
|
||||||
|
|
||||||
function startPricingSync(db) {
|
function startPricingSync(db) {
|
||||||
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
|
if (!syncEnabled()) return;
|
||||||
void syncPricing(db);
|
void syncPricing(db);
|
||||||
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _resetCache() {
|
function _resetCache() {
|
||||||
cached = null;
|
cached = null;
|
||||||
|
failedAt = -Infinity;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
|
/** Test seam: pretend the price list was just fetched. */
|
||||||
|
function _setCache(list, at = Date.now()) {
|
||||||
|
cached = { at, list };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
syncPricing,
|
||||||
|
syncEnabled,
|
||||||
|
startPricingSync,
|
||||||
|
familyOf,
|
||||||
|
displayName,
|
||||||
|
toRow,
|
||||||
|
_resetCache,
|
||||||
|
_setCache,
|
||||||
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
const tmux = require("./tmux");
|
const tmux = require("./tmux");
|
||||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||||
const { desktopEnv } = require("./desktop-env");
|
const { desktopEnv } = require("./desktop-env");
|
||||||
|
const { getCodexHome } = require("./codex-home");
|
||||||
const aiTools = require("./ai-tools");
|
const aiTools = require("./ai-tools");
|
||||||
|
|
||||||
let dashboardRuns = null;
|
let dashboardRuns = null;
|
||||||
@@ -329,6 +330,10 @@ function spawnRun(args) {
|
|||||||
AGENTHUB_LANE_ID: String(laneId),
|
AGENTHUB_LANE_ID: String(laneId),
|
||||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||||
...desktopEnv(),
|
...desktopEnv(),
|
||||||
|
// A codex pane must read the same home the dashboard reads — its rollout
|
||||||
|
// journals and the router layer `-p <name>` loads live there — and the
|
||||||
|
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||||
|
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const routers = require("./ai-routers");
|
|||||||
const { patchSettings } = require("./cc-mutate");
|
const { patchSettings } = require("./cc-mutate");
|
||||||
const { excludeFromGit } = require("./git-exclude");
|
const { excludeFromGit } = require("./git-exclude");
|
||||||
const { getCodexHome } = require("./codex-home");
|
const { getCodexHome } = require("./codex-home");
|
||||||
const { lanesByRouter, setRouterProfile } = require("./lanes");
|
const { lanesByRouter, listLanes, setRouterProfile } = require("./lanes");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The env var Codex is told to read the router key from. One fixed name rather
|
* The env var Codex is told to read the router key from. One fixed name rather
|
||||||
@@ -255,6 +255,43 @@ function reapplyToLanes(profileName) {
|
|||||||
return { updated, failed };
|
return { updated, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-write the router layer of every codex lane on a router, into the CURRENT
|
||||||
|
* `$CODEX_HOME`. Called after CODEX_HOME moves: the layer `codex -p <name>`
|
||||||
|
* loads lives there, so without this every such lane would fail to start.
|
||||||
|
* Per-lane best effort, like `reapplyToLanes`.
|
||||||
|
*
|
||||||
|
* @returns {{updated:number, failed:string[]}}
|
||||||
|
*/
|
||||||
|
function reapplyCodexLayers() {
|
||||||
|
const failed = [];
|
||||||
|
let updated = 0;
|
||||||
|
for (const lane of listLanes()) {
|
||||||
|
if (lane.tool !== "codex" || !lane.router_profile || lane.router_profile === "native") continue;
|
||||||
|
try {
|
||||||
|
applyRouterToLane(lane, lane.router_profile);
|
||||||
|
updated += 1;
|
||||||
|
} catch (e) {
|
||||||
|
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { updated, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lanes naming an AI Router profile the store no longer has — after
|
||||||
|
* CLAUDE_HOME moved (the store is `<CLAUDE_HOME>/agenthub-routers/`), such a
|
||||||
|
* lane starts on its base configuration (`readOrNull`), so the move says so.
|
||||||
|
* @returns {string[]} `"<id> (<title>): <profile>"` per lane.
|
||||||
|
*/
|
||||||
|
function lanesWithMissingRouter() {
|
||||||
|
return listLanes()
|
||||||
|
.filter(
|
||||||
|
(l) => l.router_profile && l.router_profile !== "native" && !readOrNull(l.router_profile)
|
||||||
|
)
|
||||||
|
.map((l) => `${l.id} (${l.title || l.cwd}): ${l.router_profile}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach `profileName` from every lane using it, and delete the Codex layer
|
* Detach `profileName` from every lane using it, and delete the Codex layer
|
||||||
* file it wrote.
|
* file it wrote.
|
||||||
@@ -291,8 +328,10 @@ function detachFromLanes(profileName) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
applyRouterToLane,
|
applyRouterToLane,
|
||||||
reapplyToLanes,
|
reapplyToLanes,
|
||||||
|
reapplyCodexLayers,
|
||||||
detachFromLanes,
|
detachFromLanes,
|
||||||
routerArgvForSpawn,
|
routerArgvForSpawn,
|
||||||
routerEnvForSpawn,
|
routerEnvForSpawn,
|
||||||
|
lanesWithMissingRouter,
|
||||||
CODEX_KEY_ENV,
|
CODEX_KEY_ENV,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* @file Where a setting saved from the dashboard (CLAUDE_HOME, CODEX_HOME, the
|
||||||
|
* pinned DASHBOARD_DATA_DIR) persists, and the startup env load order.
|
||||||
|
*
|
||||||
|
* The file is `$XDG_CONFIG_HOME/agenthub/settings.env` (default
|
||||||
|
* `~/.config/agenthub/`, or `AGENTHUB_SETTINGS_FILE`) — outside the repo,
|
||||||
|
* because a plugin install runs from the plugin cache Claude Code replaces on
|
||||||
|
* every update, and outside CLAUDE_HOME, because the data dir is derived from
|
||||||
|
* CLAUDE_HOME and the setting that moves it cannot live under it.
|
||||||
|
*
|
||||||
|
* Load order: the real environment wins, then a checkout's own `<repo>/.env`
|
||||||
|
* (per checkout, so more specific than a per-user file: a dev checkout whose
|
||||||
|
* `.env` names its own DASHBOARD_DATA_DIR never opens the live database), then
|
||||||
|
* this file; a key is only ever set while still unset, and a key the real
|
||||||
|
* environment or `.env` set is `env_owned` — a value saved from the dashboard
|
||||||
|
* would be overridden again at the next start, and the UI says so. The keys
|
||||||
|
* that came from a file are listed in `AGENTHUB_FILE_KEYS`, so a server that
|
||||||
|
* inherits this process's env (a self-restart spawns its successor with it)
|
||||||
|
* does not mistake them for the real environment's and re-reads them. Deliberately dependency-free —
|
||||||
|
* `server/index.js` runs it before anything else is required.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
/** Keys the real environment supplied at startup; filled by `loadEnvFiles`. */
|
||||||
|
const envOwned = new Set();
|
||||||
|
|
||||||
|
function settingsFilePath(env = process.env) {
|
||||||
|
if (env.AGENTHUB_SETTINGS_FILE) return env.AGENTHUB_SETTINGS_FILE;
|
||||||
|
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||||
|
return path.join(base, "agenthub", "settings.env");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandHome(value) {
|
||||||
|
return value.replace(/^~(?=\/|$)/, os.homedir());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnv(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
let val = trimmed.slice(eq + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
out.push([key, val]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill `env` from `owned` then `files`, in order, never overwriting a key
|
||||||
|
* already set. A key from an `owned` file (the checkout's `.env`) counts as
|
||||||
|
* the environment's, like one the real environment held. `files` defaults to
|
||||||
|
* the settings file, located AFTER the owned files load, so an
|
||||||
|
* `AGENTHUB_SETTINGS_FILE` set in `.env` is the file read and written.
|
||||||
|
* @returns {Set<string>} the owned keys (inherited file-loaded keys, named in
|
||||||
|
* `AGENTHUB_FILE_KEYS`, excluded).
|
||||||
|
*/
|
||||||
|
function loadEnvFiles({ env = process.env, owned = [], files } = {}) {
|
||||||
|
for (const k of (env.AGENTHUB_FILE_KEYS || "").split(",")) if (k) delete env[k];
|
||||||
|
delete env.AGENTHUB_FILE_KEYS;
|
||||||
|
const before = new Set(Object.keys(env).filter((k) => env[k]));
|
||||||
|
const loaded = new Set();
|
||||||
|
const load = (file, into) => {
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(file, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [key, val] of parseEnv(text)) {
|
||||||
|
if (!env[key]) {
|
||||||
|
env[key] = expandHome(val);
|
||||||
|
into.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const file of owned) load(file, before);
|
||||||
|
for (const file of files ?? [settingsFilePath(env)]) load(file, loaded);
|
||||||
|
if (loaded.size) env.AGENTHUB_FILE_KEYS = [...loaded].join(",");
|
||||||
|
if (env === process.env) for (const k of before) envOwned.add(k);
|
||||||
|
return before;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a setting and apply it to this process. A key the real environment
|
||||||
|
* does not own joins `AGENTHUB_FILE_KEYS`, like one loaded from the file.
|
||||||
|
*/
|
||||||
|
function applySetting(key, value) {
|
||||||
|
writeSetting(key, value);
|
||||||
|
process.env[key] = value;
|
||||||
|
if (envOwned.has(key)) return;
|
||||||
|
const keys = new Set((process.env.AGENTHUB_FILE_KEYS || "").split(",").filter(Boolean));
|
||||||
|
keys.add(key);
|
||||||
|
process.env.AGENTHUB_FILE_KEYS = [...keys].join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or replace one `KEY=value` line — every earlier line for that key,
|
||||||
|
* `KEY = value` included, is dropped, or the first one would still win at the
|
||||||
|
* next load. Atomic, dir 0700, file 0600, through a symlink to its target.
|
||||||
|
*/
|
||||||
|
// ponytail: read-modify-write without a lock — two servers saving at the same
|
||||||
|
// instant can drop one key; add a lock file if that ever happens for real.
|
||||||
|
function writeSetting(key, value, file = settingsFilePath()) {
|
||||||
|
if (/[\r\n]/.test(value)) throw new Error(`${key} may not contain a line break`);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||||
|
let lines = [];
|
||||||
|
try {
|
||||||
|
lines = fs
|
||||||
|
.readFileSync(file, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l.trim() !== "");
|
||||||
|
} catch {
|
||||||
|
/* first write */
|
||||||
|
}
|
||||||
|
const isKey = (l) => {
|
||||||
|
const t = l.trim();
|
||||||
|
return !t.startsWith("#") && t.includes("=") && t.slice(0, t.indexOf("=")).trim() === key;
|
||||||
|
};
|
||||||
|
let done = false;
|
||||||
|
lines = lines.flatMap((l) =>
|
||||||
|
!isKey(l) ? [l] : done ? [] : ((done = true), [`${key}=${value}`])
|
||||||
|
);
|
||||||
|
if (!done) lines.push(`${key}=${value}`);
|
||||||
|
let target = file;
|
||||||
|
try {
|
||||||
|
target = fs.realpathSync(file);
|
||||||
|
} catch {
|
||||||
|
/* first write */
|
||||||
|
}
|
||||||
|
const tmp = `${target}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, `${lines.join("\n")}\n`, { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one check an editable home directory passes. A directory that IS, or
|
||||||
|
* CONTAINS, the OS user's home — `/`, `/home`, `~` itself, or a symlink, a
|
||||||
|
* bind mount or (on a case-insensitive disk) a differently-cased path to any of
|
||||||
|
* them — is refused (`EBADHOME`): everything under such a home becomes
|
||||||
|
* readable, and partly writable, through its config panel. Compared by device
|
||||||
|
* and inode against the home and each of its ancestors.
|
||||||
|
* @returns {string} the normalized absolute path (not symlink-resolved).
|
||||||
|
*/
|
||||||
|
function validateHomeDir(label, input) {
|
||||||
|
const invalid = (message) => Object.assign(new Error(message), { code: "INVALID_PATH" });
|
||||||
|
const trimmed = String(input).trim();
|
||||||
|
if (/[\x00-\x1f]/.test(trimmed)) throw invalid(`${label} may not contain control characters`);
|
||||||
|
const expanded = expandHome(trimmed);
|
||||||
|
if (!path.isAbsolute(expanded)) throw invalid(`${label} must be an absolute path`);
|
||||||
|
const resolved = path.resolve(expanded);
|
||||||
|
const tooBroad = () =>
|
||||||
|
Object.assign(new Error(`Too broad for ${label}: ${resolved} contains your home directory`), {
|
||||||
|
code: "EBADHOME",
|
||||||
|
});
|
||||||
|
const contains = (dir, home) =>
|
||||||
|
home === dir || home.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
|
||||||
|
if (contains(resolved, path.resolve(os.homedir()))) throw tooBroad();
|
||||||
|
if (!fs.existsSync(resolved)) throw invalid(`Directory does not exist: ${resolved}`);
|
||||||
|
if (!fs.statSync(resolved).isDirectory()) throw invalid(`Not a directory: ${resolved}`);
|
||||||
|
let realHome;
|
||||||
|
try {
|
||||||
|
realHome = fs.realpathSync(os.homedir());
|
||||||
|
} catch {
|
||||||
|
realHome = path.resolve(os.homedir());
|
||||||
|
}
|
||||||
|
const idOf = (p) => {
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(p);
|
||||||
|
return `${st.dev}:${st.ino}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const target = idOf(resolved);
|
||||||
|
for (let dir = realHome; ; dir = path.dirname(dir)) {
|
||||||
|
if (idOf(dir) === target) throw tooBroad();
|
||||||
|
if (dir === path.dirname(dir)) break;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
settingsFilePath,
|
||||||
|
loadEnvFiles,
|
||||||
|
writeSetting,
|
||||||
|
applySetting,
|
||||||
|
validateHomeDir,
|
||||||
|
envOwned,
|
||||||
|
};
|
||||||
@@ -287,6 +287,11 @@ const schemas = {
|
|||||||
"Absolute path to the active Claude Code home directory (CLAUDE_HOME, or `<homedir>/.claude` when unset).",
|
"Absolute path to the active Claude Code home directory (CLAUDE_HOME, or `<homedir>/.claude` when unset).",
|
||||||
example: "/Users/son/.claude",
|
example: "/Users/son/.claude",
|
||||||
},
|
},
|
||||||
|
env_owned: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"True when the real environment, or the checkout's own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -308,7 +313,7 @@ const schemas = {
|
|||||||
SettingsClaudeHomeUpdateResponse: {
|
SettingsClaudeHomeUpdateResponse: {
|
||||||
type: "object",
|
type: "object",
|
||||||
description:
|
description:
|
||||||
"Confirmation that CLAUDE_HOME was updated. The new value is applied to process.env immediately and persisted to the project `.env` file.",
|
"Confirmation that CLAUDE_HOME was updated. The new value is applied to process.env immediately and persisted to the settings file (`$XDG_CONFIG_HOME/agenthub/settings.env`, default `~/.config/agenthub/`, or AGENTHUB_SETTINGS_FILE).",
|
||||||
required: ["ok", "claude_home"],
|
required: ["ok", "claude_home"],
|
||||||
properties: {
|
properties: {
|
||||||
ok: { type: "boolean", enum: [true] },
|
ok: { type: "boolean", enum: [true] },
|
||||||
@@ -317,6 +322,22 @@ const schemas = {
|
|||||||
description: "The resolved absolute path now in effect (after `~` expansion).",
|
description: "The resolved absolute path now in effect (after `~` expansion).",
|
||||||
example: "/Users/son/.codefuse/engine/cc",
|
example: "/Users/son/.codefuse/engine/cc",
|
||||||
},
|
},
|
||||||
|
env_owned: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"True when the real environment, or the checkout's own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.",
|
||||||
|
},
|
||||||
|
routers: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
missing: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description:
|
||||||
|
"`<lane id> (<title>): <profile>` for every lane naming an AI Router profile the new home's store (`<CLAUDE_HOME>/agenthub-routers/`) does not have — such a lane starts on its base configuration.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -988,6 +1009,258 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"/api/settings/codex-hooks": (() => {
|
||||||
|
const status = {
|
||||||
|
description: "Where AgentHub's Codex hooks stand",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["supported", "readable", "path", "script", "installed", "trusted", "events"],
|
||||||
|
properties: {
|
||||||
|
supported: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "False on Windows (the command is a POSIX script)",
|
||||||
|
},
|
||||||
|
readable: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "False when hooks.json is not a JSON object",
|
||||||
|
},
|
||||||
|
error: { type: "string" },
|
||||||
|
path: { type: "string", description: "`$CODEX_HOME/hooks.json`" },
|
||||||
|
script: {
|
||||||
|
type: "string",
|
||||||
|
description: "`$CODEX_HOME/agenthub-hook.sh`, the forwarder",
|
||||||
|
},
|
||||||
|
installed: { type: "boolean" },
|
||||||
|
trusted: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Codex's config.toml holds a trust entry for every one of AgentHub's hooks",
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: { installed: { type: "boolean" }, trusted: { type: "boolean" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const refused = {
|
||||||
|
description:
|
||||||
|
"EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)",
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
|
||||||
|
};
|
||||||
|
const failed = {
|
||||||
|
description: "ESAVE: the file system refused the write",
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Status of AgentHub's hooks in Codex",
|
||||||
|
description:
|
||||||
|
"Per event (SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd): whether AgentHub's group is in `$CODEX_HOME/hooks.json`, and whether Codex's `config.toml` holds a trust entry for it. Codex runs a hook only once the user trusts it; AgentHub only reads that trust.",
|
||||||
|
operationId: "getCodexHooks",
|
||||||
|
responses: { 200: status, 500: failed },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Install (or reinstall) AgentHub's hooks in Codex",
|
||||||
|
description:
|
||||||
|
"Refuses a cross-origin request (403 EBADORIGIN). Writes the forwarder script and adds one hook of AgentHub's per event to `$CODEX_HOME/hooks.json`, keeping every other entry. The command is the script's stable path, so Codex's trust survives AgentHub updates; the server rewrites the script's content at every start. Codex asks the user to trust the new hooks the next time it starts.",
|
||||||
|
operationId: "installCodexHooks",
|
||||||
|
responses: { 200: status, 400: refused, 500: failed },
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Remove AgentHub's hooks from Codex",
|
||||||
|
description:
|
||||||
|
"Refuses a cross-origin request (403 EBADORIGIN). Takes AgentHub's own hooks (matched by their exact command) out of `$CODEX_HOME/hooks.json` (deleting the file when nothing else is left, or emptying it through the link when it is a symlink) and deletes the forwarder script when it is AgentHub's. 400 EBADJSON when hooks.json is not a JSON object.",
|
||||||
|
operationId: "removeCodexHooks",
|
||||||
|
responses: { 200: status, 400: refused, 500: failed },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
"/api/settings/antigravity-hooks": (() => {
|
||||||
|
const status = {
|
||||||
|
description: "Where AgentHub's Antigravity hook stands",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["supported", "readable", "path", "script", "installed", "enabled", "events"],
|
||||||
|
properties: {
|
||||||
|
supported: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "False on Windows (the command is a POSIX script)",
|
||||||
|
},
|
||||||
|
readable: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "False when hooks.json is not a JSON object",
|
||||||
|
},
|
||||||
|
error: { type: "string" },
|
||||||
|
path: { type: "string", description: "`~/.gemini/config/hooks.json`" },
|
||||||
|
script: {
|
||||||
|
type: "string",
|
||||||
|
description: "`~/.gemini/config/agenthub-hook.sh`, the forwarder",
|
||||||
|
},
|
||||||
|
installed: { type: "boolean" },
|
||||||
|
enabled: {
|
||||||
|
type: "boolean",
|
||||||
|
description: 'Installed and not switched off (`"enabled": false`) in the file',
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: { installed: { type: "boolean" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const refused = {
|
||||||
|
description:
|
||||||
|
"EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)",
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
|
||||||
|
};
|
||||||
|
const failed = {
|
||||||
|
description: "ESAVE: the file system refused the write",
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Status of AgentHub's hook in Antigravity",
|
||||||
|
description:
|
||||||
|
"Per event (PreInvocation, PostToolUse, Stop): whether AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` runs its forwarder. PreToolUse is never installed: agy treats its answer as a permission decision.",
|
||||||
|
operationId: "getAntigravityHooks",
|
||||||
|
responses: { 200: status, 500: failed },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Install (or reinstall) AgentHub's hook in Antigravity",
|
||||||
|
description:
|
||||||
|
"Refuses a cross-origin request (403 EBADORIGIN). Writes the forwarder script and sets the named hook `agenthub` in `~/.gemini/config/hooks.json`, keeping every other named hook. agy loads it at its next start, with no trust step.",
|
||||||
|
operationId: "installAntigravityHooks",
|
||||||
|
responses: { 200: status, 400: refused, 500: failed },
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Remove AgentHub's hook from Antigravity",
|
||||||
|
description:
|
||||||
|
"Refuses a cross-origin request (403 EBADORIGIN). Deletes the named hook `agenthub` (and the file when nothing else is left, unless it is a symlink, which is emptied through the link) and the forwarder script when it is AgentHub's. 400 EBADJSON when hooks.json is not a JSON object.",
|
||||||
|
operationId: "removeAntigravityHooks",
|
||||||
|
responses: { 200: status, 400: refused, 500: failed },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
"/api/settings/codex-home": {
|
||||||
|
get: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Get the active Codex home directory",
|
||||||
|
description:
|
||||||
|
"Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Router layers into, and hands every codex lane pane through tmux `-e`.",
|
||||||
|
operationId: "getCodexHome",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Current Codex home directory",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["codex_home", "env_owned"],
|
||||||
|
properties: {
|
||||||
|
codex_home: { type: "string" },
|
||||||
|
env_owned: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example: { codex_home: "/Users/son/.codex", env_owned: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
put: {
|
||||||
|
tags: ["Settings"],
|
||||||
|
summary: "Update the Codex home directory",
|
||||||
|
description:
|
||||||
|
"Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). On success it re-writes the AI Router layer (`<name>.config.toml`) of every codex lane on a router into the new home, reported as `routers`, because `codex -p <name>` loads it from there. A codex pane already running keeps its old home until restarted.",
|
||||||
|
operationId: "updateCodexHome",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path"],
|
||||||
|
properties: { path: { type: "string" } },
|
||||||
|
},
|
||||||
|
example: { path: "~/.codex-work" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Codex home updated",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["ok", "codex_home", "env_owned", "routers"],
|
||||||
|
properties: {
|
||||||
|
ok: { type: "boolean", enum: [true] },
|
||||||
|
codex_home: { type: "string" },
|
||||||
|
env_owned: { type: "boolean" },
|
||||||
|
routers: {
|
||||||
|
type: "object",
|
||||||
|
required: ["updated", "failed"],
|
||||||
|
properties: {
|
||||||
|
updated: { type: "integer" },
|
||||||
|
failed: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
ok: true,
|
||||||
|
codex_home: "/Users/son/.codex-work",
|
||||||
|
env_owned: false,
|
||||||
|
routers: { updated: 1, failed: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: "Invalid path (INVALID_PATH) or too broad (EBADHOME).",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||||
|
example: {
|
||||||
|
error: {
|
||||||
|
code: "EBADHOME",
|
||||||
|
message: "Too broad for CODEX_HOME: / contains your home directory",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "The settings file could not be written (ESAVE).",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/settings/claude-home": {
|
"/api/settings/claude-home": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Settings"],
|
tags: ["Settings"],
|
||||||
@@ -1011,7 +1284,7 @@ const paths = {
|
|||||||
tags: ["Settings"],
|
tags: ["Settings"],
|
||||||
summary: "Update the Claude Code home directory",
|
summary: "Update the Claude Code home directory",
|
||||||
description:
|
description:
|
||||||
"Changes the Claude Code home directory used for transcript/settings discovery. A leading `~/` in `path` is expanded to the OS home directory; the resolved value must be an absolute path that exists and is a directory. On success the new value is applied to process.env immediately (so subsequent reads use it) and persisted to the project `.env` file. Returns 400 INVALID_PATH when `path` is missing/not a string, or when the resolved path is not absolute, does not exist, or is not a directory.",
|
"Changes the Claude Code home directory used for transcript/settings discovery. A leading `~/` in `path` is expanded to the OS home directory; the resolved value must be an absolute path that exists and is a directory. On success the new value is applied to process.env immediately (so subsequent reads use it) and persisted to the settings file (`$XDG_CONFIG_HOME/agenthub/settings.env`, default `~/.config/agenthub/`, or AGENTHUB_SETTINGS_FILE — never the repo `.env`, which a plugin update replaces). The data dir (database, runtime) is derived from CLAUDE_HOME, so the first change also pins the one in use as DASHBOARD_DATA_DIR unless the environment already sets it. Refuses a cross-origin request (403 EBADORIGIN). Returns 400 EBADHOME when the path is, or contains (symlinks resolved), the OS user's home directory — `/`, `/home`, `~` — because everything under CLAUDE_HOME becomes readable through the config panel; 400 INVALID_PATH when `path` is missing/not a string, or the resolved path is not absolute, does not exist, or is not a directory.",
|
||||||
operationId: "updateClaudeHome",
|
operationId: "updateClaudeHome",
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -1034,7 +1307,7 @@ const paths = {
|
|||||||
},
|
},
|
||||||
400: {
|
400: {
|
||||||
description:
|
description:
|
||||||
"Invalid path — `path` missing or not a string, or the resolved path is not absolute / does not exist / is not a directory (code INVALID_PATH).",
|
"Invalid path — `path` missing or not a string, or the resolved path is not absolute / does not exist / is not a directory (code INVALID_PATH), or it is or contains the OS home directory (code EBADHOME).",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||||
@@ -1047,6 +1320,12 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
500: {
|
||||||
|
description: "The settings file could not be written (ESAVE).",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -401,6 +401,12 @@ const paths = {
|
|||||||
"the normalized row that was just inserted (`session_id`, `agent_id`, `event_type`, " +
|
"the normalized row that was just inserted (`session_id`, `agent_id`, `event_type`, " +
|
||||||
"`tool_name`, `summary`, `created_at`). Ingestion is designed to be fail-safe and " +
|
"`tool_name`, `summary`, `created_at`). Ingestion is designed to be fail-safe and " +
|
||||||
"non-blocking for the hook caller.\n\n" +
|
"non-blocking for the hook caller.\n\n" +
|
||||||
|
"The optional `tool` (`codex`, `antigravity`; anything else is `claude`) names the " +
|
||||||
|
"CLI that sent the hook. A Codex or agy hook is mapped onto the same hook types " +
|
||||||
|
"first (a Codex PermissionRequest becomes a Notification, an Interrupt a Stop; an " +
|
||||||
|
"agy turn's first PreInvocation a UserPromptSubmit), and one that maps to nothing " +
|
||||||
|
"is answered `{ ok: true, ignored: true }`. A hook delivered twice is answered " +
|
||||||
|
"`{ ok: true, duplicate: true }` and stored once.\n\n" +
|
||||||
"Validation failures return HTTP 400 with an `ErrorResponse` body " +
|
"Validation failures return HTTP 400 with an `ErrorResponse` body " +
|
||||||
"(`{ error: { code, message } }`): `INVALID_INPUT` when `hook_type` or `data` is " +
|
"(`{ error: { code, message } }`): `INVALID_INPUT` when `hook_type` or `data` is " +
|
||||||
"missing, and `MISSING_SESSION` when `data.session_id` is absent.",
|
"missing, and `MISSING_SESSION` when `data.session_id` is absent.",
|
||||||
|
|||||||
@@ -1081,8 +1081,25 @@ const paths = {
|
|||||||
tags: ["Settings"],
|
tags: ["Settings"],
|
||||||
summary: "Reset pricing table to defaults",
|
summary: "Reset pricing table to defaults",
|
||||||
description:
|
description:
|
||||||
"⚠ DESTRUCTIVE to pricing customizations. Deletes EVERY row in the model_pricing table and re-seeds it from the dashboard's built-in DEFAULT_PRICING list. Any custom rates or custom model patterns you added are permanently lost — there is no undo. Captured session/token data is untouched (only the pricing rules used to *compute* cost change). The response returns the full freshly-seeded pricing table.",
|
'⚠ DESTRUCTIVE to pricing customizations. Deletes EVERY row in the model_pricing table and re-seeds it from the dashboard\'s built-in DEFAULT_PRICING list. Any custom rates or custom model patterns you added are permanently lost — there is no undo. Captured session/token data is untouched (only the pricing rules used to *compute* cost change). The response returns the full freshly-seeded pricing table. With `{"scope": "claude"}` (the Claude Code tab\'s button) only `claude-…` rows are deleted and re-seeded; every other vendor\'s rows and custom patterns are kept.',
|
||||||
operationId: "resetPricing",
|
operationId: "resetPricing",
|
||||||
|
requestBody: {
|
||||||
|
required: false,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
scope: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["claude"],
|
||||||
|
description: "Reset only `claude-…` rows. Omitted: every row.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Pricing defaults restored",
|
description: "Pricing defaults restored",
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ const paths = {
|
|||||||
"Case-insensitive search across `id` / `name` / `cwd`. Composes with the status filter when both are present.",
|
"Case-insensitive search across `id` / `name` / `cwd`. Composes with the status filter when both are present.",
|
||||||
example: "pricing",
|
example: "pricing",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "tool",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string", enum: ["claude", "codex", "antigravity"] },
|
||||||
|
description:
|
||||||
|
"Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.",
|
||||||
|
example: "claude",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
$ref: "#/components/parameters/SourcesQuery",
|
$ref: "#/components/parameters/SourcesQuery",
|
||||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||||
|
|||||||
+24
-3
@@ -241,6 +241,11 @@ function createOpenApiSpec() {
|
|||||||
description: "JSON-encoded session metadata",
|
description: "JSON-encoded session metadata",
|
||||||
},
|
},
|
||||||
updated_at: { type: "string", format: "date-time" },
|
updated_at: { type: "string", format: "date-time" },
|
||||||
|
tool: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"The AI coding CLI that produced the session: `claude` (Claude Code's hooks and imported transcripts), `codex` (Codex's rollout journals and its opt-in hooks) or `antigravity` (agy's conversation files and its opt-in hooks).",
|
||||||
|
},
|
||||||
agent_count: { type: "integer", nullable: true },
|
agent_count: { type: "integer", nullable: true },
|
||||||
last_activity: { type: "string", format: "date-time", nullable: true },
|
last_activity: { type: "string", format: "date-time", nullable: true },
|
||||||
cost: { type: "number", nullable: true },
|
cost: { type: "number", nullable: true },
|
||||||
@@ -822,11 +827,18 @@ function createOpenApiSpec() {
|
|||||||
hook_type: {
|
hook_type: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
description:
|
||||||
"Hook type from Claude Code (common values: PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionStart, SessionEnd)",
|
"Hook type from the CLI that sent it (Claude Code: PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionStart, SessionEnd; Codex adds PermissionRequest and Interrupt; agy sends PreInvocation, PostToolUse and Stop)",
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["claude", "codex", "antigravity"],
|
||||||
|
description:
|
||||||
|
"Which CLI sent the hook (`scripts/hook-handler.js <Event> [codex|antigravity]`). An allowlist: anything else is `claude`.",
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["session_id"],
|
description:
|
||||||
|
"The raw hook payload. Claude Code and Codex name the session `session_id`; agy names it `conversationId` and is accepted only with a `transcriptPath` under its own CLI directory.",
|
||||||
properties: {
|
properties: {
|
||||||
session_id: { type: "string" },
|
session_id: { type: "string" },
|
||||||
tool_name: { type: "string" },
|
tool_name: { type: "string" },
|
||||||
@@ -838,10 +850,19 @@ function createOpenApiSpec() {
|
|||||||
},
|
},
|
||||||
HookEventResponse: {
|
HookEventResponse: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["ok", "event"],
|
required: ["ok"],
|
||||||
properties: {
|
properties: {
|
||||||
ok: { type: "boolean", enum: [true] },
|
ok: { type: "boolean", enum: [true] },
|
||||||
event: { $ref: "#/components/schemas/DashboardEvent" },
|
event: { $ref: "#/components/schemas/DashboardEvent" },
|
||||||
|
ignored: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"The hook maps to nothing AgentHub records (a Codex sub-agent's lifecycle hook, an agy event other than a turn's first PreInvocation, a PostToolUse or a Stop, or a hook from the Antigravity IDE).",
|
||||||
|
},
|
||||||
|
duplicate: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "The same hook delivered twice within 10 seconds; stored once.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PricingRule: {
|
PricingRule: {
|
||||||
|
|||||||
+238
-68
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @file Express router for handling incoming hook events from Claude CLI. It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
|
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -19,6 +19,9 @@ const lanesLib = require("../lib/lanes");
|
|||||||
const { broadcastLane } = require("./lanes");
|
const { broadcastLane } = require("./lanes");
|
||||||
const { detect } = require("../lib/stage-detect");
|
const { detect } = require("../lib/stage-detect");
|
||||||
const { getPipeline } = require("../lib/pipelines");
|
const { getPipeline } = require("../lib/pipelines");
|
||||||
|
const path = require("path");
|
||||||
|
const { threadOf } = require("../lib/codex-usage");
|
||||||
|
const { agyCliDirs } = require("../lib/antigravity-home");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -166,12 +169,13 @@ const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]);
|
|||||||
* running/idle, so without this an adopted lane read "idle" for the entire
|
* running/idle, so without this an adopted lane read "idle" for the entire
|
||||||
* time Claude was working in it.
|
* time Claude was working in it.
|
||||||
*/
|
*/
|
||||||
function touchLaneFromHook(hookType, data) {
|
function touchLaneFromHook(hookType, data, tool = "claude") {
|
||||||
try {
|
try {
|
||||||
if (!data || !data.cwd) return;
|
if (!data || !data.cwd) return;
|
||||||
// Hooks are only ever emitted by the Claude Code CLI, so pin to the
|
// Pin to the lane of the CLI that sent the hook — a Claude hook never
|
||||||
// `claude` lane at this cwd — never the `codex` lane that might share it.
|
// touches the codex lane sharing its directory, nor a Codex hook the
|
||||||
const lane = lanesLib.resolveLaneByCwd(data.cwd, "claude");
|
// claude one.
|
||||||
|
const lane = lanesLib.resolveLaneByCwd(data.cwd, tool);
|
||||||
if (!lane) return;
|
if (!lane) return;
|
||||||
|
|
||||||
// Infer the lane's stage from this one tool event, best-effort. A real
|
// Infer the lane's stage from this one tool event, best-effort. A real
|
||||||
@@ -188,30 +192,38 @@ function touchLaneFromHook(hookType, data) {
|
|||||||
// of that bookkeeping because the block below returns early when the patch is
|
// of that bookkeeping because the block below returns early when the patch is
|
||||||
// empty — the common case — so detection placed after it would be unreachable
|
// empty — the common case — so detection placed after it would be unreachable
|
||||||
// on most hooks.
|
// on most hooks.
|
||||||
try {
|
// Only a claude lane carries a pipeline; every other tool's lane has no
|
||||||
// Credit the lane whose files/directory the tool actually touched, which
|
// stage to infer.
|
||||||
// is not always the session's lane - see resolveLaneForWork. Bookkeeping
|
if (tool === "claude") {
|
||||||
// below still uses `lane`, the session's own.
|
try {
|
||||||
const workLane = lanesLib.resolveLaneForWork(data.cwd, data.tool_input) || lane;
|
// Credit the lane whose files/directory the tool actually touched, which
|
||||||
const detection = detect(getPipeline(workLane.pipeline), {
|
// is not always the session's lane - see resolveLaneForWork. Bookkeeping
|
||||||
tool_name: data.tool_name,
|
// below still uses `lane`, the session's own.
|
||||||
tool_input: data.tool_input,
|
const workLane = lanesLib.resolveLaneForWork(data.cwd, data.tool_input) || lane;
|
||||||
});
|
const detection = detect(getPipeline(workLane.pipeline), {
|
||||||
if (detection) {
|
tool_name: data.tool_name,
|
||||||
const detectResult = lanesLib.recordDetection(workLane.id, detection);
|
tool_input: data.tool_input,
|
||||||
if (detectResult.written) broadcastLane(workLane.id);
|
});
|
||||||
|
if (detection) {
|
||||||
|
const detectResult = lanesLib.recordDetection(workLane.id, detection);
|
||||||
|
if (detectResult.written) broadcastLane(workLane.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* an inference is never worth dropping real bookkeeping for */
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* an inference is never worth dropping real bookkeeping for */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const patch = {};
|
const patch = {};
|
||||||
|
// A Codex sub-agent's tool call (see processEvent): not the user's session
|
||||||
|
// resuming, so it neither clears needs_action nor moves the lane.
|
||||||
|
const childEvent = tool === "codex" && data._codexChild === true;
|
||||||
const idleNudge = hookType === "Notification" && IDLE_NUDGE_RE.test(data.message || "");
|
const idleNudge = hookType === "Notification" && IDLE_NUDGE_RE.test(data.message || "");
|
||||||
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
|
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
|
||||||
if (hookType === "Notification" && !idleNudge) {
|
if (hookType === "Notification" && !idleNudge) {
|
||||||
patch.needs_action = data.message || "needs you";
|
patch.needs_action = data.message || "needs you";
|
||||||
patch.needs_action_session = data.session_id || null;
|
patch.needs_action_session = data.session_id || null;
|
||||||
} else if (
|
} else if (
|
||||||
|
!childEvent &&
|
||||||
lane.needs_action &&
|
lane.needs_action &&
|
||||||
data.session_id &&
|
data.session_id &&
|
||||||
data.session_id === (lane.needs_action_session || lane.session_id)
|
data.session_id === (lane.needs_action_session || lane.session_id)
|
||||||
@@ -226,7 +238,7 @@ function touchLaneFromHook(hookType, data) {
|
|||||||
|
|
||||||
// Only idle↔running is mirrored: provisioning and failed are lifecycle
|
// Only idle↔running is mirrored: provisioning and failed are lifecycle
|
||||||
// states this path must never stomp on.
|
// states this path must never stomp on.
|
||||||
if (!lane.run_id && (lane.status === "idle" || lane.status === "running")) {
|
if (!childEvent && !lane.run_id && (lane.status === "idle" || lane.status === "running")) {
|
||||||
const next = LANE_WORKING_HOOKS.has(hookType)
|
const next = LANE_WORKING_HOOKS.has(hookType)
|
||||||
? "running"
|
? "running"
|
||||||
: LANE_DONE_HOOKS.has(hookType) || idleNudge
|
: LANE_DONE_HOOKS.has(hookType) || idleNudge
|
||||||
@@ -243,40 +255,51 @@ function touchLaneFromHook(hookType, data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureSession(sessionId, data) {
|
const setSessionTool = db.prepare("UPDATE sessions SET tool = ? WHERE id = ?");
|
||||||
|
|
||||||
|
/** The session's `<id>-main` agent, created and broadcast. */
|
||||||
|
function createMainAgent(session) {
|
||||||
|
const mainAgentId = `${session.id}-main`;
|
||||||
|
const sessionLabel = session.name || `Session ${session.id.slice(0, 8)}`;
|
||||||
|
stmts.insertAgent.run(
|
||||||
|
mainAgentId,
|
||||||
|
session.id,
|
||||||
|
`Main Agent - ${sessionLabel}`,
|
||||||
|
"main",
|
||||||
|
null,
|
||||||
|
"working",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const mainAgent = stmts.getAgent.get(mainAgentId);
|
||||||
|
if (mainAgent) broadcast("agent_created", mainAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_LABEL = { codex: "Codex", antigravity: "Antigravity" };
|
||||||
|
|
||||||
|
function ensureSession(sessionId, data, tool = "claude") {
|
||||||
let session = stmts.getSession.get(sessionId);
|
let session = stmts.getSession.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
stmts.insertSession.run(
|
stmts.insertSession.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
data.session_name || `Session ${sessionId.slice(0, 8)}`,
|
data.session_name || `${SESSION_LABEL[tool] || "Session"} ${sessionId.slice(0, 8)}`,
|
||||||
"active",
|
"active",
|
||||||
data.cwd || null,
|
data.cwd || null,
|
||||||
data.model || null,
|
data.model || null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
if (tool !== "claude") setSessionTool.run(tool, sessionId);
|
||||||
session = stmts.getSession.get(sessionId);
|
session = stmts.getSession.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.error(`[HOOKS] Failed to create session ${sessionId} — insert returned no row`);
|
console.error(`[HOOKS] Failed to create session ${sessionId} — insert returned no row`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
broadcast("session_created", session);
|
broadcast("session_created", session);
|
||||||
|
createMainAgent(session);
|
||||||
// Create main agent for new session
|
} else if (tool !== "claude" && !stmts.getAgent.get(`${sessionId}-main`)) {
|
||||||
const mainAgentId = `${sessionId}-main`;
|
// A Codex session the journal poll imported first has no main agent yet.
|
||||||
const sessionLabel = session.name || `Session ${sessionId.slice(0, 8)}`;
|
createMainAgent(session);
|
||||||
stmts.insertAgent.run(
|
|
||||||
mainAgentId,
|
|
||||||
sessionId,
|
|
||||||
`Main Agent - ${sessionLabel}`,
|
|
||||||
"main",
|
|
||||||
null,
|
|
||||||
"working",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const mainAgent = stmts.getAgent.get(mainAgentId);
|
|
||||||
if (mainAgent) broadcast("agent_created", mainAgent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// First-seen transcript_path → write to session row so the periodic sweep
|
// First-seen transcript_path → write to session row so the periodic sweep
|
||||||
@@ -284,7 +307,9 @@ function ensureSession(sessionId, data) {
|
|||||||
// (NULL/'' check), so subsequent hooks for the same session are no-ops.
|
// (NULL/'' check), so subsequent hooks for the same session are no-ops.
|
||||||
// Type guard: hook payloads are unvalidated JSON — a non-string value would
|
// Type guard: hook payloads are unvalidated JSON — a non-string value would
|
||||||
// make better-sqlite3 throw inside the surrounding processEvent transaction.
|
// make better-sqlite3 throw inside the surrounding processEvent transaction.
|
||||||
if (typeof data.transcript_path === "string" && data.transcript_path) {
|
// A Codex rollout is not a Claude transcript, and every sweep that reads
|
||||||
|
// transcript_path parses Claude's format — it is never stored for one.
|
||||||
|
if (tool === "claude" && typeof data.transcript_path === "string" && data.transcript_path) {
|
||||||
stmts.setSessionTranscriptPath.run(data.transcript_path, sessionId);
|
stmts.setSessionTranscriptPath.run(data.transcript_path, sessionId);
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
@@ -419,11 +444,14 @@ function applyFirstUserDescriptor(sessionId, result) {
|
|||||||
if (refreshedAgent) broadcast("agent_updated", refreshedAgent);
|
if (refreshedAgent) broadcast("agent_updated", refreshedAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
const processEvent = db.transaction((hookType, data) => {
|
const processEvent = db.transaction((hookType, data, tool = "claude") => {
|
||||||
const sessionId = data.session_id;
|
const sessionId = data.session_id;
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
|
|
||||||
const session = ensureSession(sessionId, data);
|
const session = ensureSession(sessionId, data, tool);
|
||||||
|
// Only a Claude transcript is parsed here; a Codex session's tokens, model
|
||||||
|
// and name come from its rollout journal (server/lib/codex-usage.js).
|
||||||
|
const transcriptPath = tool === "claude" ? data.transcript_path : null;
|
||||||
|
|
||||||
// Remote household hooks (aideck-hook.js on other machines) cannot rely on
|
// Remote household hooks (aideck-hook.js on other machines) cannot rely on
|
||||||
// the transcript being readable on THIS host (the JSONL lives on the remote
|
// the transcript being readable on THIS host (the JSONL lives on the remote
|
||||||
@@ -442,6 +470,11 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
|
|
||||||
let mainAgent = getMainAgent(sessionId);
|
let mainAgent = getMainAgent(sessionId);
|
||||||
const mainAgentId = mainAgent?.id ?? null;
|
const mainAgentId = mainAgent?.id ?? null;
|
||||||
|
// A Codex sub-agent's tool call folded onto its root session (codexHookInput):
|
||||||
|
// work, but not the user resuming — it neither reactivates the session, nor
|
||||||
|
// clears its waiting flag, nor puts the main agent to work. Its own Stop is
|
||||||
|
// dropped, so anything it set here would never be undone.
|
||||||
|
const childEvent = tool === "codex" && data._codexChild === true;
|
||||||
|
|
||||||
// Reactivate non-active sessions when we receive hook events proving the session is alive.
|
// Reactivate non-active sessions when we receive hook events proving the session is alive.
|
||||||
// - UserPromptSubmit and PreToolUse always reactivate (user actively retried, even from error).
|
// - UserPromptSubmit and PreToolUse always reactivate (user actively retried, even from error).
|
||||||
@@ -455,6 +488,7 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
const isStopLike = hookType === "Stop" || hookType === "SubagentStop";
|
const isStopLike = hookType === "Stop" || hookType === "SubagentStop";
|
||||||
const isImportedOrAbandoned = session.status === "completed" || session.status === "abandoned";
|
const isImportedOrAbandoned = session.status === "completed" || session.status === "abandoned";
|
||||||
const needsReactivation =
|
const needsReactivation =
|
||||||
|
!childEvent &&
|
||||||
session.status !== "active" &&
|
session.status !== "active" &&
|
||||||
isNonTerminalEvent &&
|
isNonTerminalEvent &&
|
||||||
(isUserAction ||
|
(isUserAction ||
|
||||||
@@ -495,12 +529,14 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
// (reason='notification'), in which case the flag is preserved
|
// (reason='notification'), in which case the flag is preserved
|
||||||
// (clearAwaitingInputRespectingActor). Computed before the clear because
|
// (clearAwaitingInputRespectingActor). Computed before the clear because
|
||||||
// the Agent-spawn branch below mutates agent rows.
|
// the Agent-spawn branch below mutates agent rows.
|
||||||
clearAwaitingInputRespectingActor(
|
if (!childEvent) {
|
||||||
sessionId,
|
clearAwaitingInputRespectingActor(
|
||||||
mainAgentId,
|
sessionId,
|
||||||
subagentIsActorNow(sessionId, mainAgent),
|
mainAgentId,
|
||||||
true
|
subagentIsActorNow(sessionId, mainAgent),
|
||||||
);
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// If the tool is Agent, a subagent is being created
|
// If the tool is Agent, a subagent is being created
|
||||||
if (toolName === "Agent") {
|
if (toolName === "Agent") {
|
||||||
@@ -562,6 +598,7 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
mainAgent &&
|
mainAgent &&
|
||||||
|
!childEvent &&
|
||||||
!subagentIsActor &&
|
!subagentIsActor &&
|
||||||
(mainAgent.status === "working" || mainAgent.status === "waiting")
|
(mainAgent.status === "working" || mainAgent.status === "waiting")
|
||||||
) {
|
) {
|
||||||
@@ -581,12 +618,14 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
// a clear here, we'd be stuck in waiting until the next PreToolUse.
|
// a clear here, we'd be stuck in waiting until the next PreToolUse.
|
||||||
// Same actor-guard as PreToolUse: a background subagent's PostToolUse
|
// Same actor-guard as PreToolUse: a background subagent's PostToolUse
|
||||||
// must not clear a genuine 'notification' wait held by the main agent.
|
// must not clear a genuine 'notification' wait held by the main agent.
|
||||||
clearAwaitingInputRespectingActor(
|
if (!childEvent) {
|
||||||
sessionId,
|
clearAwaitingInputRespectingActor(
|
||||||
mainAgentId,
|
sessionId,
|
||||||
subagentIsActorNow(sessionId, mainAgent),
|
mainAgentId,
|
||||||
true
|
subagentIsActorNow(sessionId, mainAgent),
|
||||||
);
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: PostToolUse for "Agent" tool fires immediately when a subagent is
|
// NOTE: PostToolUse for "Agent" tool fires immediately when a subagent is
|
||||||
// backgrounded — it does NOT mean the subagent finished its work.
|
// backgrounded — it does NOT mean the subagent finished its work.
|
||||||
@@ -787,7 +826,7 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
// tail. A transient API error that the CLI retried past (successful turns
|
// tail. A transient API error that the CLI retried past (successful turns
|
||||||
// after it) has recovered, so exiting after a long healthy run must land
|
// after it) has recovered, so exiting after a long healthy run must land
|
||||||
// as `completed`, not a stale `error` frozen from days earlier.
|
// as `completed`, not a stale `error` frozen from days earlier.
|
||||||
const endResult = data.transcript_path ? transcriptCache.extract(data.transcript_path) : null;
|
const endResult = transcriptPath ? transcriptCache.extract(transcriptPath) : null;
|
||||||
const finalSessionStatus =
|
const finalSessionStatus =
|
||||||
endSession?.status === "error" && isErrorAtTail(endResult) ? "error" : "completed";
|
endSession?.status === "error" && isErrorAtTail(endResult) ? "error" : "completed";
|
||||||
const allAgents = stmts.listAgentsBySession.all(sessionId);
|
const allAgents = stmts.listAgentsBySession.all(sessionId);
|
||||||
@@ -824,8 +863,10 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
|
|
||||||
case "Notification": {
|
case "Notification": {
|
||||||
const msg = data.message || "Notification received";
|
const msg = data.message || "Notification received";
|
||||||
// Tag compaction-related notifications so they show as Compaction events
|
// Tag compaction-related notifications so they show as Compaction events.
|
||||||
if (/compact|compress|context.*(reduc|truncat|summar)/i.test(msg)) {
|
// Claude's only: another CLI's Notification is a permission prompt, whose
|
||||||
|
// message quotes the command ("tar --compress …").
|
||||||
|
if (tool === "claude" && /compact|compress|context.*(reduc|truncat|summar)/i.test(msg)) {
|
||||||
eventType = "Compaction";
|
eventType = "Compaction";
|
||||||
summary = msg;
|
summary = msg;
|
||||||
} else if (isWaitingForUserMessage(msg)) {
|
} else if (isWaitingForUserMessage(msg)) {
|
||||||
@@ -862,8 +903,8 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
//
|
//
|
||||||
// Also detects compaction events (isCompactSummary in JSONL) and creates a
|
// Also detects compaction events (isCompactSummary in JSONL) and creates a
|
||||||
// Compaction agent + event so the dashboard shows when context was compressed.
|
// Compaction agent + event so the dashboard shows when context was compressed.
|
||||||
if (data.transcript_path) {
|
if (transcriptPath) {
|
||||||
const result = transcriptCache.extract(data.transcript_path);
|
const result = transcriptCache.extract(transcriptPath);
|
||||||
if (result) {
|
if (result) {
|
||||||
const { tokensByModel, compaction, latestModel } = result;
|
const { tokensByModel, compaction, latestModel } = result;
|
||||||
|
|
||||||
@@ -1115,8 +1156,8 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
|
|
||||||
// Evict transcript from cache on SessionEnd — session is done, no more reads expected.
|
// Evict transcript from cache on SessionEnd — session is done, no more reads expected.
|
||||||
// Must happen after token extraction above to avoid re-populating the cache.
|
// Must happen after token extraction above to avoid re-populating the cache.
|
||||||
if (hookType === "SessionEnd" && data.transcript_path) {
|
if (hookType === "SessionEnd" && transcriptPath) {
|
||||||
transcriptCache.invalidate(data.transcript_path);
|
transcriptCache.invalidate(transcriptPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bump session updated_at on every event
|
// Bump session updated_at on every event
|
||||||
@@ -1144,13 +1185,131 @@ const processEvent = db.transaction((hookType, data) => {
|
|||||||
return event;
|
return event;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A Codex sub-agent thread's own lifecycle is not the user's session's: its
|
||||||
|
// Stop is not the end of the user's turn, nor its SessionStart a new session.
|
||||||
|
const CODEX_CHILD_DROPPED = new Set([
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"Stop",
|
||||||
|
"Interrupt",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Codex hook, in the terms the rest of this route speaks. A hook from a
|
||||||
|
* sub-agent thread (its rollout's session_meta names another root session)
|
||||||
|
* is put on the root session, or dropped for a lifecycle event; a
|
||||||
|
* PermissionRequest becomes the Notification Claude Code sends for a
|
||||||
|
* permission prompt, so the waiting flag, needs_action, alerts and the
|
||||||
|
* browser notification follow unchanged; an Interrupt (declined permission,
|
||||||
|
* Esc) ends the turn like a Stop. Null means "ignore this hook".
|
||||||
|
*/
|
||||||
|
function codexHookInput(hookType, data) {
|
||||||
|
let d = data;
|
||||||
|
const thread = threadOf(data.transcript_path);
|
||||||
|
if (thread && thread.sessionId !== thread.id) {
|
||||||
|
if (CODEX_CHILD_DROPPED.has(hookType)) return null;
|
||||||
|
// Marked: a sub-agent's tool call is not the user resuming the root.
|
||||||
|
d = { ...d, session_id: thread.sessionId, _codexChild: true };
|
||||||
|
}
|
||||||
|
if (hookType === "PermissionRequest") {
|
||||||
|
const input = d.tool_input && typeof d.tool_input === "object" ? d.tool_input : {};
|
||||||
|
const what = [input.description, input.command, d.tool_name].find(
|
||||||
|
(v) => typeof v === "string" && v.trim()
|
||||||
|
);
|
||||||
|
const message = `Codex needs your permission: ${String(what || "a tool call").slice(0, 200)}`;
|
||||||
|
return { hookType: "Notification", data: { ...d, message } };
|
||||||
|
}
|
||||||
|
// A declined permission or Esc ends the turn with Interrupt, not Stop, and
|
||||||
|
// leaves Codex at its prompt just as a Stop does.
|
||||||
|
if (hookType === "Interrupt") return { hookType: "Stop", data: d };
|
||||||
|
return { hookType, data: d };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Antigravity (`agy`) hook, in the terms the rest of this route speaks.
|
||||||
|
* Its payload names the conversation `conversationId` and the workspace
|
||||||
|
* `workspacePaths[0]`; a run's first `PreInvocation` (or the first after a
|
||||||
|
* Stop) is the user's prompt, a `PostToolUse` carries `toolCall.{name,args}`,
|
||||||
|
* a `Stop` ends the turn — as an error when it carries one, and not at all
|
||||||
|
* while `fullyIdle` is false (background work still running). A hook
|
||||||
|
* whose transcript is not under the CLI's own directory is the Antigravity
|
||||||
|
* IDE's (it loads the same global hooks.json) and is ignored, as is every
|
||||||
|
* other event. Null means "ignore this hook".
|
||||||
|
*/
|
||||||
|
const AGY_TURN_GAP_MS = 120_000;
|
||||||
|
|
||||||
|
function antigravityHookInput(hookType, data) {
|
||||||
|
const id = data.conversationId;
|
||||||
|
if (typeof id !== "string" || !/^[0-9a-f-]{36}$/.test(id)) return null;
|
||||||
|
if (typeof data.transcriptPath !== "string") return null;
|
||||||
|
const transcript = path.resolve(data.transcriptPath);
|
||||||
|
if (!agyCliDirs().some((dir) => transcript.startsWith(dir + path.sep))) return null;
|
||||||
|
const cwd = Array.isArray(data.workspacePaths) ? data.workspacePaths[0] : null;
|
||||||
|
const d = {
|
||||||
|
session_id: id,
|
||||||
|
cwd: typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : undefined,
|
||||||
|
model: typeof data.modelName === "string" ? data.modelName : undefined,
|
||||||
|
};
|
||||||
|
if (hookType === "PreInvocation") {
|
||||||
|
// A run's first model call is the user's prompt; so is the first one after
|
||||||
|
// a Stop, after a turn that ended with none (an API error, a refused
|
||||||
|
// permission), or on a session the poll already settled — whatever agy
|
||||||
|
// numbers it. Inside a turn a model call follows a hook within seconds.
|
||||||
|
const last = stmts.lastEvent.get(id);
|
||||||
|
const turn =
|
||||||
|
data.invocationNum === 0 ||
|
||||||
|
!last ||
|
||||||
|
last.event_type === "Stop" ||
|
||||||
|
stmts.getSession.get(id)?.status !== "active" ||
|
||||||
|
Date.now() - Date.parse(last.created_at) > AGY_TURN_GAP_MS;
|
||||||
|
return turn ? { hookType: "UserPromptSubmit", data: d } : null;
|
||||||
|
}
|
||||||
|
if (hookType === "PostToolUse") {
|
||||||
|
const call = data.toolCall && typeof data.toolCall === "object" ? data.toolCall : {};
|
||||||
|
return {
|
||||||
|
hookType,
|
||||||
|
data: {
|
||||||
|
...d,
|
||||||
|
tool_name: typeof call.name === "string" ? call.name : "unknown",
|
||||||
|
// Two identical calls in a row stay two events (the duplicate guard).
|
||||||
|
step: Number.isInteger(data.stepIdx) ? data.stepIdx : undefined,
|
||||||
|
tool_input: call.args && typeof call.args === "object" ? call.args : {},
|
||||||
|
...(typeof data.error === "string" &&
|
||||||
|
data.error && { tool_response: { error: data.error } }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hookType === "Stop") {
|
||||||
|
// Background work still running: the turn is not over yet.
|
||||||
|
if (data.fullyIdle === false) return null;
|
||||||
|
const failed =
|
||||||
|
(typeof data.error === "string" && data.error !== "") ||
|
||||||
|
(typeof data.terminationReason === "string" && /error|fail/i.test(data.terminationReason));
|
||||||
|
return { hookType, data: failed ? { ...d, stop_reason: "error" } : d };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
||||||
|
|
||||||
router.post("/event", (req, res) => {
|
router.post("/event", (req, res) => {
|
||||||
const { hook_type, data } = req.body;
|
let { hook_type, data } = req.body;
|
||||||
if (!hook_type || !data) {
|
if (!hook_type || !data) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
|
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||||
|
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||||
|
// Code — an allowlist, never a value stored as sent.
|
||||||
|
const tool = Object.hasOwn(HOOK_TOOLS, req.body.tool) ? req.body.tool : "claude";
|
||||||
|
if (tool !== "claude") {
|
||||||
|
if (!data || typeof data !== "object") return res.json({ ok: true, ignored: true });
|
||||||
|
const mapped = HOOK_TOOLS[tool](hook_type, data);
|
||||||
|
if (!mapped) return res.json({ ok: true, ignored: true });
|
||||||
|
({ hookType: hook_type, data } = mapped);
|
||||||
|
}
|
||||||
|
|
||||||
// The same hook delivered twice — AgentHub's hooks registered twice (plugin
|
// The same hook delivered twice — AgentHub's hooks registered twice (plugin
|
||||||
// + a leftover checkout install, two marketplaces), or two servers on one DB
|
// + a leftover checkout install, two marketplaces), or two servers on one DB
|
||||||
@@ -1166,7 +1325,7 @@ router.post("/event", (req, res) => {
|
|||||||
return res.json({ ok: true, duplicate: true });
|
return res.json({ ok: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = processEvent(hook_type, data);
|
const result = processEvent(hook_type, data, tool);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: { code: "MISSING_SESSION", message: "session_id is required in data" },
|
error: { code: "MISSING_SESSION", message: "session_id is required in data" },
|
||||||
@@ -1174,7 +1333,7 @@ router.post("/event", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
|
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
|
||||||
touchLaneFromHook(hook_type, data);
|
touchLaneFromHook(hook_type, data, tool);
|
||||||
|
|
||||||
res.json({ ok: true, event: result });
|
res.json({ ok: true, event: result });
|
||||||
|
|
||||||
@@ -1191,7 +1350,12 @@ router.post("/event", (req, res) => {
|
|||||||
// tool calls that aren't yet in the events table. Subagent tool_use blocks
|
// tool calls that aren't yet in the events table. Subagent tool_use blocks
|
||||||
// never fire hooks on the parent session — this scan is the only path that
|
// never fire hooks on the parent session — this scan is the only path that
|
||||||
// attributes them to the subagent's agent_id.
|
// attributes them to the subagent's agent_id.
|
||||||
if (hook_type === "SubagentStop" && data.session_id && data.transcript_path) {
|
if (
|
||||||
|
tool === "claude" &&
|
||||||
|
hook_type === "SubagentStop" &&
|
||||||
|
data.session_id &&
|
||||||
|
data.transcript_path
|
||||||
|
) {
|
||||||
// Models the MAIN transcript wrote. The subagent scan must SKIP these
|
// Models the MAIN transcript wrote. The subagent scan must SKIP these
|
||||||
// buckets — the main-transcript writer above owns them, and writing one
|
// buckets — the main-transcript writer above owns them, and writing one
|
||||||
// from two sources of different magnitude would trip replaceTokenUsage's
|
// from two sources of different magnitude would trip replaceTokenUsage's
|
||||||
@@ -1245,6 +1409,7 @@ router.post("/event", (req, res) => {
|
|||||||
// Run on the lifecycle hooks that bracket a workflow finishing, off the
|
// Run on the lifecycle hooks that bracket a workflow finishing, off the
|
||||||
// response path and fail-safe — a partial/absent journal is expected.
|
// response path and fail-safe — a partial/absent journal is expected.
|
||||||
if (
|
if (
|
||||||
|
tool === "claude" &&
|
||||||
["Stop", "SubagentStop", "SessionEnd"].includes(hook_type) &&
|
["Stop", "SubagentStop", "SessionEnd"].includes(hook_type) &&
|
||||||
data.session_id &&
|
data.session_id &&
|
||||||
data.transcript_path
|
data.transcript_path
|
||||||
@@ -1329,6 +1494,8 @@ function watchdogCheck() {
|
|||||||
// is a delayed rsync mirror on this host and their activity clock is the
|
// is a delayed rsync mirror on this host and their activity clock is the
|
||||||
// remote machine's, so the transcript-tail error/interrupt heuristics below
|
// remote machine's, so the transcript-tail error/interrupt heuristics below
|
||||||
// would misfire. Their lifecycle is owned by server/lib/remote-sync.js.
|
// would misfire. Their lifecycle is owned by server/lib/remote-sync.js.
|
||||||
|
// Codex sessions (tool = 'codex') have no Claude transcript to tail; their
|
||||||
|
// lifecycle is owned by server/lib/codex-usage.js.
|
||||||
const staleSessions = db
|
const staleSessions = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT s.id, s.status, s.cwd,
|
`SELECT s.id, s.status, s.cwd,
|
||||||
@@ -1338,7 +1505,7 @@ function watchdogCheck() {
|
|||||||
ORDER BY e.created_at DESC LIMIT 1) as last_data
|
ORDER BY e.created_at DESC LIMIT 1) as last_data
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
|
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
|
||||||
AND (s.source = 'local' OR s.source IS NULL)`
|
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
|
||||||
)
|
)
|
||||||
.all(cutoff);
|
.all(cutoff);
|
||||||
|
|
||||||
@@ -1560,12 +1727,15 @@ function livenessReap({ ignoreIdleGate = false } = {}) {
|
|||||||
// absolute (e.g. /home/ubuntu/matroid), so the posix-cwd guard below does not
|
// absolute (e.g. /home/ubuntu/matroid), so the posix-cwd guard below does not
|
||||||
// catch them. Their liveness is owned entirely by the sync/import path
|
// catch them. Their liveness is owned entirely by the sync/import path
|
||||||
// (server/lib/remote-sync.js reconciles their status from the mirrored
|
// (server/lib/remote-sync.js reconciles their status from the mirrored
|
||||||
// transcript), so the local process probe must leave them alone.
|
// transcript), so the local process probe must leave them alone. A Codex
|
||||||
|
// session (`tool = 'codex'`) is spared for the same reason: a `claude`
|
||||||
|
// process probe proves nothing about it, and codex-usage.js settles it from
|
||||||
|
// its own journals.
|
||||||
const activeSessions = db
|
const activeSessions = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, name, cwd, transcript_path, updated_at FROM sessions
|
`SELECT id, name, cwd, transcript_path, updated_at FROM sessions
|
||||||
WHERE status = 'active' AND cwd IS NOT NULL AND cwd <> ''
|
WHERE status = 'active' AND cwd IS NOT NULL AND cwd <> ''
|
||||||
AND (source = 'local' OR source IS NULL)`
|
AND (source = 'local' OR source IS NULL) AND tool = 'claude'`
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
if (activeSessions.length === 0) return; // nothing to check — skip the ps/lsof cost
|
if (activeSessions.length === 0) return; // nothing to check — skip the ps/lsof cost
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ router.get("/", (req, res) => {
|
|||||||
const status = req.query.status;
|
const status = req.query.status;
|
||||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||||
const cwd = req.query.cwd;
|
const cwd = req.query.cwd;
|
||||||
|
// Which CLI's sessions (claude / codex / antigravity). Applied here, before
|
||||||
|
// the limit, so a busy Codex lane cannot push Claude rows out of a page.
|
||||||
|
const tool = typeof req.query.tool === "string" ? req.query.tool : "";
|
||||||
const sortBy = req.query.sort_by || "time"; // "time", "duration", "price"
|
const sortBy = req.query.sort_by || "time"; // "time", "duration", "price"
|
||||||
const sortDesc = req.query.sort_desc !== "false";
|
const sortDesc = req.query.sort_desc !== "false";
|
||||||
|
|
||||||
@@ -146,6 +149,10 @@ router.get("/", (req, res) => {
|
|||||||
where.push("s.cwd = ?");
|
where.push("s.cwd = ?");
|
||||||
params.push(cwd);
|
params.push(cwd);
|
||||||
}
|
}
|
||||||
|
if (tool) {
|
||||||
|
where.push("s.tool = ?");
|
||||||
|
params.push(tool);
|
||||||
|
}
|
||||||
// Data-scope filter: restrict to sessions collected from a chosen set of
|
// Data-scope filter: restrict to sessions collected from a chosen set of
|
||||||
// machines (local + any configured remote sources). Absent = all sources.
|
// machines (local + any configured remote sources). Absent = all sources.
|
||||||
const sourceFilter = sourceColumnClause(parseSources(req));
|
const sourceFilter = sourceColumnClause(parseSources(req));
|
||||||
|
|||||||
+102
-30
@@ -23,7 +23,10 @@ const APP_VERSION = (() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
||||||
const CLAUDE_SETTINGS_PATH = getSettingsPath();
|
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
|
||||||
|
const { envOwned } = require("../lib/settings-env");
|
||||||
|
const { reapplyCodexLayers, lanesWithMissingRouter } = require("../lib/router-apply");
|
||||||
|
const pricingSync = require("../lib/pricing-sync");
|
||||||
|
|
||||||
function getDbSize() {
|
function getDbSize() {
|
||||||
try {
|
try {
|
||||||
@@ -48,10 +51,10 @@ function getTableCounts() {
|
|||||||
|
|
||||||
function getHookStatus() {
|
function getHookStatus() {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
if (!fs.existsSync(getSettingsPath())) {
|
||||||
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
return { installed: false, path: getSettingsPath(), hooks: {} };
|
||||||
}
|
}
|
||||||
const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8");
|
const raw = fs.readFileSync(getSettingsPath(), "utf8");
|
||||||
const settings = JSON.parse(raw);
|
const settings = JSON.parse(raw);
|
||||||
const hookTypes = [
|
const hookTypes = [
|
||||||
"PreToolUse",
|
"PreToolUse",
|
||||||
@@ -73,9 +76,9 @@ function getHookStatus() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const installed = Object.values(hooks).every(Boolean);
|
const installed = Object.values(hooks).every(Boolean);
|
||||||
return { installed, path: CLAUDE_SETTINGS_PATH, hooks };
|
return { installed, path: getSettingsPath(), hooks };
|
||||||
} catch {
|
} catch {
|
||||||
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
return { installed: false, path: getSettingsPath(), hooks: {} };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +147,10 @@ router.post("/clear-data", (_req, res) => {
|
|||||||
db.prepare("DELETE FROM events").run();
|
db.prepare("DELETE FROM events").run();
|
||||||
db.prepare("DELETE FROM agents").run();
|
db.prepare("DELETE FROM agents").run();
|
||||||
db.prepare("DELETE FROM sessions").run();
|
db.prepare("DELETE FROM sessions").run();
|
||||||
|
// What the Codex/Antigravity polls have read: kept, it would stop them ever
|
||||||
|
// importing those sessions again (and bring one back half, on its next write).
|
||||||
|
db.prepare("DELETE FROM codex_rollouts").run();
|
||||||
|
db.prepare("DELETE FROM antigravity_conversations").run();
|
||||||
// Fired alerts reference the cleared sessions — wipe the feed too. Alert
|
// Fired alerts reference the cleared sessions — wipe the feed too. Alert
|
||||||
// *rules* survive: they're user configuration, like model_pricing.
|
// *rules* survive: they're user configuration, like model_pricing.
|
||||||
db.prepare("DELETE FROM alert_events").run();
|
db.prepare("DELETE FROM alert_events").run();
|
||||||
@@ -182,9 +189,48 @@ router.post("/reinstall-hooks", (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/settings/reset-pricing — reset pricing to defaults
|
// GET/POST/DELETE /api/settings/codex-hooks — AgentHub's hooks in Codex's
|
||||||
router.post("/reset-pricing", (_req, res) => {
|
// `$CODEX_HOME/hooks.json`: status, install (or reinstall), remove. Only ever
|
||||||
db.prepare("DELETE FROM model_pricing").run();
|
// on a click; Codex itself asks the user to trust them (server/lib/codex-hooks.js).
|
||||||
|
const codexHooks = require("../lib/codex-hooks");
|
||||||
|
const { sameOriginGuard } = require("../lib/same-origin");
|
||||||
|
function codexHooksRoute(fn) {
|
||||||
|
return (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(fn());
|
||||||
|
} catch (err) {
|
||||||
|
const client = ["EBADJSON", "EUNSUPPORTED", "EFOREIGN"].includes(err.code);
|
||||||
|
res.status(client ? 400 : 500).json({
|
||||||
|
error: { code: client ? err.code : "ESAVE", message: err.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
router.get("/codex-hooks", codexHooksRoute(codexHooks.status));
|
||||||
|
// Writing makes Codex run a command (once trusted), so no other site may ask.
|
||||||
|
router.post("/codex-hooks", sameOriginGuard, codexHooksRoute(codexHooks.install));
|
||||||
|
router.delete("/codex-hooks", sameOriginGuard, codexHooksRoute(codexHooks.remove));
|
||||||
|
|
||||||
|
// GET/POST/DELETE /api/settings/antigravity-hooks — AgentHub's named hook in
|
||||||
|
// `~/.gemini/config/hooks.json` (server/lib/antigravity-hooks.js). agy runs it
|
||||||
|
// with no trust step, so the write routes refuse another site the same way.
|
||||||
|
const antigravityHooks = require("../lib/antigravity-hooks");
|
||||||
|
router.get("/antigravity-hooks", codexHooksRoute(antigravityHooks.status));
|
||||||
|
router.post("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.install));
|
||||||
|
router.delete("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.remove));
|
||||||
|
|
||||||
|
// POST /api/settings/reset-pricing — reset pricing to defaults. The seed is
|
||||||
|
// Claude's; a model priced by the automatic sync (an OpenAI one, from Codex
|
||||||
|
// sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it.
|
||||||
|
// `{scope: "claude"}` (the Claude tab's button) resets only `claude-…` rows,
|
||||||
|
// so another vendor's edits and a custom router row survive it.
|
||||||
|
const RESET_SYNC_MS = 3000;
|
||||||
|
router.post("/reset-pricing", async (req, res) => {
|
||||||
|
if (req.body?.scope === "claude") {
|
||||||
|
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-%'").run();
|
||||||
|
} else {
|
||||||
|
db.prepare("DELETE FROM model_pricing").run();
|
||||||
|
}
|
||||||
|
|
||||||
const seedPricing = db.prepare(
|
const seedPricing = db.prepare(
|
||||||
"INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
@@ -195,6 +241,12 @@ router.post("/reset-pricing", (_req, res) => {
|
|||||||
// Re-apply time-limited intro rates (e.g. Sonnet 5) — the seed above only
|
// Re-apply time-limited intro rates (e.g. Sonnet 5) — the seed above only
|
||||||
// carries standard rates, so without this a reset silently drops the promo.
|
// carries standard rates, so without this a reset silently drops the promo.
|
||||||
applyIntroPricing(db);
|
applyIntroPricing(db);
|
||||||
|
if (pricingSync.syncEnabled()) {
|
||||||
|
await Promise.race([
|
||||||
|
pricingSync.syncPricing(db),
|
||||||
|
new Promise((r) => setTimeout(r, RESET_SYNC_MS).unref()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const pricing = stmts.listPricing.all();
|
const pricing = stmts.listPricing.all();
|
||||||
res.json({ ok: true, pricing });
|
res.json({ ok: true, pricing });
|
||||||
@@ -223,28 +275,48 @@ router.get("/export", (_req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/settings/claude-home — get current CLAUDE_HOME path
|
// The two editable home directories share one shape: GET answers the value and
|
||||||
router.get("/claude-home", (_req, res) => {
|
// whether the real environment set it (`env_owned` — then a saved value is
|
||||||
res.json({ claude_home: getClaudeHome() });
|
// overridden again at the next start), PUT validates, applies and persists
|
||||||
});
|
// (server/lib/settings-env.js). EBADHOME = too broad, INVALID_PATH = any other
|
||||||
|
// bad path, 500 ESAVE = the settings file could not be written.
|
||||||
|
function homeRoutes(route, key, field, get, set, after = () => ({})) {
|
||||||
|
router.get(route, (_req, res) => {
|
||||||
|
res.json({ [field]: get(), env_owned: envOwned.has(key) });
|
||||||
|
});
|
||||||
|
// A home decides which config a pane runs (CODEX_HOME: MCP servers, notify
|
||||||
|
// commands), so no other site may set one.
|
||||||
|
router.put(route, sameOriginGuard, (req, res) => {
|
||||||
|
const newPath = req.body && req.body.path;
|
||||||
|
if (!newPath || typeof newPath !== "string") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: { code: "INVALID_PATH", message: "path is required and must be a string" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = set(newPath);
|
||||||
|
} catch (err) {
|
||||||
|
// A bad path is 400; anything else (the settings file unwritable) is ours.
|
||||||
|
const bad = err.code === "EBADHOME" || err.code === "INVALID_PATH";
|
||||||
|
return res.status(bad ? 400 : 500).json({
|
||||||
|
error: { code: bad ? err.code : "ESAVE", message: err.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key), ...after() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// PUT /api/settings/claude-home — update CLAUDE_HOME path
|
// GET/PUT /api/settings/claude-home — a PUT names the lanes whose AI Router
|
||||||
router.put("/claude-home", (req, res) => {
|
// profile the new home's store does not have (they would start on native).
|
||||||
const { path: newPath } = req.body;
|
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome, () => ({
|
||||||
if (!newPath || typeof newPath !== "string") {
|
routers: { missing: lanesWithMissingRouter() },
|
||||||
return res.status(400).json({
|
}));
|
||||||
error: { code: "INVALID_PATH", message: "path is required and must be a string" },
|
// GET/PUT /api/settings/codex-home — a PUT re-writes every codex lane's AI
|
||||||
});
|
// Router layer into the new home, or `codex -p <name>` fails at the next start.
|
||||||
}
|
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome, () => ({
|
||||||
try {
|
routers: reapplyCodexLayers(),
|
||||||
const resolved = setClaudeHome(newPath);
|
}));
|
||||||
res.json({ ok: true, claude_home: resolved });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: { code: "INVALID_PATH", message: err.message },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
||||||
router.post("/cleanup", (req, res) => {
|
router.post("/cleanup", (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user