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:
+20
-20
@@ -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", () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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}` : ""}`,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)." },
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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, "..", "..");
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user