Compare commits

...

48 Commits

Author SHA1 Message Date
nntrivi2001 61cc37ba32 chore: remove unused surfaces (VS Code extension, statusline, landing assets, Docker)
The repo shipped five install/publish surfaces that nothing in the project builds, tests or documents as supported any more. Each one carried its own stale docs, and the Docker/Podman path additionally described a Dockerfile whose COPY of statusline/ would have broken the build the moment that directory went away.

Removed: vscode-extension/ (16 files, marketplace extension for an ID we do not own); statusline/ (3 files, standalone CLI statusline); the repo-root landing/wiki PWA assets (sw.js, manifest.json, robots.txt, sitemap.xml, og-image.svg, favicon.svg, fonts/ - 17 files for pages that no longer exist); .superpowers/ (4 files of scratch brainstorm state, already covered by .gitignore:55); and the container publish path (Dockerfile, docker-compose.yml, .dockerignore, 5 Makefile targets, 2 npm scripts).

Kept deliberately - these are about the USER's containers, not ours: scripts/install-hooks.js container detection and AGENTHUB_ALLOW_CONTAINER_HOOKS (issue #193), server/lib/lane-detect.js docker-compose service detection, server/data/profile-templates/postgres-compose/, server/lib/stage-detect.js 'docker push' detection, the host.docker.internal Prometheus notes, the Config Explorer's reads of the user's own ~/.claude/statusline.py, and .devcontainer/ (its docker-in-docker feature is dropped since it existed only to build the removed Dockerfile).

Docs follow the code: ARCHITECTURE.md loses the VS Code, statusline and container sections plus two dead TOC anchors, and its PWA section is rewritten from three PWAs to one; INSTALL.md, SETUP.md, docs/DEPLOYMENT.md, server/README.md, client/README.md, docs/README.md, docs/HOOKS.md and docs/DATABASE.md drop the same. The Makefile target count in ARCHITECTURE.md is corrected 25 -> 17 (it was already stale).
2026-09-10 13:44:28 +07:00
nntrivi2001 e972267a42 fix(plugins): make the 10 focused plugins installable, finish the AgentHub rename
The ten `agenthub-*` plugins were listed by the marketplace but could never be
installed — two schema mistakes present since the entries were first authored
(57dc915), both invisible until you actually run `claude plugin install`:

- marketplace entries used `"path": "plugins/<name>"`; the CLI only accepts
  `"source"`, so every one failed with
  `This plugin's marketplace entry is invalid: source: Invalid input`
- each `plugin.json` carried npm's `"repository": { "type", "url" }` object;
  the schema wants a plain string, so the next attempt failed with
  `Validation errors: repository: Invalid input`

Verified by installing all 11 entries into a throwaway CLAUDE_CONFIG_DIR:
1/11 before, 11/11 after. `plugins-marketplace.test.js` was asserting the
broken shape, which is why CI stayed green — it now asserts `source` and
rejects `path`.

Marketplace renamed `claude-code-agent-monitor-plugins` -> `agenthub`, so the
install line is `agenthub@agenthub`. ARCHITECTURE.md documented a marketplace
name (`smartgift-claude-code-monitor`) that never existed in any revision.

The documented `/plugin marketplace add Smartgift-AI/Claude-Code-Monitor` also
never worked: the `owner/repo` shorthand resolves to github.com only, and this
repo is self-hosted on Gitea. Confirmed the CLI rejects `host/owner/repo` and
ignores GH_HOST / GITHUB_HOST / CLAUDE_CODE_MARKETPLACE_URL, so the full
https:// clone URL is the only supported form. Same GitHub-habit class fixed
elsewhere: raw.githubusercontent.com icon URLs (404, and the repo ships
favicon.svg not favicon.ico — the client had the same dead /favicon.ico),
a smartgift.github.io docs link (404), and Gitea `/blob/<ref>/` paths that
must be `/src/branch/<ref>/`.

Repository renamed to Smartgift-AI/AgentHub throughout. Gitea 301-redirects
the old name, so these were stale rather than broken; `docs/superpowers/`
keeps the old name because those are dated decision records.

Adds `repo-links.test.js` to guard both classes (it runs under the existing
pre-commit gate) plus an openapi.yaml drift check — the committed spec had
silently fallen a minor version behind package.json. `generate-openapi-yaml.js`
now exports `render()` so the test compares against one definition of the dump
options instead of a second copy.
2026-09-10 11:03:14 +07:00
nntrivi2001 876c6de6d2 fix(lanes): count every pipeline run, not one per lane
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.

Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.

Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.

Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
2026-09-09 15:32:09 +07:00
nntrivi2001 3549000fa9 chore(release): bump version to 1.5.0
Minor, not patch: this cycle added the Codex config panel, made Claude
Code's MCP surface editable, and added the project-memory route both
panels use — new surfaces, no breaking change.
2026-09-09 14:46:02 +07:00
nntrivi2001 2942ced3ec style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab
The Codex tab strip wrapped onto a second row while Claude Code's stayed
one scrollable row, so two panels that are meant to be twins read as
different components. Both now render the same ConfigTabBar — the
scroller lifted out of CcConfig, with the fade edges and chevrons that
only appear when there is somewhere to scroll. The deferral note in
CodexConfig that predicted this is gone with it.

The bar also carries role="tablist"/"tab" and aria-selected now: Codex
had them, Claude Code did not, and one of the two had to win.

Cursor's placeholder tab goes away with the machinery that served only
it — the optional Panel, the "soon" badge, the coming-soon card, and
their four i18n keys.

Element.scrollBy and friends move to test-setup: jsdom has none of them,
and the tab bar now calls them from both pages.
2026-09-09 14:44:25 +07:00
nntrivi2001 742e23e418 Merge branch 'feat/claude-settings-editable' into main
Makes the Claude Code MCP surface editable from the dashboard (add and
remove via the claude CLI), corrects which .claude.json the dashboard
reads so it matches the one the CLI writes, and adds the project-memory
route both panels use to create a new project's CLAUDE.md / AGENTS.md.
2026-09-09 13:56:02 +07:00
nntrivi2001 d7c1a10724 Merge branch 'fix/config-path-containment' into main
Closes two pre-existing holes in the Claude Code writer: a symlink at
(or above) a write target escaped the config root, and PUT/DELETE
/file honoured a caller-supplied ?cwd=. Both now go through the shared
guard in config-write.js.
2026-09-09 13:55:57 +07:00
nntrivi2001 e63bbf825e Merge branch 'feat/codex-config-panel' into main
Codex config panel in /settings, at full parity with the Claude Code
panel: skills, agents, rules, AGENTS.md memory and config.toml are
readable and editable, with MCP servers, plugins and marketplaces
driven through the codex CLI.
2026-09-09 13:55:53 +07:00
nntrivi2001 2ade30f670 fix(cc-config): close the symlink write escape and the caller-chosen write target
The Claude writer's containment was lexical only: a symlink at
~/.claude/<type>/<name>, or at any ancestor of it, is a string inside the
root while the write lands wherever it points. The backup destination had
the same exposure, and delete copies through the link before unlinking it.

PUT/DELETE /file also honoured ?cwd=, so a project-scope write could be
aimed at any directory on disk. PATCH /settings already refused that, with
the reasoning in a comment; the two other write routes never adopted it.

The guard moves into config-write.js so both writers run one copy.
2026-09-09 08:30:45 +07:00
nntrivi2001 5720e8d5f8 docs(plans): record the Codex config panel and Claude settings plans 2026-09-09 08:22:17 +07:00
nntrivi2001 85187dfc78 feat(config): create a new project's memory file from Settings
The Memory tab could only show this machine's user memory and the
dashboard's own project file. Setting up a project you had not run
through the dashboard meant opening a terminal — `PUT /file`'s
"project" scope always resolves against the server's cwd, on purpose.

Add one narrow route per tool that writes into the directory you name:
CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same
`project-memory.js`. Being the only write path that honours a
caller-supplied directory, it pays for that:

  - the directory must be absolute and already exist — nothing is
    created for you;
  - the filename comes from the route, never from the body;
  - an existing file, symlink included, is a 409 — this can only
    create, never overwrite;
  - it sits behind the loopback-Origin guard /api/run uses, so a page
    in the user's browser cannot plant agent instructions in a project
    directory. That guard moves to lib/same-origin.js to be shared.

The modal takes the dashboard's known project dirs as suggestions on a
free-text input rather than a closed list: a project it has never seen
is exactly the case this exists for.

Mutation-checked: dropping the origin guard, and swapping lstat for
stat, each fail a test.
2026-09-09 01:45:00 +07:00
nntrivi2001 697384145a test(cc-config): cover the env runClaudePlugin actually spawns with
`autoInstallSkillPlugins` takes `run` as a seam, so every existing test
stops short of the real `spawnSync` — the line that decides whether
CLAUDE_CONFIG_DIR is pinned was untested. Restoring the unconditional
pin here (the bug that sent `.claude.json` to `~/.claude/.claude.json`)
kept the whole suite green.

Export `runClaudePlugin` and drive it against a fake `claude` on PATH
that prints the variable back: one test for CLAUDE_HOME set, one for
unset. Mutation-checked — the unconditional pin fails the second.
2026-09-09 10:16:58 +07:00
nntrivi2001 114823ceef fix(cc-config): stop pinning CLAUDE_CONFIG_DIR in the bootstrap's plugin installer too
skill-plugins.js carried its own copy of the unconditional pin, with the
same comment claiming it keeps writes where cc-discovery reads. Same rule
as runClaude now: pin only when the operator set CLAUDE_HOME.

Also corrects three comments that described the old behaviour — the
confidently-wrong pair at these two call sites is what let two readers
believe reads and writes were aligned when they were not.
2026-09-09 10:09:24 +07:00
nntrivi2001 c34931c3f5 fix(cc-config): .claude.json sits BESIDE ~/.claude by default, not inside it
The previous commit routed the read through getClaudeHome(), which appends
/.claude when CLAUDE_HOME is unset — pointing the dashboard at
~/.claude/.claude.json, a file the CLI never writes by default. Verified
against the real binary: with CLAUDE_CONFIG_DIR unset the CLI writes
$HOME/.claude.json; with it set, .claude.json sits flat inside that dir.
Branch on CLAUDE_HOME accordingly.

The test now drives a fake claude that writes where the real one does and
asserts getClaudeJsonPath() names that exact file, under both branches —
the previous test only restated the implementation.
2026-09-09 10:00:05 +07:00
nntrivi2001 17e4321761 fix(cc-config): read .claude.json from the same root the CLI writes it to
getClaudeJsonPath() was the only one of cc-discovery's 20 read paths not
derived from getClaudeHome(), so a CLAUDE_HOME install read one file while
the CLI wrote another. The default case was fixed by dropping the
CLAUDE_CONFIG_DIR pin; this closes the mirrored half.

Also replaces the Remove button's display-string comparison with a
removable flag from the reader: rewording the source label silently
resurrected a button that always fails.
2026-09-09 09:54:27 +07:00
nntrivi2001 0ad4b8c920 fix(cc-config): stop pinning CLAUDE_CONFIG_DIR when CLAUDE_HOME is unset
runClaude() unconditionally set CLAUDE_CONFIG_DIR to getClaudeHome()
(~/.claude), so the CLI wrote ~/.claude/.claude.json — a file
cc-discovery.getClaudeJsonPath() never reads (it reads ~/.claude.json,
beside ~/.claude/, by design). Every default-install MCP add/remove and
plugin/marketplace mutation was inert: add toasted success and vanished on
refetch, remove 422'd for every server the panel lists.

With CLAUDE_HOME unset (the default), leave CLAUDE_CONFIG_DIR unset too —
the CLI's own default config dir already lines up with what cc-discovery
reads, so alignment comes from doing nothing. Only pin it when the operator
explicitly set CLAUDE_HOME, which is the one case the pin is meant for.
Left a residual, documented at both sites: with CLAUDE_HOME set, reads still
come from $HOME/.claude.json while the CLI writes
<CLAUDE_HOME>/.claude.json — a split predating this task, affecting a
configuration nobody here runs.

New tests pin the env contract directly (no real CLI needed): runClaude
must not set CLAUDE_CONFIG_DIR when CLAUDE_HOME is absent, and must set it
to CLAUDE_HOME's value when present.
2026-09-09 09:48:28 +07:00
nntrivi2001 6d27c115d8 fix(cc-config): MCP scope was inverted — expose user/local, not user/project
readMcpServers only ever parses ~/.claude.json (top-level = user scope,
projects[<root>] = the panel's "projectScoped" bucket, which the CLI itself
calls `local`). The previous user/project scope choice was the exact
inverse: `--scope project` writes .mcp.json, which readMcpServers never
reads (a successful add would vanish), and every "projectScoped" row was
actually `local`, so Remove sent a scope the CLI could never match.

- cc-mcp-cli.js: MCP_SCOPES -> ["user", "local"]; drop the local
  mutationQueue/serialize in favor of cc-plugin-cli.js's exported one, so a
  plugin install and an MCP add can't spawn `claude` concurrently; drop the
  String(v) coercion in env/header flags (a wrong-typed value is now a 400,
  not a written "[object Object]"); assertEmbeddedValue allows empty strings
  (a legitimate empty stdio arg was previously a 400).
- cc-plugin-cli.js: export `serialize` for reuse.
- CcConfig.tsx: CcMcpScope narrowed to user/local; the projectScoped bucket's
  McpCard now sends scope="local"; rows sourced from
  ~/.claude/settings.json (no CLI scope addresses them) no longer render a
  Remove button. Cleaned up i18n keys my own earlier change made dead
  (explain.mcp.addCmd/remove/removeButton) and gave the scope <select> its
  own short option labels instead of reusing full-sentence section headings.
2026-09-09 09:36:52 +07:00
nntrivi2001 fd4edaac98 feat(cc-config): add and remove MCP servers through the claude CLI
Mutation goes through `claude mcp add|remove`, never a direct edit of
~/.claude.json — the running CLI rewrites that file concurrently, so a
dashboard write would race it. Reads stay on readMcpServers.

The plan specified a --url flag; the real CLI (v2.1.266) has none —
commandOrUrl is positional, and --header must trail it or the variadic
flag swallows the url. Built against the verified shape.
2026-09-09 09:15:53 +07:00
nntrivi2001 22fd4797aa Revert "feat(cc-config): edit settings.json values from the dashboard"
This reverts commit ce64132771.
2026-09-09 08:47:46 +07:00
nntrivi2001 ce64132771 feat(cc-config): edit settings.json values from the dashboard
Replace the batch multi-key settings.json editor with a recursive per-leaf
editor: each scalar value gets its own inline edit control, the dotted path
is rebuilt from the node's ancestry, and a redacted value renders as a
static non-editable chip. Saves go straight through the existing
PATCH /api/cc-config/settings per-key merge.
2026-09-09 08:44:57 +07:00
nntrivi2001 ed418d5696 fix(codex): treat a literal null from the CLI as an empty list, not a 500 2026-09-09 01:29:17 +07:00
nntrivi2001 42db57ee18 fix(codex-config): stop rejecting env values that start with "-"
--env's value is the RHS of one KEY=VALUE argv element, never its own
argv position, so a leading "-" can never be parsed as a flag — unlike
the key, which stays validated as an identifier. assertArg's leading-dash
guard was reused for the value too, refusing ordinary values like "-1".
Split it into assertEnvValue (no leading-dash check) for values only.
2026-09-09 01:22:12 +07:00
nntrivi2001 acff059e92 fix(codex-config): close whole-branch review defects in the Codex panel
Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
2026-09-09 01:17:06 +07:00
nntrivi2001 fef3e640d0 fix(codex): add the missing common.close key so the backups dialog announces itself 2026-09-08 21:40:23 +07:00
nntrivi2001 4d4449866b fix(codex): let plugin remove take the same spec plugin add and list use 2026-09-08 21:37:41 +07:00
nntrivi2001 56bf5bbf4d fix(codex-config): close fix-round-1 parity gaps against CcConfig
Isolate the codex-CLI-backed reads (mcp/plugins/marketplaces) from the
file-backed ones in fetchAll via two independent Promise.all groups, so a
missing `codex` binary degrades only the mcp/plugins tabs instead of
leaving the whole panel stuck on skeletons. Generalize EditorModal's
nameless-type detection behind a `namelessTypes` prop (defaulting to
Claude's own ["memory"]) so Codex's config.toml can be created without a
name. Port BackupsModal, disable the plugin/mcp Remove button when no
stable identifier resolved, hide the inert search box on memory/config,
drop a redundant empty state, and reword the delete-dialog copy to be
tool-neutral.

Co-authored-by: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
2026-09-08 21:34:06 +07:00
nntrivi2001 8015730adf feat(codex): create and customize Codex config from /settings
Adds the Codex config panel (skills/agents/rules/memory/config editing,
mcp/plugins/marketplaces add-remove via the codex CLI) as the last piece
of parity with the Claude Code settings tab, plus the codex_config_changed
WebSocket type the client was missing, and docs for the new
/api/codex-config surface.
2026-09-08 21:09:28 +07:00
nntrivi2001 f6973d24d6 refactor(config): make EditorState/ConfirmDeleteState generic over the artifact type
Review feedback: string widening dropped the compile-time guard at the
write/delete boundary right where the Codex panel (the next task) starts
writing into this state. Threading <TType extends string = CcArtifactType>
through EditorState/EditorModal and ConfirmDeleteState/ConfirmDeleteModal
keeps CcConfig.tsx's write/delete calls checked against CcArtifactType by
default while still letting a Codex-typed caller instantiate its own union
later, removing the two `as CcArtifactType` casts.
2026-09-08 20:48:58 +07:00
nntrivi2001 865c6c1d1e refactor(config): lift the tool-agnostic config modals out of CcConfig
Moves FileViewer, EditorModal, ConfirmDeleteModal, ToastNotice, MdItemList,
ScopeBadge, and the shared atoms (Empty/SkeletonRows/CopyButton/formatBytes)
into client/src/components/config/ so the upcoming Codex config panel can
reuse them. EditorModal now takes a fetchContent callback instead of calling
api.ccConfig.file itself; CcConfig.tsx supplies the Claude-specific fetcher.
The moved EditorState/ConfirmDeleteState type field widens from
CcArtifactType to string so non-Claude callers can use them too, with a cast
at the two remaining Claude-specific call sites in CcConfig.tsx.
2026-09-08 20:40:47 +07:00
nntrivi2001 e38dc5555c fix(codex): keep element types through the array coercion, model mcpAdd as a union 2026-09-08 20:31:10 +07:00
nntrivi2001 8021e569f2 fix(codex): add array coercion and typed arguments for CLI-backed routes
- Coerce mcp/plugins/marketplaces GET responses to arrays (CLI may emit
  object), degrading to empty list rather than crash on unexpected shape
- Add typed interfaces: CodexMcpAddArgs, CodexPluginActionArgs,
  CodexMarketplaceActionArgs with validated action literals
- Mirrors ccConfig's type safety across the namespace
2026-09-08 20:27:52 +07:00
nntrivi2001 2ae9b06652 feat(codex): add the codexConfig API namespace
Adds a typed client wrapper for the Codex config API alongside ccConfig,
with methods for reading/writing skills, agents, rules, memory, config,
MCP servers, plugins, and marketplaces. Mirrors ccConfig construction and
error handling. All response shapes match the server's wire format.
2026-09-08 20:20:59 +07:00
nntrivi2001 aca8fd02ee fix(codex): stop 502ing empty CLI output, GET /file no longer mkdirs or leaks paths 2026-09-08 20:15:12 +07:00
nntrivi2001 29ed86d672 fix(codex): close symlink escape on GET /file, pin project writes to server cwd 2026-09-08 20:07:57 +07:00
nntrivi2001 32839332f2 feat(codex): expose the Codex config API 2026-09-08 17:54:36 +07:00
nntrivi2001 58623f37c0 fix(codex): close parent-dir symlink escape in the containment check 2026-09-08 17:43:09 +07:00
nntrivi2001 f3a2f41ed7 fix(codex): close symlink write escape and validate listBackups scope/type 2026-09-08 17:34:10 +07:00
nntrivi2001 c9541dc509 feat(codex): write Codex artifacts with backup and atomic rename 2026-09-08 17:22:31 +07:00
nntrivi2001 dcd65f891c fix(codex): support mcp add --url form, guard specs/URLs, cover --env 2026-09-08 17:15:24 +07:00
nntrivi2001 ba79610697 feat(codex): wrap the codex mcp/plugin CLI in argv-safe calls 2026-09-08 17:06:44 +07:00
nntrivi2001 b0788eea80 fix(codex): backslash parity in the TOML closing-quote scan
Round 2's escape check rejected a quote whenever a single backslash
preceded it, so an escaped backslash before a real closing quote (e.g.
path = "path\\" # c) was misread as an escaped quote and the field was
dropped. Now counts the run of backslashes and only treats an ODD run as
escaping the quote. Folded the old two-branch quote handling into one
scan (it already finds the terminator correctly with or without a
trailing comment) and decode \\ -> \ in the extracted value, since the
scan already knows which backslash run is real.
2026-09-08 16:57:02 +07:00
nntrivi2001 236e94760f fix(codex): escape-aware closing-quote scan, honest symlink coverage
readTomlMeta's trailing-comment stripper found the first quote character
regardless of escaping, so a description containing \"escaped quotes\"
before a real comment got silently truncated instead of dropped. The scan
now skips a quote preceded by a backslash.

The "never walks outside" containment test passed by construction (row.file
is always built off a fixed root) and would not fail if the isUnder guard
were removed. Replaced it with two tests that actually pin the regression
risk: a skill dir and an agent .toml symlinked in from outside CODEX_HOME,
asserting the row is discovered and its content read through the link.
isUnder stays as a defensive check (matches cc-discovery.js), now commented
as such.
2026-09-08 16:48:45 +07:00
nntrivi2001 98b1921ca6 fix(codex): keep commented TOML fields, follow symlinked config surfaces
readTomlMeta was dropping any field with a trailing `# comment` (common in
real agents/*.toml). readSkills/readAgents/readRules used bare
isDirectory()/isFile(), which miss symlinked entries per cc-discovery.js's
own documented Dirent quirk — reused its isDirLike/isFileLike instead
(exporting isDirLike from cc-discovery.js, which wasn't previously public).
Also swapped the "never walks outside" test for one that actually exercises
a symlink escape, and dropped the unnecessary isUnder re-export.
2026-09-08 16:41:46 +07:00
nntrivi2001 7618766861 feat(codex): read the Codex config surfaces 2026-09-08 16:32:06 +07:00
nntrivi2001 57849d2f75 refactor(codex): give the CODEX_HOME lookup its own module 2026-09-08 16:24:17 +07:00
nntrivi2001 aef7ec130b refactor(config): extract the safe-write primitives cc-mutate guards with 2026-09-08 16:17:48 +07:00
nntrivi2001 6f9b9c36b8 feat(doctor): catch half-written installs, not just absent ones
Runtime deps checked that node_modules existed, which is true of the broken
tree too — it now verifies every declared dependency landed. Dashboard UI
quotes the last client-build.log error instead of repeating the build command,
and npm and free disk get their own rows: both truncate installs silently.
2026-09-08 15:44:41 +07:00
nntrivi2001 dab01b7f2b fix(plugin): install the client build's deps with npm ci
npm install reuses whatever the cache and an earlier run left behind and still
reports success, so a truncated cache entry produced a node_modules that was
missing typescript's lib/*.d.ts — the build then died with TS2318, every global
type undefined. npm ci starts from an empty tree and verifies each tarball
against the lockfile's integrity hash. Falls back to install when the copied
client has no usable lockfile.
2026-09-08 15:43:20 +07:00
178 changed files with 13507 additions and 7879 deletions
+13 -13
View File
@@ -1,12 +1,12 @@
{
"name": "claude-code-agent-monitor-plugins",
"name": "agenthub",
"description": "Official plugin marketplace for AgentHub — the `agenthub` plugin installs the dashboard itself (hooks, server, CLI), plus 10 focused plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
"owner": {
"name": "smartgift",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"plugins": [
{
"name": "agenthub",
@@ -16,61 +16,61 @@
},
{
"name": "agenthub-analytics",
"path": "plugins/agenthub-analytics",
"source": "./plugins/agenthub-analytics",
"description": "Deep analytics on Claude Code sessions — token usage (total_input/total_output/total_cache_read/total_cache_write with compaction baselines pre-summed), cost breakdowns via the pricing engine, usage trends over 365 days, cache efficiency, model mix, and productivity scoring.",
"tags": ["analytics", "tokens", "cost", "productivity"]
},
{
"name": "agenthub-cost-guard",
"path": "plugins/agenthub-cost-guard",
"source": "./plugins/agenthub-cost-guard",
"description": "Budget guardrails — set spend limits, forecast week/month-end cost from daily trends, surface cost-threshold alerts via the alert-rule API, and quantify model-routing savings.",
"tags": ["budget", "cost", "forecast", "alerts"]
},
{
"name": "agenthub-productivity",
"path": "plugins/agenthub-productivity",
"source": "./plugins/agenthub-productivity",
"description": "Productivity automation — daily standups, weekly/monthly reviews, sprint summaries, time-of-day focus analysis, and workflow optimization using the workflow intelligence API's 11 datasets.",
"tags": ["productivity", "reports", "workflows", "standup"]
},
{
"name": "agenthub-devtools",
"path": "plugins/agenthub-devtools",
"source": "./plugins/agenthub-devtools",
"description": "Developer tools for the Agent Monitor — session debugging with full event-chain inspection, data-integrity inspection, event tracing, transcript search, hook diagnostics, data export (JSON/CSV/Markdown), and system health checks.",
"tags": ["devtools", "debug", "diagnostics", "export"]
},
{
"name": "agenthub-insights",
"path": "plugins/agenthub-insights",
"source": "./plugins/agenthub-insights",
"description": "AI-powered insights — pattern detection via workflow intelligence (tool flow transitions, agent co-occurrence), anomaly alerting, trend forecasting, regression watch, benchmarking, optimization recommendations, and session comparison.",
"tags": ["insights", "patterns", "anomaly", "optimization"]
},
{
"name": "agenthub-sessions",
"path": "plugins/agenthub-sessions",
"source": "./plugins/agenthub-sessions",
"description": "Session forensics — search by project/model/status, reconstruct event timelines, replay transcripts turn-by-turn, roll up activity per working directory, and review stale sessions before cleanup.",
"tags": ["sessions", "search", "transcript", "replay"]
},
{
"name": "agenthub-workflows",
"path": "plugins/agenthub-workflows",
"source": "./plugins/agenthub-workflows",
"description": "Multi-agent orchestration & fleet intelligence — map the subagent DAG, audit model delegation and effectiveness, report concurrency lanes, trace error propagation by depth, and review Workflow-tool fleet runs.",
"tags": ["workflows", "orchestration", "subagents", "fleet"]
},
{
"name": "agenthub-quality",
"path": "plugins/agenthub-quality",
"source": "./plugins/agenthub-quality",
"description": "Reliability & SLOs — scan for API errors and tool failures, audit hook delivery health (PreToolUse/PostToolUse balance), track completion/error-rate SLOs with error budgets, and alert on reliability regressions.",
"tags": ["reliability", "errors", "slo", "quality"]
},
{
"name": "agenthub-config",
"path": "plugins/agenthub-config",
"source": "./plugins/agenthub-config",
"description": "Claude Code config & memory governance — audit skills/subagents/commands/MCP servers/hooks/settings via the Config Explorer API and curate the file-based memory store (CLAUDE.md + per-project auto-memory files).",
"tags": ["config", "memory", "governance", "mcp"]
},
{
"name": "agenthub-dashboard",
"path": "plugins/agenthub-dashboard",
"source": "./plugins/agenthub-dashboard",
"description": "Dashboard connector — quick status checks, one-line metrics, live activity watch, and endpoint probing against the Agent Monitor API.",
"tags": ["dashboard", "status", "connector"]
}
+2 -2
View File
@@ -5,8 +5,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git",
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "claude-code"],
"commands": [
+2 -2
View File
@@ -1,7 +1,7 @@
# Optional dev-container image for AgentHub.
# Node 22 to match the production Dockerfile and the project's engines.
# Node 22 to match the project's engines.
# Adds the native-addon toolchain (better-sqlite3 builds via node-gyp) plus
# Python 3 (statusline.py and helper scripts) and the sqlite3 CLI.
# Python 3 (scripts/expand-ts-module-docs.py) and the sqlite3 CLI.
#
# This image is ONLY used by Dev Containers / Codespaces. Host-based development
# (npm run dev / npm start) is unaffected.
+4 -4
View File
@@ -22,13 +22,13 @@ dependencies (`npm run setup`).
| Component | Detail |
| ---------------- | ------------------------------------------------------------------- |
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` (matches prod) |
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` |
| Native toolchain | `build-essential` + `python3` so `better-sqlite3` compiles |
| Python | `python3` / `python` for `statusline.py` and helper scripts |
| Python | `python3` / `python` for `scripts/expand-ts-module-docs.py` |
| sqlite3 CLI | inspect the dashboard DB during development |
| Features | GitHub CLI, Docker-in-Docker (build/run the project's own Dockerfile) |
| Features | GitHub CLI |
| Forwarded ports | `4820` (server API + WebSocket), `5173` (Vite client) |
| Editor | ESLint + Prettier (format on save), Vitest, Docker, YAML, Tailwind |
| Editor | ESLint + Prettier (format on save), Vitest, YAML, Tailwind |
## Everyday commands
+5 -9
View File
@@ -4,9 +4,9 @@
// Used only when you choose "Dev Containers: Reopen in Container" (VS Code) or
// open the repo in a GitHub Codespace. It changes nothing for host-based dev.
//
// Covers the full project: the Express server (4820), the React/Vite client
// (5173) and the VS Code extension. The native `better-sqlite3`
// addon builds here (build-essential + python3 are installed in the Dockerfile).
// Covers the full project: the Express server (4820) and the React/Vite
// client (5173). The native `better-sqlite3` addon builds here
// (build-essential + python3 are installed in the Dockerfile).
//
// NOTE (issue #193): Claude Code hooks are a HOST-side concern. This container
// intentionally does NOT bind-mount ~/.claude and does NOT install hooks — the
@@ -20,10 +20,7 @@
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
// Lets you build/run the project's own production Dockerfile + docker-compose
// from inside the dev container (e.g. to reproduce issue #193 deliberately).
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Server (API + WebSocket) and the Vite client dev server. VS Code forwards
@@ -40,7 +37,7 @@
}
},
// Install root + client + vscode-extension deps.
// Install root + client deps.
// Never installs Claude Code hooks (host-only — see note above).
"postCreateCommand": "bash .devcontainer/post-create.sh",
"waitFor": "postCreateCommand",
@@ -59,7 +56,6 @@
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"vitest.explorer",
"ms-azuretools.vscode-docker",
"redhat.vscode-yaml",
"yzhang.markdown-all-in-one",
"ms-python.python",
+1 -1
View File
@@ -7,7 +7,7 @@
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
echo "▶ Installing server + client + vscode-extension dependencies (npm run setup)…"
echo "▶ Installing server + client dependencies (npm run setup)…"
npm run setup
cat <<'EOF'
-15
View File
@@ -1,15 +0,0 @@
node_modules/
client/node_modules/
client/dist/
data/
.git/
.github/
*.md
!README.md
images/
wiki/
index.html
og-image.svg
favicon.svg
.prettierrc
.prettierignore
-10
View File
@@ -13,13 +13,3 @@
*.ttf binary
*.eot binary
*.db binary
# ── GitHub Linguist overrides — keep language stats honest ──────────────────
# wiki/mermaid.min.js is a vendored, minified third-party library (Mermaid);
# it is not project source code and must not count toward language stats.
wiki/mermaid.min.js linguist-vendored
# wiki/i18n-content.js is machine-assembled translation data (see its header:
# "AUTO-GENERATED wiki body-content translations ... Do not hand-edit").
# It is a data bundle keyed by English innerHTML, not hand-written JavaScript.
wiki/i18n-content.js linguist-generated
-12
View File
@@ -9,17 +9,5 @@ package-lock.json
client/package-lock.json
.vs/
*.md
index.html
wiki/index.html
*.yml
*.yaml
desktop/out/
desktop/release/
desktop/node_modules/
desktop/package-lock.json
desktop/assets/*.svg
desktop/assets/*.png
desktop/assets/*.icns
desktop/assets/icon.iconset/
wiki/mermaid.min.js
fonts/*.woff2
-130
View File
@@ -1,130 +0,0 @@
# Superpowers Workspace
This directory contains project-specific configuration and artifacts for the Superpowers workflow.
It acts as the working memory layer for agent-driven development, enabling structured planning, execution, and iteration across sessions.
---
## Purpose
Superpowers transforms coding agents into structured, process-driven collaborators. Instead of jumping straight into code, the agent:
1. Clarifies intent
2. Produces a design
3. Breaks work into executable steps
4. Executes via subagents
5. Verifies and iterates
This directory stores the outputs of that workflow so progress is persistent, inspectable, and reproducible.
---
## Directory Structure
Typical contents may include:
```
.superpowers/
├── brainstorm/ # Design explorations and refined specs
├── plans/ # Task breakdowns and execution plans
├── reviews/ # Code review outputs and feedback
├── runs/ # Execution logs or agent traces
└── README.md # This file
```
> Exact structure may evolve depending on which skills are triggered.
---
## Workflow Overview
Superpowers operates through automatic skill activation:
### 1. Brainstorming
* Refines vague ideas into concrete specs
* Explores alternatives
* Produces structured, reviewable design docs
### 2. Planning
* Converts approved designs into granular tasks
* Each task is:
* Small (2–5 min)
* Explicit (exact file paths + code)
* Verifiable
### 3. Execution
* Tasks are executed by subagents
* Includes:
* Spec compliance checks
* Code quality review
* Can run sequentially or in parallel
### 4. Verification
* Enforces test-first development (TDD)
* Ensures correctness before completion
* Prevents silent regressions
### 5. Completion
* Validates final state
* Offers merge / PR / discard options
* Cleans up working branches
---
## Key Principles
* **Test-first development** (RED → GREEN → REFACTOR)
* **Small, deterministic tasks**
* **Explicit over implicit**
* **Process over intuition**
* **Verification over assumption**
---
## How to Use
You don’t interact with this directory directly most of the time.
Instead:
* Start a task in your coding agent (Claude, Cursor, etc.)
* Let Superpowers skills activate automatically
* Review outputs when prompted (designs, plans, reviews)
Artifacts will be written here as the workflow progresses.
---
## When to Look Here
Check this directory when you want to:
* Review the current plan
* Inspect prior design decisions
* Debug agent behavior
* Resume interrupted work
* Audit what was executed
---
## Notes
* Files here are **source-of-truth for agent state**
* Safe to commit (recommended for team workflows)
* Avoid manual edits unless you understand the workflow implications
---
## Related
* Project root `CLAUDE.md` → global agent context
* `.claude/` → rules, skills, and subagents
* Superpowers upstream docs →
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
{"reason":"idle timeout","timestamp":1774536800234}
@@ -1 +0,0 @@
1542
+38 -220
View File
@@ -39,8 +39,6 @@ Architectural overview and technical reference for the Agent Dashboard system, c
![React Testing Library](https://img.shields.io/badge/React_Testing_Library-13.0-FF5733?style=flat-square&logo=testinglibrary&logoColor=white)
![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white)
![Prettier](https://img.shields.io/badge/Prettier-3.8-F7B93E?style=flat-square&logo=prettier&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-20.10-2496ED?style=flat-square&logo=docker&logoColor=white)
![Podman](https://img.shields.io/badge/Podman-4.0-CC342D?style=flat-square&logo=podman&logoColor=white)
![Make](https://img.shields.io/badge/Make-4.3-000000?style=flat-square&logo=make&logoColor=white)
---
@@ -63,11 +61,10 @@ Architectural overview and technical reference for the Agent Dashboard system, c
- [Browser Notification System](#browser-notification-system)
- [Update Notifier Subsystem](#update-notifier-subsystem)
- [Sagi Companion Subsystem](#sagi-companion-subsystem)
- [VS Code Extension Architecture](#vs-code-extension-architecture)
- [Security Considerations](#security-considerations)
- [Performance Characteristics](#performance-characteristics)
- [Deployment Modes](#deployment-modes)
- [Statusline Utility](#statusline-utility)
- [Model Name Formatting](#model-name-formatting-client)
- [Technology Choices](#technology-choices)
- [Build & Run Targets](#build--run-targets)
@@ -350,6 +347,10 @@ graph TD
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
| `lib/codex-cli.js` | Shells out to the `codex` CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (`mcp list\|add\|remove`), plugins (`plugin list\|add\|remove`), and marketplaces (`marketplace list\|add\|remove`). `runCodex()` parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with `stdout`/`stderr` preserved |
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
@@ -469,57 +470,37 @@ graph TD
### Self-hosted assets (no external CDN)
Nothing the dashboard or docs render is fetched from a third-party CDN at runtime — all fonts and scripts are served locally, so every surface works fully offline and leaks nothing to external hosts.
Nothing the dashboard renders is fetched from a third-party CDN at runtime — fonts are served locally, so the UI works fully offline and leaks nothing to external hosts.
- **React app fonts** — Inter + JetBrains Mono are imported from `@fontsource` (latin subset) in `client/src/main.tsx`. Vite bundles the per-weight WOFF2 into `client/dist/assets/` with content hashes at build time; there is no Google Fonts `<link>`. Importing the `latin-*` subset entry points keeps the emitted set to one WOFF2 per weight.
- **Static pages (landing + wiki)** — load a self-hosted `@font-face` sheet at the repo-root `fonts/` directory (`fonts/fonts.css` + the `*.woff2` files). The root `index.html` references `fonts/fonts.css`; the wiki references `../fonts/fonts.css` (relative paths resolve under GitHub Pages).
- **Wiki Mermaid** — vendored as `wiki/mermaid.min.js` (the genuine minified `mermaid@10.9.6` from npm, with a provenance banner; `.prettierignore`d) and loaded via a local `<script>` instead of `cdn.jsdelivr.net`.
- **VS Code extension** — the inline `getErrorHtml()` error page dropped its Google Fonts loader for a system font stack (no bundler / local font path available in that webview).
Net effect: no `fonts.googleapis.com`, `fonts.gstatic.com`, or `cdn.jsdelivr.net` requests anywhere (verified by `git grep`).
Net effect: no `fonts.googleapis.com` or `fonts.gstatic.com` requests anywhere (verified by `git grep`).
### PWA Architecture
The project ships three independent Progressive Web Apps. Each has its own Web App Manifest and Service Worker, so the browser treats them as separate installable applications with isolated caches.
The dashboard ships as a Progressive Web App with its own Web App Manifest and
Service Worker (`client/public/manifest.json` + `client/public/sw.js`), so the
browser treats it as an installable application with its own cache.
```
┌─────────────────────────────────────────────────────────────────┐
│ PWA Surface Map │
├──────────────────┬──────────────────┬───────────────────────────┤
│ Dashboard │ Landing Page │ Wiki │
│ (client/) │ (root) │ (wiki/) │
├──────────────────┼──────────────────┼───────────────────────────┤
│ manifest.json │ manifest.json │ manifest.json │
│ sw.js │ sw.js │ sw.js │
│ id: dashboard │ id: landing │ id: wiki │
├──────────────────┼──────────────────┼───────────────────────────┤
│ Precache: │ Precache: │ Precache: │
│ /, manifest, │ index.html, │ index.html, style.css, │
│ favicon.svg │ favicon, og-img, │ script.js, manifest, │
│ │ manifest │ favicon │
│ Runtime cache: │ Runtime cache: │ Runtime cache: │
│ JS/CSS bundles │ screenshot PNGs │ (all precached) │
│ (cache-first) │ (cache-first) │ │
│ │ │ │
│ Skip: /api/*, │ N/A │ N/A │
│ /ws, __vite │ │ │
│ │ │ │
│ + Push notifs │ │ │
│ (VAPID pipeline) │ │ │
└──────────────────┴──────────────────┴───────────────────────────┘
```
**Service Worker lifecycle (all three):**
**Service Worker lifecycle:**
1. **Install** → `skipWaiting()` — new SW activates immediately, no waiting for tabs to close.
2. **Activate** → old caches deleted (keyed by `CACHE_NAME`: `dashboard-v1`, `landing-v1`, `wiki-v1`). Bump the version string to force a cache bust.
2. **Activate** → old caches deleted (keyed by `CACHE_NAME`, e.g. `dashboard-v2`). Bump the version string to force a cache bust.
3. **Fetch** → Navigation requests are network-first with offline fallback to cached HTML. Static assets are cache-first with runtime caching on miss.
**Dashboard SW specifics:** The fetch handler skips `/api/*`, `/ws`, and Vite HMR (`__vite`) URLs so live data and development tooling are never cached. Only responses with `response.type === "basic"` (same-origin) are stored. The existing push notification handlers (`push`, `notificationclick`) are preserved alongside the caching logic.
The fetch handler skips `/api/*`, `/ws`, and Vite HMR (`__vite`) URLs so live data
and development tooling are never cached. Only responses with
`response.type === "basic"` (same-origin) are stored. The push notification
handlers (`push`, `notificationclick`) live alongside the caching logic — their
payload icon is the same-origin `/favicon.svg` (see `server/lib/push.js`).
**Manifest icons:** All three manifests reference `favicon.svg` with `sizes="any"` and `type="image/svg+xml"` — supported in Chrome 107+, Firefox 110+, Edge 107+. Two icon entries per manifest: one with `purpose: "any"` and one with `purpose: "maskable"`.
**Manifest icons:** `favicon.svg` with `sizes="any"` and `type="image/svg+xml"` —
supported in Chrome 107+, Firefox 110+, Edge 107+. Two icon entries: one with
`purpose: "any"` and one with `purpose: "maskable"`.
**iOS meta tags:** All HTML files include `<meta name="apple-mobile-web-app-capable" content="yes">` and `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">` for standalone home-screen mode on Safari.
**iOS meta tags:** `client/index.html` includes
`<meta name="apple-mobile-web-app-capable" content="yes">` and
`<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`
for standalone home-screen mode on Safari.
### Client Module Graph
@@ -597,10 +578,13 @@ graph LR
DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail]
ACT_R["/activity"] --> ACT[ActivityFeed]
WF_R["/workflows"] --> WF[Workflows]
CC_R["/cc-config"] --> CC[CcConfig]
CC_R["/cc-config"] -->|redirect| SET_R
LANES_R["/lanes"] -->|redirect| RUN_R["/run"]
RUN_R --> WORKSPACE["Workspace<br/>lanes + runs merged"]
SET_R["/settings"] --> SET[Settings]
SET_R["/settings"] --> SETHUB["SettingsHub<br/>(?tool= tab shell)"]
SETHUB --> SETDASH["dashboard tab<br/>Settings"]
SETHUB --> SETCC["claude-code tab<br/>CcConfig"]
SETHUB --> SETCODEX["codex tab<br/>CodexConfig"]
NF_R["/*"] --> NF[NotFound]
ALL["All routes"] --> LAYOUT["Layout wrapper<br/>(Sidebar + Outlet)"]
@@ -613,9 +597,9 @@ graph LR
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only |
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
| `/*` | NotFound | None (static 404 page) |
### Activity Feed Interaction Model
@@ -1049,7 +1033,7 @@ flowchart TD
- Uses Node.js `http` module directly — no dependencies.
- Resolution order is **env override → discovery file → default**:
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when Docker and `npm run dev` share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel.
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel.
- If neither yields anything, the handler falls back to `4820`.
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
@@ -1772,7 +1756,7 @@ Skills are namespaced: `/agenthub-analytics:session-report`, `/agenthub-producti
| **agenthub-config** | `config-audit`, `memory-review`, `skill-inventory`, `mcp-audit`, `hook-inventory` | `config-auditor` | — | — |
| **agenthub-dashboard** | `dashboard-status`, `quick-stats` | — | — | — |
**Totals**: 10 plugins, 53 skills, 14 agents, 30 slash commands, 3 CLI tools, 3 hook configurations. Each plugin is installable via `claude plugin install <name>@smartgift-claude-code-monitor`, and a server test (`server/__tests__/plugins-marketplace.test.js`) validates the marketplace↔directory bijection plus every `plugin.json`, agent, skill, and command.
**Totals**: 10 plugins, 53 skills, 14 agents, 30 slash commands, 3 CLI tools, 3 hook configurations. Each plugin is installable via `claude plugin install <name>@agenthub`, and a server test (`server/__tests__/plugins-marketplace.test.js`) validates the marketplace↔directory bijection plus every `plugin.json`, agent, skill, and command.
### Data Model Grounding
@@ -1801,8 +1785,8 @@ Plugins compute these from raw API data:
```bash
# Marketplace install
claude plugin marketplace add Smartgift-AI/Claude-Code-Monitor
claude plugin install agenthub-analytics@smartgift-claude-code-monitor
claude plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
claude plugin install agenthub-analytics@agenthub
# Local development testing
claude --plugin-dir plugins/agenthub-analytics
@@ -2109,7 +2093,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
The dashboard does not expose an apply/restart endpoint by design. A process cannot reliably replace itself without an external supervisor, and several real constraints make an in-process self-update path strictly worse than letting the user run two commands in a terminal:
- **Supervisor ambiguity.** `npm run dev` (concurrently), `npm start`, `pm2`, `systemd`, `launchd`, and Docker each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest.
- **Supervisor ambiguity.** `npm run dev` (concurrently), `npm start`, `pm2`, `systemd`, and `launchd` each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest.
- **Silent failures.** `npm install` / `npm run build` / port-release timing issues surface as a dead server with no user-facing feedback once the original process has exited.
- **No rollback.** A partial pull + install leaves a broken checkout with no atomic recovery — the working tree is mutated mid-flight.
- **Branch coverage.** Even with the situation-aware `manual_command` produced by the detection layer, an automatic apply would still need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon.
@@ -2217,64 +2201,6 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
---
## VS Code Extension Architecture
The **AgentHub** VS Code extension provides an integrated monitoring experience directly within the editor. It communicates with the local dashboard server via standard HTTP APIs and renders the dashboard UI in a webview.
<p align="center">
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
</p>
### Extension Components
| Component | Responsibility |
| --- | --- |
| **Extension Host** (`extension.js`) | Manages the extension lifecycle, registers commands, creates the status bar item, and coordinates the webview panel. |
| **Sidebar Provider** (`sidebar.js`) | Implements the `TreeDataProvider` for the Activity Bar. It performs background polling of the dashboard APIs (`/api/stats`, `/api/analytics`, `/api/sessions`) every 5 seconds. |
| **Status Bar Item** | Provides a persistent "Pulse" indicator in the VS Code status bar, showing active session and agent counts. |
| **Webview Panel** | A native VS Code tab that renders the dashboard React application. It supports deep linking to specific sessions or sub-pages. |
### Data Flow
```mermaid
graph TD
subgraph "VS Code Process"
SB[Status Bar]
SIDE[Sidebar TreeView]
WV[Webview Panel]
end
subgraph "Extension Host"
EH[extension.js]
SP[sidebar.js]
end
subgraph "Dashboard Server (localhost)"
API["/api/stats<br/>/api/analytics<br/>/api/sessions"]
DS[Express Server]
end
SIDE -->|Poll 5s| SP
SP -->|HTTP GET| API
EH -->|HTTP GET| API
SB -->|Pulse| EH
WV -->|Iframe src| DS
```
### Key Implementation Details
1. **Auto-Detection**: The extension checks both port `5173` (Vite dev server) and `4820` (Production server) on `localhost`. It prioritizes the production port for API data but can render the UI from either.
2. **Real-time Status**: The `SidebarProvider` uses a background loop with `onDidChangeTreeData` to automatically toggle between **Online** and **Offline** states as the local server starts or stops.
3. **Deep Linking**: Commands like `claude-code-agent-monitor.openDashboard` accept arguments (e.g., a session ID or page path like `analytics`) to route the webview to specific views within the React SPA.
4. **Theme Awareness**: The Activity Bar icon (`icon.svg` or `apple-touch-icon.png`) and sidebar icons use VS Code's `ThemeIcon` and `ThemeColor` to ensure they adapt to Light, Dark, and High Contrast themes.
For the extension source code, refer to the [vscode-extension/](./vscode-extension/) directory.
> [!TIP]
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
---
## Security Considerations
| Area | Approach |
@@ -2361,59 +2287,7 @@ graph LR
| **File watching** | `node --watch` + Vite HMR | None |
| **Source maps** | Inline | External files |
### Container (Docker / Podman)
A multi-stage `Dockerfile` builds the client and server into a single production image. Both Docker and Podman are fully supported — the image is OCI-compliant.
```mermaid
graph LR
subgraph "Multi-Stage Build"
S1["Stage 1: server-deps\nnode:22-alpine\nnpm ci --omit=dev"]
S2["Stage 2: client-build\nnode:22-alpine\nnpm ci + vite build"]
S3["Stage 3: runtime\nnode:22-alpine\nCopies node_modules + client/dist"]
S1 --> S3
S2 --> S3
end
subgraph "Container Runtime"
VOL1["~/.claude (ro)\nlegacy session import"]
VOL2["agent-monitor-data\nSQLite persistence"]
S3 -->|"EXPOSE 4820"| SRV["node server/index.js\nport 4820"]
VOL1 --> SRV
VOL2 --> SRV
end
style S3 fill:#339933,stroke:#5cb85c,color:#fff
style SRV fill:#6366f1,stroke:#818cf8,color:#fff
```
**Usage:**
```bash
# Docker Compose
docker compose up -d --build
# Podman Compose
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
# Plain Docker / Podman (equivalent)
docker build -t agent-monitor .
docker run -d -p 4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v agent-monitor-data:/app/data \
agent-monitor
```
> [!NOTE]
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
---
## Statusline Utility
The `statusline/` directory contains a standalone CLI statusline for Claude Code, separate from the web dashboard. It renders a color-coded bar at the bottom of the Claude Code terminal showing model, user, working directory, git branch, context window usage, per-direction token counts, and session cost in USD.
### Model Name Formatting (Client)
## Model Name Formatting (Client)
The `client/src/lib/format.ts` module exports a `formatModelName()` utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere **except** the Settings page, which shows raw patterns for pricing rule configuration).
@@ -2440,60 +2314,6 @@ The function handles:
Components that consume this: SessionDetail, Analytics (donut chart + breakdown), Dashboard (model stats), MessageList, AgentCard, SessionDrillIn, and EventDetail.
### Data Flow
```mermaid
sequenceDiagram
participant CC as Claude Code
participant SH as statusline-command.sh
participant PY as statusline.py
participant GIT as git CLI
CC->>SH: stdin (JSON payload)
SH->>PY: Pipes stdin through
PY->>PY: Parse JSON (model, cwd, context_window, cost)
PY->>GIT: git symbolic-ref --short HEAD
GIT-->>PY: Branch name
PY->>PY: Build ANSI-colored segments (incl. tokens by direction, cost)
PY-->>CC: stdout (formatted statusline)
```
### Segments
| Segment | Source | Color Logic |
| ------------ | ------------------------------------- | ------------------------------------------------------------------------------------ |
| Model | `data.model.display_name` | Always cyan |
| User | `$USERNAME` / `$USER` env var | Always green |
| Working Dir | `data.workspace.current_dir` | Always yellow, `~` prefix for home |
| Git Branch | `git symbolic-ref --short HEAD` | Always magenta, hidden outside git repos |
| Context Bar | `data.context_window.used_percentage` | Green < 50%, Yellow 50–79%, Red >= 80% |
| Token Counts | `data.context_window.current_usage` | Green `↑` input, cyan `↓` output, dim `c` cache reads |
| Session Cost | `data.cost.total_cost_usd` | Green < $5, Yellow $5–$20, Red >= $20 (shown on API and subscription plans) |
### Integration
The statusline is configured in `~/.claude/settings.json` via the `statusLine` key:
```json
{
"statusLine": {
"type": "command",
"command": "bash \"/path/to/.claude/statusline-command.sh\""
}
}
```
Claude Code invokes this command on each update, piping a JSON payload to stdin. The script reads the JSON, extracts fields, runs `git` for branch info, and prints ANSI-formatted output to stdout.
**Design decisions:**
- **Python 3.6+** -- available on virtually all systems, handles ANSI and JSON natively
- **No dependencies** -- uses only stdlib (`sys`, `json`, `os`, `subprocess`)
- **Shell wrapper** -- `statusline-command.sh` sets `PYTHONUTF8=1` for Windows Unicode support and resolves the absolute path to the Python script
- **Fail-safe** -- exits silently on empty input or JSON parse errors, never blocks Claude Code
---
## Technology Choices
| Technology | Why This Over Alternatives |
@@ -2521,8 +2341,6 @@ make build Build the React client for production
make start Start the production server
make test Run all tests (server + client)
make format Format all files with Prettier
make docker-up Start via docker-compose
make docker-down Stop docker-compose stack
```
See `Makefile` for the complete set of 25 targets covering setup, dev, testing, formatting, data management, Codex extensions, and Docker/Podman workflows.
See `Makefile` for the complete set of 17 targets covering setup, dev, testing, formatting, and data management.
-67
View File
@@ -1,67 +0,0 @@
# Multi-stage Dockerfile for AgentHub - a Node.js server with a React client.
# This setup optimizes the final image size by separating the build and runtime stages.
# The first stage installs only the production dependencies for the server, while the second stage builds the React client.
# The final stage combines the necessary files and dependencies to run the application in production.
# Compatibility: This setup is compatible with both Podman and Docker. Runnable on any platform that supports Node.js and Alpine Linux.
#
# Author: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
# ── Stage 1: Install server production deps ───────────────────────────
FROM node:22-alpine AS server-deps
WORKDIR /app
COPY package.json package-lock.json ./
# The root `postinstall` hook (scripts/postinstall.js) fires during `npm ci`, so
# the file must exist here or npm aborts with MODULE_NOT_FOUND before installing
# anything. It self-skips when client/ is absent (as it is in this stage), so
# copying just the one script keeps this deps-cache layer from busting on
# unrelated scripts/ edits. Do NOT use --ignore-scripts: that would also skip
# better-sqlite3's prebuild fetch and silently drop the native SQLite driver.
COPY scripts/postinstall.js ./scripts/postinstall.js
RUN npm ci --omit=dev
# ── Stage 2: Build React client ───────────────────────────────────────
FROM node:22-alpine AS client-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
# vite.config.ts stamps the UI version from the repo-root package.json (one level
# up from the client dir). Provide it here so the built client shows the real
# release version; the config falls back gracefully if it is ever absent.
COPY package.json /app/package.json
RUN npm run build
# ── Stage 3: Production runtime ───────────────────────────────────────
FROM node:22-alpine
WORKDIR /app
# Runs Claude Code sessions inside a named tmux session per lane so both the
# dashboard (via node-pty attach) and a real terminal can share one live pane.
RUN apk add --no-cache tmux
COPY --from=server-deps /app/node_modules ./node_modules/
COPY package.json ./
COPY server/ ./server/
COPY scripts/ ./scripts/
COPY statusline/ ./statusline/
COPY --from=client-build /app/client/dist ./client/dist/
RUN mkdir -p data
EXPOSE 4820
ENV NODE_ENV=production
# In a container the app MUST bind all interfaces: the server binds loopback by
# default (GHSA-gr74-4xfh-6jw9), but a container's loopback is a separate
# namespace the published port cannot reach, so a loopback bind makes the port
# unreachable. Inside a container the trust boundary is the *host* port publish
# (keep it on 127.0.0.1 — see docker-compose.yml / INSTALL.md), not this bind.
ENV DASHBOARD_HOST=0.0.0.0
# The recommended ~/.claude bind mount is read-only, so the server cannot write
# its default data dir (~/.claude/agenthub). Persist to the mounted
# volume at /app/data instead.
ENV DASHBOARD_DATA_DIR=/app/data
CMD ["node", "server/index.js"]
+6 -91
View File
@@ -7,8 +7,8 @@ A step-by-step guide to get AgentHub — the local control surface for Claude Co
If all you want is a working dashboard, skip every step below. From Claude Code:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install agenthub@claude-code-agent-monitor-plugins
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
/plugin install agenthub@agenthub
```
The next session start installs the hooks, boots the server, puts the `agenthub` CLI
@@ -30,7 +30,7 @@ dashboard, run it from source, or stay on Node 20.
| Node.js | 20+ (22+ recommended) | Required for server and client |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Python | 3.6+ | Optional — statusline utility only |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
| Git | Any | For cloning the repository |
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
@@ -39,8 +39,8 @@ dashboard, run it from source, or stay on Node 20.
## Step 1 — Clone the repository
```bash
git clone https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git
cd Claude-Code-Monitor
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
cd AgentHub
```
---
@@ -85,7 +85,7 @@ This starts two processes concurrently:
Open **http://localhost:5173** in your browser.
> [!TIP]
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard in Docker or Podman, install hooks from the host with `npm run install-hooks` after the container is up.
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
---
@@ -120,8 +120,6 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
2. Once installed, the dashboard launches in its own window with no browser chrome
3. Offline support: previously visited pages and assets are served from the Service Worker cache when the network is unavailable
The landing page and wiki are also installable PWAs with their own manifests and service workers — visit each in a browser to install independently.
---
## Step 5 — (Optional) Import existing Claude Code history
@@ -223,89 +221,6 @@ See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
---
## Optional: VS Code extension
**AgentHub** is also available as a dedicated VS Code extension for seamless, integrated monitoring.
<p align="center">
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
</p>
### Features
- **Real-time Sidebar**: Monitor agent status, health, and usage stats in the Activity Bar.
- **Pulse Status Bar**: High-level session and agent counts in the bottom status bar.
- **Direct Navigation**: Jump to specific dashboard pages or recent sessions.
- **Embedded Dashboard**: Full dashboard interface within a native VS Code tab.
### Installation
1. Open the [vscode-extension](./vscode-extension) folder in VS Code.
2. Install via the Marketplace or package it manually:
```bash
cd vscode-extension
npm install
# Generate .vsix for local install
npm run package
```
3. After installation, ensure the main dashboard server is running (`npm run dev`).
4. Look for the **Radar icon** in your VS Code Activity Bar.
For advanced configuration, refer to the [.vscode](./.vscode) and [vscode-extension](./vscode-extension) directories.
> [!TIP]
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
---
## Container mode (Docker / Podman)
The repository includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. Docker and Podman are both supported.
### Compose
```bash
# Docker Compose
docker compose up -d --build
# Podman Compose
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
```
Open **http://localhost:4820** in your browser.
### Plain Docker / Podman
```bash
# Docker
docker build -t agent-monitor .
docker run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v agent-monitor-data:/app/data \
agent-monitor
# Podman
podman build -t agent-monitor .
podman run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v "$HOME/.claude/agenthub:/app/data" \
agent-monitor
```
### Container notes
| Mount | Purpose |
|---|---|
| `~/.claude:/root/.claude:ro` | Lets the server import legacy Claude session history |
| `~/.claude/agenthub:/app/data` | **Canonical SQLite database** (shared with native installs) |
> [!NOTE]
> The image sets `DASHBOARD_HOST=0.0.0.0` and `DASHBOARD_DATA_DIR=/app/data` internally (baked into the `Dockerfile`). The `0.0.0.0` bind is required because a container's loopback is a separate namespace the published port cannot reach; `/app/data` is bind-mounted to `~/.claude/agenthub` on the host so Docker uses the same database as `npm start`. The trust boundary is the **host** port publish — the examples use `-p 127.0.0.1:4820:4820`, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`.
> [!IMPORTANT]
> Claude Code hooks run on the host, not inside the container. After the container is healthy on `http://localhost:4820`, run `npm run install-hooks` on the host so Claude Code posts hook events back to the containerized server. The installer refuses to run inside a container (issue #193) to avoid writing a container-internal handler path into a bind-mounted `~/.claude`; use `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only if you run Claude Code inside the same container.
---
## Troubleshooting
### `npm run setup` shows `better-sqlite3` errors
-24
View File
@@ -94,30 +94,6 @@ import-history: ## Import sessions from ~/.claude/ history
clear-data: ## Delete all data rows (preserves schema)
node scripts/clear-data.js
# ─── Docker / Podman ───────────────────────────────────────────────────────
.PHONY: docker-build
docker-build: ## Build dashboard Docker image
docker build -t claude-agent-monitor:local .
.PHONY: docker-up
docker-up: ## Start via docker-compose
docker compose up -d
.PHONY: docker-down
docker-down: ## Stop docker-compose stack
docker compose down
.PHONY: docker-logs
docker-logs: ## Tail docker-compose logs
docker compose logs -f
.PHONY: podman-build
podman-build: ## Build dashboard Podman image
podman build -t localhost/claude-agent-monitor:local .
# ─── Help ───────────────────────────────────────────────────────────────────
.PHONY: help
+3 -3
View File
@@ -68,8 +68,8 @@ Two commands on a machine that has nothing but Claude Code, no clone and no
`npm run setup`:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install agenthub@claude-code-agent-monitor-plugins
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
/plugin install agenthub@agenthub
```
The first session start installs the hooks, boots the server, puts `agenthub` on
@@ -83,7 +83,7 @@ Details, including what to delete on uninstall:
## Install from a checkout
```bash
npm run setup # root, client and vscode-extension dependencies
npm run setup # root and client dependencies
npm run build # builds the client into client/dist
npm start # serves the built client and the API on :4820
```
+3 -3
View File
@@ -41,8 +41,8 @@ Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không c
`npm run setup`:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install agenthub@claude-code-agent-monitor-plugins
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
/plugin install agenthub@agenthub
```
Lần session-start đầu tiên sẽ cài hooks, boot server, đưa `agenthub` vào PATH và
@@ -55,7 +55,7 @@ cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
## Cài từ checkout
```bash
npm run setup # cài dependency cho root, client và vscode-extension
npm run setup # cài dependency cho root và client
npm run build # build client vào client/dist
npm start # serve client đã build + API trên :4820
```
+4 -105
View File
@@ -48,48 +48,11 @@ npm run install-hooks
```
> [!TIP]
> Container note: do not rely on hook auto-install from inside Docker or Podman. The hook path written by a container would point at the container filesystem, not the host. Start the container first, then run `npm run install-hooks` on the host. As a safeguard (issue #193), the installer now **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`; the containerized server logs the same guidance instead of silently writing a bad path. If you genuinely run Claude Code inside the same container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Container runtime (Docker / Podman)
The repo includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. The container image serves the built client and API on port `4820`, stores SQLite data under `/app/data`, and can import legacy Claude history from a read-only `~/.claude` mount.
```bash
# Docker Compose
docker compose up -d --build
# Podman Compose
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
# Plain Docker
docker build -t agent-monitor .
docker run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v agent-monitor-data:/app/data \
agent-monitor
# Plain Podman
podman build -t agent-monitor .
podman run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v "$HOME/.claude/agenthub:/app/data" \
agent-monitor
```
Container-specific behavior:
- The dashboard is available at `http://localhost:4820`
- The image sets `DASHBOARD_HOST=0.0.0.0` (bind inside the container — its loopback is a separate namespace the published port cannot reach) and `DASHBOARD_DATA_DIR=/app/data` internally; both are baked into the `Dockerfile`
- The examples publish on `127.0.0.1` only, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`
- `~/.claude:/root/.claude:ro` is used for history import only
- `~/.claude/agenthub:/app/data` is the **canonical** SQLite database (shared with native installs)
- Claude Code hooks still execute on the host, so install them from the host with `npm run install-hooks`
### Environment variables
| Variable | Default | Description |
@@ -136,39 +99,18 @@ This repository ships extension files for both agent ecosystems:
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### VS Code extension setup
**AgentHub** is available as an integrated VS Code extension for seamless monitoring within your editor.
- **Activity Bar View**: Adds a custom "Radar" icon to the activity bar providing real-time agent health, token counts, and session stats.
- **Status Bar Integration**: Displays live session and agent pulse counts in the bottom bar.
- **Embedded Dashboard**: Renders the full web dashboard directly in a VS Code editor tab.
- **Automated Detection**: Automatically finds your dashboard server on ports `5173` or `4820`.
<p align="center">
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
</p>
To install or develop the extension:
1. Open the [vscode-extension](./vscode-extension) directory in VS Code.
2. Run `npm install` and `npm run package` to generate a local `.vsix` installer.
3. For developer details, see [vscode-extension/README.md](./vscode-extension/README.md).
> [!TIP]
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
### PWA configuration (optional)
The dashboard, landing page, and wiki each ship as independent Progressive Web Apps. No configuration is required — manifests and service workers are included out of the box.
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit the `manifest.json` in the relevant directory (`client/public/` for dashboard, root for landing, `wiki/` for wiki). Common fields to change:
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** Each SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
@@ -349,16 +291,6 @@ Commonly used targets:
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
| `make docker-up` | `docker compose up -d` | Start via docker-compose |
| `make docker-down` | `docker compose down` | Stop docker-compose stack |
---
## Statusline (optional)
The `statusline/` directory contains a standalone terminal statusline for Claude Code showing model, working directory, git branch, context window usage, and token counts. It is independent of the web dashboard.
See [statusline/README.md](./statusline/README.md) for installation instructions.
---
@@ -464,36 +396,3 @@ And make sure Claude Code posts hooks to the new port:
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
---
### Docker / Podman container starts but no sessions appear
**Check 1 — Is the container healthy?**
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Did you install hooks on the host?**
Hooks run on the host machine, not inside the container. After the container is up:
```bash
npm run install-hooks
```
**Check 3 — Are hooks pointing to the right port?**
Open `~/.claude/settings.json` and verify the hook commands reference `localhost:4820` (or whatever port the container is mapped to). If you changed the port mapping, update hooks accordingly.
---
### Docker build fails during `npm ci`
If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and should not block the build — `better-sqlite3` is an optional dependency. If the build still fails:
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
-16
View File
@@ -2766,22 +2766,6 @@ async function cmdStage(args) {
result: flag("result"),
});
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
// A stage name matching no node (nor alias) still stores — setStage takes the
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
// `current` and progress reads 0. Warn, never fail: a typo must not break a
// declaration the pipeline can still record, but it must not pass silently.
// Skipped for `--result fail`, which paints the node `failed` rather than
// `current` and would otherwise look identical to an unknown stage.
const nodes = lane.pipeline_nodes || [];
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
)
);
}
}
// ── Command catalog ─────────────────────────────────────────────────────────
-2
View File
@@ -19,8 +19,6 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
![PostCSS](https://img.shields.io/badge/PostCSS-8.5-DD3A0A?style=flat-square&logo=postcss&logoColor=white)
![Autoprefixer](https://img.shields.io/badge/Autoprefixer-10.4-DD3735?style=flat-square&logo=autoprefixer&logoColor=white)
![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-20.10-2496ED?style=flat-square&logo=docker&logoColor=white)
![Podman](https://img.shields.io/badge/Podman-4.0-CC342D?style=flat-square&logo=podman&logoColor=white)
![Prometheus](https://img.shields.io/badge/Prometheus-2.x-E6522C?style=flat-square&logo=prometheus&logoColor=white)
![Grafana](https://img.shields.io/badge/Grafana-10.x-F46800?style=flat-square&logo=grafana&logoColor=white)
![Vitest](https://img.shields.io/badge/Vitest-1.0-646CFF?style=flat-square&logo=vitest&logoColor=white)
+2 -2
View File
@@ -61,8 +61,8 @@
"name": "SmartGift",
"url": "https://smartgift.vn"
},
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates, CLI statusline"
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates"
}
</script>
</head>
+1 -4
View File
@@ -11,8 +11,6 @@ export interface PageTab {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
/** Small muted suffix, e.g. "soon" for a tool that is not integrated yet. */
badge?: string;
}
interface PageTabsProps {
@@ -34,7 +32,7 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
aria-label={ariaLabel}
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
>
{tabs.map(({ id, label, Icon, badge }) => {
{tabs.map(({ id, label, Icon }) => {
const isActive = id === activeId;
return (
<button
@@ -51,7 +49,6 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
>
<Icon className="w-3.5 h-3.5" />
{label}
{badge && <span className="text-[10px] text-fg-muted">{badge}</span>}
</button>
);
})}
@@ -0,0 +1,163 @@
/**
* @file ConfigTabBar.tsx
* @description The tab strip both config explorers wear: one horizontally
* scrollable row with fade edges and chevrons that appear only when there is
* something to scroll to.
*
* Shared rather than copied because the two pages must look the same. Codex
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
* read as a different component sitting next to Claude Code's twelve-tab
* scroller. One row that scrolls is the same shape at any tab count.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
export interface ConfigTab<K extends string> {
key: K;
icon: ComponentType<{ className?: string }>;
label: string;
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
count: number | null;
}
interface ConfigTabBarProps<K extends string> {
tabs: ConfigTab<K>[];
current: K;
onSelect: (key: K) => void;
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
testIdPrefix: string;
ariaLabel: string;
}
export function ConfigTabBar<K extends string>({
tabs,
current,
onSelect,
testIdPrefix,
ariaLabel,
}: ConfigTabBarProps<K>) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Update scroll affordances when content size or scroll position changes.
const updateAffordances = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
updateAffordances();
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateAffordances, { passive: true });
const ro = new ResizeObserver(updateAffordances);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateAffordances);
ro.disconnect();
};
}, [updateAffordances]);
// Scroll the active tab into view when it changes (e.g. user picks a tab
// that's offscreen, or window resize hides the active one).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
if (!active) return;
const elRect = el.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if (activeRect.left < elRect.left + 8) {
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
} else if (activeRect.right > elRect.right - 8) {
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
}
}, [current]);
const scrollByButton = (dir: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
};
return (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
role="tablist"
aria-label={ariaLabel}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{tabs.map(({ key, icon: Icon, label, count }) => {
const active = current === key;
return (
<button
key={key}
data-testid={`${testIdPrefix}-${key}`}
data-tab-active={active ? "true" : undefined}
role="tab"
aria-selected={active}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{label}</span>
{count !== null && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
}`}
>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`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 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
);
}
@@ -0,0 +1,102 @@
/**
* @file ConfirmDeleteModal.tsx
* @description Confirmation modal for deleting a config artifact. Tool-
* agnostic — the caller supplies the async delete action; the modal only
* tracks the busy state and Escape-to-cancel behavior.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
/** See EditorModal.tsx's `DefaultScope` — same reasoning: Codex instantiates
* `TScope` as `"user" | "project"` instead of casting into Claude's wider
* default. */
export type ConfirmDeleteState<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
> = {
type: TType;
scope: TScope;
name?: string;
path: string;
project?: string; // set for type === "auto-memory"
} | null;
interface ConfirmDeleteModalProps<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
> {
state: NonNullable<ConfirmDeleteState<TType, TScope>>;
onCancel: () => void;
onConfirm: () => Promise<void>;
}
export function ConfirmDeleteModal<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
>({ state, onCancel, onConfirm }: ConfirmDeleteModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const handleConfirm = useCallback(async () => {
setBusy(true);
try {
await onConfirm();
} finally {
setBusy(false);
}
}, [onConfirm]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
>
<div
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onCancel}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
>
{t("edit.cancel")}
</button>
<button
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="w-3.5 h-3.5" />
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,276 @@
/**
* @file EditorModal.tsx
* @description Create/edit modal for text-file config artifacts (skills,
* agents, commands, output styles, memory). Tool-agnostic: it takes a
* `fetchContent` callback instead of reaching for any particular tool's API,
* so the same modal serves both the Claude Code and Codex config pages.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
/** Claude Code's own scope union, kept as the default so existing callers
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
* its caller instantiates `TScope` as just `"user" | "project"` instead of
* casting its own narrower type into this one. */
type DefaultScope = "user" | "project" | "auto-memory";
export type EditorState<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
> =
| {
mode: "create";
type: TType;
defaultScope: "user" | "project";
template: string;
project?: string; // set for type === "auto-memory"
}
| {
mode: "edit";
type: TType;
scope: TScope;
name: string;
filePath: string;
project?: string; // set for type === "auto-memory"
}
| null;
/** Types that are a single fixed file per scope (e.g. Claude's CLAUDE.md, Codex's
* AGENTS.md / config.toml) - no `name` field, and no artifact name is ever sent. */
const DEFAULT_NAMELESS_TYPES = ["memory"];
interface EditorModalProps<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
> {
state: NonNullable<EditorState<TType, TScope>>;
fetchContent: (filePath: string) => Promise<string>;
onClose: () => void;
onSave: (args: {
type: TType;
targetScope: TScope;
name: string | undefined;
content: string;
project?: string;
}) => Promise<void>;
/** Which `state.type` values are nameless singleton files. Defaults to
* Claude Code's own `["memory"]` so existing callers are unaffected. */
namelessTypes?: readonly string[];
}
export function EditorModal<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
>({
state,
fetchContent,
onClose,
onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES,
}: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
const isNameless = namelessTypes.includes(state.type);
const isAutoMemory = state.type === "auto-memory";
const [content, setContent] = useState<string>(isCreate ? state.template : "");
const [name, setName] = useState<string>("");
const [targetScope, setTargetScope] = useState<"user" | "project">(
isCreate
? state.defaultScope
: state.scope === "auto-memory"
? "user"
: (state.scope as "user" | "project")
);
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
fetchContent(state.filePath)
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state, fetchContent]);
// Esc to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (!isNameless && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
const effectiveName =
state.mode === "edit" ? state.name : isNameless ? undefined : createName;
await onSave({
type: state.type,
// Widening back to TScope: this component is the one place that
// legitimately knows about all three scope shapes across both
// callers; a Codex caller's TScope ("user" | "project") never
// actually receives "auto-memory" since isAutoMemory can only be
// true when TType includes "auto-memory" (Claude-only).
targetScope: (isAutoMemory ? "auto-memory" : targetScope) as TScope,
name: effectiveName,
content,
project: state.project,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
const titleText = isCreate
? isAutoMemory
? t("memory.newFileTitle", { project: state.project ?? "" })
: t("edit.newTitle", { type: state.type })
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
{isCreate && !isNameless && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.nameLabel")}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
}
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<p className="mt-1 text-[10px] text-fg-muted">
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
</p>
</div>
{isAutoMemory ? (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("memory.projectLabel")}
</label>
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
{state.project}
</div>
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
</div>
) : (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.scopePicker")}
</label>
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
{(["user", "project"] as const).map((s) => (
<button
key={s}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`scope.${s}`)}
</button>
))}
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.contentLabel")}
</label>
{loading ? (
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
) : (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
)}
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("edit.cancel")}
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
/**
* @file FileViewer.tsx
* @description Read-only file-content modal for the config explorer. Takes
* already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives";
export function FileViewer({
state,
onClose,
}: {
state: { path: string; data: CcFileResponse | null; error: string | null };
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
<CopyButton value={state.path} />
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4">
{state.error ? (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{state.error}
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted">…</div>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
{state.data.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
)}
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* @file MdItemList.tsx
* @description Generic list + card for markdown-frontmatter config artifacts
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
* items and the open/edit/delete handlers, nothing here reaches for an API.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api";
import { ScopeBadge } from "./ScopeBadge";
import { Empty, SkeletonRows } from "./configPrimitives";
interface MdItemListProps {
items: CcMdItem[] | null;
search: string;
onOpen: (path: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
const filtered = useMemo(() => {
if (!items) return null;
const q = search.toLowerCase();
return items.filter((it) => {
if (!q) return true;
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
.filter(Boolean)
.join(" ")
.toLowerCase();
return blob.includes(q);
});
}, [items, search]);
if (!filtered) return <SkeletonRows n={6} />;
if (filtered.length === 0) return <Empty />;
return (
<div className="space-y-2">
{filtered.map((it) => (
<MdItemCard
key={`${it.scope}:${it.name}`}
item={it}
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
kind={kind}
/>
))}
</div>
);
}
interface MdItemCardProps {
item: CcMdItem;
onOpen: (p: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
const { t } = useTranslation("ccConfig");
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
const filePath = item.file || `${item.path}/SKILL.md`;
const description =
item.frontmatter.description ||
item.preview
.replace(/^#+\s.*\n/, "")
.trim()
.slice(0, 200);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
<ScopeBadge scope={item.scope} />
{item.frontmatter.model && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{item.frontmatter.model}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{description}
</p>
)}
{kind === "agents" && item.frontmatter.tools && (
<div className="mt-2 text-[11px] text-fg-muted">
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
</div>
)}
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button
onClick={() => onOpen(filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
<button
onClick={() => onEdit(artifactType, { scope: item.scope, name: item.name, filePath })}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
<button
onClick={() => onDelete(artifactType, item.scope, item.name, filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
>
<Trash2 className="w-3 h-3" />
{t("edit.deleteButton")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,180 @@
/**
* @file ProjectMemoryModal.tsx
* @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a
* directory that is not the dashboard's own. Shared by the Claude Code and
* Codex config pages; the caller supplies its i18n namespace and the create
* call, so this component knows about neither tool.
*
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
* offered as suggestions on a plain text input rather than a closed dropdown:
* a project you have never run through the dashboard is exactly the case this
* modal exists for, so it must stay typeable.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
ns: string;
/** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */
filename: string;
/** Starting body text, so the user edits a skeleton instead of a blank box. */
template: string;
onClose: () => void;
/** Resolves with the path actually written (the server realpaths the dir). */
onCreate: (args: { dir: string; content: string }) => Promise<string>;
}
export function ProjectMemoryModal({
ns,
filename,
template,
onClose,
onCreate,
}: ProjectMemoryModalProps) {
// Every key lives under `projectMemory.*` in BOTH namespaces, so this
// component never depends on the calling page's other key groups lining up.
const { t } = useTranslation(ns);
const listId = useId();
const [dir, setDir] = useState("");
const [content, setContent] = useState(template);
const [suggestions, setSuggestions] = useState<CwdSuggestion[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.run
.cwds()
.then((r) => setSuggestions(r.items))
// Suggestions are a convenience; the input still works without them.
.catch(() => undefined);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCreate = useCallback(async () => {
const target = dir.trim();
if (!target) {
setError(t("projectMemory.dirRequired"));
return;
}
setSaving(true);
setError(null);
try {
await onCreate({ dir: target, content });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
} finally {
setSaving(false);
}
}, [dir, content, onCreate, t]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<BookOpen className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
<div>
<label
htmlFor={`${listId}-dir`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.dirLabel")}
</label>
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.path} value={s.path}>
{s.label}
</option>
))}
</datalist>
<p className="mt-1 text-[10px] text-fg-muted">
{t("projectMemory.dirHelp", { file: filename })}
</p>
</div>
<div>
<label
htmlFor={`${listId}-content`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.contentLabel")}
</label>
<textarea
id={`${listId}-content`}
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
/>
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("projectMemory.cancel")}
</button>
<button
onClick={handleCreate}
disabled={saving}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
/**
* @file ScopeBadge.tsx
* @description Small colored badge for a config artifact's scope (user /
* project / project-local). Tool-agnostic — takes a plain string so it works
* for Claude Code and Codex scopes alike.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
export function ScopeBadge({ scope }: { scope: string }) {
const { t } = useTranslation("ccConfig");
const color =
scope === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: scope === "project"
? "bg-status-success/10 text-status-success border-status-success/30"
: scope === "project-local"
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
: "bg-surface-3 text-fg-secondary border-border";
const label =
scope === "project-local"
? t("scope.projectLocal")
: scope === "user"
? t("scope.user")
: scope === "project"
? t("scope.project")
: scope;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
);
}
@@ -0,0 +1,46 @@
/**
* @file ToastNotice.tsx
* @description Bottom-right success/error toast for the config explorer.
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
* message down.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { AlertCircle, Check, X } from "lucide-react";
export type Toast = { kind: "success" | "error"; message: string } | null;
export function ToastNotice({
toast,
onDismiss,
}: {
toast: NonNullable<Toast>;
onDismiss: () => void;
}) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
/**
* @file EditorModal.test.tsx
* @description Unit tests for the tool-agnostic `EditorModal`: it must load
* existing content through the injected `fetchContent` callback rather than
* a hardcoded API, and must refuse to save a create with no name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorModal } from "../EditorModal";
describe("EditorModal", () => {
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
const fetchContent = vi.fn().mockResolvedValue("old body");
render(
<EditorModal
state={{
mode: "edit",
type: "rules",
scope: "user",
name: "default",
filePath: "/r/default.rules",
}}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>
);
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
});
it("refuses to save a create with no name", async () => {
const onSave = vi.fn();
render(
<EditorModal
state={{ mode: "create", type: "agents", defaultScope: "user", template: "" }}
fetchContent={async () => ""}
onClose={() => {}}
onSave={onSave}
/>
);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,98 @@
/**
* @file ProjectMemoryModal.test.tsx
* @description Unit tests for `ProjectMemoryModal`: it must send the directory
* the user typed (not a suggestion it picked for them), must refuse to submit
* an empty one, and must render its labels from whichever namespace the
* calling page passes — the Codex page shares this component with the Claude
* page, and a key group that only exists in `ccConfig` would render raw.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProjectMemoryModal } from "../ProjectMemoryModal";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { run: { cwds: vi.fn() } },
}));
const cwds = vi.mocked(api.run.cwds);
beforeEach(() => {
cwds.mockResolvedValue({
items: [{ kind: "recent", path: "/home/me/known-project", label: "known-project" }],
});
});
function renderModal(over: Partial<Parameters<typeof ProjectMemoryModal>[0]> = {}) {
const onCreate = vi.fn().mockResolvedValue("/home/me/typed/CLAUDE.md");
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template="# Project memory"
onClose={() => {}}
onCreate={onCreate}
{...over}
/>
);
return { onCreate };
}
describe("ProjectMemoryModal", () => {
it("sends the typed directory and the edited content", async () => {
const { onCreate } = renderModal();
await userEvent.type(screen.getByLabelText(/project directory/i), "/home/me/typed");
const body = screen.getByLabelText(/content/i);
await userEvent.clear(body);
await userEvent.type(body, "hello");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).toHaveBeenCalledWith({ dir: "/home/me/typed", content: "hello" });
});
it("refuses to submit without a directory", async () => {
const { onCreate } = renderModal();
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).not.toHaveBeenCalled();
expect(await screen.findByText(/directory is required/i)).toBeInTheDocument();
});
it("offers the dashboard's known projects as suggestions, not as the only choice", async () => {
renderModal();
// A datalist suggests; it does not constrain. A project the dashboard has
// never run in is precisely this modal's reason to exist, so the input
// stays free text.
const input = screen.getByLabelText(/project directory/i);
expect(input.tagName).toBe("INPUT");
// jsdom exposes no accessible role for datalist options, so query the DOM.
await vi.waitFor(() => {
const list = document.getElementById(input.getAttribute("list")!);
expect(list?.querySelector('option[value="/home/me/known-project"]')).not.toBeNull();
});
});
it("renders from the Codex namespace too", async () => {
renderModal({ ns: "codexConfig", filename: "AGENTS.md" });
expect(await screen.findByText(/Create AGENTS\.md for another project/i)).toBeInTheDocument();
// A missing key renders as the bare key path — catch that directly.
expect(screen.queryByText(/projectMemory\./)).not.toBeInTheDocument();
});
it("shows the server's rejection instead of closing", async () => {
const onCreate = vi.fn().mockRejectedValue(new Error("CLAUDE.md already exists in /p"));
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template=""
onClose={() => {}}
onCreate={onCreate}
/>
);
await userEvent.type(screen.getByLabelText(/project directory/i), "/p");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(await screen.findByText(/already exists in \/p/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
/**
* @file configPrimitives.tsx
* @description Small tool-agnostic atoms shared by the config-explorer UI
* (Claude Code today, Codex next): an empty-state card, a skeleton-row list
* placeholder, a copy-to-clipboard button, and a byte-size formatter. None
* of these know about any particular tool's API.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
export function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
);
}
export function Empty() {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("common.empty")}
</div>
);
}
export function SkeletonRows({ n }: { n: number }) {
return (
<div className="space-y-2">
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
))}
</div>
);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
@@ -1,11 +1,11 @@
/**
* @file LanePipelinePanel.tsx
* @description How often each node of this dashboard's OWN pipelines actually
* gets passed, aggregated over every lane and every archived feature by
* gets passed, aggregated over every recorded run by
* `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed
* with evidence / passed without / failed, plus the raw counts, so a stage that
* everyone claims but nobody proves is visible at a glance. A lane picker
* narrows the same aggregation to one lane and its archived features.
* with evidence / passed without / failed / reached but only inferred, plus the
* raw counts, so a stage that everyone claims but nobody proves is visible at a
* glance. A lane picker narrows the same aggregation to one lane.
* Read-only — the live per-lane view and every lane action stay on the
* Workspace page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -101,7 +101,9 @@ export function LanePipelinePanel() {
);
}
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1);
// Detected-only reaches share the scale: a node the hooks saw fifty times and
// nobody ever declared should read as busy, not as empty.
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered + n.detected), 1);
return (
<div className="space-y-3">
@@ -127,6 +129,7 @@ export function LanePipelinePanel() {
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
<LegendSwatch className="bg-fg-muted/60" label={t("lanes.legendDetected")} />
</div>
</div>
@@ -178,11 +181,23 @@ export function LanePipelinePanel() {
style={{ width: `${pct(n.failed, maxEntered)}%` }}
title={t("lanes.legendFailed")}
/>
<div
className="h-full bg-fg-muted/60"
style={{ width: `${pct(n.detected, maxEntered)}%` }}
title={t("lanes.legendDetected")}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
{n.entered === 0 ? (
t("lanes.never")
// Reached but never declared still beats "never reached" — it
// is the difference between "you skip this stage" and "you do
// it without saying so".
n.detected > 0 ? (
t("lanes.detectedOnly", { count: n.detected })
) : (
t("lanes.never")
)
) : (
<>
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
@@ -29,6 +29,7 @@ const node = (id: string, over: Record<string, number | string> = {}) => ({
passed: 0,
withEvidence: 0,
failed: 0,
detected: 0,
...over,
});
@@ -114,6 +115,24 @@ describe("LanePipelinePanel", () => {
const ship = await screen.findByTestId("pipeline-stat-ship");
expect(ship).not.toHaveTextContent("%");
expect(ship).toHaveTextContent(/never reached/i);
});
it("separates a node the hooks saw from one that never happened", async () => {
// `gate` was reached in two runs but never declared. Reporting that as
// "never reached" is what made the panel look broken to someone who works
// without running the pipeline commands.
statsMock.mockResolvedValue({
...STATS,
pipelines: STATS.pipelines.map((p, i) =>
i === 0 ? { ...p, nodes: [...p.nodes, node("gate", { detected: 2 })] } : p
),
});
render(<LanePipelinePanel />);
const gate = await screen.findByTestId("pipeline-stat-gate");
expect(gate).not.toHaveTextContent(/never reached/i);
expect(gate).toHaveTextContent("2");
});
it("switches templates through the picker", async () => {
+2 -2
View File
@@ -123,9 +123,9 @@ async function notify(title: string, body: string) {
try {
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, { body, icon: "/favicon.ico", silent: false });
await registration.showNotification(title, { body, icon: "/favicon.svg", silent: false });
} else {
new Notification(title, { body, icon: "/favicon.ico" });
new Notification(title, { body, icon: "/favicon.svg" });
}
} catch {
// Silently ignore
+5
View File
@@ -83,6 +83,8 @@ import updates_en from "./locales/en/updates.json";
import updates_vi from "./locales/vi/updates.json";
import ccConfig_en from "./locales/en/ccConfig.json";
import ccConfig_vi from "./locales/vi/ccConfig.json";
import codexConfig_en from "./locales/en/codexConfig.json";
import codexConfig_vi from "./locales/vi/codexConfig.json";
import run_en from "./locales/en/run.json";
import run_vi from "./locales/vi/run.json";
import alerts_en from "./locales/en/alerts.json";
@@ -110,6 +112,7 @@ i18n
errors: errors_en,
updates: updates_en,
ccConfig: ccConfig_en,
codexConfig: codexConfig_en,
run: run_en,
alerts: alerts_en,
splash: splash_en,
@@ -128,6 +131,7 @@ i18n
errors: errors_vi,
updates: updates_vi,
ccConfig: ccConfig_vi,
codexConfig: codexConfig_vi,
run: run_vi,
alerts: alerts_vi,
splash: splash_vi,
@@ -150,6 +154,7 @@ i18n
"errors",
"updates",
"ccConfig",
"codexConfig",
"run",
"alerts",
"splash",
+32 -6
View File
@@ -129,6 +129,10 @@
"mcp": {
"userScope": "User-scoped servers",
"projectScope": "Project-scoped servers",
"name": "Name",
"scope": "Scope",
"scopeUser": "User",
"scopeLocal": "This project",
"command": "Command",
"args": "Args",
"env": "Env names",
@@ -136,7 +140,8 @@
"headers": "Header names",
"kind": "Kind",
"source": "Source",
"noServers": "No MCP servers found."
"noServers": "No MCP servers found.",
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
},
"hooks": {
"noHooks": "No hooks configured.",
@@ -169,6 +174,20 @@
"projectLabel": "Project",
"projectHelp": "Created in this project's memory dir."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"edit": {
"newButton": "New",
"editButton": "Edit",
@@ -190,7 +209,7 @@
"saveSuccessNew": "Created.",
"deleteSuccess": "Deleted · backup at {{path}}",
"confirmDelete": "Delete this item?",
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories Claude Code scans, so the entry will disappear from your CLI.",
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories the CLI scans, so the entry will disappear from your CLI.",
"confirmDeletePath": "Path: {{path}}",
"confirmDeleteAction": "Yes, back up and delete",
"templates": {
@@ -275,10 +294,7 @@
"body": "MCP server config lives inside `~/.claude.json`, the same file the live CLI writes to constantly. Concurrent writes from the dashboard would corrupt project state, so add/remove flow goes through Claude Code itself.",
"list": "List configured servers:",
"listCmd": "claude mcp list",
"add": "Add a server:",
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Remove this server:",
"removeButton": "Copy remove command"
"add": "Add a server:"
},
"marketplaces": {
"title": "Marketplaces are where plugins come from",
@@ -317,6 +333,16 @@
"marketplaceAdded": "Added marketplace {{name}}.",
"marketplaceUpdated": "Updated marketplace {{name}}.",
"marketplaceRemoved": "Removed marketplace {{name}}.",
"addMcpTitle": "Add an MCP server",
"addMcpNamePlaceholder": "name",
"addMcpTransportStdio": "Command (stdio)",
"addMcpTransportHttp": "URL (http)",
"addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github",
"addMcpUrlPlaceholder": "https://mcp.example.com",
"addMcpButton": "Add",
"addMcpHelp": "Runs `claude mcp add`. \"This project\" scopes the server to the current checkout — the CLI's separate `project` scope (a shared `.mcp.json`) isn't offered here because this dashboard can't see servers stored there.",
"mcpAdded": "Added MCP server {{name}}.",
"mcpRemoved": "Removed MCP server {{name}}.",
"hookEvent": "Event name (e.g. PreToolUse)",
"hookAddEvent": "Add event",
"hookRemoveEvent": "Remove event",
+123
View File
@@ -0,0 +1,123 @@
{
"title": "Codex Config Explorer",
"subtitle": "Your Codex CLI setup at a glance - skills, agents, rules, AGENTS.md memory, config.toml, MCP servers, plugins and marketplaces.",
"refresh": "Refresh",
"refreshing": "Refreshing…",
"lastUpdated": "Last updated {{time}}",
"loadError": "Failed to load: {{message}}",
"scope": {
"all": "All scopes",
"user": "User",
"project": "Project"
},
"tabs": {
"overview": "Overview",
"skills": "Skills",
"agents": "Agents",
"rules": "Rules",
"memory": "Memory",
"config": "Config",
"mcp": "MCP servers",
"plugins": "Plugins"
},
"overview": {
"rootsTitle": "Roots",
"codexHome": "Codex home",
"projectCodexDir": "Project .codex",
"projectRoot": "Project root",
"summary": "Quick summary",
"user": "user",
"project": "project"
},
"common": {
"search": "Search by name…",
"clearSearch": "Clear search",
"close": "Close"
},
"list": {
"newButton": "New",
"viewButton": "View source",
"editButton": "Edit",
"deleteButton": "Delete"
},
"edit": {
"saveSuccess": "Saved · {{path}}",
"saveSuccessNew": "Created.",
"deleteSuccess": "Deleted · {{path}}",
"deleteError": "Delete failed: {{message}}",
"templates": {
"skills": "---\nname: my-skill\ndescription: One-line description (shown when Codex picks the skill)\n---\n\nBody - what the skill does, when to use it, examples.\n",
"agents": "name = \"my-agent\"\ndescription = \"When to invoke this agent\"\n\ninstructions = \"\"\"\nSystem prompt for the agent.\n\"\"\"\n",
"rules": "# Rules\n\nCodex follows these rules for this scope.\n",
"memory": "# Project memory\n\nCodex reads this on session start.\n",
"config": "# config.toml\n"
}
},
"memory": {
"missing": "No AGENTS.md found."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"config": {
"missing": "No config.toml found."
},
"mcp": {
"addTitle": "Add an MCP server",
"nameLabel": "Name",
"modeCommand": "Command",
"modeUrl": "URL",
"commandLabel": "Command",
"argsLabel": "Args (space-separated)",
"urlLabel": "URL",
"bearerLabel": "Bearer token env var (optional)",
"addButton": "Add",
"removeButton": "Remove",
"added": "Added {{name}}.",
"removed": "Removed {{name}}."
},
"plugins": {
"installTitle": "Install a plugin",
"installPlaceholder": "name or name@marketplace",
"installButton": "Add",
"removeButton": "Remove",
"installed": "Installed {{name}}.",
"removed": "Removed {{name}}.",
"noPlugins": "No plugins installed.",
"marketplacesTitle": "Marketplaces",
"addMarketplaceTitle": "Add a marketplace",
"addMarketplacePlaceholder": "https://github.com/owner/repo",
"addMarketplaceButton": "Add",
"marketplaceAdded": "Added marketplace.",
"marketplaceRemoved": "Removed marketplace {{name}}."
},
"manage": {
"working": "Working…",
"noIdentifier": "No stable identifier for this entry - remove it from the CLI directly."
},
"cli": {
"unavailable": "The codex CLI isn't available on this machine ({{message}}) - install it or add it to PATH to manage this."
},
"backups": {
"openButton": "Backups",
"title": "Backups",
"subtitle": "Every save / delete writes a copy here first. Backup folders live outside the directories the CLI scans, so they don't reappear as ghosts in your CLI.",
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
"restoreHint": "Restore by renaming the backup back into place:"
},
"snippet": {
"copy": "Copy",
"copied": "Copied"
}
}
+1 -5
View File
@@ -445,10 +445,6 @@
"label": "Tools",
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"cursor": "Cursor",
"soonBadge": "soon",
"comingSoon": "{{tool}} is not integrated yet",
"comingSoonDesc": "Its configuration will show up here once support lands."
"codex": "Codex"
}
}
+3 -1
View File
@@ -246,7 +246,9 @@
"never": "never reached",
"copyCommand": "Copy: {{command}}",
"empty": "No lane has recorded a stage yet. Create one with",
"emptyLane": "This lane has not recorded a stage yet."
"emptyLane": "This lane has not recorded a stage yet.",
"legendDetected": "reached, not declared",
"detectedOnly": "detected ×{{count}}"
},
"errorPropagation": {
"title": "Error Propagation Map",
+32 -6
View File
@@ -124,6 +124,10 @@
"mcp": {
"userScope": "Máy chủ phạm vi người dùng",
"projectScope": "Máy chủ phạm vi dự án",
"name": "Tên",
"scope": "Phạm vi",
"scopeUser": "Người dùng",
"scopeLocal": "Dự án này",
"command": "Lệnh",
"args": "Tham số",
"env": "Tên env",
@@ -131,7 +135,8 @@
"headers": "Tên Header",
"kind": "Loại",
"source": "Nguồn",
"noServers": "Không tìm thấy máy chủ MCP."
"noServers": "Không tìm thấy máy chủ MCP.",
"notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này."
},
"hooks": {
"noHooks": "Chưa cấu hình hook.",
@@ -164,6 +169,20 @@
"projectLabel": "Dự án",
"projectHelp": "Được tạo trong thư mục bộ nhớ của dự án này."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"edit": {
"newButton": "Tạo mới",
"editButton": "Sửa",
@@ -185,7 +204,7 @@
"saveSuccessNew": "Đã tạo.",
"deleteSuccess": "Đã xoá · bản sao tại {{path}}",
"confirmDelete": "Xoá mục này?",
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục Claude Code quét, nên mục này sẽ biến mất khỏi CLI.",
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục CLI quét, nên mục này sẽ biến mất khỏi CLI.",
"confirmDeletePath": "Đường dẫn: {{path}}",
"confirmDeleteAction": "Xác nhận sao lưu và xoá",
"templates": {
@@ -270,10 +289,7 @@
"body": "Cấu hình MCP server nằm trong `~/.claude.json`, đúng tệp mà CLI đang chạy ghi liên tục. Ghi đồng thời từ dashboard sẽ phá hỏng trạng thái dự án, nên flow add/remove đi qua chính Claude Code.",
"list": "Liệt kê server đã cấu hình:",
"listCmd": "claude mcp list",
"add": "Thêm server:",
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Gỡ server này:",
"removeButton": "Sao chép lệnh gỡ"
"add": "Thêm server:"
},
"marketplaces": {
"title": "Marketplace là nơi plugin đến từ đó",
@@ -312,6 +328,16 @@
"marketplaceAdded": "Đã thêm marketplace {{name}}.",
"marketplaceUpdated": "Đã cập nhật marketplace {{name}}.",
"marketplaceRemoved": "Đã xóa marketplace {{name}}.",
"addMcpTitle": "Thêm máy chủ MCP",
"addMcpNamePlaceholder": "tên",
"addMcpTransportStdio": "Lệnh (stdio)",
"addMcpTransportHttp": "URL (http)",
"addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github",
"addMcpUrlPlaceholder": "https://mcp.example.com",
"addMcpButton": "Thêm",
"addMcpHelp": "Chạy lệnh `claude mcp add`. \"Dự án này\" giới hạn server trong checkout hiện tại — phạm vi `project` riêng của CLI (một `.mcp.json` dùng chung) không được cung cấp ở đây vì dashboard không thể thấy các server lưu ở đó.",
"mcpAdded": "Đã thêm máy chủ MCP {{name}}.",
"mcpRemoved": "Đã xóa máy chủ MCP {{name}}.",
"hookEvent": "Tên sự kiện (vd PreToolUse)",
"hookAddEvent": "Thêm sự kiện",
"hookRemoveEvent": "Xóa sự kiện",
+123
View File
@@ -0,0 +1,123 @@
{
"title": "Trình khám phá cấu hình Codex",
"subtitle": "Toàn cảnh thiết lập Codex CLI của bạn - skill, agent, rule, bộ nhớ AGENTS.md, config.toml, máy chủ MCP, plugin và marketplace.",
"refresh": "Làm mới",
"refreshing": "Đang làm mới…",
"lastUpdated": "Cập nhật lần cuối {{time}}",
"loadError": "Tải thất bại: {{message}}",
"scope": {
"all": "Tất cả phạm vi",
"user": "Người dùng",
"project": "Dự án"
},
"tabs": {
"overview": "Tổng quan",
"skills": "Skill",
"agents": "Agent",
"rules": "Rule",
"memory": "Bộ nhớ",
"config": "Cấu hình",
"mcp": "Máy chủ MCP",
"plugins": "Plugin"
},
"overview": {
"rootsTitle": "Thư mục gốc",
"codexHome": "Thư mục Codex",
"projectCodexDir": "Thư mục .codex của dự án",
"projectRoot": "Thư mục gốc dự án",
"summary": "Tóm tắt nhanh",
"user": "người dùng",
"project": "dự án"
},
"common": {
"search": "Tìm theo tên…",
"clearSearch": "Xóa tìm kiếm",
"close": "Đóng"
},
"list": {
"newButton": "Mới",
"viewButton": "Xem nguồn",
"editButton": "Sửa",
"deleteButton": "Xóa"
},
"edit": {
"saveSuccess": "Đã lưu · {{path}}",
"saveSuccessNew": "Đã tạo.",
"deleteSuccess": "Đã xóa · {{path}}",
"deleteError": "Xóa thất bại: {{message}}",
"templates": {
"skills": "---\nname: my-skill\ndescription: Mô tả một dòng (hiển thị khi Codex chọn skill)\n---\n\nNội dung - skill làm gì, khi nào dùng, ví dụ.\n",
"agents": "name = \"my-agent\"\ndescription = \"Khi nào gọi agent này\"\n\ninstructions = \"\"\"\nSystem prompt cho agent.\n\"\"\"\n",
"rules": "# Rules\n\nCodex tuân theo các quy tắc này ở phạm vi này.\n",
"memory": "# Bộ nhớ dự án\n\nCodex đọc tệp này khi bắt đầu phiên làm việc.\n",
"config": "# config.toml\n"
}
},
"memory": {
"missing": "Không tìm thấy AGENTS.md."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"config": {
"missing": "Không tìm thấy config.toml."
},
"mcp": {
"addTitle": "Thêm máy chủ MCP",
"nameLabel": "Tên",
"modeCommand": "Lệnh",
"modeUrl": "URL",
"commandLabel": "Lệnh",
"argsLabel": "Tham số (cách nhau bằng dấu cách)",
"urlLabel": "URL",
"bearerLabel": "Biến môi trường bearer token (tùy chọn)",
"addButton": "Thêm",
"removeButton": "Gỡ bỏ",
"added": "Đã thêm {{name}}.",
"removed": "Đã gỡ bỏ {{name}}."
},
"plugins": {
"installTitle": "Cài đặt plugin",
"installPlaceholder": "tên hoặc tên@marketplace",
"installButton": "Thêm",
"removeButton": "Gỡ bỏ",
"installed": "Đã cài đặt {{name}}.",
"removed": "Đã gỡ bỏ {{name}}.",
"noPlugins": "Chưa cài đặt plugin nào.",
"marketplacesTitle": "Marketplace",
"addMarketplaceTitle": "Thêm marketplace",
"addMarketplacePlaceholder": "https://github.com/owner/repo",
"addMarketplaceButton": "Thêm",
"marketplaceAdded": "Đã thêm marketplace.",
"marketplaceRemoved": "Đã gỡ bỏ marketplace {{name}}."
},
"manage": {
"working": "Đang xử lý…",
"noIdentifier": "Mục này không có định danh ổn định - hãy gỡ trực tiếp từ CLI."
},
"cli": {
"unavailable": "CLI codex không có sẵn trên máy này ({{message}}) - cài đặt hoặc thêm vào PATH để quản lý mục này."
},
"backups": {
"openButton": "Bản sao lưu",
"title": "Bản sao lưu",
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí CLI quét, nên không xuất hiện như mục thừa trong CLI.",
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
"restoreHint": "Khôi phục bằng cách đổi tên bản sao về vị trí cũ:"
},
"snippet": {
"copy": "Sao chép",
"copied": "Đã sao chép"
}
}
+1 -5
View File
@@ -445,10 +445,6 @@
"label": "Công cụ",
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"cursor": "Cursor",
"soonBadge": "sắp có",
"comingSoon": "{{tool}} chưa được tích hợp",
"comingSoonDesc": "Cấu hình của công cụ này sẽ hiện ở đây khi có hỗ trợ."
"codex": "Codex"
}
}
+3 -1
View File
@@ -246,7 +246,9 @@
"never": "chưa từng tới",
"copyCommand": "Sao chép: {{command}}",
"empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng",
"emptyLane": "Lane này chưa ghi nhận giai đoạn nào."
"emptyLane": "Lane này chưa ghi nhận giai đoạn nào.",
"legendDetected": "đã chạm, chưa khai báo",
"detectedOnly": "suy đoán ×{{count}}"
},
"errorPropagation": {
"title": "Bản đồ lan truyền lỗi",
@@ -127,9 +127,7 @@ describe("agentOriginLabel - parent chain walk", () => {
describe("projectFromCwd - session-cwd fallback for the dir prefix", () => {
it("returns the last path segment of a POSIX cwd", () => {
expect(projectFromCwd("/Users/dev/WebstormProjects/Claude-Code-Agent-Monitor")).toBe(
"Claude-Code-Agent-Monitor"
);
expect(projectFromCwd("/Users/dev/WebstormProjects/AgentHub")).toBe("AgentHub");
});
it("ignores a trailing slash", () => {
@@ -0,0 +1,47 @@
/**
* @file Pins the codexConfig API routes and payloads. Tests that the client
* sends the right method, URL, and body shape to the Codex config backend.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api } from "../api";
afterEach(() => {
vi.unstubAllGlobals();
});
function stubFetch(response: unknown) {
const spy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => response,
text: async () => JSON.stringify(response),
});
vi.stubGlobal("fetch", spy);
return spy;
}
describe("api.codexConfig", () => {
it("sends a write as a single PUT with the artifact envelope", async () => {
const spy = stubFetch({ ok: true, file: "/x", created: true });
await api.codexConfig.write({
scope: "user",
type: "rules",
name: "default",
content: "allow",
});
expect(spy).toHaveBeenCalledTimes(1);
const [, options] = spy.mock.calls[0] as [string, RequestInit | undefined];
expect(options?.method).toBe("PUT");
expect(JSON.parse(options?.body as string)).toEqual({
scope: "user",
type: "rules",
name: "default",
content: "allow",
});
});
});
+448
View File
@@ -97,6 +97,8 @@
* - `CcPluginsResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpServer` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpScope` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpAddArgs` — exported API; see TSDoc on the symbol for behavior.
* - `CcHookEntry` — exported API; see TSDoc on the symbol for behavior.
* - `CcHookSource` — exported API; see TSDoc on the symbol for behavior.
* - `CcSettingsSource` — exported API; see TSDoc on the symbol for behavior.
@@ -1404,6 +1406,28 @@ export const api = {
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
*/
mcp: () => request<CcMcpResponse>("/cc-config/mcp"),
/**
* POST /api/cc-config/mcp/add - add an MCP server via `claude mcp add`.
* Either `url` (http/sse) or `command`+`args` (stdio) must be given, not
* both. `scope` is required — `user` or `project` only, never the
* settings union's third `project-local` value.
* @returns {@link CcCliResult} — the CLI's stdout/stderr on success.
*/
mcpAdd: (args: CcMcpAddArgs) =>
request<CcCliResult>("/cc-config/mcp/add", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/cc-config/mcp/remove - remove an MCP server via
* `claude mcp remove`. `scope` narrows which config layer to remove
* from; omit it to let the CLI find the server wherever it exists.
*/
mcpRemove: (name: string, scope?: CcMcpScope) =>
request<CcCliResult>("/cc-config/mcp/remove", {
method: "POST",
body: JSON.stringify({ name, scope }),
}),
/**
* GET /api/cc-config/hooks - hook entries from every settings.json layer.
* @returns `{ items }` — one {@link CcHookSource} per settings layer.
@@ -1446,6 +1470,22 @@ export const api = {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/cc-config/project-memory - create `CLAUDE.md` in a project the
* dashboard has never run in.
*
* Unlike {@link api.ccConfig.write}, whose "project" scope always means the
* dashboard server's own directory, this one writes into the directory you
* name. It only ever creates: an existing CLAUDE.md comes back as a 409.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/cc-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/cc-config/file - remove a config artifact (also backed up).
*
@@ -1609,6 +1649,191 @@ export const api = {
}),
},
// ─────────────────────────────── Codex-Config API ──────────────────────────────
/** Read/write access to on-disk Codex CLI configuration - skills, agents,
* rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and
* marketplaces - for the dashboard's "Codex Config" explorer/editor pages. */
codexConfig: {
/**
* GET /api/codex-config/overview - counts of every artifact kind, for the
* explorer's landing page.
* @returns {@link CodexOverview} — filesystem roots plus per-kind counts.
*/
overview: () => request<CodexOverview>("/codex-config/overview"),
/**
* GET /api/codex-config/skills - user and/or project skill definitions.
*
* The optional `scope` is appended as `?scope=` only when provided; omitting
* it lets the server apply its default scope.
*
* @param scope Optional {@link CodexScope} ("user"|"project"|"all") filter.
* @returns `{ skills }` — the {@link CodexSkill} items for each skill.
*/
skills: (scope?: CodexScope) =>
request<{ skills: CodexSkill[] }>(`/codex-config/skills${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/agents - user and/or project agent definitions.
* @param scope Optional {@link CodexScope} filter.
* @returns `{ agents }` — {@link CodexAgent} items for each agent.
*/
agents: (scope?: CodexScope) =>
request<{ agents: CodexAgent[] }>(`/codex-config/agents${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/rules - user and/or project rules files.
* @param scope Optional {@link CodexScope} filter.
* @returns `{ rules }` — {@link CodexRule} items for each rule file.
*/
rules: (scope?: CodexScope) =>
request<{ rules: CodexRule[] }>(`/codex-config/rules${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/memory - AGENTS.md files at both scopes.
* @returns `{ memory }` — {@link CodexMemoryFile} items.
*/
memory: () => request<{ memory: CodexMemoryFile[] }>("/codex-config/memory"),
/**
* GET /api/codex-config/config - config.toml files at both scopes.
* @returns `{ config }` — {@link CodexConfigFile} items.
*/
config: () => request<{ config: CodexConfigFile[] }>("/codex-config/config"),
/**
* GET /api/codex-config/file - raw contents of one config file by absolute path.
*
* The absolute path is passed as a URL-encoded `path` query param (not a
* path segment) so arbitrary filesystem paths survive intact.
*
* @param path Absolute path of the config file to read.
* @returns {@link CodexFileResponse} — file text + metadata.
*/
file: (path: string) =>
request<CodexFileResponse>(`/codex-config/file?path=${encodeURIComponent(path)}`),
/**
* PUT /api/codex-config/file - create/overwrite a config artifact; the
* server writes a backup of any previous content first.
*
* @param args {@link CodexWriteArgs} — scope/type/name/content.
* @returns {@link CodexMutationResult} — the written path and whether a new file was created.
*/
write: (args: CodexWriteArgs) =>
request<CodexMutationResult>("/codex-config/file", {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/project-memory - create `AGENTS.md` in a project
* the dashboard has never run in. The Codex twin of
* {@link api.ccConfig.createProjectMemory}, same create-only rule.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/codex-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/codex-config/file - remove a config artifact (also backed up).
*
* @param args {@link CodexDeleteArgs} — which artifact to delete.
* @returns {@link CodexMutationResult} — the deleted path and its backup path.
*/
remove: (args: CodexDeleteArgs) =>
request<CodexMutationResult>("/codex-config/file", {
method: "DELETE",
body: JSON.stringify(args),
}),
/**
* GET /api/codex-config/backups - timestamped backups written by `write`/
* `remove`, optionally filtered by scope/artifact type.
*
* @param params Optional `{ scope, type }` filter for the backup listing.
* @returns `{ items }` — the matching {@link CodexBackup} entries.
*/
backups: (params?: { scope?: "user" | "project"; type?: CodexArtifactType }) =>
requestCodexBackupsHelper(params),
/**
* GET /api/codex-config/mcp - configured MCP servers from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ mcp }` — {@link CodexMcpServer} items.
*/
mcp: async (): Promise<{ mcp: CodexMcpServer[] }> => {
const res = await request<{ mcp: unknown }>("/codex-config/mcp");
return { mcp: Array.isArray(res.mcp) ? (res.mcp as CodexMcpServer[]) : [] };
},
/**
* GET /api/codex-config/plugins - installed plugins from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ plugins }` — {@link CodexPlugin} items.
*/
plugins: async (): Promise<{ plugins: CodexPlugin[] }> => {
const res = await request<{ plugins: unknown }>("/codex-config/plugins");
return { plugins: Array.isArray(res.plugins) ? (res.plugins as CodexPlugin[]) : [] };
},
/**
* GET /api/codex-config/marketplaces - registered marketplaces from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ marketplaces }` — {@link CodexMarketplace} items.
*/
marketplaces: async (): Promise<{ marketplaces: CodexMarketplace[] }> => {
const res = await request<{ marketplaces: unknown }>("/codex-config/marketplaces");
return {
marketplaces: Array.isArray(res.marketplaces)
? (res.marketplaces as CodexMarketplace[])
: [],
};
},
/**
* POST /api/codex-config/mcp/add - add an MCP server via the `codex` CLI.
* @param args {@link CodexMcpAddArgs} for `codex mcp add`.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
mcpAdd: (args: CodexMcpAddArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/add", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/mcp/remove - remove an MCP server via the `codex` CLI.
* @param name The MCP server name to remove.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
mcpRemove: (name: string) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/remove", {
method: "POST",
body: JSON.stringify({ name }),
}),
/**
* POST /api/codex-config/plugins/action - add or remove a plugin via the `codex` CLI.
* @param args {@link CodexPluginActionArgs} with `spec` and `action` fields.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
pluginAction: (args: CodexPluginActionArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/plugins/action", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/marketplaces/add - register a marketplace via the `codex` CLI.
* @param url The marketplace URL to register.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
marketplaceAdd: (url: string) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/add", {
method: "POST",
body: JSON.stringify({ url }),
}),
/**
* POST /api/codex-config/marketplaces/action - update or remove a marketplace via the `codex` CLI.
* @param args {@link CodexMarketplaceActionArgs} with `name` and `action` fields.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
marketplaceAction: (args: CodexMarketplaceActionArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/action", {
method: "POST",
body: JSON.stringify(args),
}),
},
// ────────────────────────────────── Run API ─────────────────────────────────
/** Spawn/manage headless or conversational `claude` CLI child processes
* launched from the dashboard's Run page, and stream their output. */
@@ -2230,6 +2455,23 @@ function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcAr
return request<{ items: CcBackup[] }>(`/cc-config/backups${q ? `?${q}` : ""}`);
}
/** Helper for GET /api/codex-config/backups with optional `scope` and `type` filters.
* Builds an optional `?scope=&type=` query string (each part appended only when
* present) and calls {@link request} for GET /api/codex-config/backups.
*
* @param params Optional `{ scope, type }` filter for the backup listing.
* @returns `{ items }` — the matching {@link CodexBackup} entries. */
function requestCodexBackupsHelper(params?: {
scope?: "user" | "project";
type?: CodexArtifactType;
}) {
const qs = new URLSearchParams();
if (params?.scope) qs.set("scope", params.scope);
if (params?.type) qs.set("type", params.type);
const q = qs.toString();
return request<{ items: CodexBackup[] }>(`/codex-config/backups${q ? `?${q}` : ""}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// CC-Config types — request/response shapes for the "CC Config" explorer/editor.
// These describe on-disk Claude Code configuration artifacts (skills, agents,
@@ -2438,6 +2680,11 @@ export interface CcMcpServer {
name: string;
/** Which config file this entry came from (e.g. a `.mcp.json` path). */
source: string;
/**
* False when no `claude mcp` scope can address this entry, so the dashboard
* cannot remove it. Absent means removable — only the reader sets it.
*/
removable?: boolean;
/** Transport: local subprocess ("stdio"), remote HTTP, or undetermined. */
kind: "stdio" | "http" | "unknown";
/** Launch command, for `kind === "stdio"`. */
@@ -2458,6 +2705,28 @@ export interface CcMcpResponse {
projectScoped: CcMcpServer[];
}
/** Scope the `claude mcp` CLI accepts from this form — `user` and `local`
* ONLY, matching what GET /mcp's `readMcpServers` can actually see:
* `user` is `~/.claude.json` top-level, `local` is its `projects[<root>]`
* block (the panel's `projectScoped` bucket, despite the name). The CLI's
* `project` scope writes `.mcp.json`, which `readMcpServers` never reads —
* offering it here would add a server the dashboard can never show exists. */
export type CcMcpScope = "user" | "local";
/** Body of POST /api/cc-config/mcp/add. Either `url` (http/sse) or
* `command`+`args` (stdio) is required, never both. `env` is stdio-only;
* `headers` is http/sse-only. */
export interface CcMcpAddArgs {
name: string;
scope: CcMcpScope;
transport?: "http" | "sse";
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
}
/** One hook binding within a settings.json `hooks` block. */
export interface CcHookEntry {
/** Tool-name matcher pattern (e.g. "Bash", "Edit|Write", or "*"). */
@@ -2675,6 +2944,185 @@ export interface CcHookScripts {
items: { name: string; file: string; size: number; mtime: number }[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Codex-Config types — request/response shapes for the "Codex Config" explorer/editor.
// These describe on-disk Codex CLI configuration artifacts (skills, agents, rules,
// memory, config, MCP servers, plugins, marketplaces) as surfaced by the
// /api/codex-config/* routes. They live in this client because they are specific
// to the explorer UI.
// ─────────────────────────────────────────────────────────────────────────────
/** Kind of Codex config artifact manageable via `api.codexConfig.write`/
* `remove` - each maps to a distinct on-disk location. */
export type CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config";
/** Scope filter accepted by most `codexConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CodexScope = "user" | "project" | "all";
/** Body for PUT /api/codex-config/file - create or overwrite one artifact. */
export interface CodexWriteArgs {
scope: "user" | "project";
type: CodexArtifactType;
/** Artifact name (e.g. skill/agent/rule name); omitted for singleton
* artifacts like memory/config. */
name?: string;
/** Full file contents to write. */
content: string;
}
/** Body for DELETE /api/codex-config/file - remove one artifact. */
export interface CodexDeleteArgs {
scope: "user" | "project";
type: CodexArtifactType;
name?: string;
}
/** Response shape of a successful `codexConfig.write`/`remove` call. */
export interface CodexMutationResult {
file: string;
created?: boolean;
backup?: { [key: string]: unknown } | null;
}
/**
* Result of creating a project's memory file (CLAUDE.md / AGENTS.md) via
* POST /api/{cc,codex}-config/project-memory. `file` is the resolved path —
* worth showing back, since the server realpaths the directory it was given.
*/
export interface ProjectMemoryResult {
file: string;
size: number;
created: true;
}
/** One timestamped backup of a config artifact, from GET /api/codex-config/backups. */
export interface CodexBackup {
type: string;
name: string;
file: string;
mtime: number;
size: number | null;
}
/** A discovered skill: a directory containing a SKILL.md under skills/<name>/. */
export interface CodexSkill {
scope: "user" | "project";
name: string;
path: string;
file: string;
size: number;
mtime: number;
truncated: boolean;
frontmatter: Record<string, string>;
preview: string;
}
/** A discovered agent definition (TOML file). */
export interface CodexAgent {
scope: "user" | "project";
name: string;
file: string;
size: number;
mtime: number;
meta: Record<string, unknown>;
preview: string;
}
/** A discovered rules file. */
export interface CodexRule {
scope: "user" | "project";
name: string;
file: string;
size: number;
mtime: number;
preview: string;
}
/** AGENTS.md file at either user or project scope. */
export interface CodexMemoryFile {
scope: "user" | "project";
file: string;
exists: boolean;
size?: number;
mtime?: number;
preview?: string;
}
/** config.toml file at either user or project scope. */
export interface CodexConfigFile {
scope: "user" | "project";
file: string;
exists: boolean;
size?: number;
mtime?: number;
content?: string;
truncated?: boolean;
}
/** Response shape of GET /api/codex-config/file - full contents of one config artifact. */
export interface CodexFileResponse {
ok: true;
file: string;
size: number;
mtime: number;
truncated: boolean;
text: string;
}
/** Response shape of GET /api/codex-config/overview. */
export interface CodexOverview {
roots: {
codexHome: string;
projectCodexDir: string;
projectRoot: string;
};
counts: {
skills: { user: number; project: number };
agents: { user: number; project: number };
rules: { user: number; project: number };
memory: { user: number; project: number };
config: { user: number; project: number };
};
}
/** One configured MCP server, from GET /api/codex-config/mcp. */
export interface CodexMcpServer {
[key: string]: unknown;
}
/** One installed plugin, from GET /api/codex-config/plugins. */
export interface CodexPlugin {
[key: string]: unknown;
}
/** One registered marketplace, from GET /api/codex-config/marketplaces. */
export interface CodexMarketplace {
[key: string]: unknown;
}
/**
* Arguments for POST /api/codex-config/mcp/add. A union, not one optional-heavy
* shape: `codex mcp add` takes either a URL or a command and rejects both or
* neither, and `env` is stdio-only. Modelling that here turns three of the
* server's `EBADARG` responses into compile errors.
*/
export type CodexMcpAddArgs =
| { name: string; url: string; bearerTokenEnvVar?: string }
| { name: string; command: string; args?: string[]; env?: Record<string, string> };
/** Arguments for POST /api/codex-config/plugins/action. */
export interface CodexPluginActionArgs {
spec: string;
action: "add" | "remove";
}
/** Arguments for POST /api/codex-config/marketplaces/action. */
export interface CodexMarketplaceActionArgs {
name: string;
action: "remove";
}
// ─────────────────────────────────────────────────────────────────────────────
// Run types — request/response shapes for the Run page's `claude` process
// spawning/management. `RunMode`/`RunStatus`/`PermissionMode`/`EffortLevel`
+35 -5
View File
@@ -545,6 +545,7 @@
* - `run_stream` / `run_status` / `run_input_ack` ... {@link RunStreamPayload} /
* {@link RunStatusPayload} / {@link RunInputAckPayload}
* - `cc_config_changed` ............................ {@link CcConfigChangedPayload}
* - `codex_config_changed` ......................... {@link CodexConfigChangedPayload}
* - `alert_triggered` / `alert_updated` ............ {@link AlertEvent}
* - `workflow_upserted` ............................ {@link WorkflowRun}
*
@@ -1234,7 +1235,7 @@ export interface UpdateStatusPayload {
* Drives whether the "Update available" call-to-action is shown. */
update_available: boolean;
/** Absolute path to the detected git repository root. Example:
* `"/Users/dev/Claude-Code-Agent-Monitor"`. */
* `"/Users/dev/AgentHub"`. */
repo_root?: string;
/** Resolved ref compared against, e.g. "upstream/master" or "origin/main";
* null when no remote could be resolved (e.g. no remotes configured). */
@@ -1331,6 +1332,27 @@ export interface CcConfigChangedPayload {
paths?: string[];
}
// ───── Codex config change notifications ─────
/** Payload for the `codex_config_changed` WebSocket message: broadcast
* whenever a Codex CLI config artifact (skill, agent, rule, memory, config,
* MCP server, plugin, marketplace) is written or removed through the
* dashboard's codex-config editor, so other open tabs can refresh their
* view. Codex has no filesystem watcher counterpart to `cc-watcher.js`, so
* unlike {@link CcConfigChangedPayload} this is always dashboard-sourced. */
export interface CodexConfigChangedPayload {
source: "dashboard";
/** "write"/"delete" for file mutations; "add"/"remove" for the CLI-driven
* mcp/plugin/marketplace routes. */
action?: "write" | "delete" | "add" | "remove";
/** "user" or "project" - Codex has no "project-local" scope. */
scope?: "user" | "project";
/** Artifact kind, e.g. "skills", "agents", "rules", "memory", "config",
* or "mcp"/"plugin"/"marketplace" for the CLI-driven routes. */
type?: string;
name?: string | null;
}
// ───── Alerting ─────
// User-defined rules that watch the ingest stream (or a periodic sweep) and fire
// {@link AlertEvent}s. {@link AlertRuleConfig} is a tagged bag of settings whose
@@ -1624,6 +1646,7 @@ export interface WSMessage {
* new_event → DashboardEvent; import.progress → ImportProgressMessage;
* update_status → UpdateStatusPayload; run_stream/run_status/run_input_ack
* → their matching Run*Payload; cc_config_changed → CcConfigChangedPayload;
* codex_config_changed → CodexConfigChangedPayload;
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
* remote_source.status → RemoteSourceStatusPayload;
* remote_data.updated → RemoteDataUpdatedPayload;
@@ -1640,6 +1663,7 @@ export interface WSMessage {
| "run_status"
| "run_input_ack"
| "cc_config_changed"
| "codex_config_changed"
| "alert_triggered"
| "alert_updated"
| "workflow_upserted"
@@ -1661,6 +1685,7 @@ export interface WSMessage {
| UpdateStatusPayload
| RunStatusPayload
| CcConfigChangedPayload
| CodexConfigChangedPayload
| AlertEvent
| WorkflowRun
| RemoteSourceStatusPayload
@@ -2482,9 +2507,9 @@ export interface LaneCounts {
}
/**
* How often one pipeline node was actually passed, summed over every lane and
* every archived feature. `passed + failed === entered`; `withEvidence` is the
* subset of passes that left an artifact, so a claim never reads as a proof.
* How often one pipeline node was actually passed, summed over every recorded
* run. `passed + failed === entered`; `withEvidence` is the subset of passes
* that left an artifact, so a claim never reads as a proof.
*/
export interface PipelineNodeStat {
id: string;
@@ -2498,6 +2523,10 @@ export interface PipelineNodeStat {
passed: number;
withEvidence: number;
failed: number;
/** Runs that reached this node by inference from the hook stream only, with
* no declaration. Deliberately outside `entered`: it says the work happened,
* not that the gate was met. */
detected: number;
}
/** Per-node pass counts, grouped by pipeline template (busiest first). */
@@ -2505,7 +2534,8 @@ export interface PipelineStats {
pipelines: {
id: string;
name: string;
/** Lanes + archived features recorded against this template. */
/** Passes through this template that recorded at least one stage. A lane
* that ships three features contributes three. */
runs: number;
nodes: PipelineNodeStat[];
}[];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -18
View File
@@ -1,22 +1,22 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus per-AI-tool configuration panels (Claude Code today, Codex/Cursor reserved), with the active tab kept in the `?tool=` query param.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex), with the active tab kept in the `?tool=` query param.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } from "lucide-react";
import { Boxes, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
interface ToolTab {
id: string;
labelKey: string;
Icon: ComponentType<{ className?: string }>;
/** Omit for tools that are not integrated yet - the tab renders a placeholder. */
Panel?: ComponentType;
Panel: ComponentType;
}
const DASHBOARD_TAB: ToolTab = {
@@ -30,8 +30,7 @@ const DASHBOARD_TAB: ToolTab = {
const TOOL_TABS: ToolTab[] = [
DASHBOARD_TAB,
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal },
{ id: "cursor", labelKey: "tools.cursor", Icon: MousePointerClick },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
];
/**
@@ -50,24 +49,14 @@ export function SettingsHub() {
ariaLabel={t("tools.label", "Tools")}
activeId={active.id}
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
tabs={TOOL_TABS.map(({ id, labelKey, Icon, Panel }) => ({
tabs={TOOL_TABS.map(({ id, labelKey, Icon }) => ({
id,
Icon,
label: t(labelKey),
...(Panel ? {} : { badge: t("tools.soonBadge", "soon") }),
}))}
/>
{active.Panel ? (
<active.Panel />
) : (
<div className="card p-8 text-center space-y-1">
<p className="text-sm font-medium text-fg-primary">
{t("tools.comingSoon", { tool: t(active.labelKey) })}
</p>
<p className="text-xs text-fg-muted">{t("tools.comingSoonDesc")}</p>
</div>
)}
<active.Panel />
</div>
);
}
@@ -0,0 +1,187 @@
/**
* @file CodexConfig.test.tsx
* @description Covers the two load-bearing behaviors of the Codex config
* panel: create/edit/delete is offered on the authored surfaces (skills,
* agents, rules, memory, config) and withheld on the CLI-owned ones (mcp,
* plugins); and saves go through `api.codexConfig`, not the Claude client.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// vi.mock factories are hoisted above the rest of the module, so the mock
// object they reference must be created inside vi.hoisted() too.
const codexConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
overview: r({
roots: { codexHome: "/home/.codex", projectCodexDir: "/repo/.codex", projectRoot: "/repo" },
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
rules: { user: 0, project: 0 },
memory: { user: 0, project: 0 },
config: { user: 0, project: 0 },
},
}),
skills: r({ skills: [] }),
agents: r({ agents: [] }),
rules: r({ rules: [] }),
memory: r({ memory: [] }),
config: r({ config: [] }),
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
write: r({ file: "/f", created: true }),
remove: r({ file: "/f" }),
mcp: r({ mcp: [] }),
plugins: r({ plugins: [] }),
marketplaces: r({ marketplaces: [] }),
mcpAdd: r({ ok: true, stdout: "", stderr: "" }),
mcpRemove: r({ ok: true, stdout: "", stderr: "" }),
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAdd: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAction: r({ ok: true, stdout: "", stderr: "" }),
backups: r({ items: [] }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
...(actual.api as Record<string, unknown>),
codexConfig: codexConfigMock,
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CodexConfig } from "../CodexConfig";
/** Reset the mock to its defaults, then apply the given overrides (each
* value is wrapped in a resolved-promise mock unless already a mock fn). */
function stubCodexApi(overrides: {
skills?: unknown[];
rules?: { scope: "user" | "project"; name: string; file: string; size: number }[];
mcp?: { servers?: unknown[] };
write?: ReturnType<typeof vi.fn>;
}) {
codexConfigMock.skills.mockResolvedValue({ skills: overrides.skills ?? [] });
codexConfigMock.rules.mockResolvedValue({ rules: overrides.rules ?? [] });
codexConfigMock.mcp.mockResolvedValue({ mcp: overrides.mcp?.servers ?? [] });
if (overrides.write) codexConfigMock.write = overrides.write as never;
}
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
afterEach(() => {
vi.clearAllMocks();
codexConfigMock.write = vi.fn().mockResolvedValue({ file: "/f", created: true }) as never;
codexConfigMock.remove = vi.fn().mockResolvedValue({ file: "/f" }) as never;
});
describe("CodexConfig", () => {
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
});
it("writes an edited rule back through the codex API, not the Claude one", async () => {
const write = vi.fn().mockResolvedValue({ file: "/r/default.rules", created: false });
stubCodexApi({
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
write,
});
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await settle();
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
});
it("keeps the file-backed tabs working when the codex CLI read rejects", async () => {
stubCodexApi({ skills: [{ scope: "user", name: "my-skill", file: "/s/my-skill", size: 3 }] });
codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
render(<CodexConfig />);
await settle();
// No page-wide error banner - the CLI-backed rejection is isolated.
expect(screen.queryByText(/failed to load/i)).not.toBeInTheDocument();
// Pure file reads (skills) still rendered their data.
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByText("my-skill")).toBeInTheDocument();
// The CLI-owned tab degrades gracefully instead of spinning forever.
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.getByText(/codex cli isn't available/i)).toBeInTheDocument();
});
it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
stubCodexApi({ write });
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
const newButtons = screen.getAllByRole("button", { name: /new/i });
await userEvent.click(newButtons[0]!);
await settle();
// No name field, no client-side "Name" validation error blocking save.
expect(screen.queryByPlaceholderText(/name/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(
expect.objectContaining({ type: "config", name: undefined })
);
});
it("opens the backups modal and lists what the codex API returns", async () => {
codexConfigMock.backups.mockResolvedValueOnce({
items: [
{
type: "rules",
name: "default.2026-09-08.bak",
file: "/r/default.bak",
mtime: 0,
size: 3,
},
],
});
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
await settle();
expect(screen.getByText("default.2026-09-08.bak")).toBeInTheDocument();
});
});
@@ -80,11 +80,6 @@ class ObserverStub {
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
async function settle() {
await act(async () => {
@@ -936,13 +936,17 @@ exports[`screen snapshots > Claude Config 1`] = `
class="pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 opacity-0"
/>
<div
aria-label="Claude Config Explorer"
class="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
role="tablist"
style="scrollbar-width: none;"
>
<button
aria-selected="true"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap bg-accent/15 text-accent border border-accent/30"
data-tab-active="true"
data-testid="cc-tab-overview"
role="tab"
>
<svg
class="lucide lucide-boxes w-3.5 h-3.5"
@@ -998,8 +1002,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-skills"
role="tab"
>
<svg
class="lucide lucide-sparkles w-3.5 h-3.5"
@@ -1039,8 +1045,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-agents"
role="tab"
>
<svg
class="lucide lucide-user-round w-3.5 h-3.5"
@@ -1073,8 +1081,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-commands"
role="tab"
>
<svg
class="lucide lucide-slash w-3.5 h-3.5"
@@ -1102,8 +1112,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-memory"
role="tab"
>
<svg
class="lucide lucide-book-open w-3.5 h-3.5"
@@ -1134,8 +1146,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-plugins"
role="tab"
>
<svg
class="lucide lucide-plug-zap w-3.5 h-3.5"
@@ -1175,8 +1189,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-marketplaces"
role="tab"
>
<svg
class="lucide lucide-store w-3.5 h-3.5"
@@ -1216,8 +1232,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-mcp"
role="tab"
>
<svg
class="lucide lucide-server w-3.5 h-3.5"
@@ -1270,8 +1288,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-hooks"
role="tab"
>
<svg
class="lucide lucide-webhook w-3.5 h-3.5"
@@ -1305,8 +1325,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-keybindings"
role="tab"
>
<svg
class="lucide lucide-keyboard w-3.5 h-3.5"
@@ -1362,8 +1384,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-settings"
role="tab"
>
<svg
class="lucide lucide-settings w-3.5 h-3.5"
@@ -1396,8 +1420,10 @@ exports[`screen snapshots > Claude Config 1`] = `
</span>
</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-outputStyles"
role="tab"
>
<svg
class="lucide lucide-palette w-3.5 h-3.5"
@@ -9511,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.4.6 (rebuild client to match)
UI build v1.5.0 (rebuild client to match)
</p>
</div>
<div
@@ -521,11 +521,6 @@ if (!window.matchMedia) {
},
}) as unknown as MediaQueryList;
}
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
// Flush pending promises (mocked API resolves) + effects so the loaded state
// is rendered before snapshotting.
+7
View File
@@ -23,6 +23,13 @@ global.ResizeObserver = class {
disconnect() {}
};
/** jsdom implements no scrolling — stub the methods the tab bars call. */
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
/** Pin locale to English — LanguageDetector may otherwise pick up zh/vi from the host OS. */
beforeEach(() => {
i18n.changeLanguage("en");
-23
View File
@@ -1,23 +0,0 @@
services:
agent-monitor:
build: .
container_name: agent-monitor
ports:
# Publish on the host loopback only, so the dashboard is not reachable
# from the LAN out of the box (matches the server's default trust
# boundary — GHSA-gr74-4xfh-6jw9). To expose it on a LAN, change this to
# "${DASHBOARD_PORT:-4820}:4820" and set DASHBOARD_TOKEN below.
- "127.0.0.1:${DASHBOARD_PORT:-4820}:4820"
volumes:
# Same SQLite file as `npm start` / `npm run dev` on the host.
- ${CLAUDE_HOME:-~/.claude}/agenthub:/app/data
- ${CLAUDE_HOME:-~/.claude}:/root/.claude:ro
environment:
- NODE_ENV=production
- DASHBOARD_PORT=4820
# Optional — required when a Prometheus-in-Docker scraper reaches this
# container as anything other than loopback (e.g. host.docker.internal).
- DASHBOARD_ALLOWED_HOSTS=${DASHBOARD_ALLOWED_HOSTS:-}
# DASHBOARD_HOST=0.0.0.0 and DASHBOARD_DATA_DIR=/app/data are baked into
# the image (see Dockerfile) so both `docker run` and Compose work as-is.
restart: unless-stopped
+71 -9
View File
@@ -611,14 +611,23 @@ Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`)
GET /api/lanes/pipeline-stats
```
How often each pipeline node was actually passed, aggregated over every lane
**and** every archived feature (an archived feature is a finished run of a
pipeline, so it carries most of the history once a lane starts its next
feature). Grouped by pipeline template, busiest first.
How often each pipeline node was actually passed, aggregated over every recorded
run and grouped by pipeline template, busiest first.
Read from `lane_stage_events`, the append-only log every stage declaration
writes — **not** from `lanes.stages`. That blob describes the pass in flight: it
is overwritten when a node is re-entered and truncated when the lane moves
backwards, so reading it counted one run per lane row no matter how many
features that lane shipped, and no amount of use ever moved the numbers. A run
is one pass through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node.
Templates configured on a lane are always listed, at zero, even before that lane
records anything — a pipeline going unused has to be visible as such.
**Query parameters:**
- **`lane`** — lane id; narrows the aggregation to that lane and its own
archived features. Omit (or pass `all`) for every lane.
- **`lane`** — lane id; narrows the aggregation to that lane's own runs. Omit
(or pass `all`) for every lane.
```json
{
@@ -629,7 +638,7 @@ feature). Grouped by pipeline template, busiest first.
"runs": 4,
"nodes": [
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 }
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0, "detected": 0 }
]
}
],
@@ -637,6 +646,11 @@ feature). Grouped by pipeline template, busiest first.
}
```
- **`detected`** — runs that reached the node by inference from the hook stream
with no declaration. Counted apart from `entered`: it says the work happened,
not that the gate was met. A node the hooks saw is reported as such rather
than as "never reached", which is what a session that never runs the pipeline
commands used to look like.
- **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the
response always offers the way back to the other lanes.
- **`runs`** — lanes plus archived features recorded against this template.
@@ -1560,7 +1574,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
### Claude Config Explorer
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin and marketplace management is delegated to the `claude plugin …` CLI. MCP server config stays read-only, because it is written concurrently by the running Claude Code CLI.
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
```http
GET /api/cc-config/overview
@@ -1587,12 +1601,15 @@ POST /api/cc-config/profiles/activate Body: { name }
DELETE /api/cc-config/profiles Body: { name }
PUT /api/cc-config/file Body: { scope, type, name?, content }
DELETE /api/cc-config/file Body: { scope, type, name? }
POST /api/cc-config/project-memory Body: { dir, content }
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
GET /api/cc-config/plugins/available
POST /api/cc-config/plugins/install Body: { spec, scope? }
POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall }
POST /api/cc-config/marketplaces/add Body: { source, scope? }
POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?, headers?, command?, args?, env? }
POST /api/cc-config/mcp/remove Body: { name, scope? }
```
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
@@ -1601,9 +1618,13 @@ POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
`GET /api/cc-config/memory` also surfaces the per-project file-based memory store — every `*.md` under `~/.claude/projects/<slug>/memory/` (the common pattern of a `MEMORY.md` index plus one file per remembered fact). Those items have `scope: "auto-memory"` and carry `project` (the `projects/<slug>` dir name), `name` (filename), `isIndex` (true for `MEMORY.md` / `INDEX-*.md`, which sort first), and parsed `frontmatter`. They are **editable**: `PUT`/`DELETE /api/cc-config/file` accept `{ scope: "auto-memory", type: "auto-memory", project, name, content? }` and create a timestamped backup under `<memory-dir>/.cc-config-backups/auto-memory/` before mutating (an invalid `project` slug returns `EBADPROJECT`). `GET /api/cc-config/backups` lists these with `scope: "auto-memory"` and `project` set. Bodies are also readable via `GET /api/cc-config/file` (they live under `CLAUDE_HOME`).
**`POST /api/{cc,codex}-config/project-memory` (a new project's memory file).** Creates `CLAUDE.md` (Claude) or `AGENTS.md` (Codex) inside the directory named by `dir` — the one write path that honours a caller-supplied directory, because bootstrapping a project the dashboard has never run in is impossible from a writer pinned to the server's own cwd (which is what `PUT /file`'s `"project"` scope means). It stays narrow: `dir` must be absolute and already exist (nothing is created for you), the filename comes from the route and is never taken from the body, and an existing file — including a symlink under that name — returns **409 `EEXISTS`** rather than being overwritten, so this endpoint can only ever create. Bad or missing directories return 400 `EBADDIR`. Unlike the other config routes it sits behind the same loopback-`Origin` guard `/api/run` uses (403 `EBADORIGIN`): a page in the user's browser could otherwise plant agent instructions in a project directory. Returns `{ file, size, created: true }`, where `file` is the realpath'd target, and broadcasts `cc_config_changed`.
**`PATCH /api/cc-config/settings` (key merge).** `scope` is `"user"`, `"project"`, or `"project-local"` (`settings.local.json`). `set` maps dotted key paths to JSON values; `unset` is a list of dotted key paths to delete. The server re-reads the target file **unredacted**, applies only the named keys, backs it up under `<dir>/.cc-config-backups/settings/`, and writes atomically — so keys the UI never displayed (including secret-valued ones) survive byte-for-byte. Returns `{ path, backupPath, created, changed }`. Hooks are edited through this same route, as `set: { "hooks.PreToolUse": [{ matcher, hooks: [{ type, command, timeout? }] }] }` — i.e. `settings.json`'s own nested shape, not the flattened shape `GET /hooks` returns. Errors: `EBADSCOPE` (unknown scope), `EBADNAME` (a key segment outside `[A-Za-z0-9_$][A-Za-z0-9._$-]*`, or `__proto__`/`prototype`/`constructor`), `EREDACTED` (400 — the payload contains the `<redacted>` placeholder, which would destroy a real secret), `EBADJSON` (409 — the file on disk is not valid JSON; it is left untouched rather than clobbered), `ETOOLARGE`. The project layers always resolve against the **server's** cwd; `?cwd=` is honoured on reads only, never on this write.
**Plugins and marketplaces.** These five endpoints shell out to the `claude plugin …` CLI with an explicit argv array (never a shell string), pinned to the dashboard's `CLAUDE_HOME` via `CLAUDE_CONFIG_DIR`; mutating calls are serialized in-process. `source` must be `owner/repo`, an `http(s)://` URL, or an absolute path; `spec`/`key` must be `<name>` or `<name>@<marketplace>`; `scope` is `user` (default), `project` or `local`. Validation failures return 400 `EBADARG`; a missing `claude` binary returns 503 `ECLINOTFOUND`; a CLI timeout returns 504 `ETIMEOUT`; a non-zero CLI exit returns **422 `ECLIEXIT`** with the CLI's message plus `stdout`/`stderr`. Successful runs return `{ ok: true, stdout, stderr }` and broadcast `cc_config_changed`. `GET /plugins/available` proxies `claude plugin list --available --json` as `{ installed, available }`.
**Plugins and marketplaces.** These five endpoints shell out to the `claude plugin …` CLI with an explicit argv array (never a shell string); mutating calls are serialized in-process. `CLAUDE_CONFIG_DIR` is pinned to `CLAUDE_HOME` only when the operator explicitly set `CLAUDE_HOME` — left unset (the default), the CLI's own default config dir already matches what `cc-discovery` reads, so the pin is left off rather than pointed at the wrong directory (see `runClaude()`'s comment in `cc-plugin-cli.js` for the incident this fixed). `source` must be `owner/repo`, an `http(s)://` URL, or an absolute path; `spec`/`key` must be `<name>` or `<name>@<marketplace>`; `scope` is `user` (default), `project` or `local`. Validation failures return 400 `EBADARG`; a missing `claude` binary returns 503 `ECLINOTFOUND`; a CLI timeout returns 504 `ETIMEOUT`; a non-zero CLI exit returns **422 `ECLIEXIT`** with the CLI's message plus `stdout`/`stderr`. Successful runs return `{ ok: true, stdout, stderr }` and broadcast `cc_config_changed`. `GET /plugins/available` proxies `claude plugin list --available --json` as `{ installed, available }`.
**MCP servers.** `POST /api/cc-config/mcp/add` and `POST /api/cc-config/mcp/remove` shell out to `claude mcp add`/`claude mcp remove` the same way the plugin routes shell out to `claude plugin …` — an argv array, never a shell string, serialized on the SAME in-process queue `cc-plugin-cli.js` exports (`serialize`), so a plugin install and an MCP add can never spawn `claude` concurrently. `scope` is `user` or `local` only (required on add, optional on remove — omitted, the CLI removes from whichever scope the server exists in). This is the inverse of what it sounds like: `user` writes `~/.claude.json`'s top level, `local` writes its `projects[<root>]` block — the exact source `GET /mcp`'s `projectScoped` bucket reads. The CLI's own `project` scope (a separate, shared `.mcp.json`) is refused with 400: `readMcpServers` never reads that file, so a server added there would report success and never appear in the list. `add` takes either `url` (+ optional `transport: "http"|"sse"`, `headers`) for a remote server, or `command` + `args` (+ optional `env`) for a stdio server — exactly one of the two forms. Validation failures return 400 `EBADARG`; a non-zero CLI exit returns 422 `ECLIEXIT`. `GET /api/cc-config/mcp` is unaffected — it still reads `~/.claude.json` directly; the client refetches it after either call succeeds. `GET /mcp`'s server objects carry `removable: false` for rows sourced from `~/.claude/settings.json` (absent/true means removable) — no CLI scope addresses them, so the panel reads this flag rather than matching the `source` display string to decide whether to offer Remove.
`PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `<CLAUDE_HOME>/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. `keybindings.json` is replaced wholesale here because the live CLI does not rewrite it mid-session; `settings.json`, which it does, is only ever key-merged (see `PATCH /settings` above).
@@ -1619,6 +1640,38 @@ The dashboard **cannot log an account in** — only `claude auth login` mints to
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
### Codex Config Explorer
The `/api/codex-config/*` namespace powers the **Codex** tab of Settings (`/settings?tool=codex`) — the Codex CLI counterpart of the Claude Config Explorer above. Codex has a narrower scope union than Claude Code: `"user"` or `"project"` only, no `"project-local"` and no per-project file-based memory. Reads for skills/agents/rules/memory/config are pure file reads (`server/lib/codex-discovery.js`); MCP servers, plugins, and marketplaces have no on-disk reader and are read *and* mutated entirely through the `codex` CLI (`server/lib/codex-cli.js`), because the CLI owns `config.toml`'s schema.
```http
GET /api/codex-config/overview
GET /api/codex-config/skills?scope=user|project|all
GET /api/codex-config/agents?scope=user|project|all
GET /api/codex-config/rules?scope=user|project|all
GET /api/codex-config/memory
GET /api/codex-config/config
GET /api/codex-config/file?path=<absolute-path>
PUT /api/codex-config/file Body: { scope, type, name?, content }
DELETE /api/codex-config/file Body: { scope, type, name? }
POST /api/codex-config/project-memory Body: { dir, content }
GET /api/codex-config/backups[?scope=&type=]
GET /api/codex-config/mcp
GET /api/codex-config/plugins
GET /api/codex-config/marketplaces
POST /api/codex-config/mcp/add Body: { name, url, bearerTokenEnvVar? } | { name, command, args?, env? }
POST /api/codex-config/mcp/remove Body: { name }
POST /api/codex-config/plugins/action Body: { spec, action: add|remove }
POST /api/codex-config/marketplaces/add Body: { url }
POST /api/codex-config/marketplaces/action Body: { name, action: remove }
```
`type` is one of `skills`, `agents`, `rules`, `memory`, `config`. `name` is required for `skills`/`agents`/`rules` and forbidden for `memory`/`config` (each is a single fixed file per scope — `AGENTS.md` and `config.toml` respectively). `PUT`/`DELETE` always back the previous content up first (`server/lib/codex-mutate.js`) and always resolve `project` scope against the **dashboard server's own cwd** — unlike the read routes, there is no `?cwd=` override on a write, for the same reason `cc-config.js`'s `PATCH /settings` refuses one. Mutation errors: `EBADTYPE`/`EBADSCOPE`/`EBADNAME`/`EBADCONTENT`/`EOUTOFROOT` (400), `ETOOLARGE` (413), `ENOTFOUND` (404). Every successful write/delete broadcasts `codex_config_changed`.
`GET /mcp`, `/plugins`, `/marketplaces` proxy `codex mcp list --json` / `codex plugin list --json` / `codex plugin marketplace list --json`; a fresh install with none of a kind returns `{ mcp: [] }` etc. rather than a parse error. Unlike `mcp list --json` (a bare array), `plugin list --json` and `plugin marketplace list --json` return `{installed, available}` / `{marketplaces}` objects — `server/lib/codex-cli.js` unwraps those down to a plain array before the route ever sees them, so every list route's contract stays "the named field is a list"; the client's `Array.isArray` coercion is only a defensive fallback for CLI output that fails to parse at all. The add/remove routes shell out to `codex …` with an explicit argv array (never a shell string); a non-zero CLI exit is reported as **422 `ECLIEXIT`** with `stdout`/`stderr` preserved, never a 500. `POST /mcp/add` takes either `{ name, url, bearerTokenEnvVar? }` (HTTP transport) or `{ name, command, args?, env? }` (stdio transport) — never both, mirroring `codex mcp add`'s own mutually-exclusive flags. `POST /marketplaces/add`'s `url` field is misnamed for history but accepts the same source shapes as `codex plugin marketplace add`: `owner/repo`, an http(s) git URL, or an absolute local path (ssh remotes are out of scope). Other CLI errors: `EBADARG` (400), `ECLINOTFOUND` (503 — the `codex` binary isn't on `PATH`), `ETIMEOUT` (504).
Because `CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are opaque JSON blobs from the CLI (`[key: string]: unknown`) rather than a shape the dashboard controls, the Codex tab renders them as generic key/value cards instead of the richly-typed plugin/MCP cards the Claude Code tab shows.
### Run Claude via tmux+PTY
The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per lane. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
@@ -1837,6 +1890,15 @@ Broadcast whenever Claude Code configuration changes — either by dashboard mut
{ "type": "cc_config_changed", "data": { "source": "fs", "paths": ["/Users/foo/.claude/settings.json"] } }
```
#### codex_config_changed
The Codex counterpart of `cc_config_changed` — broadcast by `routes/codex-config.js` after every successful mutation: `PUT`/`DELETE /api/codex-config/file`, and the CLI-backed `mcp/add`, `mcp/remove`, `plugins/action`, `marketplaces/add`, `marketplaces/action` routes. Always `source: "dashboard"` — there is no filesystem watcher on `$CODEX_HOME` (unlike the Claude side's `cc-watcher.js`). The Codex Config page subscribes and refetches automatically.
```json
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "rules", "name": "default" } }
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "add", "type": "mcp", "name": "docs" } }
```
#### remote_data.updated
Broadcast once per successful remote sync (background poller, manual **Sync now**, or immediate pull after add/re-enable). Clients use this — and the per-session `session_created` / `session_updated` frames emitted in the same pass — to refetch sessions, costs, and analytics without polling.
+3 -3
View File
@@ -53,8 +53,8 @@ flowchart LR
`npm run setup` ends with a fail-soft `npm link` (the `link-cli` script), so after a normal local setup `agenthub` is on your PATH from any directory:
```bash
git clone https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git
cd Claude-Code-Agent-Monitor
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
cd AgentHub
npm run setup # installs deps AND links agenthub globally
agenthub help
```
@@ -110,7 +110,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
/\ \ /\ \ /\ \ /\ \
/::\ \ /::\ \ /::\ \ /::\____\
… (AgentHub word-mark) …
AgentHub · interactive shell · v1.4.6 ● 127.0.0.1:4820
AgentHub · interactive shell · v1.5.0 ● 127.0.0.1:4820
Type commands without the 'agenthub' prefix — e.g. sessions --limit 5
help all commands · help <cmd> details · Tab completes · ↑/↓ history · exit to quit
+1 -1
View File
@@ -64,7 +64,7 @@ graph TB
```
**Database Location:**
- **Canonical (default):** `~/.claude/agenthub/dashboard.db` — shared by `npm start`, `npm run dev`, Docker (bind mount), 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
- **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.
+7 -116
View File
@@ -10,7 +10,6 @@ Enterprise deployment strategies for Agent Dashboard across development, staging
- [Deployment Architecture](#deployment-architecture)
- [Local Development](#local-development)
- [Production Deployment](#production-deployment)
- [Docker Deployment](#docker-deployment)
- [Cloud Deployment](#cloud-deployment)
- [Process Management](#process-management)
- [Monitoring & Logging](#monitoring--logging)
@@ -26,7 +25,6 @@ Enterprise deployment strategies for Agent Dashboard across development, staging
Agent Dashboard supports multiple deployment modes:
- **Local Development** - Hot reload for rapid iteration
- **Docker** - Containerized deployment with Docker/Podman
- **PM2** - Process management for production
- **Systemd** - System service on Linux
- **Cloud** - Deploy to AWS, Azure, GCP, or other cloud providers
@@ -35,7 +33,6 @@ Agent Dashboard supports multiple deployment modes:
graph TB
subgraph "Deployment Modes"
Dev[Local Development<br/>npm run dev]
Docker[Docker Container<br/>docker compose up]
PM2[PM2 Process Manager<br/>pm2 start]
Systemd[Systemd Service<br/>systemctl start]
Cloud[Cloud Platform<br/>Kubernetes, ECS, etc.]
@@ -48,8 +45,6 @@ graph TB
end
Dev --> DevEnv
Docker --> DevEnv
Docker --> StagingEnv
PM2 --> ProdEnv
Systemd --> ProdEnv
Cloud --> ProdEnv
@@ -260,118 +255,14 @@ CODEX_HOME= # where Codex keeps sessions/ (default ~
LOG_LEVEL=info # debug | info | warn | error
```
> **Reverse-proxy / Docker exposure:** the app binds loopback, so publish it to a
> **Reverse-proxy / container exposure:** the app binds loopback, so publish it to a
> network only through a proxy you control that adds TLS + auth, or set
> `DASHBOARD_HOST=0.0.0.0` **with** `DASHBOARD_TOKEN`. A `-p 4820:4820` Docker
> mapping assumes a trusted host network — do not expose it publicly without a
> `DASHBOARD_HOST=0.0.0.0` **with** `DASHBOARD_TOKEN`. A published `4820` port
> assumes a trusted host network — do not expose it publicly without a
> token and a proxy.
---
## Docker Deployment
### Docker Compose (Recommended)
```yaml
# docker-compose.yml
version: '3.8'
services:
agenthub:
build: .
ports:
- "4820:4820"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- PORT=4820
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4820/api/sessions"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
### Build & Run
```bash
# Build image
docker compose build
# Start container
docker compose up -d
# View logs
docker compose logs -f
# Stop container
docker compose down
```
### Multi-Stage Dockerfile
```dockerfile
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
COPY client/package*.json ./client/
RUN npm ci && cd client && npm ci
# Build client
COPY client ./client
RUN cd client && npm run build
# Production stage
FROM node:22-alpine
WORKDIR /app
# Copy built artifacts
COPY --from=builder /app/client/dist ./client/dist
COPY --from=builder /app/node_modules ./node_modules
COPY server ./server
COPY package.json ./
# Create data directory
RUN mkdir -p /app/data
EXPOSE 4820
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:4820/api/sessions', (res) => process.exit(res.statusCode === 200 ? 0 : 1))"
CMD ["node", "server/index.js"]
```
### Container Architecture
```mermaid
graph TB
subgraph "Docker Host"
subgraph "Container"
App[Node.js App<br/>:4820]
Volume[Volume Mount<br/>/app/data]
end
end
Host[Host Filesystem<br/>./data] -->|Bind Mount| Volume
App --> Volume
Client[External Clients] -->|Port 4820| App
style App fill:#2496ED,color:#fff
style Volume fill:#FFA500
```
---
## Cloud Deployment
### AWS (Elastic Beanstalk)
@@ -395,6 +286,9 @@ eb open
### Kubernetes
The repo ships no container image or `Dockerfile` — build your own from
`npm ci && npm run build` and push it to your registry, then reference it here.
```yaml
# k8s/deployment.yaml
apiVersion: apps/v1
@@ -603,19 +497,16 @@ graph TB
subgraph "Log Aggregation"
Stdout --> PM2[PM2 Logs]
Stdout --> Systemd[Systemd Journal]
Stdout --> Docker[Docker Logs]
end
subgraph "Monitoring Tools"
PM2 --> File[Log Files]
Systemd --> Journalctl[journalctl]
Docker --> DockerLogs[docker logs]
end
subgraph "Analysis"
File --> Splunk[Splunk/ELK]
Journalctl --> Splunk
DockerLogs --> Splunk
end
style App fill:#000000,color:#fff
@@ -912,7 +803,7 @@ NODE_ENV=development node server/index.js
This deployment guide covers:
- ✅ **Multiple deployment modes** - Local, Docker, PM2, Systemd, Cloud
- ✅ **Multiple deployment modes** - Local, PM2, Systemd, Cloud
- ✅ **Production best practices** - Environment variables, health checks, logging
- ✅ **Process management** - PM2, systemd service files
- ✅ **Monitoring & logging** - Metrics endpoint, log aggregation
+2 -2
View File
@@ -578,8 +578,8 @@ function sendToServer(hookType, payload) {
> Each server appends its `{port, pid, startedAt, dataDir}` entry on
> startup and removes it on a clean shutdown. The handler **prunes any
> entry whose PID is no longer alive** and POSTs the hook payload to one
> port per **unique SQLite data directory** (lowest port wins when Docker
> and `npm run dev` share `~/.claude/agenthub`).
> port per **unique SQLite data directory** (lowest port wins when two
> instances share `~/.claude/agenthub`).
> 3. If neither yields a target, the handler falls back to `4820`.
>
> Dashboards with **different** databases (e.g. the packaged desktop app using
+12 -2
View File
@@ -687,8 +687,18 @@ things beyond the ordinary transition:
The node the declaration lands on keeps its own record (its evidence and
`result` survive), and `stage_since` restarts, so time-on-phase measures this
pass. Records stored under a key matching **no** node in the pipeline are left
untouched — their position is unknown, so they cannot be proven to be ahead;
for the same reason, declaring a stage that matches no node prunes nothing.
untouched — their position is unknown, so they cannot be proven to be ahead.
Such keys only exist on lanes that predate validation: `POST
/api/lanes/:id/stage` now rejects a stage naming no node of the lane's pipeline
with **400 `EBADSTAGE`** and lists the ones that exist. Aliases still resolve,
so this catches typos and names borrowed from another template — `agenthub
stage test` on a `ship-feature` lane, whose CI node is `gates` — which used to
be stored, warned about on stderr, and then counted nowhere.
Every declaration is also appended to `lane_stage_events`, the append-only log
the pass-rate report reads. `lanes.stages` describes the pass in flight and is
overwritten and truncated by the rules above; the log is what remembers that
the previous pass happened at all.
Going back this way is a *declaration*, not a reset: the lane, its worktree, and
its archived features are untouched. To start a genuinely separate piece of work
+23 -16
View File
@@ -9,13 +9,13 @@ Every plugin is powered by the local Agent Monitor REST API at `http://localhost
### Add the marketplace
```bash
claude plugin marketplace add Smartgift-AI/Claude-Code-Monitor
claude plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
```
### Install the dashboard itself
```bash
claude plugin install agenthub@claude-code-agent-monitor-plugins
claude plugin install agenthub@agenthub
```
That is the whole install: no clone, no `npm run setup`, no `npm run install-hooks`, no manual `npm start`. See [The `agenthub` plugin](#the-agenthub-plugin) below for what it does on first session start.
@@ -39,16 +39,16 @@ marketplace name, not per source).
### Install a focused plugin
```bash
claude plugin install agenthub-analytics@claude-code-agent-monitor-plugins
claude plugin install agenthub-cost-guard@claude-code-agent-monitor-plugins
claude plugin install agenthub-productivity@claude-code-agent-monitor-plugins
claude plugin install agenthub-devtools@claude-code-agent-monitor-plugins
claude plugin install agenthub-insights@claude-code-agent-monitor-plugins
claude plugin install agenthub-sessions@claude-code-agent-monitor-plugins
claude plugin install agenthub-workflows@claude-code-agent-monitor-plugins
claude plugin install agenthub-quality@claude-code-agent-monitor-plugins
claude plugin install agenthub-config@claude-code-agent-monitor-plugins
claude plugin install agenthub-dashboard@claude-code-agent-monitor-plugins
claude plugin install agenthub-analytics@agenthub
claude plugin install agenthub-cost-guard@agenthub
claude plugin install agenthub-productivity@agenthub
claude plugin install agenthub-devtools@agenthub
claude plugin install agenthub-insights@agenthub
claude plugin install agenthub-sessions@agenthub
claude plugin install agenthub-workflows@agenthub
claude plugin install agenthub-quality@agenthub
claude plugin install agenthub-config@agenthub
claude plugin install agenthub-dashboard@agenthub
```
### Or install locally during development
@@ -250,7 +250,7 @@ single port.
### Uninstalling
`claude plugin uninstall agenthub@claude-code-agent-monitor-plugins` only removes
`claude plugin uninstall agenthub@agenthub` only removes
the plugin from the enabled list — verified against a real install/uninstall,
not assumed. It does **not**:
@@ -545,6 +545,13 @@ my-plugin/
Structure is validated by `server/__tests__/plugins-marketplace.test.js`, which enforces the marketplace↔directory bijection, `plugin.json` shape, name/dir agreement, and required frontmatter on every agent / skill / command.
Two of those shape rules exist because getting them wrong makes a plugin **listed but uninstallable**, with no other symptom — the marketplace loads fine and only `claude plugin install` fails:
- A subdirectory entry declares **`"source": "./plugins/<name>"`**, never `"path"`. `path` is not a source key the CLI accepts (`This plugin's marketplace entry is invalid: source: Invalid input`).
- A `plugin.json`'s **`repository` is a string**, not npm's `{ "type", "url" }` object (`Validation errors: repository: Invalid input`).
`server/__tests__/repo-links.test.js` guards the link side: no GitHub `owner/repo` marketplace shorthand (it resolves to github.com only — this repo is on Gitea, so the shorthand cannot work and no `GH_HOST`-style env var redirects it), no `raw.githubusercontent.com` / `*.github.io` / GitHub `/blob/<branch>/` URLs, no retired repository name, and no stale committed `openapi.yaml`.
### Testing locally
```bash
@@ -555,20 +562,20 @@ claude --plugin-dir /path/to/my-plugin # then use /my-plugin:my-skill some arg
### Dashboard not reachable
```bash
cd /path/to/Claude-Code-Agent-Monitor
cd /path/to/AgentHub
npm start # or: npm run dev
```
### Hooks not installed
```bash
cd /path/to/Claude-Code-Agent-Monitor
cd /path/to/AgentHub
npm run setup
```
### Plugin not found
```bash
claude plugin marketplace list
claude plugin marketplace add Smartgift-AI/Claude-Code-Monitor
claude plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
```
## License
+1 -1
View File
@@ -429,7 +429,7 @@ This documentation covers:
- ✅ **Complete architecture** - Frontend, backend, database, integrations
- ✅ **API reference** - REST endpoints, WebSocket events
- ✅ **Deployment guides** - Docker, PM2, systemd, cloud
- ✅ **Deployment guides** - PM2, systemd, cloud
- ✅ **Performance tuning** - Database, server, client optimizations
- ✅ **Integration guides** - Hooks, MCP, custom clients
- ✅ **Internationalization** - Language resources, switching flow, locale formatting, rollout checklist
@@ -0,0 +1,152 @@
# Editable settings.json + MCP for the Claude Code Panel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the Claude Code panel's two read-only surfaces — `settings.json` and MCP servers — into surfaces you can change from `/settings?tool=claude-code`.
**Architecture:** The backend already does the dangerous half: `PATCH /api/cc-config/settings` writes through `patchSettings`, which validates every dotted key path, refuses prototype-polluting segments, rejects a payload still carrying the read path's `<redacted>` marker, backs the file up and writes atomically. So settings editing is a UI task against an existing, tested API. MCP is the opposite: `cc-mutate.js` excludes it on purpose, because the live `claude` CLI writes `~/.claude.json` concurrently and a dashboard write would race it. MCP therefore gets the same treatment plugins already get — mutation through the vendor CLI (`claude mcp add|remove`), never by editing the file.
**Tech Stack:** Node 22 + Express + `node:test`, React 18 + Vitest + react-i18next, `claude` CLI.
**Spec:** none written — scope fixed by the user in-session: "Mở thêm settings.json + MCP". This plan is the spec of record.
## Global Constraints
- **Every file created or modified gets the project header** with the exact author line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
- **User-visible strings live in i18n JSON**, `en` and `vi` together.
- **Never write `~/.claude.json` directly.** MCP changes go through the `claude` CLI, argv array, no shell.
- **A value the read path redacted must never be written back.** `patchSettings`' `assertNoRedactionMarker` already enforces this; the UI must additionally render redacted fields as non-editable so the user is never invited to try.
- **Depends on:** `2026-09-08-codex-config-panel.md` Task 8 (the extracted `components/config/` modals). Run that task first, or inline the modal locally and lift it later.
- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`.
---
### Task 1: Editable settings.json
**Files:**
- Modify: `client/src/pages/CcConfig.tsx` (the `SettingsPanel` / `SettingsBlock` components), `client/src/lib/api.ts` (add `api.ccConfig.patchSettings`), `client/src/i18n/locales/{en,vi}/ccConfig.json`
- Test: `client/src/pages/__tests__/CcConfigSettings.test.tsx`
**Interfaces:**
- Consumes: existing `PATCH /api/cc-config/settings` — body `{scope: "user" | "project" | "project-local", set: Record<string, unknown>, unset?: string[]}`, response `{ok, file, backup}`
- Produces: `api.ccConfig.patchSettings(args: {scope, set, unset?}): Promise<{ok: boolean, file: string, backup: string | null}>`
- [ ] **Step 1: Write the failing test**
```tsx
it("sends only the edited key, as a dotted path", async () => {
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.bak" });
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
await userEvent.click(screen.getByRole("button", { name: /edit model/i }));
await userEvent.clear(screen.getByRole("textbox"));
await userEvent.type(screen.getByRole("textbox"), "sonnet");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(patchSettings).toHaveBeenCalledWith({ scope: "user", set: { model: "sonnet" } });
});
it("will not let a redacted value be edited", () => {
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { apiKey: "<redacted>" } }] });
expect(screen.queryByRole("button", { name: /edit apiKey/i })).not.toBeInTheDocument();
});
it("names the backup it wrote, so the change is reversible in one glance", async () => {
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.json.2026.bak" });
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
await saveAnEdit();
expect(await screen.findByText(/s\.json\.2026\.bak/)).toBeInTheDocument();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx`
Expected: FAIL — no edit affordance exists
- [ ] **Step 3: Implement**
`SettingsBlock` renders each leaf with an inline edit control; the dotted path is built from the node's ancestry as it recurses. A value equal to `"<redacted>"` renders as a static chip with a tooltip saying the dashboard never received it.
- [ ] **Step 4: Run test + typecheck**
Run: `cd client && npx tsc -b && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/src/pages/CcConfig.tsx client/src/lib/api.ts client/src/i18n client/src/pages/__tests__/CcConfigSettings.test.tsx
git commit -m "feat(cc-config): edit settings.json values from the dashboard"
```
---
### Task 2: MCP servers through the claude CLI
**Files:**
- Create: `server/lib/cc-mcp-cli.js`, `server/__tests__/cc-mcp-cli.test.js`
- Modify: `server/routes/cc-config.js` (add `POST /mcp/add`, `POST /mcp/remove`), `server/openapi-extra/cc-config.js`, `client/src/lib/api.ts`, `client/src/pages/CcConfig.tsx` (`McpPanel`), `client/src/i18n/locales/{en,vi}/ccConfig.json`
- Test: `server/__tests__/cc-mcp-cli.test.js`, extend `server/__tests__/cc-config.test.js`
**Interfaces:**
- Produces:
- `mcpAdd({name, scope, transport, command, args, url, headers, env})` → `claude mcp add <name> [--scope user|project] [--transport http --url <url> | -- <command> <args…>]`
- `mcpRemove({name, scope})` → `claude mcp remove <name> [--scope …]`
- both async, returning `{ok, code, stdout, stderr}`, argv array, no shell — same contract as `cc-plugin-cli.js`
- Routes return **422** on a non-zero CLI exit, **400** on a name failing `NAME_RE`.
- [ ] **Step 1: Write the failing test**
```js
it("builds an http server as a transport flag, not a shell string", async () => {
const spawned = [];
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
await cli.mcpAdd({ name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" });
assert.deepEqual(spawned[0][1],
["mcp", "add", "vercel", "--scope", "user", "--transport", "http", "--url", "https://mcp.vercel.com"]);
});
it("puts stdio command args after the -- separator", async () => {
const spawned = [];
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
await cli.mcpAdd({ name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] });
assert.deepEqual(spawned[0][1].slice(-5), ["--", "node", "s.js", "--port", "3000"]);
});
it("refuses a server name that reads as a flag", async () => {
await assert.rejects(() => require("../lib/cc-mcp-cli").mcpRemove({ name: "--all" }), /name/i);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/cc-mcp-cli.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the wrapper, then the two routes, then the UI**
`McpPanel` gains an "Add server" form (name, scope, stdio-vs-http toggle) and a remove action per row. Each row keeps its existing read-only detail view — the source of truth stays `readMcpServers`, refetched after every CLI call.
- [ ] **Step 4: Run both suites**
Run: `npm run test:server && cd client && npx tsc -b && npm test`
Expected: PASS
- [ ] **Step 5: Regenerate OpenAPI, update docs, commit**
```bash
npm run openapi:yaml
git add server/lib/cc-mcp-cli.js server/routes/cc-config.js server/openapi-extra/cc-config.js server/__tests__ client/src openapi.yaml docs/API.md
git commit -m "feat(cc-config): add and remove MCP servers through the claude CLI"
```
---
## Self-Review
**Spec coverage:** "Mở thêm settings.json + MCP" → Task 1 (settings.json, using the API that already exists) and Task 2 (MCP, via CLI because direct file writes would race the CLI). Hooks stay read-only — they live inside settings.json, so Task 1 already covers editing them by key path, and a bespoke hook editor was not asked for.
**Placeholder scan:** none.
**Type consistency:** `scope` for settings is Claude's three-value union `"user" | "project" | "project-local"` (matching `readSettings`), while MCP's CLI scope flag accepts only `user | project` — the UI must not offer `project-local` on the MCP form.
@@ -0,0 +1,612 @@
# Codex Config Panel Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give `/settings?tool=codex` the same create/read/update/delete power the Claude Code tab already has, against Codex's own config surfaces.
**Architecture:** Mirror the existing `cc-discovery` / `cc-mutate` / `cc-plugin-cli` / `routes/cc-config.js` trio with a Codex-side quartet, sharing the safe-write primitives instead of copying them. Codex's structured surfaces (MCP servers, plugins, marketplaces) are read and mutated through the `codex` CLI, which emits JSON — so no TOML parser enters the tree. File-shaped surfaces (skills, agents, rules, AGENTS.md, config.toml) go through the same containment + backup + atomic-rename path Claude's artifacts use. The client extracts CcConfig's already-generic modals and list components into `components/config/` and adds a Codex orchestrator beside them.
**Tech Stack:** Node 22 + Express + `node:test` (backend), React 18 + Vite + Vitest + react-i18next (frontend), `codex` CLI 0.149.0.
**Spec:** none written — the scope was fixed by the user in-session: "parity đầy đủ với tab Claude Code". This plan is the spec of record.
## Global Constraints
- **Every file created or modified gets the project header** — `@file`, `@description`, and the exact author line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>` (see `skills/file-headers/`).
- **User-visible strings live in i18n JSON**, never as literals in components. Both `en` and `vi` must be updated in the same commit.
- **No new npm dependencies.** TOML is never parsed in-process; `codex … --json` or a raw-text editor covers every case.
- **Path containment is non-negotiable.** Every write resolves through one chokepoint and is re-checked with `isUnder(root, target)` after regex validation.
- **Backup before every mutation of an existing file**, atomic rename on write — the same guarantees `cc-mutate.js` gives today.
- **`CODEX_HOME` overrides `~/.codex`** everywhere, exactly as `CLAUDE_HOME` overrides `~/.claude`. Tests inject a temp home through it; nothing in the suite may touch the real one.
- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`. Both must be green before a commit.
---
### Task 1: Shared safe-write primitives
`cc-mutate.js` owns the three guarantees a config writer needs — containment, backup, atomic rename. Codex needs the identical guarantees; copying them would mean two divergent copies of security code. Extract first, then reuse.
**Files:**
- Create: `server/lib/config-write.js`
- Modify: `server/lib/cc-mutate.js` (delete the local `createBackup`, `atomicWriteFile`, `timestamp`; import them instead)
- Test: `server/__tests__/config-write.test.js`
**Interfaces:**
- Consumes: `isUnder` from `server/lib/cc-discovery.js`
- Produces:
- `atomicWriteFile(filePath: string, content: string): void`
- `createBackup(opts: {source: string, backupRoot: string, type: string, name: string}): string | null` — returns the backup path, or `null` when `source` does not exist. Copies a directory recursively when `source` is one.
- `assertUnder(root: string, target: string): void` — throws `Object.assign(new Error(...), {code: "EOUTOFROOT"})`
- `timestamp(): string` — filesystem-safe ISO stamp
- [ ] **Step 1: Write the failing test**
```js
const { atomicWriteFile, createBackup, assertUnder } = require("../lib/config-write");
it("leaves no temp file behind after a successful write", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
atomicWriteFile(path.join(dir, "a.md"), "body");
assert.equal(fs.readFileSync(path.join(dir, "a.md"), "utf8"), "body");
assert.deepEqual(fs.readdirSync(dir), ["a.md"]);
});
it("backs a directory up as a tree, not a single file", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
fs.mkdirSync(path.join(dir, "src", "sk"), { recursive: true });
fs.writeFileSync(path.join(dir, "src", "sk", "SKILL.md"), "x");
const out = createBackup({
source: path.join(dir, "src", "sk"),
backupRoot: path.join(dir, "bak"),
type: "skills",
name: "sk",
});
assert.ok(fs.existsSync(path.join(out, "SKILL.md")));
});
it("rejects a target outside the root", () => {
assert.throws(() => assertUnder("/a/b", "/a/c/x"), (e) => e.code === "EOUTOFROOT");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/config-write.test.js`
Expected: FAIL — `Cannot find module '../lib/config-write'`
- [ ] **Step 3: Move the implementations**
Cut `timestamp`, `createBackup`, `atomicWriteFile` out of `server/lib/cc-mutate.js` verbatim into `server/lib/config-write.js`; generalize `createBackup`'s hardcoded backup root into the `backupRoot` argument; add `assertUnder` wrapping the existing `isUnder` check. Re-export nothing from `cc-mutate` — it imports what it needs.
- [ ] **Step 4: Run both suites to verify nothing regressed**
Run: `node --test server/__tests__/config-write.test.js server/__tests__/cc-mutate-settings.test.js server/__tests__/cc-config.test.js`
Expected: PASS — the cc-mutate suites are the proof the extraction was behavior-preserving.
- [ ] **Step 5: Commit**
```bash
git add server/lib/config-write.js server/lib/cc-mutate.js server/__tests__/config-write.test.js
git commit -m "refactor(config): extract the safe-write primitives cc-mutate guards with"
```
---
### Task 2: Codex home resolution
`codexHome()` already exists inside `server/lib/codex-ingest.js:101` but is not exported, so every new module would re-derive it. Give it a home of its own, matching `claude-home.js`'s role.
**Files:**
- Create: `server/lib/codex-home.js`
- Modify: `server/lib/codex-ingest.js` (drop the local `codexHome`, import it)
- Test: `server/__tests__/codex-home.test.js`
**Interfaces:**
- Produces:
- `getCodexHome(): string` — `process.env.CODEX_HOME || path.join(os.homedir(), ".codex")`
- `getProjectCodexDir(cwd: string): string` — `<cwd>/.codex`
- `getProjectRoot(cwd: string): string` — `cwd` (where `AGENTS.md` lives)
- [ ] **Step 1: Write the failing test**
```js
it("honours CODEX_HOME over the home directory", () => {
process.env.CODEX_HOME = "/tmp/injected-codex";
delete require.cache[require.resolve("../lib/codex-home")];
assert.equal(require("../lib/codex-home").getCodexHome(), "/tmp/injected-codex");
});
it("puts the project surfaces under <cwd>/.codex", () => {
const { getProjectCodexDir } = require("../lib/codex-home");
assert.equal(getProjectCodexDir("/repo"), path.join("/repo", ".codex"));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-home.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Write the module and re-point codex-ingest**
`getCodexHome` must read `process.env` at call time (not at module load) so tests can inject.
- [ ] **Step 4: Run the codex suites**
Run: `node --test server/__tests__/codex-home.test.js server/__tests__/codex-ingest.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-home.js server/lib/codex-ingest.js server/__tests__/codex-home.test.js
git commit -m "refactor(codex): give the CODEX_HOME lookup its own module"
```
---
### Task 3: Codex discovery (read side)
**Files:**
- Create: `server/lib/codex-discovery.js`
- Test: `server/__tests__/codex-discovery.test.js`
**Interfaces:**
- Consumes: `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2); `parseFrontmatter`, `isUnder`, `MAX_FILE_BYTES` from `server/lib/cc-discovery.js`
- Produces (every reader takes `{scope, cwd}` where `scope` is `"user" | "project" | undefined` meaning both):
- `readSkills(opts): Skill[]` — `{scope, name, path, file, size, mtime, truncated, frontmatter, preview}` from `<home>/skills/*/SKILL.md` and `<cwd>/.codex/skills/*/SKILL.md`
- `readAgents(opts): Agent[]` — `{scope, name, file, size, mtime, meta, preview}` from `*/agents/*.toml`; `meta` is `{name, description, model, sandbox_mode}` best-effort
- `readRules(opts): Rule[]` — `{scope, name, file, size, mtime, preview}` from `*/rules/*.rules`
- `readMemory(opts): Memory[]` — `{scope, file, exists, size, mtime, preview}` for `<home>/AGENTS.md` and `<cwd>/AGENTS.md`
- `readConfig(opts): ConfigFile[]` — `{scope, file, exists, size, mtime, content, truncated}` for `<home>/config.toml` and `<cwd>/.codex/config.toml`
- `readOverview(opts)` — `{counts: {skills, agents, rules, memory, config}, roots: {codexHome, projectCodexDir, projectRoot}}`
- `readTomlMeta(text): object` — the best-effort top-level scalar reader used by `readAgents`
- Note: MCP servers and plugins are NOT here — they come from the CLI in Task 4.
- [ ] **Step 1: Write the failing test**
```js
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-"));
process.env.CODEX_HOME = TMP;
const cx = require("../lib/codex-discovery");
it("reads a user skill and a project skill into one scoped list", () => {
write(path.join(TMP, "skills", "release-guard", "SKILL.md"), "---\nname: release-guard\n---\nbody");
write(path.join(REPO, ".codex", "skills", "onboarding", "SKILL.md"), "---\nname: onboarding\n---\nb");
const rows = cx.readSkills({ cwd: REPO });
assert.deepEqual(
rows.map((r) => [r.scope, r.name]).sort(),
[["project", "onboarding"], ["user", "release-guard"]]
);
assert.equal(rows.find((r) => r.name === "release-guard").frontmatter.name, "release-guard");
});
it("pulls an agent's identity out of its TOML without a TOML parser", () => {
write(path.join(REPO, ".codex", "agents", "reviewer.toml"),
'name = "reviewer"\ndescription = "Read-only review agent."\nmodel = "gpt-5.4"\n' +
'developer_instructions = """\nmulti\nline\n"""\n');
const [agent] = cx.readAgents({ cwd: REPO, scope: "project" });
assert.equal(agent.meta.name, "reviewer");
assert.equal(agent.meta.description, "Read-only review agent.");
assert.equal(agent.meta.model, "gpt-5.4");
});
it("reports a missing AGENTS.md as a row that does not exist, not a gap", () => {
const rows = cx.readMemory({ cwd: REPO });
const user = rows.find((r) => r.scope === "user");
assert.equal(user.exists, false, "the UI offers to create it, so the row must be present");
});
it("never walks outside the codex roots", () => {
write(path.join(TMP, "skills", "esc", "SKILL.md"), "x");
for (const row of cx.readSkills({ cwd: REPO })) {
assert.ok(row.file.startsWith(TMP) || row.file.startsWith(REPO));
}
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-discovery.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the readers**
`readTomlMeta` handles exactly what the UI shows: top-level `key = "value"` / `key = 123` / `key = true` lines before the first `[section]`, skipping `"""` blocks. Anything richer is the raw-text editor's job.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test server/__tests__/codex-discovery.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-discovery.js server/__tests__/codex-discovery.test.js
git commit -m "feat(codex): read the Codex config surfaces"
```
---
### Task 4: Codex CLI wrapper
**Files:**
- Create: `server/lib/codex-cli.js`
- Test: `server/__tests__/codex-cli.test.js`
**Interfaces:**
- Produces (all async, all returning `{ok: boolean, code: number, stdout: string, stderr: string, data?: object}`; `data` is the parsed JSON when the command emits it):
- `mcpList()` → `codex mcp list --json`
- `mcpAdd({name, command, args, env})` → `codex mcp add <name> -- <command> <args…>`
- `mcpRemove(name)` → `codex mcp remove <name>`
- `pluginList()` → `codex plugin list --json`
- `pluginAdd(spec)` / `pluginRemove(name)`
- `marketplaceList()` → `codex plugin marketplace list --json`
- `marketplaceAdd(url)` / `marketplaceRemove(name)`
- `NAME_RE` — the identifier guard shared with the routes
- Every call builds an **argv array** and spawns without a shell, exactly like `cc-plugin-cli.js`. A name failing `NAME_RE` throws before spawn.
- [ ] **Step 1: Write the failing test**
```js
it("passes the server name as one argv entry, never through a shell", async () => {
const spawned = [];
const cli = proxy({ spawn: (cmd, args) => { spawned.push([cmd, args]); return ok('{"servers":[]}'); } });
await cli.mcpAdd({ name: "docs", command: "node", args: ["server.js"] });
assert.deepEqual(spawned[0][0], "codex");
assert.deepEqual(spawned[0][1], ["mcp", "add", "docs", "--", "node", "server.js"]);
});
it("refuses a name that could be read as a flag or a path", async () => {
const cli = require("../lib/codex-cli");
await assert.rejects(() => cli.mcpRemove("--force"), /name/i);
await assert.rejects(() => cli.mcpRemove("../../etc"), /name/i);
});
it("surfaces a non-zero exit as ok:false with stderr kept", async () => {
const cli = proxy({ spawn: () => fail(2, "no such marketplace") });
const res = await cli.marketplaceRemove("nope");
assert.equal(res.ok, false);
assert.match(res.stderr, /no such marketplace/);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-cli.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement, copying cc-plugin-cli.js's spawn/injection shape**
Read `server/lib/cc-plugin-cli.js` first and follow its structure — same injectable spawn, same result object, same argv discipline.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test server/__tests__/codex-cli.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-cli.js server/__tests__/codex-cli.test.js
git commit -m "feat(codex): wrap the codex mcp/plugin CLI in argv-safe calls"
```
---
### Task 5: Codex mutation (write side)
**Files:**
- Create: `server/lib/codex-mutate.js`
- Test: `server/__tests__/codex-mutate.test.js`
**Interfaces:**
- Consumes: `atomicWriteFile`, `createBackup`, `assertUnder`, `timestamp` (Task 1); `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2)
- Produces:
- `TYPES` — `{skills: {kind: "dir", entry: "SKILL.md"}, agents: {kind: "file", ext: ".toml"}, rules: {kind: "file", ext: ".rules"}, memory: {kind: "fixed", file: "AGENTS.md"}, config: {kind: "fixed", file: "config.toml"}}`
- `resolveTarget(scope, type, name, opts): {kind, target, filePath, containmentRoot}` — the single chokepoint
- `writeArtifact({scope, type, name, content, cwd}): {file, created, backup}`
- `deleteArtifact({scope, type, name, cwd}): {file, backup}`
- `listBackups({scope, type}): {type, name, file, mtime, size}[]`
- `NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`
- Error codes match cc-mutate's so the route error map is shared in shape: `EBADTYPE`, `EBADSCOPE`, `EBADNAME`, `EBADCONTENT`, `ETOOLARGE`, `ENOTFOUND`, `EOUTOFROOT`.
- [ ] **Step 1: Write the failing test**
```js
it("creates a project skill as a directory with SKILL.md inside", () => {
const { file, created } = cm.writeArtifact({
scope: "project", type: "skills", name: "release-guard", content: "---\nname: x\n---\n", cwd: REPO,
});
assert.equal(created, true);
assert.equal(file, path.join(REPO, ".codex", "skills", "release-guard", "SKILL.md"));
});
it("backs the old body up before overwriting it", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "old", cwd: REPO });
const { backup } = cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "new", cwd: REPO });
assert.equal(fs.readFileSync(backup, "utf8"), "old");
assert.equal(fs.readFileSync(path.join(TMP, "rules", "default.rules"), "utf8"), "new");
});
it("refuses a name that climbs out of the root", () => {
assert.throws(
() => cm.writeArtifact({ scope: "user", type: "agents", name: "../../evil", content: "x", cwd: REPO }),
(e) => e.code === "EBADNAME"
);
});
it("writes AGENTS.md at the repo root, not inside .codex", () => {
const { file } = cm.writeArtifact({ scope: "project", type: "memory", content: "# rules", cwd: REPO });
assert.equal(file, path.join(REPO, "AGENTS.md"));
});
it("keeps a deleted skill's backup out of the skills tree", () => {
cm.writeArtifact({ scope: "user", type: "skills", name: "gone", content: "x", cwd: REPO });
const { backup } = cm.deleteArtifact({ scope: "user", type: "skills", name: "gone", cwd: REPO });
assert.ok(!backup.includes(path.join(TMP, "skills", "gone")), "a backup must never be discoverable as a skill");
assert.equal(fs.existsSync(path.join(TMP, "skills", "gone")), false);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-mutate.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement against `server/lib/cc-mutate.js`'s structure**
Backups land in `<root>/codex-config-backups/<type>/<name>.<ts>.bak`, mirroring Claude's dotted-away location so Codex never rediscovers a backup as a skill.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test server/__tests__/codex-mutate.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-mutate.js server/__tests__/codex-mutate.test.js
git commit -m "feat(codex): write Codex artifacts with backup and atomic rename"
```
---
### Task 6: Codex config routes
**Files:**
- Create: `server/routes/codex-config.js`, `server/openapi-extra/codex-config.js`
- Modify: `server/index.js` (mount `/api/codex-config`), `server/openapi-extra.js` (merge the fragment)
- Test: `server/__tests__/codex-config.test.js`
**Interfaces:**
- Consumes: Tasks 3, 4, 5
- Produces: `GET /overview|/skills|/agents|/rules|/memory|/config|/mcp|/plugins|/marketplaces`, `GET /file?path=`, `PUT /file`, `DELETE /file`, `GET /backups`, `POST /mcp/add`, `POST /mcp/remove`, `POST /plugins/action`, `POST /marketplaces/add`, `POST /marketplaces/action`
- Mirrors `server/routes/cc-config.js`: same `ERR_TO_STATUS` mapping, same `emitChanged` broadcast (event name `codex_config_changed`), CLI non-zero exit → **422**.
- [ ] **Step 1: Write the failing test**
```js
it("lists skills for both scopes", async () => {
const res = await request(app).get("/api/codex-config/skills");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.skills));
});
it("rejects a write whose name escapes the root with 400, not 500", async () => {
const res = await request(app)
.put("/api/codex-config/file")
.send({ scope: "user", type: "agents", name: "../evil", content: "x" });
assert.equal(res.status, 400);
});
it("reports a failed CLI call as 422 and keeps the stderr", async () => {
const res = await request(app).post("/api/codex-config/marketplaces/action")
.send({ name: "nope", action: "remove" });
assert.equal(res.status, 422);
});
it("broadcasts codex_config_changed after a successful write", async () => {
const seen = [];
onBroadcast((msg) => seen.push(msg.type));
await request(app).put("/api/codex-config/file")
.send({ scope: "user", type: "rules", name: "default", content: "allow" });
assert.ok(seen.includes("codex_config_changed"));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-config.test.js`
Expected: FAIL — 404 on every route
- [ ] **Step 3: Implement the router following `server/routes/cc-config.js`**
- [ ] **Step 4: Run the full backend suite**
Run: `npm run test:server`
Expected: PASS, no regressions
- [ ] **Step 5: Regenerate the OpenAPI YAML and commit**
```bash
npm run openapi:yaml
git add server/routes/codex-config.js server/openapi-extra/codex-config.js server/openapi-extra.js server/index.js server/__tests__/codex-config.test.js openapi.yaml
git commit -m "feat(codex): expose the Codex config API"
```
---
### Task 7: Client API namespace
**Files:**
- Modify: `client/src/lib/api.ts`
- Test: `client/src/lib/__tests__/codexConfigApi.test.ts`
**Interfaces:**
- Produces: `api.codexConfig` with `overview()`, `skills(scope?)`, `agents(scope?)`, `rules(scope?)`, `memory()`, `config()`, `mcp()`, `plugins()`, `marketplaces()`, `file(path)`, `write(args)`, `remove(args)`, `backups()`, `mcpAdd(args)`, `mcpRemove(name)`, `pluginAction(args)`, `marketplaceAdd(url)`, `marketplaceAction(args)`; plus exported types `CodexScope = "user" | "project"`, `CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config"`, `CodexSkill`, `CodexAgent`, `CodexRule`, `CodexMemoryFile`, `CodexConfigFile`, `CodexMcpServer`, `CodexPlugin`, `CodexMarketplace`, `CodexOverview`.
- [ ] **Step 1: Write the failing test**
```ts
it("sends a write as a single PUT with the artifact envelope", async () => {
const calls: RequestInit[] = [];
stubFetch(calls, { ok: true, file: "/x", created: true });
await api.codexConfig.write({ scope: "user", type: "rules", name: "default", content: "allow" });
expect(calls[0].method).toBe("PUT");
expect(JSON.parse(calls[0].body as string)).toEqual({
scope: "user", type: "rules", name: "default", content: "allow",
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts`
Expected: FAIL — `api.codexConfig` is undefined
- [ ] **Step 3: Add the namespace beside `api.ccConfig`, following its exact shape**
- [ ] **Step 4: Run test + typecheck**
Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts && npx tsc -b`
Expected: PASS, no type errors
- [ ] **Step 5: Commit**
```bash
git add client/src/lib/api.ts client/src/lib/__tests__/codexConfigApi.test.ts
git commit -m "feat(codex): add the codexConfig API namespace"
```
---
### Task 8: Extract the tool-agnostic config UI
`CcConfig.tsx` is 4902 lines and its modals are already generic over `type: string` / `scope: string`. Lift them before a second tool copies them.
**Files:**
- Create: `client/src/components/config/FileViewer.tsx`, `EditorModal.tsx`, `ConfirmDeleteModal.tsx`, `ToastNotice.tsx`, `MdItemList.tsx`, `ScopeBadge.tsx`, `configPrimitives.tsx` (`Empty`, `SkeletonRows`, `CopyButton`, `formatBytes`)
- Modify: `client/src/pages/CcConfig.tsx` (delete the moved definitions, import them)
- Test: `client/src/components/config/__tests__/EditorModal.test.tsx`
**Interfaces:**
- Produces: the components above, with `EditorModal` gaining one new prop so it stops reaching for Claude's API directly:
- `fetchContent: (filePath: string) => Promise<string>` — supplied by whichever orchestrator opens it
- existing props keep their names: `state`, `onClose`, `onSave`
- The moved components must not import `api` at all.
- [ ] **Step 1: Write the failing test**
```tsx
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
const fetchContent = vi.fn().mockResolvedValue("old body");
render(<EditorModal
state={{ mode: "edit", type: "rules", scope: "user", name: "default", filePath: "/r/default.rules" }}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>);
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
});
it("refuses to save a create with no name", async () => {
const onSave = vi.fn();
render(<EditorModal state={{ mode: "create", type: "agents", defaultScope: "user" }}
fetchContent={async () => ""} onClose={() => {}} onSave={onSave} />);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).not.toHaveBeenCalled();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/components/config/__tests__/EditorModal.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Move the components; pass `fetchContent={(p) => api.ccConfig.file(p)}` from CcConfig**
- [ ] **Step 4: Verify the Claude tab is unchanged**
Run: `cd client && npm test`
Expected: PASS — `screens.snapshot.test.tsx`'s Claude Config snapshot must NOT change. If it does, the move was not behavior-preserving; fix rather than update the snapshot.
- [ ] **Step 5: Commit**
```bash
git add client/src/components/config client/src/pages/CcConfig.tsx
git commit -m "refactor(config): lift the tool-agnostic config modals out of CcConfig"
```
---
### Task 9: The Codex panel
**Files:**
- Create: `client/src/pages/CodexConfig.tsx`, `client/src/i18n/locales/en/codexConfig.json`, `client/src/i18n/locales/vi/codexConfig.json`, `client/src/pages/__tests__/CodexConfig.test.tsx`
- Modify: `client/src/pages/SettingsHub.tsx:33` (add `Panel: CodexConfig` to the existing `codex` entry), `client/src/i18n/index.ts` (register the namespace)
- Test: `client/src/pages/__tests__/CodexConfig.test.tsx`
**Interfaces:**
- Consumes: `api.codexConfig` (Task 7), the extracted components (Task 8)
- Produces: `export function CodexConfig()` — tabs `overview | skills | agents | rules | memory | config | mcp | plugins`, with create/edit/delete on `skills | agents | rules | memory | config`, and CLI-driven actions on `mcp | plugins`.
- [ ] **Step 1: Write the failing test**
```tsx
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
});
it("writes an edited rule back through the codex API, not the Claude one", async () => {
const write = vi.fn().mockResolvedValue({ ok: true });
stubCodexApi({ rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }], write });
render(<CodexConfig />);
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/pages/__tests__/CodexConfig.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the panel and both locale files**
Every string goes through `useTranslation("codexConfig")`. The `vi` file is a real translation, not an English copy.
- [ ] **Step 4: Run the whole client suite + typecheck**
Run: `cd client && npx tsc -b && npm test`
Expected: PASS. The `screens.snapshot.test.tsx` Settings snapshot changes here (the Codex tab loses its "soon" badge) — that one is expected; review the diff before updating.
- [ ] **Step 5: Update the docs and commit**
Update `docs/API.md` (new `/api/codex-config` surface) and `ARCHITECTURE.md` (the Settings hub now hosts two tool panels), per `skills/update-project-docs/`.
```bash
git add client/src/pages/CodexConfig.tsx client/src/pages/SettingsHub.tsx client/src/i18n client/src/pages/__tests__ docs/API.md ARCHITECTURE.md
git commit -m "feat(codex): create and customize Codex config from /settings"
```
---
## Self-Review
**Spec coverage:** skills, agents, rules, AGENTS.md, config.toml → Tasks 3/5/9. MCP + plugins + marketplaces → Tasks 4/6/9. "Create and customize in settings" → Task 9's create/edit/delete wiring. The Claude-side settings.json/MCP editing the user also asked for is deliberately **not** here — it is a separate subsystem with its own plan (`2026-09-08-claude-settings-editable.md`).
**Placeholder scan:** none — every step names its file, its command, and its expected output.
**Type consistency:** `CodexArtifactType` (Task 7) matches `TYPES` (Task 5) matches the tab keys (Task 9): `skills | agents | rules | memory | config`. `scope` is `"user" | "project"` everywhere; Codex has no `project-local` or `auto-memory` analogue, which is why its scope union is narrower than Claude's.
-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#818cf8"/>
</linearGradient>
<linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#a5b4fc" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#c7d2fe" stop-opacity="0.6"/>
</linearGradient>
</defs>
<!-- Hexagon background -->
<polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="url(#bg)" rx="2"/>
<!-- Center node -->
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95"/>
<!-- Three connector lines radiating outward -->
<line x1="16" y1="13" x2="16" y2="7" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
<line x1="18.6" y1="17.5" x2="24" y2="20.5" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
<line x1="13.4" y1="17.5" x2="8" y2="20.5" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
<!-- Outer nodes -->
<circle cx="16" cy="6" r="1.8" fill="url(#glow)"/>
<circle cx="24.5" cy="21" r="1.8" fill="url(#glow)"/>
<circle cx="7.5" cy="21" r="1.8" fill="url(#glow)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-109
View File
@@ -1,109 +0,0 @@
/**
* @file fonts.css
* @description Self-hosted Inter + JetBrains Mono @font-face declarations — no Google Fonts dependency.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* Inter */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("inter-latin-400-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("inter-latin-500-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("inter-latin-600-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("inter-latin-700-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("inter-latin-800-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("inter-latin-900-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* JetBrains Mono */
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("jetbrains-mono-latin-400-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("jetbrains-mono-latin-500-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("jetbrains-mono-latin-600-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("jetbrains-mono-latin-700-normal.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-28
View File
@@ -1,28 +0,0 @@
{
"id": "claude-code-agent-monitor-landing",
"name": "AgentHub",
"short_name": "CC Monitor",
"description": "Local-first monitoring dashboard for Claude Code sessions.",
"author": "Nguyễn Ngọc Trí Vĩ",
"start_url": ".",
"scope": ".",
"display": "standalone",
"theme_color": "#6366f1",
"background_color": "#07070b",
"orientation": "any",
"categories": ["developer-tools", "productivity"],
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
-122
View File
@@ -1,122 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0d0d16"/>
<stop offset="100%" stop-color="#12121e"/>
</linearGradient>
<linearGradient id="hexGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#818cf8"/>
</linearGradient>
<linearGradient id="nodeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#a5b4fc" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#c7d2fe" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="titleGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#e2e2f0"/>
<stop offset="55%" stop-color="#a5b4fc"/>
<stop offset="100%" stop-color="#818cf8"/>
</linearGradient>
<radialGradient id="glow1" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow2" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#10b981" stop-opacity="0.10"/>
<stop offset="100%" stop-color="#10b981" stop-opacity="0"/>
</radialGradient>
<filter id="blur1">
<feGaussianBlur stdDeviation="40"/>
</filter>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bgGrad)"/>
<!-- Ambient glow blobs -->
<ellipse cx="200" cy="200" rx="340" ry="280" fill="url(#glow1)" filter="url(#blur1)"/>
<ellipse cx="1050" cy="480" rx="280" ry="220" fill="url(#glow2)" filter="url(#blur1)"/>
<!-- Grid dots (subtle background texture) -->
<g opacity="0.07" fill="#6366f1">
<circle cx="80" cy="80" r="1.5"/><circle cx="160" cy="80" r="1.5"/><circle cx="240" cy="80" r="1.5"/><circle cx="320" cy="80" r="1.5"/>
<circle cx="80" cy="160" r="1.5"/><circle cx="160" cy="160" r="1.5"/><circle cx="240" cy="160" r="1.5"/><circle cx="320" cy="160" r="1.5"/>
<circle cx="80" cy="240" r="1.5"/><circle cx="160" cy="240" r="1.5"/><circle cx="240" cy="240" r="1.5"/><circle cx="320" cy="240" r="1.5"/>
<circle cx="900" cy="400" r="1.5"/><circle cx="980" cy="400" r="1.5"/><circle cx="1060" cy="400" r="1.5"/><circle cx="1140" cy="400" r="1.5"/>
<circle cx="900" cy="480" r="1.5"/><circle cx="980" cy="480" r="1.5"/><circle cx="1060" cy="480" r="1.5"/><circle cx="1140" cy="480" r="1.5"/>
<circle cx="900" cy="560" r="1.5"/><circle cx="980" cy="560" r="1.5"/><circle cx="1060" cy="560" r="1.5"/><circle cx="1140" cy="560" r="1.5"/>
</g>
<!-- Large background hex (decorative) -->
<polygon points="980,120 1120,200 1120,360 980,440 840,360 840,200"
fill="none" stroke="#6366f1" stroke-width="1" opacity="0.12"/>
<polygon points="980,150 1095,215 1095,345 980,410 865,345 865,215"
fill="none" stroke="#6366f1" stroke-width="0.5" opacity="0.08"/>
<!-- Left-side decorative connection lines -->
<line x1="60" y1="560" x2="200" y2="460" stroke="#6366f1" stroke-width="1" opacity="0.15"/>
<line x1="200" y1="460" x2="380" y2="540" stroke="#6366f1" stroke-width="1" opacity="0.15"/>
<circle cx="60" cy="560" r="3" fill="#6366f1" opacity="0.3"/>
<circle cx="200" cy="460" r="3" fill="#6366f1" opacity="0.3"/>
<circle cx="380" cy="540" r="3" fill="#6366f1" opacity="0.3"/>
<!-- ── Main icon (hex + nodes) centered-left ── -->
<g transform="translate(128, 265)">
<!-- Hex -->
<polygon points="64,0 112,28 112,92 64,120 16,92 16,28" fill="url(#hexGrad)"/>
<!-- Center node -->
<circle cx="64" cy="60" r="11" fill="white" opacity="0.95"/>
<!-- Lines -->
<line x1="64" y1="49" x2="64" y2="18" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
<line x1="73.5" y1="65.5" x2="97" y2="79" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
<line x1="54.5" y1="65.5" x2="31" y2="79" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
<!-- Outer nodes -->
<circle cx="64" cy="14" r="7" fill="url(#nodeGrad)"/>
<circle cx="100" cy="80" r="7" fill="url(#nodeGrad)"/>
<circle cx="28" cy="80" r="7" fill="url(#nodeGrad)"/>
</g>
<!-- ── Typography ── -->
<!-- Title -->
<text x="290" y="298"
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
font-size="72" font-weight="800" letter-spacing="-2"
fill="url(#titleGrad)">Agent Dashboard</text>
<!-- Subtitle -->
<text x="292" y="355"
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
font-size="26" font-weight="400"
fill="#8888aa">Real-time monitoring platform for Claude Code agent activity</text>
<!-- Divider line -->
<line x1="290" y1="385" x2="840" y2="385" stroke="#252538" stroke-width="1"/>
<!-- Tech pills -->
<g transform="translate(290, 408)" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="15" font-weight="600">
<!-- Node.js -->
<rect x="0" y="0" width="88" height="28" rx="6" fill="#33993320" stroke="#33993340"/>
<text x="44" y="19" text-anchor="middle" fill="#6dbf6d">Node.js 18+</text>
<!-- React -->
<rect x="100" y="0" width="82" height="28" rx="6" fill="#61dafb18" stroke="#61dafb30"/>
<text x="141" y="19" text-anchor="middle" fill="#61dafb">React 18</text>
<!-- TypeScript -->
<rect x="194" y="0" width="100" height="28" rx="6" fill="#3178c618" stroke="#3178c630"/>
<text x="244" y="19" text-anchor="middle" fill="#5a9fd4">TypeScript</text>
<!-- SQLite -->
<rect x="306" y="0" width="72" height="28" rx="6" fill="#003b5718" stroke="#005f8a30"/>
<text x="342" y="19" text-anchor="middle" fill="#5bb8e0">SQLite</text>
<!-- WebSocket -->
<rect x="390" y="0" width="100" height="28" rx="6" fill="#10b98118" stroke="#10b98130"/>
<text x="440" y="19" text-anchor="middle" fill="#34d399">WebSocket</text>
<!-- MIT -->
<rect x="502" y="0" width="62" height="28" rx="6" fill="#f59e0b18" stroke="#f59e0b30"/>
<text x="533" y="19" text-anchor="middle" fill="#fbbf24">MIT</text>
</g>
<!-- Bottom author strip -->
<text x="290" y="580"
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
font-size="18" font-weight="500"
fill="#55556a">github.com/SmartGift · smartgift.vn</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.4 KiB

+1441 -20
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.4.6",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.4.6",
"version": "1.5.0",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+6 -8
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.4.6",
"version": "1.5.0",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -18,13 +18,13 @@
"author": "Nguyễn Ngọc Trí Vĩ",
"repository": {
"type": "git",
"url": "git+https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
"url": "git+https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git"
},
"bugs": {
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/issues"
"url": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub/issues"
},
"license": "UNLICENSED",
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor#readme",
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub#readme",
"keywords": [
"claude",
"claude-code",
@@ -55,7 +55,7 @@
"dev:client": "cd client && npm run dev",
"build": "cd client && npm run build",
"start": "node server/index.js",
"setup": "npm install && (cd client && npm install) && (cd vscode-extension && npm install) && npm run link-cli",
"setup": "npm install && (cd client && npm install) && npm run link-cli",
"update:pull-setup": "git pull --ff-only && npm run setup",
"install-hooks": "node scripts/install-hooks.js",
"seed": "node scripts/seed.js",
@@ -68,9 +68,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"openapi:yaml": "node scripts/generate-openapi-yaml.js",
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\"",
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down"
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\""
},
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": [
"analytics",
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["config", "memory", "governance", "audit", "mcp", "hooks", "claude-code"],
"categories": ["config", "governance", "monitoring"]
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["budget", "cost", "forecast", "alerts", "spend", "pricing", "claude-code"],
"categories": ["cost", "monitoring", "alerts"]
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["dashboard", "connector", "real-time", "monitoring", "claude-code"],
"categories": ["integration", "monitoring"]
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["devtools", "debugging", "diagnostics", "export", "health-check", "claude-code"],
"categories": ["devtools", "debugging", "diagnostics"]
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": [
"insights",
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["productivity", "standup", "reports", "sprint", "workflow", "claude-code"],
"categories": ["productivity", "workflow", "reporting"]
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": [
"reliability",
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["sessions", "search", "transcript", "replay", "timeline", "cleanup", "claude-code"],
"categories": ["sessions", "monitoring", "devtools"]
@@ -6,11 +6,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": {
"type": "git",
"url": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": [
"workflows",
+9 -3
View File
@@ -1,5 +1,5 @@
---
description: Diagnose a plugin-installed AgentHub — Node, bootstrap, server, hooks, CLI
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, UI build
---
Run the diagnostic and report what it found. Do not fix anything unless the user
@@ -17,8 +17,13 @@ Print the output verbatim, then add at most three lines of guidance based on the
- `FAIL Hooks` → duplicate hook entries double every token and cost figure.
Starting a new Claude Code session removes them automatically (the bootstrap
backs the file up as `settings.json.agenthub-bak` first).
- `FAIL Server` or `FAIL Runtime deps` → run `/agenthub-update`, then check
- `FAIL Server` → run `/agenthub-update`, then check
`~/.claude/agenthub/runtime/bootstrap.log` and `server.log`.
- `FAIL Runtime deps` → the install is incomplete, not merely absent. Delete
`~/.claude/agenthub/runtime/node_modules` before running `/agenthub-update`;
reinstalling over the broken tree is what let it survive.
- `FAIL npm` or `FAIL Disk` → nothing else in the report can be trusted until
these are fixed; both silently truncate installs.
- `WARN agenthub CLI` → print the exact `export PATH=...` line from the output.
- `WARN Skill plugins` → the pipelines name slash commands no installed plugin
provides. The dashboard is unaffected; `/agenthub:ship-feature(-lane)` is what
@@ -26,6 +31,7 @@ Print the output verbatim, then add at most three lines of guidance based on the
means that attempt has not run or did not succeed — say `/agenthub-update` retries
it, and print the `claude plugin install …` line from the output as the manual
route.
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle.
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle. When the row
quotes a build error, report that error — it is the actual problem.
No preamble, no summary of things that passed.
-5
View File
@@ -1,5 +0,0 @@
# https://smartgift.github.io/Claude-Code-Agent-Monitor/
User-agent: *
Allow: /
Sitemap: https://smartgift.github.io/Claude-Code-Agent-Monitor/sitemap.xml
+15 -6
View File
@@ -15,7 +15,11 @@ const { createOpenApiSpec } = require("../server/openapi");
const OUT = path.join(__dirname, "..", "openapi.yaml");
function main() {
/** The exact bytes `openapi.yaml` must contain for the current spec. Exported so
* the drift test can compare against the committed file without duplicating the
* dump options — a second copy of them would let the two definitions diverge,
* which is the very staleness this file exists to prevent. */
function render() {
const spec = createOpenApiSpec();
const body = yaml.dump(spec, {
lineWidth: -1, // don't wrap long strings (keeps descriptions/examples intact)
@@ -25,10 +29,15 @@ function main() {
const header =
"# DO NOT EDIT BY HAND. Generated from server/openapi.js via `npm run openapi:yaml`.\n" +
"# This YAML mirrors the live spec served at GET /api/openapi.json.\n";
fs.writeFileSync(OUT, header + body, "utf8");
const stat = fs.statSync(OUT);
const pathCount = Object.keys(spec.paths || {}).length;
console.log(`Wrote ${OUT} (${pathCount} paths, ${stat.size} bytes).`);
return { text: header + body, pathCount: Object.keys(spec.paths || {}).length };
}
main();
function main() {
const { text, pathCount } = render();
fs.writeFileSync(OUT, text, "utf8");
console.log(`Wrote ${OUT} (${pathCount} paths, ${fs.statSync(OUT).size} bytes).`);
}
if (require.main === module) main();
module.exports = { render, OUT };

Some files were not shown because too many files have changed in this diff Show More