refactor!: rebrand CCAM to AgentHub

Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
This commit is contained in:
2026-09-07 12:09:15 +07:00
parent ae9b3dbcd5
commit 17d56d0e01
345 changed files with 2705 additions and 2727 deletions
+20 -20
View File
@@ -467,7 +467,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
| `GET` | `/api/analytics` | Analytics aggregates for charts/trends |
| `GET` | `/api/metrics` | Prometheus / OpenMetrics exposition (text; v0.0.4) |
**Prometheus metrics (`GET /api/metrics`).** Exposes the dashboard's live counters — `ccam_sessions`/`ccam_agents` by status, `ccam_events_total`, `ccam_tokens_total` by kind, `ccam_websocket_clients`, `ccam_remote_sources` by enabled state, `ccam_process_uptime_seconds`/`ccam_process_resident_memory_bytes`, and `ccam_build_info{version}` — in the Prometheus v0.0.4 text-exposition format for scraping into Prometheus / Grafana (`server/routes/metrics.js`). Values come from the same `server/db.js` prepared statements the REST API uses, so they match the UI; status series are enumerated so a gauge never drops out of the exposition at zero. The route is read-only and, being under `/api`, sits behind both the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard: a non-loopback scraper (e.g. Prometheus in Docker via `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS` or it gets `403 EBADHOST`, and must send the token when one is set.
**Prometheus metrics (`GET /api/metrics`).** Exposes the dashboard's live counters — `agenthub_sessions`/`agenthub_agents` by status, `agenthub_events_total`, `agenthub_tokens_total` by kind, `agenthub_websocket_clients`, `agenthub_remote_sources` by enabled state, `agenthub_process_uptime_seconds`/`agenthub_process_resident_memory_bytes`, and `agenthub_build_info{version}` — in the Prometheus v0.0.4 text-exposition format for scraping into Prometheus / Grafana (`server/routes/metrics.js`). Values come from the same `server/db.js` prepared statements the REST API uses, so they match the UI; status series are enumerated so a gauge never drops out of the exposition at zero. The route is read-only and, being under `/api`, sits behind both the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard: a non-loopback scraper (e.g. Prometheus in Docker via `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS` or it gets `403 EBADHOST`, and must send the token when one is set.
**Data scope (`?sources=`).** `GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` all accept an optional `sources` query param — a comma-separated list of source ids (`local` plus any remote source id, see [Remote Data Sources](#remote-data-sources)) — that narrows the result to sessions with a matching `sessions.source`. It is parsed by `server/lib/source-filter.js` into SQL predicates; `/api/stats` and `/api/analytics` route to the source-scoped aggregates in `server/lib/scoped-stats.js` only when a scope is present, leaving the unscoped fast paths unchanged. `GET /api/sessions/facets` additionally returns a `sources` facet enumerating the known source ids.
@@ -523,7 +523,7 @@ Request body shape:
| `POST` | `/api/lanes/:id/purge` | Confirmed deletion of eligible lane sessions, events, and token usage rows. |
| `PATCH` | `/api/lanes/:id` | Same-origin guarded partial lane update. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are provisioning facts and are not patchable; an invalid `kind` returns `400 EBADKIND`. |
| `DELETE` | `/api/lanes/:id` | Same-origin guarded non-destructive lane-row removal. |
| `GET` | `/api/lanes/:id/runtime` | The lane's own application stack: slot, ports, per-service liveness, last boot error. Recomputed per call from pid files and port probes, never cached. A lane with no `.ccam/profile` returns `{available:false}` with `200`. |
| `GET` | `/api/lanes/:id/runtime` | The lane's own application stack: slot, ports, per-service liveness, last boot error. Recomputed per call from pid files and port probes, never cached. A lane with no `.agenthub/profile` returns `{available:false}` with `200`. |
| `POST` | `/api/lanes/:id/up` | Same-origin guarded stack boot through the profile's `boot` + `health` hooks; returns `202` and finishes in the background. |
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
@@ -581,27 +581,27 @@ journal's mtime, under the guard hook ingestion uses (no `run_id`, only from
`idle`/`running`); it writes nothing else.
Getting a codex lane's *declared* stage to actually happen is a separate
problem — only the session itself can call `ccam stage`, and unlike a Claude
problem — only the session itself can call `agenthub stage`, and unlike a Claude
lane it has no slash command to pre-fill. `LaneConsolePane.tsx` pre-fills a
codex pane's opening prompt with a plain-text checklist instead, one
`ccam stage <stage> --tool codex` line per node of the lane's own
`agenthub stage <stage> --tool codex` line per node of the lane's own
`pipeline_nodes`. As a safety net beyond that opening nudge,
`server/lib/codex-nudge.js`'s `sweepCodexNudges()` runs on an unref'd 60s
interval (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables): when a codex lane's fresh
`detected_stage` has sat ahead of its declared `stage` for at least two
minutes, it types a one-line `[ccam] ... run: ccam stage ...` reminder into
minutes, it types a one-line `[agenthub] ... run: agenthub stage ...` reminder into
the lane's live tmux pane via `tmux.sendText` — skipped if the pane sits at a
bare shell prompt (`pty-run.js`'s `SHELL_COMMANDS`), since pressing Enter there
would run the reminder as a shell command instead of submitting it to Codex.
It never writes `stage` or `detected_stage`, so declared-wins and
inferred-never-`done` are untouched; any resulting declaration is still the
codex session's own real `ccam stage` call.
codex session's own real `agenthub stage` call.
### Remote Data Sources
Live remote/multi-machine data collection over SSH. The dashboard pulls Claude Code history from other machines: `server/lib/remote-sync.js` uses recursive **`scp` over SSH** (built into OpenSSH — no `rsync` or extra packages on the remote) to mirror each remote's `~/.claude/projects` into a sandboxed per-source staging dir under the data dir, feeds it through the **same** importer used for local history (`scripts/import-history.js` `importFromDirectory`), and tags every imported session with the source id (`sessions.source`). Authentication defers entirely to the host SSH stack (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are stored**; every command runs via `execFile`/`spawn` argument arrays (never a shell string) and `StrictHostKeyChecking` is left at its SSH default.
> **Cursor on remotes (informational):** The same note applies on synced machines — if Cursor on a remote host writes to `~/.claude`, those sessions are imported too. CCAM reads the paths, not the app name.
> **Cursor on remotes (informational):** The same note applies on synced machines — if Cursor on a remote host writes to `~/.claude`, those sessions are imported too. AgentHub reads the paths, not the app name.
| Method | Path | Description |
| -------- | ----------------------------- | ----------- |
@@ -623,11 +623,11 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
2. **Make auth passwordless:** load your key into `ssh-agent` (`ssh-add`), or set an `IdentityFile` in `~/.ssh/config`, or point the source's optional `identity_file` at the key. Passphrase prompts and password auth will not work under `BatchMode`.
3. **OpenSSH on both sides** — the dashboard machine needs the OpenSSH **client** (`ssh` + `scp`). The remote needs a running OpenSSH **server** (default on most Linux/macOS hosts; enable the OpenSSH Server optional feature on Windows). **Nothing else is installed on the remote.**
4. **Cross-platform notes:**
- **macOS auth (Secretive, 1Password, ssh-agent, or file keys):** leave **Identity file** blank unless you need a specific key path. CCAM mirrors your shell: `ssh -G` supplies `IdentityAgent` when your `~/.ssh/config` does; otherwise it uses `SSH_AUTH_SOCK` (including `launchctl getenv` when the dashboard is GUI-launched) or plain `~/.ssh` keys. Secretive is used only when your SSH config points at it — never forced.
- **Windows dashboard:** OpenSSH Client optional feature; CCAM prefers `ssh`/`scp` on `PATH`, then falls back to `System32\OpenSSH\`.
- **Windows remote:** default `~/.claude` checks the Windows profile **and** WSL (`~/.claude` inside the default distro). If Claude Code runs only in WSL, leave remote home blank — CCAM auto-detects WSL and pulls via `wsl.exe` + `tar`, or set `wsl:~/.claude` / `wsl:/home/you/.claude` explicitly. Native Windows installs can use `C:/Users/you/.claude`; UNC paths such as `//wsl.localhost/Ubuntu/home/you/.claude` also work when `scp` can read them.
- **macOS auth (Secretive, 1Password, ssh-agent, or file keys):** leave **Identity file** blank unless you need a specific key path. AgentHub mirrors your shell: `ssh -G` supplies `IdentityAgent` when your `~/.ssh/config` does; otherwise it uses `SSH_AUTH_SOCK` (including `launchctl getenv` when the dashboard is GUI-launched) or plain `~/.ssh` keys. Secretive is used only when your SSH config points at it — never forced.
- **Windows dashboard:** OpenSSH Client optional feature; AgentHub prefers `ssh`/`scp` on `PATH`, then falls back to `System32\OpenSSH\`.
- **Windows remote:** default `~/.claude` checks the Windows profile **and** WSL (`~/.claude` inside the default distro). If Claude Code runs only in WSL, leave remote home blank — AgentHub auto-detects WSL and pulls via `wsl.exe` + `tar`, or set `wsl:~/.claude` / `wsl:/home/you/.claude` explicitly. Native Windows installs can use `C:/Users/you/.claude`; UNC paths such as `//wsl.localhost/Ubuntu/home/you/.claude` also work when `scp` can read them.
- **Linux/macOS remote:** default `~/.claude/projects`; custom POSIX paths (`/home/ubuntu/.claude`) also work. Prefer SSH directly into WSL/Linux rather than Windows→WSL when possible.
5. **Add the source** (Settings → Remote Data Sources, or `ccam remote-sources add`), click **Test**, then **Sync**.
5. **Add the source** (Settings → Remote Data Sources, or `agenthub remote-sources add`), click **Test**, then **Sync**.
| Symptom (surfaced in `last_error` / the Test result) | Cause & fix |
| --- | --- |
@@ -635,7 +635,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `Permission denied (publickey)` | No usable key for non-interactive auth. `ssh-add` your key, set `IdentityFile` in `~/.ssh/config`, or set the source's `identity_file`. |
| `… does not exist on the remote` | Claude Code's home is elsewhere on that machine. Set the source's **remote home** (default `~/.claude`). |
| `scp` / `ssh` not recognized (Windows) | Install the **OpenSSH Client** optional feature, restart the dashboard, or confirm `C:\Windows\System32\OpenSSH\scp.exe` exists. |
| `Permission denied (publickey,password)` | SSH auth failed in the **dashboard process** (not necessarily your Terminal). Leave **Identity file** blank for Secretive, ssh-agent, or default `~/.ssh` keys — CCAM follows `ssh -G` / your config and does not force Secretive. Start the dashboard from the same shell as `ssh user@host`, or ensure your agent is running. Set **Identity file** only for an explicit on-disk key. |
| `Permission denied (publickey,password)` | SSH auth failed in the **dashboard process** (not necessarily your Terminal). Leave **Identity file** blank for Secretive, ssh-agent, or default `~/.ssh` keys — AgentHub follows `ssh -G` / your config and does not force Secretive. Start the dashboard from the same shell as `ssh user@host`, or ensure your agent is running. Set **Identity file** only for an explicit on-disk key. |
| Connected but directory missing | Claude Code may not be installed on the remote, or `remote_home` points at the wrong path. On Windows SSH with Claude in WSL, leave remote home blank (auto WSL) or set `wsl:~/.claude`. Default native path is `~/.claude/projects`. |
| Sync hangs then errors after ~10 min | Bounded by `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS`; usually a network/host issue — verify with **Test** (bounded by `DASHBOARD_REMOTE_TEST_TIMEOUT_MS`). |
@@ -680,7 +680,7 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
### Run a lane's CLI (`/api/run`)
HTTP surface for the tmux-backed terminal run: one session per lane, named `ccam-lane-<laneId>`, running the CLI that lane declares (`claude` or `codex`). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares (`claude` or `codex`). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
| Method | Path | Description |
| -------- | ----------------------------- | ----------- |
@@ -762,7 +762,7 @@ sequenceDiagram
I->>DB: importSession in one tx
I->>WS: import.progress{phase:parse, complete}
R-->>UI: 200 {imported, backfilled, skipped,<br/>errors, rejected_files}
R->>A: rmTempDir(workDir + req._ccamUploadDir)
R->>A: rmTempDir(workDir + req._agenthubUploadDir)
```
**Supported source layouts.** Both canonical Claude Code JSONL layouts
@@ -776,9 +776,9 @@ matches one probed from either layout candidate.
| Variable | Default | Purpose |
| --------------------------------- | ----------- | ----------------------------------------------------------------- |
| `CCAM_IMPORT_MAX_BYTES` | `1073741824` | Maximum size per uploaded file |
| `CCAM_IMPORT_MAX_FILES` | `2000` | Maximum files per upload request |
| `CCAM_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` | Total uncompressed bytes allowed per archive (zip-bomb guard) |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` | Maximum size per uploaded file |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` | Total uncompressed bytes allowed per archive (zip-bomb guard) |
**WebSocket event schema.** Progress is broadcast on `/ws` with type
`import.progress`. Messages are throttled at ~150 ms; the terminal
@@ -794,7 +794,7 @@ matches one probed from either layout candidate.
"source": "upload",
"processed": 184,
"total": 512,
"current": "/tmp/ccam-import-work-xyz/project/<uuid>.jsonl",
"current": "/tmp/agenthub-import-work-xyz/project/<uuid>.jsonl",
"counters": { "imported": 120, "backfilled": 40, "skipped": 20, "errors": 4 }
}
}
@@ -1447,9 +1447,9 @@ test("POST /api/hooks/event ingests hook payload", async () => {
---
## Terminal Access (`ccam` CLI)
## Terminal Access (`agenthub` CLI)
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `ccam` CLI (`bin/ccam.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, lane adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed lane reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agent-dashboard.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `agenthub` CLI (`bin/agenthub.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, lane adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed lane reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agent-dashboard.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
## Deployment
@@ -1,5 +1,5 @@
/**
* @file Tests for the `ccam` umbrella CLI (bin/ccam.js). Spawns the real CLI
* @file Tests for the `agenthub` umbrella CLI (bin/agenthub.js). Spawns the real CLI
* as a subprocess against a live in-test dashboard server and asserts each
* command's output shape across the whole surface: monitoring (health, stats,
* kanban), data browsing (sessions, session detail, agents, events), insights
@@ -23,23 +23,23 @@ const os = require("os");
const http = require("http");
const { spawn } = require("child_process");
const STAMP = `ccam-cli-${Date.now()}-${process.pid}`;
const STAMP = `agenthub-cli-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Every spawn below inherits `process.env` — a shell that forces color
// (FORCE_COLOR/CCAM_COLOR set in the environment running this test) would
// (FORCE_COLOR/AGENTHUB_COLOR set in the environment running this test) would
// leak into the spawned CLI child and defeat its own "colors off when piped"
// behavior, which every plain-text assertion in this file assumes.
delete process.env.FORCE_COLOR;
delete process.env.CCAM_COLOR;
delete process.env.AGENTHUB_COLOR;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const CLI = path.resolve(__dirname, "..", "..", "bin", "ccam.js");
const CLI = path.resolve(__dirname, "..", "..", "bin", "agenthub.js");
let server;
let PORT;
@@ -49,7 +49,7 @@ let PORT;
* the server runs in this same process, so a blocking spawnSync would starve
* its event loop and every CLI request would hang until timeout.
*/
function ccam(...args) {
function agenthub(...args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, ...args], {
env: { ...process.env, DASHBOARD_PORT: String(PORT) },
@@ -98,7 +98,7 @@ before(async () => {
// Seed one session via the real hook path so list commands have a row.
const code = await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "cli-test-session-0001", cwd: "/tmp/ccam-cli" },
data: { session_id: "cli-test-session-0001", cwd: "/tmp/agenthub-cli" },
});
assert.equal(code, 200);
});
@@ -113,9 +113,9 @@ after(() => {
}
});
describe("ccam CLI — monitoring", () => {
describe("agenthub CLI — monitoring", () => {
it("health reports the dashboard as up", async () => {
const { code, out } = await ccam("health");
const { code, out } = await agenthub("health");
assert.equal(code, 0);
assert.match(out, /Dashboard/);
assert.match(out, /up/);
@@ -124,7 +124,7 @@ describe("ccam CLI — monitoring", () => {
});
it("stats prints totals including the seeded session", async () => {
const { code, out } = await ccam("stats");
const { code, out } = await agenthub("stats");
assert.equal(code, 0);
assert.match(out, /Total sessions/);
assert.match(out, /Events today/);
@@ -132,7 +132,7 @@ describe("ccam CLI — monitoring", () => {
});
it("kanban groups sessions and agents into status columns", async () => {
const { code, out } = await ccam("kanban");
const { code, out } = await agenthub("kanban");
assert.equal(code, 0);
assert.match(out, /Sessions/);
assert.match(out, /Agents/);
@@ -141,22 +141,22 @@ describe("ccam CLI — monitoring", () => {
});
});
describe("ccam CLI — data browsing", () => {
describe("agenthub CLI — data browsing", () => {
it("sessions lists the seeded session", async () => {
const { code, out } = await ccam("sessions", "--limit", "5");
const { code, out } = await agenthub("sessions", "--limit", "5");
assert.equal(code, 0);
assert.match(out, /cli-test/);
assert.match(out, /of 1 session/);
});
it("sessions --status filters correctly", async () => {
const { code, out } = await ccam("sessions", "--status", "completed");
const { code, out } = await agenthub("sessions", "--status", "completed");
assert.equal(code, 0);
assert.match(out, /of 0 session/);
});
it("session <id> shows detail with agents and recent events", async () => {
const { code, out } = await ccam("session", "cli-test-session-0001");
const { code, out } = await agenthub("session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /cli-test-session-0001/);
assert.match(out, /Agents/);
@@ -164,54 +164,54 @@ describe("ccam CLI — data browsing", () => {
});
it("session without an id exits 1 with usage", async () => {
const { code, err } = await ccam("session");
const { code, err } = await agenthub("session");
assert.equal(code, 1);
assert.match(err, /Usage: ccam session/);
assert.match(err, /Usage: agenthub session/);
});
it("agents lists the auto-created main agent", async () => {
const { code, out } = await ccam("agents", "--session", "cli-test-session-0001");
const { code, out } = await agenthub("agents", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /main/);
});
it("events lists the seeded Stop event", async () => {
const { code, out } = await ccam("events", "--session", "cli-test-session-0001");
const { code, out } = await agenthub("events", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /Stop/);
});
});
describe("ccam CLI — insights", () => {
describe("agenthub CLI — insights", () => {
it("analytics prints token totals and averages", async () => {
const { code, out } = await ccam("analytics");
const { code, out } = await agenthub("analytics");
assert.equal(code, 0);
assert.match(out, /Tokens/);
assert.match(out, /Input/);
});
it("workflows prints intelligence stats", async () => {
const { code, out } = await ccam("workflows");
const { code, out } = await agenthub("workflows");
assert.equal(code, 0);
assert.match(out, /Workflow intelligence/);
assert.match(out, /Sessions analyzed/);
});
it("runs lists dynamic workflow runs (empty is fine)", async () => {
const { code, out } = await ccam("runs");
const { code, out } = await agenthub("runs");
assert.equal(code, 0);
assert.match(out, /Run/);
assert.match(out, /Status/);
});
it("cost prints a total", async () => {
const { code, out } = await ccam("cost");
const { code, out } = await agenthub("cost");
assert.equal(code, 0);
assert.match(out, /Total estimated cost: \$/);
});
it("cost --session scopes the total to one session", async () => {
const { code, out } = await ccam("cost", "--session", "cli-test-session-0001");
const { code, out } = await agenthub("cost", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /Session cost/);
assert.match(out, /cli-test-session-0001/);
@@ -225,7 +225,7 @@ describe("ccam CLI — insights", () => {
"INSERT INTO token_usage (session_id, model, web_search_requests) VALUES (?, ?, ?)"
).run("cli-test-session-0001", "claude-sonnet-5", 200);
try {
const { code, out } = await ccam("cost", "--session", "cli-test-session-0001");
const { code, out } = await agenthub("cost", "--session", "cli-test-session-0001");
assert.equal(code, 0);
assert.match(out, /Server-tool surcharges/);
assert.match(out, /web search \$/);
@@ -241,58 +241,58 @@ describe("ccam CLI — insights", () => {
// and reports it via unpriced_models; the CLI must surface that.
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens, output_tokens) VALUES (?, ?, ?, ?)"
).run("cli-test-session-0001", "ccam-mystery-model-9", 1200, 300);
).run("cli-test-session-0001", "agenthub-mystery-model-9", 1200, 300);
try {
const { code, out } = await ccam("cost");
const { code, out } = await agenthub("cost");
assert.equal(code, 0);
assert.match(out, /no pricing rule/);
assert.match(out, /ccam-mystery-model-9/);
assert.match(out, /ccam pricing set/);
assert.match(out, /agenthub-mystery-model-9/);
assert.match(out, /agenthub pricing set/);
} finally {
db.prepare("DELETE FROM token_usage WHERE model = 'ccam-mystery-model-9'").run();
db.prepare("DELETE FROM token_usage WHERE model = 'agenthub-mystery-model-9'").run();
}
});
});
describe("ccam CLI — alerts, rules, webhooks", () => {
describe("agenthub CLI — alerts, rules, webhooks", () => {
it("alerts lists the (empty) fired-alert feed", async () => {
const { code, out } = await ccam("alerts");
const { code, out } = await agenthub("alerts");
assert.equal(code, 0);
assert.match(out, /unacknowledged of/);
});
it("alerts ack-all succeeds on an empty feed", async () => {
const { code, out } = await ccam("alerts", "ack-all");
const { code, out } = await agenthub("alerts", "ack-all");
assert.equal(code, 0);
assert.match(out, /Acknowledged/);
});
it("rules lists alert rules", async () => {
const { code, out } = await ccam("rules");
const { code, out } = await agenthub("rules");
assert.equal(code, 0);
assert.match(out, /Enabled/);
});
it("webhooks lists targets", async () => {
const { code, out } = await ccam("webhooks");
const { code, out } = await agenthub("webhooks");
assert.equal(code, 0);
assert.match(out, /Provider/);
});
});
describe("ccam CLI — pricing", () => {
describe("agenthub CLI — pricing", () => {
it("pricing lists the default rules", async () => {
const { code, out } = await ccam("pricing");
const { code, out } = await agenthub("pricing");
assert.equal(code, 0);
assert.match(out, /Pattern/);
assert.match(out, /claude/);
});
it("pricing set / delete round-trips a custom rule", async () => {
const set = await ccam(
const set = await agenthub(
"pricing",
"set",
"ccam-test-model%",
"agenthub-test-model%",
"--input",
"1",
"--output",
@@ -304,18 +304,18 @@ describe("ccam CLI — pricing", () => {
);
assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`);
assert.match(set.out, /saved/);
const list = await ccam("pricing");
assert.match(list.out, /ccam-test-model%/);
const del = await ccam("pricing", "delete", "ccam-test-model%");
const list = await agenthub("pricing");
assert.match(list.out, /agenthub-test-model%/);
const del = await agenthub("pricing", "delete", "agenthub-test-model%");
assert.equal(del.code, 0);
assert.match(del.out, /deleted/);
});
it("pricing set persists fast-mode and intro rates via flags", async () => {
const set = await ccam(
const set = await agenthub(
"pricing",
"set",
"ccam-fast-model%",
"agenthub-fast-model%",
"--input",
"5",
"--output",
@@ -332,37 +332,45 @@ describe("ccam CLI — pricing", () => {
"2099-01-01"
);
assert.equal(set.code, 0, `stderr: ${set.err} stdout: ${set.out}`);
const list = await ccam("pricing");
assert.match(list.out, /ccam-fast-model%/);
const list = await agenthub("pricing");
assert.match(list.out, /agenthub-fast-model%/);
assert.match(list.out, /\$10\/\$50/); // fast in/out column
assert.match(list.out, /\$2\/\$10/); // intro in/out column
assert.match(list.out, /2099-01-01/);
// A plain rate edit without intro flags must preserve the promo (the
// intro block is only sent when an --intro-* flag is present).
const edit = await ccam("pricing", "set", "ccam-fast-model%", "--input", "6", "--output", "30");
const edit = await agenthub(
"pricing",
"set",
"agenthub-fast-model%",
"--input",
"6",
"--output",
"30"
);
assert.equal(edit.code, 0);
const after = await ccam("pricing");
const after = await agenthub("pricing");
assert.match(after.out, /\$6/);
assert.match(after.out, /2099-01-01/);
await ccam("pricing", "delete", "ccam-fast-model%");
await agenthub("pricing", "delete", "agenthub-fast-model%");
});
});
describe("ccam CLI — import & administration", () => {
describe("agenthub CLI — import & administration", () => {
it("import rescan runs against the (empty) default projects dir", async () => {
const { code, out } = await ccam("import", "rescan");
const { code, out } = await agenthub("import", "rescan");
assert.equal(code, 0);
assert.match(out, /imported \d+/);
});
it("import without a subcommand exits 1 with usage", async () => {
const { code, err } = await ccam("import");
const { code, err } = await agenthub("import");
assert.equal(code, 1);
assert.match(err, /Usage: ccam import/);
assert.match(err, /Usage: agenthub import/);
});
it("doctor reports API, hooks, database, remotes, and uptime lines", async () => {
const { code, out } = await ccam("doctor");
const { code, out } = await agenthub("doctor");
// Exit 1 when hooks are missing (common in the test harness) — still prints a full report.
assert.ok(code === 0 || code === 1, `unexpected exit ${code}`);
assert.match(out, /API reachable/);
@@ -373,7 +381,7 @@ describe("ccam CLI — import & administration", () => {
});
it("info dumps system info JSON", async () => {
const { code, out } = await ccam("info");
const { code, out } = await agenthub("info");
assert.equal(code, 0);
const parsed = JSON.parse(out);
assert.ok(parsed.db);
@@ -382,7 +390,7 @@ describe("ccam CLI — import & administration", () => {
it("export writes a JSON file containing the seeded session", async () => {
const file = path.join(TMP, "export.json");
const { code, out } = await ccam("export", file);
const { code, out } = await agenthub("export", file);
assert.equal(code, 0);
assert.match(out, /Exported to/);
const data = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -393,49 +401,49 @@ describe("ccam CLI — import & administration", () => {
// The test process runs inside the real repo clone, so the route always
// reports git_repo: true; the CLI exits 0 whether the checkout is behind,
// current, or the remote is unreachable (fetch errors are informational).
const { code, out } = await ccam("update-check");
const { code, out } = await agenthub("update-check");
assert.equal(code, 0);
assert.match(out, /Dashboard updates/);
assert.match(out, /checkout|commit|remote|update/i);
});
it("cleanup without flags exits 1 with usage", async () => {
const { code, err } = await ccam("cleanup");
const { code, err } = await agenthub("cleanup");
assert.equal(code, 1);
assert.match(err, /Usage: ccam cleanup/);
assert.match(err, /Usage: agenthub cleanup/);
});
it("cleanup with flags runs", async () => {
const { code, out } = await ccam("cleanup", "--days", "3650");
const { code, out } = await agenthub("cleanup", "--days", "3650");
assert.equal(code, 0);
assert.match(out, /Cleanup done/);
});
it("clear-data REFUSES without --yes", async () => {
const { code, err } = await ccam("clear-data");
const { code, err } = await agenthub("clear-data");
assert.equal(code, 1);
assert.match(err, /--yes/);
// Data must be intact.
const { out } = await ccam("sessions");
const { out } = await agenthub("sessions");
assert.match(out, /of 1 session/);
});
it("clear-data --yes wipes rows (runs last)", async () => {
const { code, out } = await ccam("clear-data", "--yes");
const { code, out } = await agenthub("clear-data", "--yes");
assert.equal(code, 0);
assert.match(out, /cleared/);
const { out: after } = await ccam("sessions");
const { out: after } = await agenthub("sessions");
assert.match(after, /of 0 session/);
});
});
describe("ccam CLI — offline mode (server down, DB read directly)", () => {
describe("agenthub CLI — offline mode (server down, DB read directly)", () => {
// The online admin suite ends with clear-data, so re-seed one session here
// (through the live server) for the offline reads to find.
before(async () => {
const code = await post("/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "cli-test-offline-0002", cwd: "/tmp/ccam-cli-off" },
data: { session_id: "cli-test-offline-0002", cwd: "/tmp/agenthub-cli-off" },
});
assert.equal(code, 200);
});
@@ -463,7 +471,7 @@ describe("ccam CLI — offline mode (server down, DB read directly)", () => {
const { code, out } = await offline("sessions", "--limit", "5");
assert.equal(code, 0);
assert.match(out, /Offline mode/);
assert.match(out, /ccam start/);
assert.match(out, /agenthub start/);
assert.match(out, /cli-test/);
assert.match(out, /of 1 session/);
});
@@ -564,7 +572,7 @@ describe("ccam CLI — offline mode (server down, DB read directly)", () => {
return;
}
// Run offline WITHOUT the probe kill-switch: the seeded session's cwd
// (/tmp/ccam-cli-off) has no running claude process, so its stored
// (/tmp/agenthub-cli-off) has no running claude process, so its stored
// "active" status must be DISPLAYED as completed, with the footnote —
// and the database itself must keep the stored status.
const r = await new Promise((resolve) => {
@@ -593,9 +601,9 @@ describe("ccam CLI — offline mode (server down, DB read directly)", () => {
});
});
describe("ccam CLI — help & errors", () => {
describe("agenthub CLI — help & errors", () => {
it("help lists every command group", async () => {
const { code, out } = await ccam("help");
const { code, out } = await agenthub("help");
assert.equal(code, 0);
for (const word of [
"status",
@@ -631,45 +639,45 @@ describe("ccam CLI — help & errors", () => {
});
it("no arguments prints help and exits 0", async () => {
const { code, out } = await ccam();
const { code, out } = await agenthub();
assert.equal(code, 0);
assert.match(out, /Usage: ccam <command>/);
assert.match(out, /Usage: agenthub <command>/);
});
it("version prints the package version", async () => {
const pkg = JSON.parse(
fs.readFileSync(path.resolve(__dirname, "..", "..", "package.json"), "utf8")
);
const { code, out } = await ccam("version");
const { code, out } = await agenthub("version");
assert.equal(code, 0);
assert.equal(out.trim(), `ccam ${pkg.version}`);
const flag = await ccam("--version");
assert.equal(flag.out.trim(), `ccam ${pkg.version}`);
assert.equal(out.trim(), `agenthub ${pkg.version}`);
const flag = await agenthub("--version");
assert.equal(flag.out.trim(), `agenthub ${pkg.version}`);
});
it("--no-color is accepted anywhere on the command line", async () => {
const before = await ccam("--no-color", "health");
const before = await agenthub("--no-color", "health");
assert.equal(before.code, 0);
assert.match(before.out, /Dashboard/);
const after = await ccam("sessions", "--no-color");
const after = await agenthub("sessions", "--no-color");
assert.equal(after.code, 0);
assert.match(after.out, /of 1 session/);
});
it("piped output contains no ANSI escape codes (colors off when not a TTY)", async () => {
const { out } = await ccam("stats");
const { out } = await agenthub("stats");
assert.ok(!out.includes("\x1b["), "piped output must be plain text");
});
it("tables render with box-drawing borders and an Updated column", async () => {
const { out } = await ccam("sessions");
const { out } = await agenthub("sessions");
assert.ok(out.includes("╭"), "table should have a top border");
assert.ok(out.includes("│"), "table should have column separators");
assert.match(out, /Updated/);
});
it("unknown command exits 1 with an error", async () => {
const { code, err } = await ccam("frobnicate");
const { code, err } = await agenthub("frobnicate");
assert.equal(code, 1);
assert.match(err, /Unknown command/);
});
@@ -685,12 +693,12 @@ describe("ccam CLI — help & errors", () => {
});
assert.equal(r.code, 1);
assert.match(r.err, /Dashboard server is NOT running/);
assert.match(r.err, /ccam start/);
assert.match(r.err, /agenthub start/);
assert.match(r.err, /npm run dev/);
});
it("status reports running when the server is up", async () => {
const { code, out } = await ccam("status");
const { code, out } = await agenthub("status");
assert.equal(code, 0);
assert.match(out, /running/);
assert.match(out, new RegExp(String(PORT)));
@@ -707,20 +715,20 @@ describe("ccam CLI — help & errors", () => {
});
assert.equal(r.code, 1);
assert.match(r.out, /NOT running/);
assert.match(r.out, /ccam start/);
assert.match(r.out, /agenthub start/);
});
it("start no-ops with a pointer when a server is already running", async () => {
const { code, out } = await ccam("start");
const { code, out } = await agenthub("start");
assert.equal(code, 0);
assert.match(out, /already running/);
});
});
describe("ccam CLI — interactive REPL", () => {
describe("agenthub CLI — interactive REPL", () => {
/**
* Drive `ccam repl` with piped stdin (non-TTY). Each typed line is executed
* as a child `ccam` process, so grandchildren reach the in-test server via
* Drive `agenthub repl` with piped stdin (non-TTY). Each typed line is executed
* as a child `agenthub` process, so grandchildren reach the in-test server via
* the DASHBOARD_PORT override; async spawn keeps the event loop free.
*/
function repl(input, port = PORT) {
@@ -746,7 +754,7 @@ describe("ccam CLI — interactive REPL", () => {
const { code, out } = await repl("version\nstats\nexit\n");
assert.equal(code, 0);
// version output precedes the stats table → sequential execution.
const vIdx = out.indexOf("ccam ");
const vIdx = out.indexOf("agenthub ");
const sIdx = out.indexOf("Total sessions");
assert.ok(vIdx >= 0 && sIdx >= 0 && vIdx < sIdx, `order: v=${vIdx} s=${sIdx}`);
});
@@ -339,7 +339,7 @@ describe("readPluginContributions", () => {
assert.equal(c.items.commands[0].description, null);
});
// The real motivation: this repo's own `ccam` plugin keeps nothing in
// The real motivation: this repo's own `agenthub` plugin keeps nothing in
// `commands/` and declares each file in plugin.json instead, so a
// directory-only reader reported "0 commands" for it.
it("includes commands the manifest declares outside the conventional directory", () => {
+10 -4
View File
@@ -12,7 +12,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ccplugin-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ccplugin-"));
const cli = require("../lib/cc-plugin-cli");
/**
@@ -72,9 +72,15 @@ describe("cc-plugin-cli argument validation", () => {
});
it("accepts <name> and <name>@<marketplace> plugin specs only", () => {
assert.equal(cli.assertPluginSpec("ccam"), "ccam");
assert.equal(cli.assertPluginSpec("ccam@sg"), "ccam@sg");
for (const bad of ["ccam@bad/repo", "../ccam", "ccam ccam", "@sg", "-ccam"]) {
assert.equal(cli.assertPluginSpec("agenthub"), "agenthub");
assert.equal(cli.assertPluginSpec("agenthub@sg"), "agenthub@sg");
for (const bad of [
"agenthub@bad/repo",
"../agenthub",
"agenthub agenthub",
"@sg",
"-agenthub",
]) {
assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`);
}
});
+1 -1
View File
@@ -14,7 +14,7 @@ const path = require("path");
const { sourceHash, mcpBuildStatus, writeHash } = require("../../scripts/check-mcp-build");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-mcpbuild-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcpbuild-"));
const SRC = path.join(ROOT, "mcp", "src");
const BUILD = path.join(ROOT, "mcp", "build");
+1 -1
View File
@@ -11,7 +11,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-"));
const { ciStatus } = require("../lib/ci-status");
/**
@@ -4,7 +4,7 @@
* client bundle is served from the writable runtime dir instead of the
* checkout's client/dist. Also asserts the API still answers when the
* configured bundle directory does not exist yet (the normal state before
* /ccam-open builds it).
* /agenthub-open builds it).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -15,7 +15,7 @@ const os = require("os");
const path = require("path");
const http = require("http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-clientdist-"));
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-clientdist-"));
const DIST = path.join(TMP, "client-dist");
fs.mkdirSync(DIST);
fs.writeFileSync(path.join(DIST, "index.html"), "<!doctype html><title>from-runtime</title>");
+5 -5
View File
@@ -1,6 +1,6 @@
/**
* @file Unit tests for the codex evidence safety net: nudges a live codex pane
* to run `ccam stage --evidence` when the lane has sat at an inferred (
* to run `agenthub stage --evidence` when the lane has sat at an inferred (
* evidence-less) stage past the delay, without ever writing stage itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -52,7 +52,7 @@ describe("codex-nudge sweep", () => {
function makeCodexLane({ cwd, stage = "tests", evidence }) {
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
lanes.setStage(lane.id, { stage, evidence });
return lane;
}
@@ -65,7 +65,7 @@ describe("codex-nudge sweep", () => {
assert.equal(nudge.sweepCodexNudges(), 1);
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
assert.equal(sendKeys.length, 1);
assert.match(sendKeys[0][4], /ccam stage tests --tool codex --evidence/);
assert.match(sendKeys[0][4], /agenthub stage tests --tool codex --evidence/);
// A second pass for the same still-unevidenced stage must not repeat it.
assert.equal(nudge.sweepCodexNudges(), 0);
@@ -91,7 +91,7 @@ describe("codex-nudge sweep", () => {
it("never nudges a lane that has not entered a pipeline node", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
@@ -99,7 +99,7 @@ describe("codex-nudge sweep", () => {
it("never nudges a claude lane", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
lanes.setStage(lane.id, { stage: "tests" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 0);
+1 -1
View File
@@ -11,7 +11,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gh-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gh-"));
const { gh } = require("../lib/gh");
/** Puts a fake `gh` script on PATH for the duration of one test. */
@@ -5,7 +5,7 @@
* A Claude Code Workflow-tool run (dynamic workflow / fleet of sub-agents)
* emits NO hooks, so in a headless `claude -p` run, a CI job, or an HPC/cluster
* compute node its per-run journal is never ingested live. Before this fix the
* CLI import path (`ccam import rescan` → importAllSessions, `ccam import path`
* CLI import path (`agenthub import rescan` → importAllSessions, `agenthub import path`
* → importFromDirectory) never called the workflow-journal ingest, so the
* nested inner-agent transcripts under
* `<sid>/subagents/workflows/<runId>/agent-*.jsonl` were imported with
@@ -28,7 +28,7 @@ const path = require("path");
// Scope the importer at a throwaway CLAUDE_HOME and DB before any server module
// loads — import-history captures PROJECTS_DIR from CLAUDE_HOME at require time.
const TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), `ccam-wf-link-${process.pid}-`));
const TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), `agenthub-wf-link-${process.pid}-`));
process.env.CLAUDE_HOME = TMP_ROOT;
process.env.DASHBOARD_DATA_DIR = path.join(TMP_ROOT, "data");
process.env.DASHBOARD_DB_PATH = path.join(TMP_ROOT, "dashboard.db");
@@ -148,7 +148,7 @@ after(() => {
});
describe("offline CLI import links Workflow-tool inner agents", () => {
it("importFromDirectory (ccam import path) links every inner agent to its run", async () => {
it("importFromDirectory (agenthub import path) links every inner agent to its run", async () => {
const SID = "aaaa1111-0000-4000-8000-000000000001";
const RUN = "wf_dirimport01";
const AGENTS = ["d1", "d2", "d3"];
@@ -171,7 +171,7 @@ describe("offline CLI import links Workflow-tool inner agents", () => {
);
});
it("importAllSessions (ccam import rescan) links every inner agent to its run", async () => {
it("importAllSessions (agenthub import rescan) links every inner agent to its run", async () => {
const SID = "bbbb2222-0000-4000-8000-000000000002";
const RUN = "wf_rescan02";
const AGENTS = ["r1", "r2", "r3", "r4"];
+22 -22
View File
@@ -108,7 +108,7 @@ function fixtureLines(sessionId, cwd, model, inputTok, outputTok) {
}
function writeFixtureDir() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fixture-"));
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fixture-"));
const projDir = path.join(root, "-Users-demo-project");
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(
@@ -175,14 +175,14 @@ describe("POST /api/import/scan-path validation", () => {
it("rejects non-existent paths", async () => {
const res = await post("/api/import/scan-path", {
path: "/definitely/does/not/exist/ccam-" + Date.now(),
path: "/definitely/does/not/exist/agenthub-" + Date.now(),
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "PATH_NOT_FOUND");
});
it("rejects files (not directories)", async () => {
const tmp = path.join(os.tmpdir(), `ccam-not-dir-${Date.now()}.txt`);
const tmp = path.join(os.tmpdir(), `agenthub-not-dir-${Date.now()}.txt`);
fs.writeFileSync(tmp, "hello");
try {
const res = await post("/api/import/scan-path", { path: tmp });
@@ -260,14 +260,14 @@ describe("POST /api/import/scan-path happy path", () => {
describe("archive helpers", () => {
it("isPathInside rejects traversal", () => {
const root = path.resolve("/tmp/ccam-root");
assert.equal(archive.isPathInside(root, "/tmp/ccam-root/ok.jsonl"), true);
const root = path.resolve("/tmp/agenthub-root");
assert.equal(archive.isPathInside(root, "/tmp/agenthub-root/ok.jsonl"), true);
assert.equal(archive.isPathInside(root, "/tmp/other/bad.jsonl"), false);
assert.equal(archive.isPathInside(root, "/tmp/ccam-root/../escape"), false);
assert.equal(archive.isPathInside(root, "/tmp/agenthub-root/../escape"), false);
});
it("safeJoin rejects absolute and traversal entries", () => {
const root = path.resolve("/tmp/ccam-root");
const root = path.resolve("/tmp/agenthub-root");
assert.equal(archive.safeJoin(root, "/etc/passwd"), path.join(root, "etc/passwd"));
assert.equal(archive.safeJoin(root, "../escape.txt"), null);
assert.equal(archive.safeJoin(root, "deep/../../escape"), null);
@@ -286,7 +286,7 @@ describe("archive helpers", () => {
});
it("extractGzSingle decompresses plain gz", async () => {
const dest = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gz-"));
const dest = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gz-"));
const src = path.join(dest, "sample.jsonl.gz");
fs.writeFileSync(src, zlib.gzipSync(Buffer.from('{"ok":true}\n')));
try {
@@ -301,7 +301,7 @@ describe("archive helpers", () => {
describe("importFromDirectory directly", () => {
it("reports progress and never throws on empty dirs", async () => {
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-empty-"));
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-empty-"));
try {
const events = [];
const counters = await importHistory.importFromDirectory({ db, stmts }, empty, {
@@ -317,7 +317,7 @@ describe("importFromDirectory directly", () => {
it("matches the legacy importer's token totals on the same fixtures", async () => {
// Clean any tokens from prior tests for a fresh comparison.
const freshSession = "cccccccc-3333-4333-8333-cccccccccccc";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fresh-"));
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fresh-"));
const projDir = path.join(root, "-Users-demo-fresh");
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(
@@ -345,9 +345,9 @@ describe("importFromDirectory directly", () => {
// (default 30 days). Import must snapshot the transcript into the
// dashboard's own data dir so the conversation survives that deletion.
const prevDataDir = process.env.DASHBOARD_DATA_DIR;
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-data-"));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-data-"));
process.env.DASHBOARD_DATA_DIR = dataDir;
const src = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-src-"));
const src = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-src-"));
const sessionId = "dddddddd-4444-4444-8444-dddddddddddd";
fs.writeFileSync(
path.join(src, `${sessionId}.jsonl`),
@@ -396,7 +396,7 @@ describe("POST /api/import/rescan", () => {
describe("tar path-traversal hardening", () => {
it("extractTar rejects entries with ../ segments", async () => {
const tar = require("tar");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-tar-bad-"));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tar-bad-"));
const stageDir = path.join(tmp, "stage");
const targetDir = path.join(tmp, "target");
fs.mkdirSync(stageDir, { recursive: true });
@@ -435,12 +435,12 @@ describe("tar path-traversal hardening", () => {
describe("extraction size cap", () => {
it("extractGzSingle aborts past MAX_EXTRACT_BYTES", async () => {
const prev = process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = "128";
const prev = process.env.AGENTHUB_IMPORT_MAX_EXTRACT_BYTES;
process.env.AGENTHUB_IMPORT_MAX_EXTRACT_BYTES = "128";
// Re-require to pick up the lowered limit for this one check.
delete require.cache[require.resolve("../lib/archive")];
const localArchive = require("../lib/archive");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-bomb-"));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-bomb-"));
const gzPath = path.join(tmp, "bomb.jsonl.gz");
// 2 KB of zeros compresses to a few bytes — decompressing blows past 128 B.
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(2048, 0)));
@@ -451,8 +451,8 @@ describe("extraction size cap", () => {
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
if (prev === undefined) delete process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
else process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = prev;
if (prev === undefined) delete process.env.AGENTHUB_IMPORT_MAX_EXTRACT_BYTES;
else process.env.AGENTHUB_IMPORT_MAX_EXTRACT_BYTES = prev;
// Restore the module with production limits for subsequent tests.
delete require.cache[require.resolve("../lib/archive")];
require("../lib/archive");
@@ -464,7 +464,7 @@ describe("orphan subagent inference", () => {
it("attaches subagent via Layout 2: <proj>/subagents/<sessionId>/agent.jsonl", async () => {
const orphanSession = "dddddddd-4444-4444-8444-dddddddddddd";
// Seed a parent session first.
const seedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-seed-"));
const seedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-orphan-seed-"));
const seedProj = path.join(seedRoot, "project");
fs.mkdirSync(seedProj, { recursive: true });
fs.writeFileSync(
@@ -477,7 +477,7 @@ describe("orphan subagent inference", () => {
assert.ok(stmts.getSession.get(orphanSession), "parent session must exist before orphan pass");
// Now create an "orphan" subagent tree in the non-standard layout.
const orphanRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-"));
const orphanRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-orphan-"));
const orphanLayoutDir = path.join(orphanRoot, "project", "subagents", orphanSession);
fs.mkdirSync(orphanLayoutDir, { recursive: true });
const subAgentId = "agent-xyz";
@@ -519,8 +519,8 @@ describe("concurrent scan-path requests", () => {
it("two concurrent imports of different folders both succeed without clobbering", async () => {
const sessA = "eeeeeeee-5555-4555-8555-eeeeeeeeeeee";
const sessB = "ffffffff-6666-4666-8666-ffffffffffff";
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-a-"));
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-b-"));
const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-concurrent-a-"));
const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-concurrent-b-"));
fs.mkdirSync(path.join(rootA, "-Users-demo-a"), { recursive: true });
fs.mkdirSync(path.join(rootB, "-Users-demo-b"), { recursive: true });
fs.writeFileSync(
+13 -13
View File
@@ -2,7 +2,7 @@
* @file Tests the host-only guard in scripts/install-hooks.js (issue #193):
* the installer must refuse to write a container-internal handler path into a
* (possibly bind-mounted) host ~/.claude/settings.json. Container detection is
* driven deterministically via CCAM_FORCE_CONTAINER / CCAM_FORCE_HOST so these
* driven deterministically via AGENTHUB_FORCE_CONTAINER / AGENTHUB_FORCE_HOST so these
* tests pass whether or not the CI runner itself is containerized.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +16,7 @@ const path = require("path");
// Point the installer at a throwaway CLAUDE_HOME BEFORE requiring it — the
// settings path is resolved at module load. (`node --test` isolates each test
// file in its own process, so this does not leak into other suites.)
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-hooks-"));
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-hooks-"));
process.env.CLAUDE_HOME = TMP_HOME;
const SETTINGS = path.join(TMP_HOME, "settings.json");
@@ -34,9 +34,9 @@ const HOOK_TYPES = [
];
function clearEnv() {
delete process.env.CCAM_FORCE_CONTAINER;
delete process.env.CCAM_FORCE_HOST;
delete process.env.CCAM_ALLOW_CONTAINER_HOOKS;
delete process.env.AGENTHUB_FORCE_CONTAINER;
delete process.env.AGENTHUB_FORCE_HOST;
delete process.env.AGENTHUB_ALLOW_CONTAINER_HOOKS;
}
function rmSettings() {
@@ -63,7 +63,7 @@ describe("install-hooks host-only guard (#193)", () => {
});
it("refuses inside a container and writes no settings file", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
process.env.AGENTHUB_FORCE_CONTAINER = "1";
const ok = installHooks(true);
assert.equal(ok, false);
assert.equal(
@@ -74,8 +74,8 @@ describe("install-hooks host-only guard (#193)", () => {
});
it("writes when the explicit container override is set", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
process.env.CCAM_ALLOW_CONTAINER_HOOKS = "1";
process.env.AGENTHUB_FORCE_CONTAINER = "1";
process.env.AGENTHUB_ALLOW_CONTAINER_HOOKS = "1";
const ok = installHooks(true);
assert.equal(ok, true);
assert.ok(fs.existsSync(SETTINGS));
@@ -87,14 +87,14 @@ describe("install-hooks host-only guard (#193)", () => {
});
it("writes on a host (not a container)", () => {
process.env.CCAM_FORCE_HOST = "1";
process.env.AGENTHUB_FORCE_HOST = "1";
const ok = installHooks(true);
assert.equal(ok, true);
assert.ok(fs.existsSync(SETTINGS));
});
it("is idempotent — re-running updates in place with no duplicate entries", () => {
process.env.CCAM_FORCE_HOST = "1";
process.env.AGENTHUB_FORCE_HOST = "1";
installHooks(true);
installHooks(true);
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
@@ -105,10 +105,10 @@ describe("install-hooks host-only guard (#193)", () => {
});
it("isInsideContainer honors the force flags", () => {
process.env.CCAM_FORCE_CONTAINER = "1";
process.env.AGENTHUB_FORCE_CONTAINER = "1";
assert.equal(isInsideContainer(), true);
delete process.env.CCAM_FORCE_CONTAINER;
process.env.CCAM_FORCE_HOST = "1";
delete process.env.AGENTHUB_FORCE_CONTAINER;
process.env.AGENTHUB_FORCE_HOST = "1";
assert.equal(isInsideContainer(), false);
});
});
+4 -4
View File
@@ -10,7 +10,7 @@ const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-agents-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-agents-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
@@ -124,7 +124,7 @@ describe("installAgents", () => {
assert.deepEqual(result.installed, ["AGENTS.md"]);
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
assert.match(doc, /ccam stage <stage> --tool codex/);
assert.match(doc, /agenthub stage <stage> --tool codex/);
});
it("preserves the user's own AGENTS.md prose and replaces only the block", async () => {
@@ -138,7 +138,7 @@ describe("installAgents", () => {
const doc = fs.readFileSync(file, "utf8");
assert.match(doc, /Never force-push\./);
assert.equal(doc.split("<!-- ccam:stage:start -->").length - 1, 1);
assert.equal(doc.split("<!-- ccam:stage:end -->").length - 1, 1);
assert.equal(doc.split("<!-- agenthub:stage:start -->").length - 1, 1);
assert.equal(doc.split("<!-- agenthub:stage:end -->").length - 1, 1);
});
});
+10 -10
View File
@@ -4,7 +4,7 @@
*
* The database lifecycle tests use a SQLite-shaped fixture hook (write a file,
* remove a file) rather than a real Postgres — the property worth proving is
* CCAM's own orchestration (when a hook runs, what name it is trusted with,
* AgentHub's own orchestration (when a hook runs, what name it is trusted with,
* that a repeat call is a no-op), not any particular database engine.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,10 +13,10 @@ const os = require("node:os");
const pathMod = require("node:path");
const fsMod = require("node:fs");
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "ccam-data-"));
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "agenthub-data-"));
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
process.env.CCAM_SECRETS_PATH = pathMod.join(SUITE_ROOT, "secrets.env");
process.env.AGENTHUB_SECRETS_PATH = pathMod.join(SUITE_ROOT, "secrets.env");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
@@ -48,7 +48,7 @@ function makeLane(over = {}) {
/** Write a profile into a repo directory. `hooks` maps name -> script body. */
function writeProfile(root, envText, hooks = {}) {
const dir = pathMod.join(root, ".ccam", "profile");
const dir = pathMod.join(root, ".agenthub", "profile");
fsMod.mkdirSync(pathMod.join(dir, "hooks"), { recursive: true });
fsMod.writeFileSync(pathMod.join(dir, "profile.env"), envText);
for (const [name, body] of Object.entries(hooks)) {
@@ -70,7 +70,7 @@ const SQLITE_DB_HOOKS = {
};
describe("secrets", () => {
const secretsPath = process.env.CCAM_SECRETS_PATH;
const secretsPath = process.env.AGENTHUB_SECRETS_PATH;
it("falls back to defaults when the file is absent", () => {
assert.equal(fsMod.existsSync(secretsPath), false);
@@ -300,7 +300,7 @@ describe("lane data lifecycle: provision / reset / remove", () => {
const adoptedLane = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(adoptedLane);
await runtime.removeLaneData(adoptedLane, profile, {});
assert.ok(fsMod.existsSync(dbFile), "an adopted lane's data is never CCAM's to destroy");
assert.ok(fsMod.existsSync(dbFile), "an adopted lane's data is never AgentHub's to destroy");
slots.releaseSlot(lane.id);
});
@@ -329,8 +329,8 @@ describe("lane data lifecycle: provision / reset / remove", () => {
describe("runtimeFacts never leaks a secret value", () => {
it("reports only names and an index, never a connection string", async () => {
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=super-secret-password\n");
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
fsMod.writeFileSync(process.env.AGENTHUB_SECRETS_PATH, "PG_PASS=super-secret-password\n");
fsMod.chmodSync(process.env.AGENTHUB_SECRETS_PATH, 0o600);
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=leak_l\nREDIS=1\n", SQLITE_DB_HOOKS);
@@ -347,8 +347,8 @@ describe("runtimeFacts never leaks a secret value", () => {
it("redacts a hook echoing $DATABASE_URL, including its URL-encoded form", async () => {
// A password with a character that changes shape once URL-encoded, so
// both the raw and encoded substrings actually get exercised.
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=p@ss/word\n");
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
fsMod.writeFileSync(process.env.AGENTHUB_SECRETS_PATH, "PG_PASS=p@ss/word\n");
fsMod.chmodSync(process.env.AGENTHUB_SECRETS_PATH, 0o600);
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=echo_l\n", {
+12 -9
View File
@@ -11,13 +11,13 @@ const fs = require("node:fs");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-detect-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-detect-"));
// Must be set before the first require of anything that transitively loads
// server/lib/secrets.js (lane-detect.js does, for checkProfile) — SECRETS_PATH
// is bound once at that module's first load, and without this a test run on a
// machine with a real ~/.ccam/secrets.env would read that file instead of a
// machine with a real ~/.agenthub/secrets.env would read that file instead of a
// deterministic, isolated one.
process.env.CCAM_SECRETS_PATH = path.join(SUITE_ROOT, "secrets.env");
process.env.AGENTHUB_SECRETS_PATH = path.join(SUITE_ROOT, "secrets.env");
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
@@ -180,7 +180,7 @@ describe("scaffoldProfile", () => {
const result = detect.scaffoldProfile(repo, facts);
const profileDir = path.join(repo, ".ccam", "profile");
const profileDir = path.join(repo, ".agenthub", "profile");
const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8");
assert.match(env, /PORTS="app"/);
assert.match(env, /PORT_BASE_app=3000/);
@@ -209,7 +209,7 @@ describe("scaffoldProfile", () => {
const result = detect.scaffoldProfile(repo, facts);
const profileDir = path.join(repo, ".ccam", "profile");
const profileDir = path.join(repo, ".agenthub", "profile");
const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8");
assert.match(env, /DB_PREFIX=/);
assert.match(env, /REDIS=1/);
@@ -238,7 +238,10 @@ describe("scaffoldProfile", () => {
env: null,
};
const result = detect.scaffoldProfile(repo, facts);
const boot = fs.readFileSync(path.join(repo, ".ccam", "profile", "hooks", "boot.sh"), "utf8");
const boot = fs.readFileSync(
path.join(repo, ".agenthub", "profile", "hooks", "boot.sh"),
"utf8"
);
assert.match(boot, /# TODO: no start\/dev script found/);
assert.equal(result.todos.length, 1);
});
@@ -307,7 +310,7 @@ describe("checkProfile", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
detect.scaffoldProfile(repo, detect.detectNode(repo));
fs.rmSync(path.join(repo, ".ccam", "profile", "hooks", "health.sh"));
fs.rmSync(path.join(repo, ".agenthub", "profile", "hooks", "health.sh"));
const result = await detect.checkProfile(repo);
assert.equal(result.ok, false);
assert.ok(result.errors.some((e) => /health/.test(e)));
@@ -319,7 +322,7 @@ describe("checkProfile", () => {
writeCompose(repo, " postgres:\n image: postgres:16\n");
detect.scaffoldProfile(repo, detect.detectNode(repo));
// Remove the migrate/seed TODO lines so only the secrets warning is left to check.
const hooksDir = path.join(repo, ".ccam", "profile", "hooks");
const hooksDir = path.join(repo, ".agenthub", "profile", "hooks");
fs.writeFileSync(path.join(hooksDir, "migrate.sh"), "#!/usr/bin/env bash\nexit 0\n");
fs.writeFileSync(path.join(hooksDir, "seed.sh"), "#!/usr/bin/env bash\nexit 0\n");
const result = await detect.checkProfile(repo);
@@ -330,7 +333,7 @@ describe("checkProfile", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
detect.scaffoldProfile(repo, detect.detectNode(repo));
const hookPath = path.join(repo, ".ccam", "profile", "hooks", "health.sh");
const hookPath = path.join(repo, ".agenthub", "profile", "hooks", "health.sh");
fs.chmodSync(hookPath, 0o000);
try {
const result = await detect.checkProfile(repo);
+1 -1
View File
@@ -9,7 +9,7 @@ const path = require("node:path");
const fs = require("node:fs");
const http = require("node:http");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-features-api-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-features-api-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
+1 -1
View File
@@ -8,7 +8,7 @@ const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-features-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-features-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
+1 -1
View File
@@ -15,7 +15,7 @@ const path = require("node:path");
// LANES_ROOT is read once at require time (from worktree.js) — set it before
// the first require, then use it as a fixed root every test writes under.
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gc-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gc-"));
process.env.LANES_ROOT = SUITE_ROOT;
const laneGc = require("../lib/lane-gc");
+1 -1
View File
@@ -20,7 +20,7 @@ process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lifecycle-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lifecycle-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes");
const { createApp, startServer } = require("../index");
+1 -1
View File
@@ -13,7 +13,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-mcp-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcp-"));
const FAKE_HOME = path.join(SUITE_ROOT, "home");
fs.mkdirSync(FAKE_HOME, { recursive: true });
process.env.HOME = FAKE_HOME;
+5 -5
View File
@@ -7,10 +7,10 @@ const os = require("node:os");
const pathMod = require("node:path");
const fsMod = require("node:fs");
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "ccam-profile-"));
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "agenthub-profile-"));
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
process.env.CCAM_SECRETS_PATH = pathMod.join(SUITE_ROOT, "secrets.env");
process.env.AGENTHUB_SECRETS_PATH = pathMod.join(SUITE_ROOT, "secrets.env");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
@@ -38,7 +38,7 @@ function makeLane(over = {}) {
/** Write a profile into a repo directory. `hooks` maps name -> script body. */
function writeProfile(root, envText, hooks = {}) {
const dir = pathMod.join(root, ".ccam", "profile");
const dir = pathMod.join(root, ".agenthub", "profile");
fsMod.mkdirSync(pathMod.join(dir, "hooks"), { recursive: true });
fsMod.writeFileSync(pathMod.join(dir, "profile.env"), envText);
for (const [name, body] of Object.entries(hooks)) {
@@ -123,7 +123,7 @@ describe("isIntegrationEnabled", () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\n");
fsMod.writeFileSync(
pathMod.join(lane.cwd, ".ccam", "profile", "integrations.env"),
pathMod.join(lane.cwd, ".agenthub", "profile", "integrations.env"),
"TRACKER_ENABLED=1\nTRACKER_PROJECT=demo\n"
);
assert.equal(profileLib.isIntegrationEnabled(lanesLib.getLane(lane.id), "tracker"), true);
@@ -133,7 +133,7 @@ describe("isIntegrationEnabled", () => {
const lane = makeLane();
writeProfile(lane.cwd, "PORTS=api\n");
fsMod.writeFileSync(
pathMod.join(lane.cwd, ".ccam", "profile", "integrations.env"),
pathMod.join(lane.cwd, ".agenthub", "profile", "integrations.env"),
"DEV_QC_ENABLED=0\n"
);
assert.equal(profileLib.isIntegrationEnabled(lanesLib.getLane(lane.id), "dev_qc"), false);
+2 -2
View File
@@ -15,7 +15,7 @@ const os = require("node:os");
const pathMod = require("node:path");
const fsMod = require("node:fs");
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "ccam-runtime-"));
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "agenthub-runtime-"));
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
@@ -65,7 +65,7 @@ function makeLane(over = {}) {
/** Write a profile into a repo directory. `hooks` maps name -> script body. */
function writeProfile(root, envText, hooks = {}) {
const dir = pathMod.join(root, ".ccam", "profile");
const dir = pathMod.join(root, ".agenthub", "profile");
fsMod.mkdirSync(pathMod.join(dir, "hooks"), { recursive: true });
fsMod.writeFileSync(pathMod.join(dir, "profile.env"), envText);
for (const [name, body] of Object.entries(hooks)) {
+3 -3
View File
@@ -15,7 +15,7 @@ const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-sync-"));
const laneSync = require("../lib/lane-sync");
@@ -172,7 +172,7 @@ describe("lane-sync merge: generated-file merge driver + regen fold-in", () => {
gc(GEN_PUSHER, "push", "-q", "origin", "development");
// A regen hook the fold-in step will run.
const profileDir = path.join(GEN_LANE, ".ccam", "profile");
const profileDir = path.join(GEN_LANE, ".agenthub", "profile");
fs.mkdirSync(path.join(profileDir, "hooks"), { recursive: true });
fs.writeFileSync(path.join(profileDir, "profile.env"), "PORTS=api\n");
fs.writeFileSync(
@@ -187,7 +187,7 @@ describe("lane-sync merge: generated-file merge driver + regen fold-in", () => {
env: { MIGRATIONS_DIR: "", GENERATED_MERGE_PATHS: "api/openapi.json" },
generatedMergePaths: ["api/openapi.json"],
hooks: new Set(["regen"]),
dir: path.join(GEN_LANE, ".ccam", "profile"),
dir: path.join(GEN_LANE, ".agenthub", "profile"),
};
}
+6 -6
View File
@@ -659,7 +659,7 @@ describe("lane actions", () => {
describe("GET /api/lanes/:id/git", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-git-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-git-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
@@ -741,7 +741,7 @@ describe("GET /api/lanes/:id/git", () => {
describe("GET /api/lanes/branches", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-api-branches-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-branches-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
@@ -980,11 +980,11 @@ describe("GET /:id/ci/status", () => {
let bin;
before(async () => {
laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-route-"));
laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-route-"));
const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd });
laneId = r.body.lane.id;
bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-bin-"));
bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-bin-"));
fs.writeFileSync(
path.join(bin, "gh"),
`#!/usr/bin/env bash
@@ -1193,7 +1193,7 @@ describe("/api/lanes tool tests", () => {
it("POST /api/lanes/worktree with tool parameter creates a lane with the specified tool", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`ccam-worktree-src-${Date.now()}`
`agenthub-worktree-src-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
@@ -1221,7 +1221,7 @@ describe("/api/lanes tool tests", () => {
it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`ccam-worktree-default-${Date.now()}`
`agenthub-worktree-default-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
+48 -48
View File
@@ -1,5 +1,5 @@
/**
* @file Tests for the `ccam stage` / `ccam lanes` CLI subcommands: lane
* @file Tests for the `agenthub stage` / `agenthub lanes` CLI subcommands: lane
* resolution from the current directory, the stage round-trip through the HTTP
* API, and the non-zero exit when the cwd belongs to no lane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -14,10 +14,10 @@ const http = require("http");
const { spawn } = require("child_process");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-cli-${Date.now()}-${process.pid}.db`);
const PIPELINE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-pipeline-fixture-"));
const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-managed-lanes-cli-"));
const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-source-"));
const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-remote-"));
const PIPELINE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-pipeline-fixture-"));
const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-managed-lanes-cli-"));
const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-worktree-source-"));
const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-worktree-remote-"));
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_PIPELINES_DIR = PIPELINE_DIR;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
@@ -39,8 +39,8 @@ fs.writeFileSync(
const { createApp, startServer } = require("../index");
const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");
const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "agenthub.js");
let server;
let BASE;
@@ -188,7 +188,7 @@ after(() => {
}
});
describe("ccam stage", () => {
describe("agenthub stage", () => {
it("reports a stage for the lane owning the current directory", async () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
@@ -231,9 +231,9 @@ describe("ccam stage", () => {
// A cwd can back one `claude` lane AND one `codex` lane (idx_lanes_cwd_tool),
// so cwd alone no longer identifies a lane. Picking the first of the tie means
// a codex session's `ccam stage` silently drives the claude lane's card.
describe("ccam stage in a directory shared by two lanes", () => {
const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-dual-lane-"));
// a codex session's `agenthub stage` silently drives the claude lane's card.
describe("agenthub stage in a directory shared by two lanes", () => {
const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-dual-lane-"));
let claudeLane;
let codexLane;
// The suite itself runs under Claude Code, whose CLAUDECODE marker would
@@ -251,10 +251,10 @@ describe("ccam stage in a directory shared by two lanes", () => {
return (await get(`/api/lanes/${id}`)).body.lane.stage;
}
it("CCAM_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => {
it("AGENTHUB_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => {
const r = await cli(["stage", "gate"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: String(codexLane.id),
AGENTHUB_LANE_ID: String(codexLane.id),
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "gate");
@@ -287,7 +287,7 @@ describe("ccam stage in a directory shared by two lanes", () => {
});
it("refuses when the requested tool owns no lane here", async () => {
const solo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-solo-lane-"));
const solo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-solo-lane-"));
await post("/api/lanes", { cwd: solo, title: "solo claude" });
const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS);
assert.notEqual(r.status, 0);
@@ -302,20 +302,20 @@ describe("ccam stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(claudeLane.id), "review");
});
it("positional numeric ID acts like --lane for commands that support it (e.g. ccam lanes pipeline)", async () => {
// Commands like `ccam lanes pipeline` treat a leading numeric arg as the lane ID.
// Other commands like `ccam stage` have their own positional argument and don't support it.
it("positional numeric ID acts like --lane for commands that support it (e.g. agenthub lanes pipeline)", async () => {
// Commands like `agenthub lanes pipeline` treat a leading numeric arg as the lane ID.
// Other commands like `agenthub stage` have their own positional argument and don't support it.
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`lane #${codexLane.id}`));
});
it("non-numeric CCAM_LANE_ID is ignored", async () => {
// If CCAM_LANE_ID is garbage (not a number), it should be skipped and
it("non-numeric AGENTHUB_LANE_ID is ignored", async () => {
// If AGENTHUB_LANE_ID is garbage (not a number), it should be skipped and
// fall through to --tool or env markers
const r = await cli(["stage", "ship"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: "not-a-number",
AGENTHUB_LANE_ID: "not-a-number",
CLAUDECODE: "1", // This env marker should pick claude lane
});
assert.equal(r.status, 0, r.stderr);
@@ -323,27 +323,27 @@ describe("ccam stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(codexLane.id), "ship");
});
it("a CCAM_LANE_ID naming no lane falls through instead of addressing it", async () => {
it("a AGENTHUB_LANE_ID naming no lane falls through instead of addressing it", async () => {
// A pane outlives the lane it was started for, and lane ids are reused
// after a delete. Trusting the number blind would either 404 or, worse,
// write this session's stage onto whatever lane inherited that id.
const r = await cli(["stage", "gate"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: "999999",
AGENTHUB_LANE_ID: "999999",
CLAUDECODE: "1",
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(claudeLane.id), "gate");
});
it("a CCAM_LANE_ID for a lane outside this directory is not used", async () => {
it("a AGENTHUB_LANE_ID for a lane outside this directory is not used", async () => {
// The pane's lane is the truth only while the command runs inside it; an
// agent that cd'd into a different repo must resolve by that directory.
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-elsewhere-"));
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-elsewhere-"));
const other = (await post("/api/lanes", { cwd: elsewhere, title: "elsewhere" })).body.lane;
const r = await cli(["stage", "review"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: String(other.id),
AGENTHUB_LANE_ID: String(other.id),
CLAUDECODE: "1",
});
assert.equal(r.status, 0, r.stderr);
@@ -352,9 +352,9 @@ describe("ccam stage in a directory shared by two lanes", () => {
});
});
describe("ccam lanes add", () => {
describe("agenthub lanes add", () => {
it("creates a lane and it appears in lanes list", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-add-"));
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
assert.equal(r.status, 0, r.stderr);
@@ -369,7 +369,7 @@ describe("ccam lanes add", () => {
});
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-no-pipeline-"));
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
assert.equal(r.status, 0, r.stderr);
@@ -385,7 +385,7 @@ describe("ccam lanes add", () => {
});
it("accepts --pipeline flag and creates lane with custom pipeline", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-custom-pipeline-"));
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-custom-pipeline-"));
try {
const r = await cli(
[
@@ -458,7 +458,7 @@ describe("ccam lanes add", () => {
});
it("reports the failure notes, not success, when provisioning fails", async () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-cli-unborn-repo-"));
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-unborn-repo-"));
try {
await git(["init", "-b", "main"], emptyRepo);
const r = await cli(
@@ -475,7 +475,7 @@ describe("ccam lanes add", () => {
});
});
describe("ccam lanes destructive lifecycle", () => {
describe("agenthub lanes destructive lifecycle", () => {
// Every test provisions its OWN lane. These tests used to share one mutable
// `managedLane` across describe blocks, which meant they only ever exercised
// whatever state the previous test happened to leave behind — the same
@@ -557,7 +557,7 @@ describe("ccam lanes destructive lifecycle", () => {
});
it("refuses to RESET an adopted lane but FORGETS it on remove, leaving the directory intact", async () => {
const adoptedDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-adopted-remove-"));
const adoptedDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-adopted-remove-"));
const preserved = path.join(adoptedDir, "preserve.txt");
fs.writeFileSync(preserved, "do not delete\n");
try {
@@ -602,9 +602,9 @@ describe("ccam lanes destructive lifecycle", () => {
});
});
describe("ccam lanes — inferred stage detection", () => {
describe("agenthub lanes — inferred stage detection", () => {
it("prints the inferred stage when detection leads the declared stage", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-lead-"));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-"));
try {
const created = await post("/api/lanes", { cwd: dir, title: "Detect Lead" });
const id = created.body.lane.id;
@@ -626,7 +626,7 @@ describe("ccam lanes — inferred stage detection", () => {
const r = await cli(["lanes"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in ccam lanes output");
assert.ok(line, "lane row not found in agenthub lanes output");
assert.match(line, /⇢ detected:tests/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -634,7 +634,7 @@ describe("ccam lanes — inferred stage detection", () => {
});
it("does not print an inferred stage when the declaration already leads", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-behind-"));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-behind-"));
try {
const created = await post("/api/lanes", { cwd: dir, title: "Detect Behind" });
const id = created.body.lane.id;
@@ -658,7 +658,7 @@ describe("ccam lanes — inferred stage detection", () => {
const r = await cli(["lanes"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in ccam lanes output");
assert.ok(line, "lane row not found in agenthub lanes output");
assert.doesNotMatch(line, /detected:/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -666,15 +666,15 @@ describe("ccam lanes — inferred stage detection", () => {
});
});
describe("ccam ship-full / ccam ship-quick", () => {
describe("agenthub ship-full / agenthub ship-quick", () => {
it("ship-quick prints the 8-stage pipeline's skill, ship-full the 16-stage one", async () => {
const quick = await cli(["ship-quick", "add", "an", "export", "button", "--print"], LANE_DIR);
assert.equal(quick.status, 0, quick.stderr);
assert.match(quick.stdout, /claude "\/ccam:ship-feature add an export button"/);
assert.match(quick.stdout, /claude "\/agenthub:ship-feature add an export button"/);
const full = await cli(["ship-full", "add", "an", "export", "button", "--print"], LANE_DIR);
assert.equal(full.status, 0, full.stderr);
assert.match(full.stdout, /claude "\/ccam:ship-feature-lane add an export button"/);
assert.match(full.stdout, /claude "\/agenthub:ship-feature-lane add an export button"/);
});
it("--print switches nothing: the lane keeps whatever template it was on", async () => {
@@ -689,21 +689,21 @@ describe("ccam ship-full / ccam ship-quick", () => {
it("exits non-zero with the command's own usage when no requirement is given", async () => {
const r = await cli(["ship-full"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /usage: ccam ship-full/);
assert.match(r.stderr, /usage: agenthub ship-full/);
const q = await cli(["ship-quick"], LANE_DIR);
assert.notEqual(q.status, 0);
assert.match(q.stderr, /usage: ccam ship-quick/);
assert.match(q.stderr, /usage: agenthub ship-quick/);
});
});
describe("ccam lanes node", () => {
describe("agenthub lanes node", () => {
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
const r = await cli(["lanes", "node"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /\bintake\b/);
assert.match(r.stdout, /\bdone\b/);
assert.match(r.stderr, /usage: ccam lanes node/);
assert.match(r.stderr, /usage: agenthub lanes node/);
});
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
@@ -717,7 +717,7 @@ describe("ccam lanes node", () => {
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"ccam" "lanes" "hook" "ci-gate"/);
assert.match(r.stdout, /"agenthub" "lanes" "hook" "ci-gate"/);
});
it("every node of both shipped templates declares a runnable command", async () => {
@@ -731,7 +731,7 @@ describe("ccam lanes node", () => {
it("matches a node by alias, so one slash command works on either template", async () => {
// `default` calls the step `implement`, `ship-feature` calls it
// `implementing` — /ccam:implement must not depend on which one is active.
// `implementing` — /agenthub:implement must not depend on which one is active.
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR);
assert.equal(ship.status, 0, ship.stderr);
@@ -751,12 +751,12 @@ describe("ccam lanes node", () => {
});
});
describe("ccam lanes ci status", () => {
describe("agenthub lanes ci status", () => {
let restorePath;
let bin;
before(() => {
bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-cli-bin-"));
bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-cli-bin-"));
fs.writeFileSync(
path.join(bin, "gh"),
`#!/usr/bin/env bash
+7 -7
View File
@@ -78,7 +78,7 @@ describe("pipelines", () => {
});
it("keeps the evidence of a stage declared under an alias", () => {
// `lane.stages` is keyed by the raw declared string, so `ccam stage
// `lane.stages` is keyed by the raw declared string, so `agenthub stage
// planning --evidence x` files the record under "planning" while the node
// is "plan". Looking it up by node id alone loses the evidence and paints
// a `done` node amber — silently, and only for agents that use an alias.
@@ -151,12 +151,12 @@ describe("per-node driver command", () => {
assert.equal(byId.intake, "/superpowers:brainstorming");
assert.equal(byId.implementing, "/superpowers:test-driven-development");
assert.equal(byId.gates, "ccam lanes hook ci-gate");
assert.equal(byId.gates, "agenthub lanes hook ci-gate");
// A terminal or gate node has no work of its own to run: reaching it IS
// the declaration, so its command is the declaration - which keeps
// `ccam lanes node <id>` runnable for every node in the template.
assert.equal(byId.done, "ccam stage done");
assert.equal(byId["e2e-feature-passed"], "ccam stage e2e-feature-passed");
// `agenthub lanes node <id>` runnable for every node in the template.
assert.equal(byId.done, "agenthub stage done");
assert.equal(byId["e2e-feature-passed"], "agenthub stage e2e-feature-passed");
});
it("defaults a template node that declares no command to the empty string", () => {
@@ -334,7 +334,7 @@ describe("lanes lib", () => {
assert.equal(p.liveness, "idle");
// The template's driving slash command rides along so the run console can
// pre-fill its prompt with it.
assert.equal(p.pipeline_skill, "/ccam:ship-feature");
assert.equal(p.pipeline_skill, "/agenthub:ship-feature");
lanes.deleteLane(l.id);
});
@@ -1080,7 +1080,7 @@ describe("stage detection", () => {
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id);
// With nothing declared, the last detection IS the lane's last known
// progress — blanking it left every non-CCAM repo's card 100% pending
// progress — blanking it left every non-AgentHub repo's card 100% pending
// forever. Still amber (`detected`), still never `done`.
const payload = lanes.lanePayload(lanes.getLane(l.id));
const tests = payload.pipeline_nodes.find((n) => n.id === "tests");
+1 -1
View File
@@ -10,7 +10,7 @@ const path = require("node:path");
const fs = require("node:fs");
const http = require("node:http");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-locks-api-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-locks-api-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
+17 -17
View File
@@ -89,22 +89,22 @@ describe("GET /api/metrics", () => {
// Core families present with HELP + TYPE headers.
for (const name of [
"ccam_up",
"ccam_build_info",
"ccam_process_uptime_seconds",
"ccam_process_resident_memory_bytes",
"ccam_sessions",
"ccam_agents",
"ccam_events_total",
"ccam_websocket_clients",
"ccam_remote_sources",
"ccam_tokens_total",
"agenthub_up",
"agenthub_build_info",
"agenthub_process_uptime_seconds",
"agenthub_process_resident_memory_bytes",
"agenthub_sessions",
"agenthub_agents",
"agenthub_events_total",
"agenthub_websocket_clients",
"agenthub_remote_sources",
"agenthub_tokens_total",
]) {
assert.ok(res.body.includes(`# HELP ${name} `), `HELP for ${name}`);
assert.ok(res.body.includes(`# TYPE ${name} `), `TYPE for ${name}`);
}
assert.equal(sampleValue(res.body, "ccam_up"), 1);
assert.equal(sampleValue(res.body, "agenthub_up"), 1);
// No sample line may carry a NaN/undefined value.
for (const line of res.body.split("\n")) {
if (!line || line.startsWith("#")) continue;
@@ -117,29 +117,29 @@ describe("GET /api/metrics", () => {
const res = await request("GET", "/api/metrics");
for (const status of ["active", "completed", "error", "abandoned"]) {
assert.notEqual(
sampleValue(res.body, "ccam_sessions", `status="${status}"`),
sampleValue(res.body, "agenthub_sessions", `status="${status}"`),
null,
`ccam_sessions status=${status} present`
`agenthub_sessions status=${status} present`
);
}
for (const status of ["working", "waiting", "completed", "error"]) {
assert.notEqual(
sampleValue(res.body, "ccam_agents", `status="${status}"`),
sampleValue(res.body, "agenthub_agents", `status="${status}"`),
null,
`ccam_agents status=${status} present`
`agenthub_agents status=${status} present`
);
}
});
it("reflects real data seeded through the hook API", async () => {
const before = sampleValue(await scrapeText(), "ccam_sessions", 'status="active"') || 0;
const before = sampleValue(await scrapeText(), "agenthub_sessions", 'status="active"') || 0;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "metrics-sess-1", cwd: "/tmp/metrics" },
});
const after = sampleValue(await scrapeText(), "ccam_sessions", 'status="active"');
const after = sampleValue(await scrapeText(), "agenthub_sessions", 'status="active"');
assert.equal(after, before + 1, "a new active session bumps the gauge by 1");
});
+1 -1
View File
@@ -11,7 +11,7 @@ const fs = require("node:fs");
const { describe, it, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-named-lock-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-named-lock-"));
process.env.LANES_ROOT = SUITE_ROOT;
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
+21 -21
View File
@@ -3,7 +3,7 @@
* makes a plugin install self-sufficient. Covers the fast path, the Node
* version gate, stale-lock reclaim, legacy hook removal (the duplicate-hook
* double-counting bug) and the CLI launcher's refusal to clobber a foreign
* `ccam`. Every path is exercised against injected temp directories — nothing
* `agenthub`. Every path is exercised against injected temp directories — nothing
* here touches the real $HOME.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,7 +14,7 @@ const fs = require("fs");
const os = require("os");
const path = require("path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-bootstrap-"));
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-bootstrap-"));
process.env.CLAUDE_HOME = TMP_HOME;
delete process.env.DASHBOARD_DATA_DIR;
@@ -135,17 +135,17 @@ describe("skill-plugin auto-install gate", () => {
// Both cases here must return WITHOUT reaching server/lib/skill-plugins.js —
// that module shells out to `claude plugin install`, and no test may.
beforeEach(resetRuntime);
after(() => delete process.env.CCAM_AUTO_INSTALL_PLUGINS);
after(() => delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS);
for (const flag of ["0", "false", "off"]) {
it(`is off when CCAM_AUTO_INSTALL_PLUGINS=${flag}`, () => {
process.env.CCAM_AUTO_INSTALL_PLUGINS = flag;
it(`is off when AGENTHUB_AUTO_INSTALL_PLUGINS=${flag}`, () => {
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = flag;
assert.equal(boot.autoInstallSkillPlugins(RT), "off");
});
}
it("runs once: a settled state is returned verbatim, not re-attempted", () => {
delete process.env.CCAM_AUTO_INSTALL_PLUGINS;
delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS;
boot.writeState({ skillPluginsSettled: "installed" }, RT);
// Verbatim matters: writeState records whatever comes back, so a marker
@@ -153,8 +153,8 @@ describe("skill-plugin auto-install gate", () => {
assert.equal(boot.autoInstallSkillPlugins(RT), "installed");
});
it("is off under the flag even when forced, so /ccam-update honours the opt-out", () => {
process.env.CCAM_AUTO_INSTALL_PLUGINS = "0";
it("is off under the flag even when forced, so /agenthub-update honours the opt-out", () => {
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = "0";
boot.writeState({ skillPluginsSettled: "failed" }, RT);
assert.equal(boot.autoInstallSkillPlugins(RT, { force: true }), "off");
});
@@ -200,7 +200,7 @@ describe("legacy hook cleanup", () => {
beforeEach(() => {
fs.rmSync(settings, { force: true });
fs.rmSync(`${settings}.ccam-bak`, { force: true });
fs.rmSync(`${settings}.agenthub-bak`, { force: true });
});
it("removes checkout-installed hook entries and backs the file up first", () => {
@@ -234,7 +234,7 @@ describe("legacy hook cleanup", () => {
assert.equal(after.hooks.PreToolUse.length, 1, "unrelated hooks must survive");
assert.match(JSON.stringify(after.hooks.PreToolUse), /other\/tool\.js/);
assert.equal(after.hooks.SessionStart, undefined, "an emptied list is dropped");
assert.ok(fs.existsSync(`${settings}.ccam-bak`), "must back up before writing");
assert.ok(fs.existsSync(`${settings}.agenthub-bak`), "must back up before writing");
});
it("never removes the plugin's own cache-resolved hook entries", () => {
@@ -249,7 +249,7 @@ describe("legacy hook cleanup", () => {
"plugins",
"cache",
"claude-code-agent-monitor-plugins",
"ccam",
"agenthub",
"abc123",
"scripts",
"hook-handler.js"
@@ -280,10 +280,10 @@ describe("legacy hook cleanup", () => {
assert.match(JSON.stringify(after.hooks.PreToolUse), /plugins.*cache/);
});
it("leaves a file with no CCAM hooks untouched and writes no backup", () => {
it("leaves a file with no AgentHub hooks untouched and writes no backup", () => {
fs.writeFileSync(settings, JSON.stringify({ hooks: { Stop: [{ hooks: [] }] } }));
assert.equal(boot.stripLegacyHooks(settings), 0);
assert.equal(fs.existsSync(`${settings}.ccam-bak`), false);
assert.equal(fs.existsSync(`${settings}.agenthub-bak`), false);
});
it("is a no-op when no settings file exists", () => {
@@ -291,17 +291,17 @@ describe("legacy hook cleanup", () => {
});
});
describe("ccam CLI launcher", () => {
describe("agenthub CLI launcher", () => {
const bin = path.join(TMP_HOME, "bin");
const target = path.join(bin, process.platform === "win32" ? "ccam.cmd" : "ccam");
const target = path.join(bin, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
beforeEach(() => {
fs.rmSync(bin, { recursive: true, force: true });
});
it("writes a launcher that points at the plugin's bin/ccam.js", () => {
it("writes a launcher that points at the plugin's bin/agenthub.js", () => {
assert.equal(boot.linkCli(bin), "written");
assert.match(fs.readFileSync(target, "utf8"), /bin[/\\]ccam\.js/);
assert.match(fs.readFileSync(target, "utf8"), /bin[/\\]agenthub\.js/);
});
it("is idempotent", () => {
@@ -309,9 +309,9 @@ describe("ccam CLI launcher", () => {
assert.equal(boot.linkCli(bin), "exists");
});
it("never clobbers a ccam it did not write (a linked checkout keeps winning)", () => {
it("never clobbers a agenthub it did not write (a linked checkout keeps winning)", () => {
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(target, '#!/bin/sh\nexec node /my/checkout/bin/ccam.js "$@"\n');
fs.writeFileSync(target, '#!/bin/sh\nexec node /my/checkout/bin/agenthub.js "$@"\n');
assert.equal(boot.linkCli(bin), "foreign");
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/);
});
@@ -319,10 +319,10 @@ describe("ccam CLI launcher", () => {
describe("plugin-open / plugin-bootstrap require cycle", () => {
// bootstrap() lazily requires plugin-open for buildClient, and plugin-open
// requires bootstrap at load. When plugin-open is the entry point (/ccam-open)
// requires bootstrap at load. When plugin-open is the entry point (/agenthub-open)
// that lazy require resolves to whatever sits on its module.exports at that
// instant — so the assignment must come before the entry block that calls
// bootstrap(), or /ccam-open dies with "buildClient is not a function".
// bootstrap(), or /agenthub-open dies with "buildClient is not a function".
const src = fs.readFileSync(
path.join(__dirname, "..", "..", "scripts", "plugin-open.js"),
"utf8"
@@ -1,7 +1,7 @@
/**
* @file Tests the `Skill plugins` row of scripts/plugin-doctor.js — the check
* that tells a fresh install its pipelines name slash commands no installed
* plugin provides (today: `superpowers`, which `/ccam:ship-feature-lane` drives
* plugin provides (today: `superpowers`, which `/agenthub:ship-feature-lane` drives
* step by step). The row must WARN and never FAIL: without that plugin the
* dashboard, hooks, CLI and MCP all still work, so failing a doctor run over it
* would cry wolf. Every case runs against an injected temp CLAUDE_HOME.
@@ -14,7 +14,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-doctor-skills-"));
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-skills-"));
process.env.CLAUDE_HOME = TMP_HOME;
const { diagnose } = require("../../scripts/plugin-doctor");
+6 -6
View File
@@ -40,10 +40,10 @@ function listMd(dir) {
}
}
// The marketplace mixes two shapes: the root-source `ccam` plugin (the whole
// The marketplace mixes two shapes: the root-source `agenthub` plugin (the whole
// repo — hooks, server, CLI, MCP) and ten subdirectory plugins. The root
// plugin's commands live in plugins/ccam/, which is therefore NOT a plugin dir.
const ROOT_PLUGIN = "ccam";
// plugin's commands live in plugins/agenthub/, which is therefore NOT a plugin dir.
const ROOT_PLUGIN = "agenthub";
describe("plugin marketplace", () => {
const marketplace = readJson(MARKETPLACE);
@@ -181,7 +181,7 @@ describe("plugin marketplace", () => {
const manifest = readJson(path.join(REPO_ROOT, ".claude-plugin", "plugin.json"));
it("is declared with the repo root as its source", () => {
assert.ok(entry, "the root ccam entry is missing from marketplace.json");
assert.ok(entry, "the root agenthub entry is missing from marketplace.json");
assert.equal(entry.source, "./");
assert.equal(entry.path, undefined, "a root-source entry must not also carry a path");
assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0);
@@ -231,8 +231,8 @@ describe("plugin marketplace", () => {
it("lists every command file that exists — an unlisted one never loads", () => {
// `commands` is an explicit array, not a directory scan: a file added to
// plugins/ccam/commands/ and forgotten here is invisible to every user.
const dir = path.join(REPO_ROOT, "plugins/ccam/commands");
// plugins/agenthub/commands/ and forgotten here is invisible to every user.
const dir = path.join(REPO_ROOT, "plugins/agenthub/commands");
const onDisk = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
const listed = new Set(manifest.commands.map((rel) => path.basename(rel)));
for (const f of onDisk) assert.ok(listed.has(f), `${f} is not in plugin.json commands`);
+1 -1
View File
@@ -9,7 +9,7 @@ const path = require("node:path");
const fs = require("node:fs");
const http = require("node:http");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-proof-api-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-proof-api-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
+1 -1
View File
@@ -9,7 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-proof-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-proof-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
+11 -11
View File
@@ -48,13 +48,13 @@ describe("pty-attach", () => {
tmux.__reset();
});
it("rejects a runId that doesn't match ccam-lane-<digits>", () => {
it("rejects a runId that doesn't match agenthub-lane-<digits>", () => {
assert.throws(() => ptyAttach.validateRunId("../../etc/passwd"), /EBADRUNID/);
assert.throws(() => ptyAttach.validateRunId("some-other-session"), /EBADRUNID/);
});
it("accepts a well-formed runId", () => {
assert.doesNotThrow(() => ptyAttach.validateRunId("ccam-lane-42"));
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
});
it("turns tmux mouse mode on for the session it attaches to", () => {
@@ -63,22 +63,22 @@ describe("pty-attach", () => {
calls.push(args);
return "";
});
ptyAttach.attach(makeFakeWs(), "ccam-lane-1", { cols: 80, rows: 24 });
assert.deepEqual(calls[0], ["set-option", "-t", "ccam-lane-1", "mouse", "on"]);
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
assert.deepEqual(calls[0], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
});
it("still attaches even if the mouse-mode set-option call fails", () => {
tmux.__setExecImpl(() => {
throw new Error("no such session: ccam-lane-1");
throw new Error("no such session: agenthub-lane-1");
});
assert.doesNotThrow(() =>
ptyAttach.attach(makeFakeWs(), "ccam-lane-1", { cols: 80, rows: 24 })
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 })
);
});
it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
fakePty.__emitter.emit("data", "hello from claude");
assert.equal(ws.__sent.length, 1);
@@ -91,7 +91,7 @@ describe("pty-attach", () => {
it("routes a JSON text frame with type resize to pty.resize", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
ws.__emitter.emit(
"message",
Buffer.from(JSON.stringify({ type: "resize", cols: 100, rows: 40 })),
@@ -108,7 +108,7 @@ describe("pty-attach", () => {
// arrives here as non-binary. This must reach the pty, not be dropped as
// an unparseable control message.
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
ws.__emitter.emit("message", Buffer.from("ls -la\r"), { binary: false });
assert.deepEqual(fakePty.__writes[0], "ls -la\r");
});
@@ -119,7 +119,7 @@ describe("pty-attach", () => {
ws.close = () => {
closed = true;
};
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
fakePty.__emitter.emit("exit", { exitCode: 0 });
const last = ws.__sent[ws.__sent.length - 1];
assert.equal(last.binary, false);
@@ -129,7 +129,7 @@ describe("pty-attach", () => {
it("kills the PTY attach process when the WS connection closes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
let killed = false;
fakePty.kill = () => {
killed = true;
+17 -17
View File
@@ -30,7 +30,7 @@ describe("pty-run", () => {
tmux.__reset();
});
it("spawnRun creates a new tmux session named ccam-lane-<id> when none exists", () => {
it("spawnRun creates a new tmux session named agenthub-lane-<id> when none exists", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
@@ -42,14 +42,14 @@ describe("pty-run", () => {
return "";
});
const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" });
assert.equal(handle.id, "ccam-lane-42");
assert.equal(handle.id, "agenthub-lane-42");
const newSessionCall = calls.find((c) => c[0] === "new-session");
assert.ok(newSessionCall, "expected a new-session call");
assert.deepEqual(newSessionCall.slice(0, 6), [
"new-session",
"-d",
"-s",
"ccam-lane-42",
"agenthub-lane-42",
"-c",
"/tmp/repo",
]);
@@ -66,7 +66,7 @@ describe("pty-run", () => {
return ""; // has-session succeeds → already running
});
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
assert.equal(handle.id, "ccam-lane-7");
assert.equal(handle.id, "agenthub-lane-7");
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
});
@@ -82,7 +82,7 @@ describe("pty-run", () => {
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
assert.ok(literal, "expected a literal send-keys with the command line");
assert.match(literal[4], /^CCAM_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
assert.match(literal[4], /^AGENTHUB_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
assert.ok(
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
"expected the command to be submitted"
@@ -100,7 +100,7 @@ describe("pty-run", () => {
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
});
it("spawnRun puts CCAM_LANE_ID in the new session's environment", () => {
it("spawnRun puts AGENTHUB_LANE_ID in the new session's environment", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
@@ -114,12 +114,12 @@ describe("pty-run", () => {
pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex" });
const at = newSessionArgv.indexOf("-e");
assert.ok(at > -1, "expected a tmux -e environment flag");
assert.equal(newSessionArgv[at + 1], "CCAM_LANE_ID=33");
assert.equal(newSessionArgv[at + 1], "AGENTHUB_LANE_ID=33");
// Must stay ahead of the `--` that starts the pane's own argv.
assert.ok(at < newSessionArgv.indexOf("--"));
});
it("spawnRun prefixes CCAM_LANE_ID when typing into an existing shell pane", () => {
it("spawnRun prefixes AGENTHUB_LANE_ID when typing into an existing shell pane", () => {
let literal = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "bash\n";
@@ -129,7 +129,7 @@ describe("pty-run", () => {
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
// An assignment prefix on the same line: tmux's -e only applies at session
// creation, and a live shell takes its env from the command line.
assert.match(literal, /^CCAM_LANE_ID='12' 'codex'/);
assert.match(literal, /^AGENTHUB_LANE_ID='12' 'codex'/);
});
it("spawnRun with resumeSessionId passes --resume in argv", () => {
@@ -189,33 +189,33 @@ describe("pty-run", () => {
calls.push(args);
return "";
});
assert.equal(pty.killRun("ccam-lane-5"), true);
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "ccam-lane-5"));
assert.equal(pty.killRun("agenthub-lane-5"), true);
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "agenthub-lane-5"));
});
it("listRuns reflects live tmux-session state, not cached memory", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "ccam-lane-1\nccam-lane-2\n";
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
return "";
});
const first = pty.listRuns();
assert.deepEqual(first.map((r) => r.id).sort(), ["ccam-lane-1", "ccam-lane-2"]);
assert.deepEqual(first.map((r) => r.id).sort(), ["agenthub-lane-1", "agenthub-lane-2"]);
// Session killed out-of-band (not through killRun) — next list() call
// must self-correct, proving state is computed, not stored.
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "ccam-lane-1\n";
if (args[0] === "list-sessions") return "agenthub-lane-1\n";
return "";
});
const second = pty.listRuns();
assert.deepEqual(
second.map((r) => r.id),
["ccam-lane-1"]
["agenthub-lane-1"]
);
});
it("laneIdFromRunId parses the numeric lane id back out", () => {
assert.equal(pty.laneIdFromRunId("ccam-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
});
@@ -242,7 +242,7 @@ describe("pty-run", () => {
initialPrompt: "the live prompt",
});
const retrieved = pty.getRun(handle.id);
assert.equal(retrieved.id, "ccam-lane-99");
assert.equal(retrieved.id, "agenthub-lane-99");
assert.equal(retrieved.promptPreview, "the live prompt");
assert.equal(retrieved.status, "running");
});
+5 -5
View File
@@ -206,7 +206,7 @@ describe("remote-sync command builders", () => {
});
it("connectionSuccessMessage reflects explicit vs auto-detected WSL", () => {
const wslProbe = "wsl.exe -e sh -c 'test -d ~/.claude/projects && echo CCAM_OK'";
const wslProbe = "wsl.exe -e sh -c 'test -d ~/.claude/projects && echo AGENTHUB_OK'";
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: "wsl:~/.claude" }, wslProbe),
/wsl:~\/\.claude/
@@ -220,7 +220,7 @@ describe("remote-sync command builders", () => {
/auto-detected/i
);
assert.match(
remoteSync.connectionSuccessMessage({ remote_home: null }, "sh -c 'echo CCAM_OK'"),
remoteSync.connectionSuccessMessage({ remote_home: null }, "sh -c 'echo AGENTHUB_OK'"),
/Remote Claude Code history found/
);
});
@@ -263,7 +263,7 @@ describe("remote-sync command builders", () => {
it("uses sh probe for POSIX absolute remote homes", () => {
const probes = remoteSync.connectionProbeCommands({ remote_home: "/opt/cc" });
assert.deepEqual(probes, [
"sh -c 'test -d /opt/cc/projects && echo CCAM_OK || echo CCAM_NO_DIR'",
"sh -c 'test -d /opt/cc/projects && echo AGENTHUB_OK || echo AGENTHUB_NO_DIR'",
]);
});
it("accepts Windows-style remote_home with forward slashes", () => {
@@ -337,7 +337,7 @@ describe("remote-sync command builders", () => {
assert.equal(remoteSync.remoteProjectsPath({ remote_home: "/opt/cc" }), "/opt/cc/projects");
});
it("identifies top-level session ids in a mirrored tree (skips subagents)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-staged-"));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-staged-"));
const proj = path.join(dir, "-Users-x-proj");
fs.mkdirSync(path.join(proj, "sess-1", "subagents"), { recursive: true });
fs.writeFileSync(path.join(proj, "sess-1.jsonl"), "{}\n");
@@ -564,7 +564,7 @@ describe("reconcileRemoteSessionStatus", () => {
let stageRoot;
before(() => {
stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-recon-"));
stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-recon-"));
});
after(() => {
try {
+3 -3
View File
@@ -136,7 +136,7 @@ describe("/api/run", () => {
});
const spawned = await fetchJson("/api/run", { method: "POST", body: { laneId: 9, cwd: TMP } });
assert.equal(spawned.status, 201);
assert.equal(spawned.body.id, "ccam-lane-9");
assert.equal(spawned.body.id, "agenthub-lane-9");
const fetched = await fetchJson(`/api/run/${spawned.body.id}`);
assert.equal(fetched.status, 200);
@@ -145,7 +145,7 @@ describe("/api/run", () => {
it("DELETE /:id kills a live tmux session", async () => {
tmux.__setExecImpl(() => ""); // has-session succeeds; kill-session succeeds
const { status, body } = await fetchJson("/api/run/ccam-lane-9", { method: "DELETE" });
const { status, body } = await fetchJson("/api/run/agenthub-lane-9", { method: "DELETE" });
assert.equal(status, 200);
assert.deepEqual(body, { ok: true });
});
@@ -159,7 +159,7 @@ describe("/api/run", () => {
}
return "";
});
const { status } = await fetchJson("/api/run/ccam-lane-999", { method: "DELETE" });
const { status } = await fetchJson("/api/run/agenthub-lane-999", { method: "DELETE" });
assert.equal(status, 404);
});
+3 -3
View File
@@ -173,13 +173,13 @@ describe("probeLiveCwds — probe availability", () => {
assert.equal(r.cwds.size, 0);
});
it("is disabled inside a container (CCAM_FORCE_CONTAINER)", () => {
it("is disabled inside a container (AGENTHUB_FORCE_CONTAINER)", () => {
delete process.env.DASHBOARD_LIVENESS_PROBE;
process.env.CCAM_FORCE_CONTAINER = "1";
process.env.AGENTHUB_FORCE_CONTAINER = "1";
try {
assert.equal(realProbe().available, false);
} finally {
delete process.env.CCAM_FORCE_CONTAINER;
delete process.env.AGENTHUB_FORCE_CONTAINER;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
}
});
+1 -1
View File
@@ -21,7 +21,7 @@ const path = require("path");
const fs = require("fs");
const os = require("os");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-yield-"));
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-sync-yield-"));
process.env.CLAUDE_HOME = TMP_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP_HOME, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP_HOME, "data");
+1 -1
View File
@@ -17,7 +17,7 @@ const path = require("path");
const fs = require("fs");
const os = require("os");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-home-"));
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-sync-home-"));
process.env.CLAUDE_HOME = TMP_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP_HOME, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP_HOME, "data");
+5 -5
View File
@@ -17,8 +17,8 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-skill-plugins-home-"));
const TMP_PIPELINES = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-skill-plugins-tpl-"));
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skill-plugins-home-"));
const TMP_PIPELINES = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skill-plugins-tpl-"));
process.env.CLAUDE_HOME = TMP_HOME;
const skillPlugins = require("../lib/skill-plugins");
@@ -89,10 +89,10 @@ describe("requiredSkillPlugins", () => {
assert.ok(required.get("superpowers").has("writing-plans"));
});
it("ignores ccam's own commands and anything that is not a /plugin:skill", () => {
it("ignores agenthub's own commands and anything that is not a /plugin:skill", () => {
useTemplate({
id: "tpl-shell",
skill: "/ccam:ship-feature",
skill: "/agenthub:ship-feature",
nodes: [
{ id: "a", command: "npm test" },
{ id: "b", command: "/reload-plugins" },
@@ -102,7 +102,7 @@ describe("requiredSkillPlugins", () => {
const required = skillPlugins.requiredSkillPlugins();
assert.equal(required.has("ccam"), false, "ccam is the plugin asking");
assert.equal(required.has("agenthub"), false, "agenthub is the plugin asking");
// Only the built-ins' superpowers requirement survives.
assert.deepEqual([...required.keys()], ["superpowers"]);
});
+1 -1
View File
@@ -8,7 +8,7 @@ const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-skills-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skills-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
const { describe, it, after } = require("node:test");
+12 -12
View File
@@ -464,7 +464,7 @@ test.describe("tests rule covers the runners this repo actually uses", () => {
for (const command of [
"node scripts/generate-openapi-yaml.js",
"node -e \"console.log('hello')\"",
"node bin/ccam.js start",
"node bin/agenthub.js start",
]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.notEqual(r && r.nodeId, "tests", command);
@@ -551,25 +551,25 @@ test.describe("ship-feature pipeline template", () => {
"Agent",
{ description: "Design the fix", prompt: "Propose an approach.", subagent_type: "Plan" },
],
["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
["intake", "Bash", { command: "agenthub feature activate lane-stage-detect" }],
["plan", "Skill", { skill: "superpowers:writing-plans" }],
["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
["implementing", "Skill", { skill: "superpowers:test-driven-development" }],
["implementing", "Skill", { skill: "superpowers:systematic-debugging" }],
["implementing", "Edit", { file_path: "server/lib/app.js" }],
["implementing", "Write", { file_path: "server/lib/app.js" }],
["gates", "Bash", { command: "ccam lanes hook ci-gate" }],
["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }],
["gates", "Bash", { command: "agenthub lanes hook ci-gate" }],
["gates", "Bash", { command: "agenthub lanes sync-base --check feat/foo" }],
// Stage 1b's independent-test subagent runs the repo's raw test command
// directly, not through `ccam` — without this rule that pass has no
// detection fallback if the agent skips `ccam stage tests`.
// directly, not through `agenthub` — without this rule that pass has no
// detection fallback if the agent skips `agenthub stage tests`.
["gates", "Bash", { command: "npm run test:server" }],
["e2e-feature", "Bash", { command: "ccam lanes up --qc" }],
["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }],
["e2e-feature", "Bash", { command: "agenthub lanes up --qc" }],
["e2e-feature", "Bash", { command: "agenthub lanes hook e2e" }],
["review", "Skill", { skill: "code-review" }],
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
["qc", "Agent", { subagent_type: "qc-local" }],
["qc", "Bash", { command: "ccam lanes proof-link" }],
["qc", "Bash", { command: "agenthub lanes proof-link" }],
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
@@ -641,14 +641,14 @@ test.describe("ship-feature pipeline template", () => {
}
});
test.it("does not read the word push, or an unrelated ccam call, as a later stage", () => {
test.it("does not read the word push, or an unrelated agenthub call, as a later stage", () => {
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
for (const command of [
'git commit -m "do not push this yet"',
"ccam lanes logs 3 e2e",
"ccam stage gates --status running",
"agenthub lanes logs 3 e2e",
"agenthub stage gates --status running",
"gh pr diff 42",
]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
+17 -13
View File
@@ -16,7 +16,7 @@ describe("tmux wrapper", () => {
it("hasSession returns true when execFileSync exits 0", () => {
tmux.__setExecImpl(() => "");
assert.equal(tmux.hasSession("ccam-lane-1"), true);
assert.equal(tmux.hasSession("agenthub-lane-1"), true);
});
it("hasSession returns false when execFileSync throws", () => {
@@ -25,7 +25,7 @@ describe("tmux wrapper", () => {
e.status = 1;
throw e;
});
assert.equal(tmux.hasSession("ccam-lane-1"), false);
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
});
it("newSession builds the correct argv", () => {
@@ -34,12 +34,16 @@ describe("tmux wrapper", () => {
calls.push(args);
return "";
});
tmux.newSession({ name: "ccam-lane-1", cwd: "/tmp/repo", argv: ["claude", "--model", "opus"] });
tmux.newSession({
name: "agenthub-lane-1",
cwd: "/tmp/repo",
argv: ["claude", "--model", "opus"],
});
assert.deepEqual(calls[0], [
"new-session",
"-d",
"-s",
"ccam-lane-1",
"agenthub-lane-1",
"-c",
"/tmp/repo",
"--",
@@ -47,7 +51,7 @@ describe("tmux wrapper", () => {
"--model",
"opus",
]);
assert.deepEqual(calls[1], ["set-option", "-t", "ccam-lane-1", "mouse", "on"]);
assert.deepEqual(calls[1], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
});
it("sendText types the literal string and presses Enter, with no quoting", () => {
@@ -56,15 +60,15 @@ describe("tmux wrapper", () => {
calls.push(args);
return "";
});
tmux.sendText("ccam-lane-1", 'Reminder: run "ccam stage implement"');
tmux.sendText("agenthub-lane-1", 'Reminder: run "agenthub stage implement"');
assert.deepEqual(calls[0], [
"send-keys",
"-t",
"ccam-lane-1",
"agenthub-lane-1",
"-l",
'Reminder: run "ccam stage implement"',
'Reminder: run "agenthub stage implement"',
]);
assert.deepEqual(calls[1], ["send-keys", "-t", "ccam-lane-1", "Enter"]);
assert.deepEqual(calls[1], ["send-keys", "-t", "agenthub-lane-1", "Enter"]);
});
it("killSession never throws when the session is already gone", () => {
@@ -73,12 +77,12 @@ describe("tmux wrapper", () => {
e.status = 1;
throw e;
});
assert.doesNotThrow(() => tmux.killSession("ccam-lane-1"));
assert.doesNotThrow(() => tmux.killSession("agenthub-lane-1"));
});
it("listSessions filters by prefix and ignores unrelated sessions", () => {
tmux.__setExecImpl(() => "ccam-lane-1\nccam-lane-2\nsome-other-session\n");
assert.deepEqual(tmux.listSessions("ccam-lane-"), ["ccam-lane-1", "ccam-lane-2"]);
tmux.__setExecImpl(() => "agenthub-lane-1\nagenthub-lane-2\nsome-other-session\n");
assert.deepEqual(tmux.listSessions("agenthub-lane-"), ["agenthub-lane-1", "agenthub-lane-2"]);
});
it("listSessions returns [] when tmux has no sessions at all (exit 1)", () => {
@@ -87,7 +91,7 @@ describe("tmux wrapper", () => {
e.status = 1;
throw e;
});
assert.deepEqual(tmux.listSessions("ccam-lane-"), []);
assert.deepEqual(tmux.listSessions("agenthub-lane-"), []);
});
it("isTmuxAvailable reflects whether the binary resolves on PATH", () => {
+1 -1
View File
@@ -48,7 +48,7 @@ describe("websocket shutdown race", () => {
// reference. An unguarded handleUpgrade here takes the process down, so
// simply reaching the assertions below is the test passing.
await rawUpgrade(port, "/ws");
await rawUpgrade(port, "/ws-pty/ccam-lane-1");
await rawUpgrade(port, "/ws-pty/agenthub-lane-1");
assert.equal(ws.getConnectionCount(), 0);
await new Promise((r) => server.close(r));
+12 -9
View File
@@ -13,7 +13,7 @@ const path = require("node:path");
const fs = require("node:fs");
const http = require("node:http");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-usage-api-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-usage-api-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
@@ -175,33 +175,36 @@ describe("GET /api/workflows/skill-usage", () => {
// Two full calls of the same skill: each emits Pre + Post, so a naive
// COUNT(*) would report four.
for (let i = 0; i < 2; i += 1) {
ev("PreToolUse", "Skill", { tool_input: { skill: "ccam:ship-feature" } });
ev("PostToolUse", "Skill", { tool_input: { skill: "ccam:ship-feature" } });
ev("PreToolUse", "Skill", { tool_input: { skill: "agenthub:ship-feature" } });
ev("PostToolUse", "Skill", { tool_input: { skill: "agenthub:ship-feature" } });
}
ev("PreToolUse", "Skill", { tool_input: { skill: "daily-report" } });
ev("PreToolUse", "mcp__plugin_ccam_ccam__dashboard_health_check", {});
ev("PostToolUse", "mcp__plugin_ccam_ccam__dashboard_health_check", {});
ev("PreToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
ev("PostToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
});
it("counts one call per invocation, not one per hook event", async () => {
const res = await get("/api/workflows/skill-usage");
assert.equal(res.status, 200);
const ship = res.body.skills.find((s) => s.name === "ccam:ship-feature");
const ship = res.body.skills.find((s) => s.name === "agenthub:ship-feature");
assert.equal(ship.count, 2);
assert.equal(ship.sessions, 1);
});
it("attributes a prefixed skill to its plugin and leaves a bare one unattributed", async () => {
const res = await get("/api/workflows/skill-usage");
assert.equal(res.body.skills.find((s) => s.name === "ccam:ship-feature").plugin, "ccam");
assert.equal(
res.body.skills.find((s) => s.name === "agenthub:ship-feature").plugin,
"agenthub"
);
assert.equal(res.body.skills.find((s) => s.name === "daily-report").plugin, null);
});
it("adds a plugin's own MCP tool calls to its totals", async () => {
const res = await get("/api/workflows/skill-usage");
const ccam = res.body.plugins.find((p) => p.name === "ccam");
const agenthub = res.body.plugins.find((p) => p.name === "agenthub");
assert.deepEqual(
{ skills: ccam.skills, skillCalls: ccam.skillCalls, mcpCalls: ccam.mcpCalls },
{ skills: agenthub.skills, skillCalls: agenthub.skillCalls, mcpCalls: agenthub.mcpCalls },
{ skills: 1, skillCalls: 2, mcpCalls: 1 }
);
});
+1 -1
View File
@@ -13,7 +13,7 @@ const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-wt-"));
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-wt-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes");
const wt = require("../lib/worktree");
+3 -3
View File
@@ -1,14 +1,14 @@
{
"id": "default",
"name": "Default feature pipeline",
"skill": "/ccam:ship-feature",
"skill": "/agenthub:ship-feature",
"nodes": [
{
"id": "intake",
"label": "Intake",
"icon": "📝",
"gate": false,
"command": "ccam stage intake",
"command": "agenthub stage intake",
"aliases": [
"assigned",
"claimed",
@@ -176,7 +176,7 @@
"label": "Done",
"icon": "✅",
"gate": false,
"command": "ccam stage done",
"command": "agenthub stage done",
"aliases": [
"complete",
"completed",
+14 -14
View File
@@ -1,7 +1,7 @@
{
"id": "ship-feature",
"name": "Ship feature (lane pipeline)",
"skill": "/ccam:ship-feature-lane",
"skill": "/agenthub:ship-feature-lane",
"nodes": [
{
"id": "intake",
@@ -22,7 +22,7 @@
},
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*\\bfeature activate\\b"
"match": "\\bagenthub\\b[^;&|]*\\bfeature activate\\b"
},
{
"tool": "Agent",
@@ -90,7 +90,7 @@
"label": "CI gates + preflight",
"icon": "🧪",
"gate": true,
"command": "ccam lanes hook ci-gate",
"command": "agenthub lanes hook ci-gate",
"aliases": [
"pre-push-gate",
"tests",
@@ -100,7 +100,7 @@
"detect": [
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)"
"match": "\\bagenthub\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)"
},
{
"tool": "Bash",
@@ -113,7 +113,7 @@
"label": "E2e on feature branch",
"icon": "🧪",
"gate": false,
"command": "ccam lanes up --qc",
"command": "agenthub lanes up --qc",
"aliases": [
"e2e",
"e2e-scoped",
@@ -123,7 +123,7 @@
"detect": [
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*(\\bup --qc\\b|\\bhook e2e\\b)"
"match": "\\bagenthub\\b[^;&|]*(\\bup --qc\\b|\\bhook e2e\\b)"
}
]
},
@@ -132,7 +132,7 @@
"label": "E2e passed",
"icon": "🧪",
"gate": true,
"command": "ccam stage e2e-feature-passed",
"command": "agenthub stage e2e-feature-passed",
"aliases": [
"e2e-passed"
]
@@ -164,7 +164,7 @@
"label": "QC plan",
"icon": "📋",
"gate": false,
"command": "ccam stage qc-plan",
"command": "agenthub stage qc-plan",
"aliases": []
},
{
@@ -172,7 +172,7 @@
"label": "Browser QC",
"icon": "🔍",
"gate": true,
"command": "ccam lanes proof-link",
"command": "agenthub lanes proof-link",
"aliases": [],
"detect": [
{
@@ -181,7 +181,7 @@
},
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*\\blanes proof-link\\b"
"match": "\\bagenthub\\b[^;&|]*\\blanes proof-link\\b"
}
]
},
@@ -252,7 +252,7 @@
"label": "Reported",
"icon": "📣",
"gate": false,
"command": "ccam stage reported",
"command": "agenthub stage reported",
"aliases": []
},
{
@@ -260,7 +260,7 @@
"label": "Watching PR",
"icon": "👁",
"gate": false,
"command": "ccam lanes ci status",
"command": "agenthub lanes ci status",
"aliases": [
"pr-comment-fix"
],
@@ -276,7 +276,7 @@
"label": "Merged — post-verify",
"icon": "🔗",
"gate": false,
"command": "ccam stage merged",
"command": "agenthub stage merged",
"aliases": []
},
{
@@ -284,7 +284,7 @@
"label": "Done",
"icon": "✅",
"gate": false,
"command": "ccam stage done",
"command": "agenthub stage done",
"aliases": [
"complete",
"completed"
@@ -3,13 +3,13 @@
# db-create.sh – Create this lane's Postgres database inside a docker-compose
# service, via `createdb`. Ported verbatim from Shipyard's own lane-bootstrap
# / lane-up / lane-reset scripts, so a Postgres-via-compose repo gets parity
# by copying this template into its own .ccam/profile/hooks/.
# by copying this template into its own .agenthub/profile/hooks/.
#
# Called as: db-create.sh <db-name> (also available as $DB_NAME)
# Requires, declared in the repo's own profile.env:
# COMPOSE_FILE path to the docker-compose file (e.g. "$LANE_DIR/docker-compose.yml")
# DB_SERVICE the compose service name running Postgres
# Provided by CCAM's hook environment (server/lib/secrets.js):
# Provided by AgentHub's hook environment (server/lib/secrets.js):
# PG_USER (PG_PASS is deliberately not exported — trust auth inside the
# compose network needs no password for this call)
# ─────────────────────────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@
# ─────────────────────────────────────────────────────────────────────────────
# db-drop.sh – Drop this lane's Postgres database inside a docker-compose
# service, via `dropdb --if-exists`. Ported verbatim from Shipyard's own
# lane-reset / lane-remove scripts. CCAM only ever calls this with a name it
# lane-reset / lane-remove scripts. AgentHub only ever calls this with a name it
# derived itself (server/lib/lane-services.js:dropDatabase), so this script
# never needs to re-validate its argument.
#
@@ -1,5 +1,5 @@
# postgres-compose profile template — data-isolation additions (A2).
# Copy the declarations you need into your repo's own .ccam/profile/profile.env
# Copy the declarations you need into your repo's own .agenthub/profile/profile.env
# alongside db-create.sh / db-drop.sh from this template's hooks/ directory.
DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB
@@ -11,13 +11,13 @@ REDIS=1 # 1 = allocate a logical Redis index = slot
ENV_FILES="backend/.env" # file(s) to seed, relative to the lane
ENV_SOURCE="backend/.env" # source path in the source repo
ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys CCAM overwrites per lane
ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys AgentHub overwrites per lane
ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on --force
UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR
# Read by db-create.sh / db-drop.sh, NOT by CCAM itself:
# Read by db-create.sh / db-drop.sh, NOT by AgentHub itself:
COMPOSE_FILE="docker-compose.yml" # relative to the lane's working copy
DB_SERVICE="postgres" # the compose service name running Postgres
# Machine-level credentials (PG_HOST/PORT/USER/PASS, REDIS_HOST/PORT) come
# from ~/.ccam/secrets.env, never from here — see server/lib/secrets.js.
# from ~/.agenthub/secrets.env, never from here — see server/lib/secrets.js.
+4 -4
View File
@@ -460,7 +460,7 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id);
`);
// Managed lanes own a git worktree CCAM created and may be destroyed; adopted
// Managed lanes own a git worktree AgentHub created and may be destroyed; adopted
// lanes merely point at a directory the user already had and never may be.
// Existing rows default to 'adopted', so no lane gains a destructive path by
// upgrading. Probe each column independently so a partial migration (e.g. process
@@ -546,9 +546,9 @@ try {
// Migrate: per-lane runtime allocation. `slot` is the small integer every runtime
// fact derives from (ports now; database name and Redis index later) — the
// numbering Shipyard gets for free from its fixed lane1..lane9 directories and
// CCAM, keyed by cwd, has to allocate. `ports` is a JSON map name -> port,
// AgentHub, keyed by cwd, has to allocate. `ports` is a JSON map name -> port,
// recording the number a lane ACTUALLY got: the preferred `base + slot` may be
// taken by something outside CCAM, so the allocator steps aside and the real
// taken by something outside AgentHub, so the allocator steps aside and the real
// number has to survive a restart or the next boot would move a live lane.
//
// Allocation is lazy — a lane that is only ever watched never takes a slot — so
@@ -571,7 +571,7 @@ db.prepare(
// Migrate: per-feature state (B). `active_feature_id` points at the
// lane_features row currently "live" (unarchived) for this lane — null for a
// lane that has never called `ccam feature activate`, which is why this
// lane that has never called `agenthub feature activate`, which is why this
// column is nullable and every downstream reader of a lane row is unaffected
// by its addition. ON DELETE SET NULL, not CASCADE: deleting the ACTIVE
// feature row (which normally only happens via cascade when the LANE itself
+1 -1
View File
@@ -405,7 +405,7 @@ function startBackgroundServices() {
console.warn("codex poll failed to start:", err.message);
}
// Safety net: a codex lane only declares stage when the session calls
// `ccam stage` itself. When auto-detection has clearly moved on without a
// `agenthub stage` itself. When auto-detection has clearly moved on without a
// matching declaration, nudge the session's own pane to make the real call
// — never writes stage itself, so the declared-wins invariant is untouched.
try {
+2 -2
View File
@@ -26,7 +26,7 @@ const crypto = require("crypto");
* bundles but low enough to stop most zip-bomb attacks from filling disk.
*/
const MAX_EXTRACT_BYTES = parseInt(
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES || String(4 * 1024 * 1024 * 1024),
process.env.AGENTHUB_IMPORT_MAX_EXTRACT_BYTES || String(4 * 1024 * 1024 * 1024),
10
);
@@ -63,7 +63,7 @@ function safeJoin(root, entryName) {
* Create a unique temp directory for extraction under the OS tmpdir.
* Caller is responsible for cleanup via `rmTempDir`.
*/
function mkTempDir(prefix = "ccam-import-") {
function mkTempDir(prefix = "agenthub-import-") {
const dir = path.join(os.tmpdir(), prefix + crypto.randomBytes(6).toString("hex"));
fs.mkdirSync(dir, { recursive: true });
return dir;
+2 -2
View File
@@ -264,8 +264,8 @@ const readOutputStyles = readSimpleMdSurface("output-styles");
* Absolute `.md` files a plugin contributes for one surface: the conventional
* `<root>/<subdir>` directory PLUS whatever `plugin.json` points at for that
* key. Both matter — a manifest entry may be a single file or a directory, and
* a plugin that declares its commands explicitly (this repo's own `ccam` lists
* three files under `./plugins/ccam/commands/`) keeps NOTHING in `commands/`,
* a plugin that declares its commands explicitly (this repo's own `agenthub` lists
* three files under `./plugins/agenthub/commands/`) keeps NOTHING in `commands/`,
* so reading only the conventional directory reported zero for it.
*
* A manifest is data from a third-party plugin, so a declared path that escapes
+1 -1
View File
@@ -2,7 +2,7 @@
* @file Turns a GitHub Actions run for one ref into a single derived fact —
* {state, checks} — for ship-feature-lane's Stage 13 CI-wait. Deliberately a
* snapshot, not a poll loop: the driving skill owns the wait/retry decision
* (docs/LANES.md "Orchestration: what CCAM does NOT do"), this module only
* (docs/LANES.md "Orchestration: what AgentHub does NOT do"), this module only
* reports what gh already knows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+1 -1
View File
@@ -313,7 +313,7 @@ function ingestCodexOnce({ broadcast } = {}) {
if (detection) {
try {
changed = lanesLib.recordDetection(lane.id, detection).written;
// Auto-advance: a codex session has no hook and rarely calls `ccam
// Auto-advance: a codex session has no hook and rarely calls `agenthub
// stage` on its own, so without this its card sits at `idle` forever
// while the amber badge is the only thing that ever moves. Gated on
// recordDetection having WRITTEN, so it inherits that function's
+2 -2
View File
@@ -4,7 +4,7 @@
* from its rollout journal — that keeps the pipeline map moving, but an
* inferred stage carries no evidence, so its node renders
* `passed-no-evidence` (amber) rather than `done`. Only the session's own
* `ccam stage <node> --evidence "..."` call can supply that, and a codex
* `agenthub stage <node> --evidence "..."` call can supply that, and a codex
* session has no hook or wrapper that does it for it.
*
* This module never writes `stage`, `stages` or `detected_stage` itself. It
@@ -65,7 +65,7 @@ function sweepCodexNudges() {
tmux.sendText(
runId,
`[ccam] Stage "${lane.stage}" was inferred and has no evidence — run: ccam stage ${lane.stage} --tool codex --evidence "..."`
`[agenthub] Stage "${lane.stage}" was inferred and has no evidence — run: agenthub stage ${lane.stage} --tool codex --evidence "..."`
);
nudgedFor.set(lane.id, lane.stage);
nudged += 1;
+1 -1
View File
@@ -33,7 +33,7 @@
"use strict";
const EXPORT_FORMAT = "ccam-export";
const EXPORT_FORMAT = "agenthub-export";
const EXPORT_VERSION = 1;
// Tables serialized into the bundle. Order matters for restore (parents before
+9 -9
View File
@@ -3,8 +3,8 @@
* the ship-feature-lane pipeline's agent templates
* (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
* driving session can launch them by the BARE subagent_type name. Source is
* the repo's own `agents/` — the same files the ccam plugin ships, where
* they resolve as `ccam:qc-local`; installing them into the lane is what
* the repo's own `agents/` — the same files the agenthub plugin ships, where
* they resolve as `agenthub:qc-local`; installing them into the lane is what
* makes the unprefixed name work there too. Plain file I/O except
* for one git call — resolving where a worktree lane's shared info/exclude
* actually lives, the same git-dir/git-common-dir distinction E2's
@@ -25,29 +25,29 @@ const EXCLUDE_LINE = ".claude/agents/";
// loads from the working directory. Marker-delimited so a reinstall replaces
// exactly this block and never the user's own prose, and appended rather than
// written over an existing file.
const STAGE_BLOCK_START = "<!-- ccam:stage:start -->";
const STAGE_BLOCK_END = "<!-- ccam:stage:end -->";
const STAGE_BLOCK_START = "<!-- agenthub:stage:start -->";
const STAGE_BLOCK_END = "<!-- agenthub:stage:end -->";
const STAGE_BLOCK = `${STAGE_BLOCK_START}
## Declare your lane stage as you work
This directory is watched by CCAM. Its pipeline map advances on its own by
This directory is watched by AgentHub. Its pipeline map advances on its own by
*inferring* your stage from your tool calls, but an inference carries no
evidence — the node stays amber, never green. Only your own declaration does:
\`\`\`bash
ccam stage <stage> --tool codex --evidence "<what is actually done>"
agenthub stage <stage> --tool codex --evidence "<what is actually done>"
\`\`\`
- Call it at every real transition — starting to plan, starting to implement,
running tests, opening the PR — not once at the end.
- \`--tool codex\` matters: one directory can back both a \`claude\` lane and a
\`codex\` lane, and without a tool the command fails rather than guess. Inside
a pane the dashboard started you need nothing — it carries \`CCAM_LANE_ID\`.
- \`ccam stage\` warns on stderr and names the valid nodes if a stage matches
a pane the dashboard started you need nothing — it carries \`AGENTHUB_LANE_ID\`.
- \`agenthub stage\` warns on stderr and names the valid nodes if a stage matches
none of this lane's pipeline template.
- If it reports that no lane owns this directory, skip the stage calls and
carry on. Stage reporting is reporting, never control flow.
- A one-line \`[ccam]\` reminder may be typed straight into this pane asking for
- A one-line \`[agenthub]\` reminder may be typed straight into this pane asking for
evidence on the current stage. That is the dashboard, not the user — treat it
as the same instruction as this section and make the real call.
${STAGE_BLOCK_END}`;
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* @file Node.js project detection and `.ccam/profile/` scaffolding (A3).
* @file Node.js project detection and `.agenthub/profile/` scaffolding (A3).
* `detectNode` reads a repository's filesystem (package.json layout,
* docker-compose.yml) without executing anything; `scaffoldProfile` turns
* those facts into a working profile; `checkProfile` validates one. Only a
@@ -218,7 +218,7 @@ function bootLine(name, service) {
}
/**
* Scaffold `.ccam/profile/` under `repoPath` from `detectNode`'s facts.
* Scaffold `.agenthub/profile/` under `repoPath` from `detectNode`'s facts.
* Refuses to overwrite an existing `profile.env` unless `options.force`.
*
* @param {string} repoPath - Absolute repository path.
@@ -227,7 +227,7 @@ function bootLine(name, service) {
* @returns {{written: string[], todos: string[]}}
*/
function scaffoldProfile(repoPath, facts, options = {}) {
const profileDir = path.join(repoPath, ".ccam", "profile");
const profileDir = path.join(repoPath, ".agenthub", "profile");
const envPath = path.join(profileDir, "profile.env");
if (fs.existsSync(envPath) && !options.force) {
throw Object.assign(new Error(`profile already exists at ${envPath}`), {
@@ -328,7 +328,7 @@ function containsTodo(text) {
/**
* Validate a scaffolded (or hand-written) profile. Read-only: never mutates
* anything, never spawns a hook. `dir` may be a repo root (profile at
* `.ccam/profile/`) or a `.ccam/profile/` directory itself.
* `.agenthub/profile/`) or a `.agenthub/profile/` directory itself.
*
* @param {string} dir
* @returns {Promise<{ok: boolean, errors: string[], warnings: string[]}>}
@@ -337,7 +337,7 @@ async function checkProfile(dir) {
const errors = [];
const warnings = [];
const candidates = [path.join(dir, ".ccam", "profile"), dir];
const candidates = [path.join(dir, ".agenthub", "profile"), dir];
const profileDir = candidates.find((p) => fs.existsSync(path.join(p, "profile.env")));
if (!profileDir) {
return { ok: false, errors: [`no profile.env found under ${dir}`], warnings: [] };
+1 -1
View File
@@ -64,7 +64,7 @@ function rewriteEnvText(text, want) {
* A no-op when the profile declares no `ENV_FILES` — the feature is off by
* default, and an adopted repo that never opts in gets no `.env` writes at
* all. Refuses on an adopted lane (`assertManaged`): that file is the user's
* real working config, not a template CCAM may overwrite.
* real working config, not a template AgentHub may overwrite.
*
* @param {object} lane - Lane row; `slot` must already be allocated.
* @param {object} profile - Resolved profile.
+2 -2
View File
@@ -4,10 +4,10 @@
* got reparented to pid 1 — a live session's MCP keeps its real parent and
* is left alone) and cap hook logs that have grown past 10MB back to their
* last 2MB. Port of the two pieces of Shipyard's `lane-gc.sh` that match
* CCAM's actual architecture — see docs/superpowers/specs/2026-08-05-lane-gc-design.md
* AgentHub's actual architecture — see docs/superpowers/specs/2026-08-05-lane-gc-design.md
* for why the other three (stale-worktree auto-removal, state archiving,
* scratch-debris sweep) are out of scope. Machine-wide, not lane-scoped —
* no route, same local-only shape as `ccam skills install`.
* no route, same local-only shape as `agenthub skills install`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+5 -5
View File
@@ -1,7 +1,7 @@
/**
* @file The stack seam. A profile is a repository's own description of how to
* build, boot and check its stack: `<repo>/.ccam/profile/` holding a `profile.env`
* of declarations plus a `hooks/` directory of shell scripts. CCAM stays
* build, boot and check its stack: `<repo>/.agenthub/profile/` holding a `profile.env`
* of declarations plus a `hooks/` directory of shell scripts. AgentHub stays
* stack-agnostic and calls those hooks with a stable environment contract, which
* is the same contract Shipyard's `run_hook` exports so its profiles port over
* unchanged.
@@ -21,10 +21,10 @@ const { spawn } = require("node:child_process");
const { slotDirs, dataFacts } = require("./lane-slots");
/** Directory, relative to a repository root, holding its profile. */
const PROFILE_SUBDIR = path.join(".ccam", "profile");
const PROFILE_SUBDIR = path.join(".agenthub", "profile");
/**
* Hook names CCAM will run, ever.
* Hook names AgentHub will run, ever.
*
* A fixed allowlist rather than "whatever is in hooks/": `:name` arrives from an
* HTTP route, and a name taken from a request is a path taken from a request.
@@ -197,7 +197,7 @@ function profileSearchPaths(lane) {
/**
* Whether a named integration is turned on for this lane — reads
* .ccam/profile/integrations.env (same two-location search as profile.env:
* .agenthub/profile/integrations.env (same two-location search as profile.env:
* the lane's own working copy first, the source repo second) and checks
* <NAME>_ENABLED=1. A missing file or missing key is off, never an error —
* same off-by-default shape every other optional declaration follows.
+9 -7
View File
@@ -4,23 +4,23 @@
* data-isolation lifecycle (A2): `provisionLane`/`resetLaneData`/`removeLaneData`
* seed `.env`, create/drop the lane's own database, and run `bootstrap`/
* `migrate`/`seed` at the points Shipyard's `lane-bootstrap.sh`/`lane-up.sh`/
* `lane-reset.sh`/`lane-remove.sh` do — CCAM's version of "ports came from A1,
* `lane-reset.sh`/`lane-remove.sh` do — AgentHub's version of "ports came from A1,
* everything else a lane needs to run its own stack comes from here."
*
* Two properties shape everything here.
*
* Services are FULLY DETACHED (see `harness_spawn` in lane-profile.js), so a
* lane's stack outlives both the hook that started it and the dashboard itself —
* restarting or updating CCAM must never kill work in progress.
* restarting or updating AgentHub must never kill work in progress.
*
* Liveness is COMPUTED, never stored. Whether a stack is up is not a fact CCAM
* Liveness is COMPUTED, never stored. Whether a stack is up is not a fact AgentHub
* controls: a process dies to OOM, to a stray `kill`, to a reboot. A cached
* "running" flag would be wrong from that moment until something noticed, so
* `runtimeFacts` re-derives it from pid files and port probes on every read. The
* payoff is that adopting a stack after a dashboard restart needs no code at all.
*
* This module writes `slot` and `ports` and nothing else on the lane row. It never
* writes `stage`, `status` or `notes`: in CCAM those describe the AGENT's work, not
* writes `stage`, `status` or `notes`: in AgentHub those describe the AGENT's work, not
* the stack's state, and conflating "a server is listening" with "an agent is
* running" would corrupt lane liveness. Boot failures live in `last-error.json`
* and surface through `runtimeFacts`.
@@ -58,7 +58,9 @@ function requireProfile(lane) {
const profile = resolveProfile(lane);
if (!profile) {
throw Object.assign(
new Error(`lane has no .ccam/profile — looked in: ${profileSearchPaths(lane).join(", ")}`),
new Error(
`lane has no .agenthub/profile — looked in: ${profileSearchPaths(lane).join(", ")}`
),
{ code: "ENOPROFILE", searched: profileSearchPaths(lane) }
);
}
@@ -299,8 +301,8 @@ async function resetLaneData(lane, profile, options = {}) {
* Drop a managed lane's database and its `_test` sibling before the rest of
* removal tears down the worktree and state directory.
*
* A no-op for an adopted lane — its data was never CCAM's to create, so it is
* never CCAM's to destroy either, the same invariant `assertDestroyable`
* A no-op for an adopted lane — its data was never AgentHub's to create, so it is
* never AgentHub's to destroy either, the same invariant `assertDestroyable`
* enforces for the worktree itself. Best-effort per database: mirrors
* Shipyard's `dropdb --if-exists ... || true` — a failed drop is logged, not
* thrown, because it must never block removing the lane's dashboard record.
+5 -5
View File
@@ -1,6 +1,6 @@
/**
* @file A lane's own database: create it once at provisioning and boot, drop
* it on remove. CCAM stays stack-agnostic here — `createdb` vs `mysqladmin
* it on remove. AgentHub stays stack-agnostic here — `createdb` vs `mysqladmin
* create` vs `touch foo.db` genuinely differ, so the actual command lives in
* the profile's `db-create.sh`/`db-drop.sh` hooks (already in the A1
* allowlist); this module only decides WHEN to call them and guards WHICH
@@ -16,11 +16,11 @@ const { dbName, slotDirs } = require("./lane-slots");
const { runHook } = require("./lane-profile");
/**
* Where CCAM records that a slot's database has already been created.
* Where AgentHub records that a slot's database has already been created.
*
* A hook can't be trusted to know this on its own without being stack-aware
* (a plain `createdb` errors on a database that already exists; `touch`
* wouldn't), so CCAM tracks it itself with one flag file per database name,
* wouldn't), so AgentHub tracks it itself with one flag file per database name,
* beside the rest of a slot's runtime bookkeeping. This is also how `upLane`
* tells "freshly created" from "already existed" to decide whether to seed.
*/
@@ -63,10 +63,10 @@ async function ensureDatabase(lane, profile, options = {}) {
* Drop a database via the profile's `db-drop` hook.
*
* Two checks run before anything is spawned, and neither trusts the caller:
* `assertManaged` (an adopted lane's data is the user's own, never CCAM's to
* `assertManaged` (an adopted lane's data is the user's own, never AgentHub's to
* destroy) and a name check against what THIS lane's slot actually derives —
* the lane's own database or its `_test` sibling, and nothing else. Only a
* name CCAM itself computed can ever reach the hook.
* name AgentHub itself computed can ever reach the hook.
*
* @param {object} lane - Lane row; `slot` must already be allocated.
* @param {object} profile - Resolved profile.
+4 -4
View File
@@ -1,13 +1,13 @@
/**
* @file Slot and port allocation for lane runtimes. A slot is the small integer
* every per-lane runtime fact derives from — the numbering Shipyard gets for free
* from its fixed `lane1..lane9` directories, and CCAM, whose lanes are keyed by
* from its fixed `lane1..lane9` directories, and AgentHub, whose lanes are keyed by
* `cwd`, has to allocate. Ports come from `PORT_BASE_<name> + slot`, stepping
* aside when something outside CCAM already holds the number. Database name,
* aside when something outside AgentHub already holds the number. Database name,
* Redis logical index, and upload directory are the same idea one layer up
* (`dataFacts`) — every fact a lane's slot number determines, in one place.
*
* Allocation is the one runtime fact CCAM fully controls, which is why it lives
* Allocation is the one runtime fact AgentHub fully controls, which is why it lives
* in the database (transactional, uniquely indexed) while liveness does not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,7 +24,7 @@ const { LANES_ROOT } = require("./worktree");
* Nine by default, which is Shipyard's ceiling and worth keeping as a default:
* a single decimal digit keeps `base + slot` readable (`:8003` is lane 3), and
* Redis ships 16 logical databases, so A2's per-lane index stays in range. It is
* configurable because CCAM, unlike Shipyard, has no structural reason to stop at
* configurable because AgentHub, unlike Shipyard, has no structural reason to stop at
* nine — a machine that can run twenty stacks may raise it, at the cost of ports
* that no longer read as a slot number.
*
+3 -3
View File
@@ -190,10 +190,10 @@ async function unmergedFiles(cwd) {
* already established. */
async function setupMergeDriver(cwd, generatedPaths) {
if (!generatedPaths.length) return;
await git(cwd, ["config", "merge.ccam-generated.driver", "true"]);
await git(cwd, ["config", "merge.agenthub-generated.driver", "true"]);
await git(cwd, [
"config",
"merge.ccam-generated.name",
"merge.agenthub-generated.name",
"keep ours; regenerated post-merge by the profile regen hook",
]);
@@ -204,7 +204,7 @@ async function setupMergeDriver(cwd, generatedPaths) {
const lines = new Set(existing.split("\n").filter(Boolean));
let changed = false;
for (const gp of generatedPaths) {
const line = `${gp} merge=ccam-generated`;
const line = `${gp} merge=agenthub-generated`;
if (!lines.has(line)) {
lines.add(line);
changed = true;
+3 -3
View File
@@ -523,7 +523,7 @@ function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
* only path to `done`.
*
* The `current` node is never flagged, even when it has no `stages` entry under
* its own id: declaring by ALIAS (`ccam stage coding` → the `implement` node)
* its own id: declaring by ALIAS (`agenthub stage coding` → the `implement` node)
* keys `stages` by the raw declared string, so the node the agent says it is on
* would otherwise render as an inference instead of the blue `current` ring.
* Past nodes go through `stageRecords`, which resolves those alias keys — a
@@ -550,8 +550,8 @@ function withDetected(states, pipeline, lane) {
* The inferred stage a lane should be RENDERED at, or null when the lane has a
* declaration of its own to render.
*
* A lane whose sessions never call `ccam stage` — any repo that isn't wired to
* CCAM — has no declaration at all: `stage` sits on the DB sentinel `idle` and
* A lane whose sessions never call `agenthub stage` — any repo that isn't wired to
* AgentHub — has no declaration at all: `stage` sits on the DB sentinel `idle` and
* `stages` stays `{}` forever, so its pipeline map drew 100% pending while
* detection was quietly tracking every node the agent reached. Such a lane is
* rendered from its detection instead.
+1 -1
View File
@@ -57,7 +57,7 @@ function readOwner(name) {
/**
* Try to acquire a named lock once. Never blocks, never retries — the caller
* (the CLI) owns the polling loop; CCAM's server-side primitives are always
* (the CLI) owns the polling loop; AgentHub's server-side primitives are always
* single-shot.
*
* A stale holder (older than `effectiveMaxHoldSec()`) is broken automatically:
+1 -1
View File
@@ -41,7 +41,7 @@ function loadAll() {
icon: n.icon || "",
gate: !!n.gate,
// The one command that drives THIS node — a slash command
// (`/superpowers:writing-plans`) or a shell one (`ccam lanes hook
// (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook
// ci-gate`), mirroring whatever the node's own `detect` rules watch
// for. `doc.skill` above names the driver for the whole pipeline;
// this narrows it to the step, so a reader of the pipeline map can
+2 -2
View File
@@ -5,7 +5,7 @@
* `lane_features` already tracks. Ported from Shipyard's
* `dashboard/server/services/proof.js`; the "current feature" special-casing
* that file needed (it has no DB, so it parsed a branch regex) is dropped —
* CCAM's `lane_features` row already IS that source of truth.
* AgentHub's `lane_features` row already IS that source of truth.
*
* Path containment is the entire security surface here: every path this
* module touches is resolved, realpath'd, and checked to be inside the
@@ -249,7 +249,7 @@ function deleteProof(laneId, { slug, group, images } = {}) {
* already points at the right place.
*
* Never auto-invoked by this codebase; exposed only as the explicit
* `ccam lanes proof-link` primitive.
* `agenthub lanes proof-link` primitive.
*
* @param {object} lane - A hydrated lane row (must have `cwd`).
* @returns {{linked: boolean}} `linked: false` when nothing changed.
+2 -2
View File
@@ -12,10 +12,10 @@
const tmux = require("./tmux");
const RUN_ID_RE = /^ccam-lane-\d+$/;
const RUN_ID_RE = /^agenthub-lane-\d+$/;
/**
* Reject anything that isn't exactly `ccam-lane-<digits>` before it can ever
* Reject anything that isn't exactly `agenthub-lane-<digits>` before it can ever
* reach a tmux/PTY command — the trust boundary for this WS path, since a
* validated runId is the only thing standing between an authenticated WS
* client and naming an arbitrary session on the host.
+10 -10
View File
@@ -13,9 +13,9 @@
* exists but its pane sits at a shell prompt, the argv is typed into that
* pane instead of being dropped on the floor by a silent adopt.
*
* Every session is named `ccam-lane-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t ccam-lane-<id>`, or
* `ccam lanes shell`) — that's the whole point: the dashboard both creates
* Every session is named `agenthub-lane-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t agenthub-lane-<id>`, or
* `agenthub lanes shell`) — that's the whole point: the dashboard both creates
* the session (one-click Start/Resume) and is just one of possibly several
* attached clients tmux already keeps in sync.
*
@@ -33,7 +33,7 @@ try {
/* db-less environment, skip persistence */
}
const RUN_ID_RE = /^ccam-lane-(\d+)$/;
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
// Pane commands that mean "idle shell prompt, safe to type a command into".
@@ -45,7 +45,7 @@ const CODEX_APPROVALS = new Set(["on-request", "never"]);
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
function runIdForLane(laneId) {
return `ccam-lane-${laneId}`;
return `agenthub-lane-${laneId}`;
}
function laneIdFromRunId(id) {
@@ -216,18 +216,18 @@ function spawnRun(args) {
});
};
// Every pane the dashboard starts carries its lane id. A `ccam stage` call
// Every pane the dashboard starts carries its lane id. A `agenthub stage` call
// from inside it then resolves the lane exactly, instead of matching on cwd —
// which is ambiguous now that one directory can back both a `claude` lane and
// a `codex` lane (see bin/ccam.js's resolveLaneArg).
const env = { CCAM_LANE_ID: String(laneId) };
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
const env = { AGENTHUB_LANE_ID: String(laneId) };
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv, env });
record();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
// The session exists but its pane is sitting at a bare shell prompt — a
// `ccam lanes shell`, or a `claude` that already exited. Adopting it
// `agenthub lanes shell`, or a `claude` that already exited. Adopting it
// silently here would swallow the whole request: a Resume would spawn no
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
@@ -278,7 +278,7 @@ function getRun(id) {
/** Computed fresh from tmux state every call — see file header. */
function listRuns() {
return tmux.listSessions("ccam-lane-").map(publicRun);
return tmux.listSessions("agenthub-lane-").map(publicRun);
}
module.exports = {
Binary file not shown.
+5 -5
View File
@@ -1,9 +1,9 @@
/**
* @file Machine-level lane secrets: the database and Redis connection settings
* shared by every lane on this host. Deliberately NOT part of a repository's
* `.ccam/profile/` — a profile is committed and read by anyone who clones the
* `.agenthub/profile/` — a profile is committed and read by anyone who clones the
* repo, and a database password does not belong there. Lives instead at
* `~/.ccam/secrets.env`, parsed with the same literal `KEY=VALUE` reader
* `~/.agenthub/secrets.env`, parsed with the same literal `KEY=VALUE` reader
* `lane-profile.js` uses for `profile.env` (config is parsed, never sourced).
*
* Never returned by any route: `GET /runtime` may report which keys are
@@ -18,7 +18,7 @@ const path = require("node:path");
const { parseEnvFile } = require("./lane-profile");
const SECRETS_PATH =
process.env.CCAM_SECRETS_PATH || path.join(os.homedir(), ".ccam", "secrets.env");
process.env.AGENTHUB_SECRETS_PATH || path.join(os.homedir(), ".agenthub", "secrets.env");
/** Every declaration a lane's database/Redis facts can rely on when the file
* is absent or unreadable — a local default stack, not a guess. */
@@ -35,12 +35,12 @@ let warnedMissing = false;
let warnedPerms = false;
/**
* Read `~/.ccam/secrets.env`, merged over DEFAULTS.
* Read `~/.agenthub/secrets.env`, merged over DEFAULTS.
*
* Never throws: a missing file warns once and falls back to DEFAULTS (a lane
* with no secrets file still gets a usable local Postgres/Redis target), and a
* file readable by group or world is refused outright rather than trusted —
* loading it would make CCAM the thing that taught a shared machine's other
* loading it would make AgentHub the thing that taught a shared machine's other
* users the database password.
*
* @returns {Record<string,string>}
+5 -5
View File
@@ -1,6 +1,6 @@
/**
* @file skill-plugins.js
* @description The plugins CCAM's pipelines depend on: which ones a template
* @description The plugins AgentHub's pipelines depend on: which ones a template
* names, which of those are actually resolvable, and the narrow set the
* SessionStart bootstrap is allowed to install on the user's behalf.
*
@@ -18,7 +18,7 @@
* It is NOT right for an install. A template is a file anyone can drop into
* that directory, so installing whatever plugin name it happens to contain
* would turn "edit a JSON file" into "run this vendor's plugin install command
* on the next session" — a supply-chain path CCAM must not open. Auto-install
* on the next session" — a supply-chain path AgentHub must not open. Auto-install
* therefore consults {@link AUTO_INSTALLABLE}, a fixed map in this file, and
* ignores every requirement outside it.
*
@@ -53,7 +53,7 @@ const SLASH_COMMAND_RE = /^\/([A-Za-z0-9_-]+):([A-Za-z0-9_-]+)/;
* Every `/plugin:skill` the loaded pipeline templates name — as a pipeline's
* own `skill` or as one node's `command` — grouped by the plugin that has to
* provide it. Derived rather than hardcoded so editing a template cannot leave
* the check behind. `ccam` is dropped: it is the plugin asking.
* the check behind. `agenthub` is dropped: it is the plugin asking.
*
* @returns {Map<string, Set<string>>} plugin name → the skill names it must ship.
*/
@@ -70,7 +70,7 @@ function requiredSkillPlugins() {
const match = SLASH_COMMAND_RE.exec(command || "");
if (!match) continue; // a shell command, or a bare /command
const [, plugin, skill] = match;
if (plugin === "ccam") continue;
if (plugin === "agenthub") continue;
if (!requirements.has(plugin)) requirements.set(plugin, new Set());
requirements.get(plugin).add(skill);
}
@@ -172,7 +172,7 @@ function autoInstallSkillPlugins(log, { timeoutMs = 180_000, run = runClaudePlug
for (const entry of missing) {
if (!entry.autoInstall) {
// Reported by /ccam-doctor, never installed: not on the allowlist.
// Reported by /agenthub-doctor, never installed: not on the allowlist.
summary.skipped.push(entry.name);
continue;
}
+4 -4
View File
@@ -2,11 +2,11 @@
* @file Installs skills/ship-feature-lane/ into ~/.claude/skills/, so
* /ship-feature-lane is discoverable from a session running inside any
* lane's own working directory. This is the FALLBACK path for a plain
* checkout: the skill also ships with the `ccam` plugin (the repo root IS
* checkout: the skill also ships with the `agenthub` plugin (the repo root IS
* the plugin root, so `skills/` is the plugin's skills dir), where it is
* already global as `/ccam:ship-feature-lane`. Installing it here just
* already global as `/agenthub:ship-feature-lane`. Installing it here just
* restores the bare `/ship-feature-lane` name for people who do not run the
* plugin. Shared by bin/ccam.js's `ccam skills install` CLI and the
* plugin. Shared by bin/agenthub.js's `agenthub skills install` CLI and the
* POST /api/skills/install route, so there is exactly one copy of this
* logic. Pure filesystem action.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -18,7 +18,7 @@ const path = require("node:path");
/**
* @param {{repoRoot: string}} options - `repoRoot` is this checkout's own
* root (bin/ccam.js already computes this as `REPO_ROOT`; the server
* root (bin/agenthub.js already computes this as `REPO_ROOT`; the server
* computes its own equivalent — see the route for how).
* @returns {{installed: true, path: string}}
*/
+4 -4
View File
@@ -1,9 +1,9 @@
/**
* @file tmux.js
* @description Thin wrapper around the `tmux` CLI for the terminal-run
* feature. Every dashboard-managed session is named `ccam-lane-<id>` (see
* feature. Every dashboard-managed session is named `agenthub-lane-<id>` (see
* `pty-run.js`) so a real terminal can attach to the exact same session with
* `tmux attach -t ccam-lane-<id>` (or `ccam lanes shell`). Never builds a
* `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes shell`). Never builds a
* shell string — every call is `execFileSync("tmux", [...argv])` with an
* explicit argument array (matches this repo's rule for git in worktree.js).
* The one place a command line is composed is `sendCommand`, which types into
@@ -53,9 +53,9 @@ function envFlags(env) {
* if tmux itself fails to start (caller decides how to surface that).
*
* `env` becomes tmux `-e NAME=value` pairs, which tmux puts in the pane's own
* environment (tmux >= 3.2). The dashboard uses it for CCAM_LANE_ID so a `ccam`
* environment (tmux >= 3.2). The dashboard uses it for AGENTHUB_LANE_ID so a `agenthub`
* call from inside the pane knows exactly which lane it belongs to instead of
* guessing from cwd — see resolveLaneArg in bin/ccam.js.
* guessing from cwd — see resolveLaneArg in bin/agenthub.js.
*/
function newSession({ name, cwd, argv, env }) {
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]);
+5 -5
View File
@@ -85,7 +85,7 @@ function formatDiscord(alert) {
description: truncate(alert.message, 4000),
color: ACCENT_INT,
fields,
footer: { text: "Claude Code Agent Monitor" },
footer: { text: "AgentHub" },
timestamp: alert.triggered_at,
},
],
@@ -152,7 +152,7 @@ function formatMattermost(alert) {
color: ACCENT_HEX,
text: truncate(alert.message, 3000),
fields: attachmentFields(alert),
footer: "Claude Code Agent Monitor",
footer: "AgentHub",
},
],
};
@@ -196,7 +196,7 @@ function formatPagerDuty(alert, config) {
return {
routing_key: config.routing_key,
event_action: "trigger",
dedup_key: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
dedup_key: `agenthub:${alert.rule_id || "test"}:${alert.session_id || ""}`,
payload: {
summary: truncate(`${alert.rule_name}: ${alert.message}`, 1024),
source: alert.session_id || "claude-code-agent-monitor",
@@ -219,7 +219,7 @@ function formatPagerDuty(alert, config) {
function formatOpsgenie(alert) {
return {
message: truncate(`${alert.rule_name}: ${alert.message}`, 130),
alias: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
alias: `agenthub:${alert.rule_id || "test"}:${alert.session_id || ""}`,
description: truncate(alert.message, 15000),
source: "claude-code-agent-monitor",
tags: ["claude-code", alert.rule_type].filter(Boolean),
@@ -238,7 +238,7 @@ function formatOpsgenie(alert) {
function formatSplunkOnCall(alert, config) {
return {
message_type: config.severity || "WARNING",
entity_id: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
entity_id: `agenthub:${alert.rule_id || "test"}:${alert.session_id || ""}`,
entity_display_name: truncate(alert.rule_name, 256),
state_message: truncate(
`${alert.message}\n\ntype: ${alert.rule_type}${alert.session_id ? `\nsession: ${alert.session_id}` : ""}`,
+1 -1
View File
@@ -287,7 +287,7 @@ function sendTest(target) {
rule_type: "test",
session_id: null,
agent_id: null,
message: `Test notification from Claude Code Agent Monitor to "${target.name}". If you can read this, delivery works.`,
message: `Test notification from AgentHub to "${target.name}". If you can read this, delivery works.`,
details: { test: true, target: target.name, type: target.type },
triggered_at: new Date().toISOString(),
};
+1 -1
View File
@@ -14,7 +14,7 @@ const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "ccam-lanes");
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
const PROTECTED_BRANCHES = new Set(["main", "master"]);
/**
+2 -2
View File
@@ -285,7 +285,7 @@ const paths = {
tags: ["Lanes"],
summary: "A lane's own application stack",
description:
"Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.",
"Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .agenthub/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.",
operationId: "getLaneRuntime",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
@@ -325,7 +325,7 @@ const paths = {
},
responses: {
202: { description: "Accepted; the boot runs in the background." },
400: { description: "ENOPROFILE — no .ccam/profile, with the paths searched." },
400: { description: "ENOPROFILE — no .agenthub/profile, with the paths searched." },
403: { description: "The browser request was not same-origin/loopback." },
404: { description: "Lane not found." },
409: { description: "ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids)." },
+5 -5
View File
@@ -211,7 +211,7 @@ const RESET_PRICING_EXAMPLE = {
};
const EXPORT_EXAMPLE = {
format: "ccam-export",
format: "agenthub-export",
version: 1,
exported_at: "2026-06-26T01:12:44.913Z",
sessions: [
@@ -1101,7 +1101,7 @@ const paths = {
tags: ["Settings"],
summary: "Export all dashboard data as JSON",
description:
'Exports the entire dataset as a single versioned JSON document — all sessions, agents, events, token_usage rows, workflows, dashboard_runs, alert_rules, and model_pricing — stamped with `format: "ccam-export"`, `version`, and `exported_at`. Served with a `Content-Disposition: attachment` header (filename `agent-monitor-export-YYYY-MM-DD.json`) so browsers download it. Use it to back up before a destructive operation (clear-data / cleanup with purge_days) or to migrate/consolidate data across machines — the bundle is re-importable via POST /api/settings/import. Read-only; nothing is modified.',
'Exports the entire dataset as a single versioned JSON document — all sessions, agents, events, token_usage rows, workflows, dashboard_runs, alert_rules, and model_pricing — stamped with `format: "agenthub-export"`, `version`, and `exported_at`. Served with a `Content-Disposition: attachment` header (filename `agent-monitor-export-YYYY-MM-DD.json`) so browsers download it. Use it to back up before a destructive operation (clear-data / cleanup with purge_days) or to migrate/consolidate data across machines — the bundle is re-importable via POST /api/settings/import. Read-only; nothing is modified.',
operationId: "exportData",
responses: {
200: {
@@ -1122,7 +1122,7 @@ const paths = {
tags: ["Settings"],
summary: "Restore (import) a previously exported data bundle",
description:
'Restores a bundle produced by GET /api/settings/export. Supply it either as `multipart/form-data` with a single `file` field (browser upload) or as a JSON body `{ "path": "<absolute path>" }` (the server reads the file from disk — used by the `ccam import-data` CLI, and it also sidesteps the global 1 MB JSON body cap for large bundles). The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.',
'Restores a bundle produced by GET /api/settings/export. Supply it either as `multipart/form-data` with a single `file` field (browser upload) or as a JSON body `{ "path": "<absolute path>" }` (the server reads the file from disk — used by the `agenthub import-data` CLI, and it also sidesteps the global 1 MB JSON body cap for large bundles). The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.',
operationId: "importData",
requestBody: {
required: true,
@@ -1147,7 +1147,7 @@ const paths = {
},
},
},
example: { path: "/Users/you/ccam-export-2026-06-26.json" },
example: { path: "/Users/you/agenthub-export-2026-06-26.json" },
},
},
},
@@ -1160,7 +1160,7 @@ const paths = {
example: {
ok: true,
source: "agent-monitor-export-2026-06-26.json",
format: "ccam-export",
format: "agenthub-export",
sessions_imported: 42,
sessions_skipped: 3,
agents: 96,
+8 -8
View File
@@ -1265,8 +1265,8 @@ function createOpenApiSpec() {
format: {
type: "string",
description:
'Bundle format marker (always "ccam-export" for exports from this version).',
example: "ccam-export",
'Bundle format marker (always "agenthub-export" for exports from this version).',
example: "agenthub-export",
},
version: { type: "integer", description: "Bundle schema version.", example: 1 },
exported_at: { type: "string", format: "date-time" },
@@ -1863,12 +1863,12 @@ function createOpenApiSpec() {
"text/plain": {
schema: { type: "string" },
example:
"# HELP ccam_up 1 when the dashboard API is serving this scrape.\n" +
"# TYPE ccam_up gauge\n" +
"ccam_up 1\n" +
"# HELP ccam_sessions Number of sessions by lifecycle status.\n" +
"# TYPE ccam_sessions gauge\n" +
'ccam_sessions{status="active"} 3\n',
"# HELP agenthub_up 1 when the dashboard API is serving this scrape.\n" +
"# TYPE agenthub_up gauge\n" +
"agenthub_up 1\n" +
"# HELP agenthub_sessions Number of sessions by lifecycle status.\n" +
"# TYPE agenthub_sessions gauge\n" +
'agenthub_sessions{status="active"} 3\n',
},
},
},
+1 -1
View File
@@ -202,7 +202,7 @@ router.delete("/file", (req, res) => {
// These shell out to `claude plugin …` with an argv array — never a shell
// string — because the CLI owns the manifest + cache tree that plugin state
// lives in. Identifiers travel in the body, not the URL, so a key like
// `ccam@sg` needs no encoding (same reason `DELETE /file` takes a body).
// `agenthub@sg` needs no encoding (same reason `DELETE /file` takes a body).
const CLI_ERR_TO_STATUS = {
EBADARG: 400,
+9 -9
View File
@@ -43,10 +43,10 @@ const { getClaudeHome, getProjectsDir } = require("../lib/claude-home");
// Upload limits — deliberately generous because transcripts can be large.
// Configurable at runtime via env for deployments that need tighter bounds.
const MAX_UPLOAD_BYTES = parseInt(
process.env.CCAM_IMPORT_MAX_BYTES || String(1024 * 1024 * 1024), // 1 GB default
process.env.AGENTHUB_IMPORT_MAX_BYTES || String(1024 * 1024 * 1024), // 1 GB default
10
);
const MAX_UPLOAD_FILES = parseInt(process.env.CCAM_IMPORT_MAX_FILES || "2000", 10);
const MAX_UPLOAD_FILES = parseInt(process.env.AGENTHUB_IMPORT_MAX_FILES || "2000", 10);
/**
* Lazily build a multer upload middleware. Kept lazy so the server still
@@ -67,8 +67,8 @@ function getUploader() {
}
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
if (!req._ccamUploadDir) req._ccamUploadDir = mkTempDir("ccam-upload-");
cb(null, req._ccamUploadDir);
if (!req._agenthubUploadDir) req._agenthubUploadDir = mkTempDir("agenthub-upload-");
cb(null, req._agenthubUploadDir);
},
filename: (_req, file, cb) => {
// Preserve the original name for kind-detection later, but prefix with
@@ -90,8 +90,8 @@ function getUploader() {
if (kind === "unknown") {
// Track rejected filenames on the request so we can surface the count
// in the response — users wonder why their upload "partially worked".
if (!req._ccamRejected) req._ccamRejected = [];
req._ccamRejected.push(file.originalname);
if (!req._agenthubRejected) req._agenthubRejected = [];
req._agenthubRejected.push(file.originalname);
cb(null, false);
} else {
cb(null, true);
@@ -296,8 +296,8 @@ router.post("/upload", uploadMiddleware, async (req, res) => {
});
}
const files = Array.isArray(req.files) ? req.files : [];
const rejectedNames = Array.isArray(req._ccamRejected) ? req._ccamRejected : [];
const reqUploadDir = req._ccamUploadDir || null;
const rejectedNames = Array.isArray(req._agenthubRejected) ? req._agenthubRejected : [];
const reqUploadDir = req._agenthubUploadDir || null;
if (files.length === 0) {
// Clean up the upload dir if multer created one before rejecting all files.
@@ -314,7 +314,7 @@ router.post("/upload", uploadMiddleware, async (req, res) => {
});
}
const workDir = mkTempDir("ccam-import-work-");
const workDir = mkTempDir("agenthub-import-work-");
let extractedCount = 0;
let skippedEntries = 0;
+4 -4
View File
@@ -562,8 +562,8 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
/**
* Detect a Node.js project at this lane's OWN directory and scaffold
* .ccam/profile/ if one is found — the HTTP equivalent of
* `ccam lanes profile init`, always targeting lane.cwd (never an arbitrary
* .agenthub/profile/ if one is found — the HTTP equivalent of
* `agenthub lanes profile init`, always targeting lane.cwd (never an arbitrary
* path; the CLI's <repo> argument has no meaning here, this lane's own
* directory is the only sensible target). "No Node.js project detected" is
* a normal 200 outcome, not an error — most lanes won't be auto-detectable
@@ -590,7 +590,7 @@ router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
/**
* Whether a named integration (tracker, dev_qc, ci_wait, ...) is turned on
* for this lane — reads .ccam/profile/integrations.env. Read-only, no
* for this lane — reads .agenthub/profile/integrations.env. Read-only, no
* sameOriginGuard needed (same reasoning GET /:id/git already documents:
* that guard exists for destructive actions).
*/
@@ -628,7 +628,7 @@ router.get("/:id/git", async (req, res) => {
/**
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
* the wait/retry decision (docs/LANES.md "Orchestration: what CCAM does NOT
* the wait/retry decision (docs/LANES.md "Orchestration: what AgentHub does NOT
* do"); this route only reports what `gh` currently knows. Read-only, no
* same-origin guard, same reasoning as GET /:id/git.
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* @file HTTP surface for cross-lane named locks (`server/lib/named-lock.js`).
* Every route is single-shot — this layer never blocks waiting for a lock to
* free up. The CLI (`ccam lock acquire`) owns the polling loop; CCAM's
* free up. The CLI (`agenthub lock acquire`) owns the polling loop; AgentHub's
* server-side primitives stay non-orchestrating, same as every other lane
* route in this project.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
+10 -10
View File
@@ -74,26 +74,26 @@ function appendMetric(out, name, help, type, samples) {
router.get("/", (_req, res) => {
const out = [];
appendMetric(out, "ccam_up", "1 when the dashboard API is serving this scrape.", "gauge", [
appendMetric(out, "agenthub_up", "1 when the dashboard API is serving this scrape.", "gauge", [
{ value: 1 },
]);
appendMetric(
out,
"ccam_build_info",
"agenthub_build_info",
"Dashboard build info; the value is always 1, the version rides on the label.",
"gauge",
[{ labels: { version: APP_VERSION }, value: 1 }]
);
appendMetric(
out,
"ccam_process_uptime_seconds",
"agenthub_process_uptime_seconds",
"Uptime of the dashboard server process in seconds.",
"gauge",
[{ value: Math.round(process.uptime()) }]
);
appendMetric(
out,
"ccam_process_resident_memory_bytes",
"agenthub_process_resident_memory_bytes",
"Resident set size (RSS) of the dashboard server process in bytes.",
"gauge",
[{ value: process.memoryUsage().rss }]
@@ -103,7 +103,7 @@ router.get("/", (_req, res) => {
const sessionCounts = new Map(stmts.sessionStatusCounts.all().map((r) => [r.status, r.count]));
appendMetric(
out,
"ccam_sessions",
"agenthub_sessions",
"Number of sessions by lifecycle status.",
"gauge",
SESSION_STATUSES.map((status) => ({
@@ -116,7 +116,7 @@ router.get("/", (_req, res) => {
const agentCounts = new Map(stmts.agentStatusCounts.all().map((r) => [r.status, r.count]));
appendMetric(
out,
"ccam_agents",
"agenthub_agents",
"Number of agents (main + subagents) by status.",
"gauge",
AGENT_STATUSES.map((status) => ({ labels: { status }, value: agentCounts.get(status) || 0 }))
@@ -125,7 +125,7 @@ router.get("/", (_req, res) => {
// Event throughput (monotonic — a counter).
appendMetric(
out,
"ccam_events_total",
"agenthub_events_total",
"Total hook and synthetic events recorded since the database was created.",
"counter",
[{ value: stmts.countEvents.get().count }]
@@ -140,7 +140,7 @@ router.get("/", (_req, res) => {
}
appendMetric(
out,
"ccam_websocket_clients",
"agenthub_websocket_clients",
"Currently connected realtime (WebSocket) dashboard clients.",
"gauge",
[{ value: clients }]
@@ -158,7 +158,7 @@ router.get("/", (_req, res) => {
}
appendMetric(
out,
"ccam_remote_sources",
"agenthub_remote_sources",
"Configured Remote Data Sources, split by auto-sync enabled state.",
"gauge",
[
@@ -181,7 +181,7 @@ router.get("/", (_req, res) => {
.get();
appendMetric(
out,
"ccam_tokens_total",
"agenthub_tokens_total",
"Cumulative token usage across all sessions, by kind.",
"counter",
[
+1 -1
View File
@@ -10,7 +10,7 @@ const { sameOriginGuard } = require("./run");
const router = Router();
/** This server's own checkout root — server/routes/skills.js is two levels
* under it (server/routes/), same computation bin/ccam.js's REPO_ROOT
* under it (server/routes/), same computation bin/agenthub.js's REPO_ROOT
* already does from its own location. */
const REPO_ROOT = path.resolve(__dirname, "..", "..");
+1 -1
View File
@@ -95,7 +95,7 @@ router.get("/skill-usage", (req, res) => {
for (const m of mcpCalls) {
// mcp__plugin_<plugin>_<server>__<tool>. Both halves may contain
// underscores, so the split is only unambiguous when the plugin names its
// server after itself — the overwhelmingly common case (`ccam_ccam`,
// server after itself — the overwhelmingly common case (`agenthub_agenthub`,
// `vercel_vercel`), which the dedupe below collapses back to one name.
// ponytail: a plugin whose server has its own name reports under
// "<plugin>_<server>"; read the real pairing off the MCP config if that
+1 -1
View File
@@ -80,7 +80,7 @@ function initWebSocket(server) {
}
let ptyWss = null;
const PTY_PATH_RE = /^\/ws-pty\/(ccam-lane-\d+)$/;
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+)$/;
/**
* Second WebSocket server, dedicated to the terminal-run PTY transport