mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c46b1e43d | |||
| 71453707ba | |||
| 0aa9e668f6 | |||
| 023128def5 | |||
| c4a1679666 | |||
| 2e40e17155 | |||
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 803f3413ec | |||
| a8b7da7bcf | |||
| 0b6ad5550d | |||
| c1965cfcaf | |||
| 3cefbfccf4 | |||
| 6f22c73aac | |||
| 31dd37b3a8 | |||
| 0dfde3aa5b | |||
| 33d2366252 | |||
| 9bf1514f26 | |||
| be3502c23b | |||
| e3f38dbed9 | |||
| d8a6b99f0e | |||
| 8ca7564fbc | |||
| afa07f0566 | |||
| 8059ee4eec | |||
| 41c486cacc | |||
| 190a7d4f43 | |||
| e3d2d43b0e | |||
| afde23bbe6 | |||
| 776bc14ca0 | |||
| a9461afef1 | |||
| ca81558ec8 | |||
| 2f88e4fa15 | |||
| 9b0d1dd044 | |||
| 79e490dfcf | |||
| 01b07d6652 | |||
| be20e9f723 | |||
| e3d156f800 | |||
| b4da018d8c | |||
| 4b11079007 | |||
| 31cc35ea32 | |||
| bf0a3d2612 | |||
| 6dc25a9b8a | |||
| cafba7fb18 | |||
| 10d400ec64 | |||
| 1a9643cfa3 | |||
| 2ba2555032 | |||
| 687d7d13ac | |||
| 2938bbf94a | |||
| d1a2b04d32 | |||
| 455014e7b9 | |||
| 5032c431ae | |||
| ac8273c698 | |||
| 0835089a51 | |||
| c82f062d57 | |||
| 3906104960 | |||
| ed9a3e330d | |||
| 102ce80fda | |||
| 0626dcec92 | |||
| b42329551d | |||
| 855f204d8a | |||
| d4ca8a4ea6 | |||
| 7615d7d846 | |||
| f094f56bf6 | |||
| fcee39ea14 | |||
| 2bce84781b | |||
| f4be00e6ed | |||
| c9aa69dea9 | |||
| 3731e26769 | |||
| d472d9d32b | |||
| 6c2d26836f | |||
| b28c917997 | |||
| a00a5610bd | |||
| 05dc1b0a3c | |||
| a7b34bc872 | |||
| aecad864e6 | |||
| c87f2e55dc | |||
| 411f5e71df | |||
| cb6e3d117c | |||
| ed7e3f70da | |||
| 557e073c2f | |||
| 91ebc9def2 | |||
| 14c89e9716 | |||
| 549c424a38 | |||
| 186b9befcd | |||
| 863d413d32 | |||
| cf8478ffbb | |||
| 8be5be93ba | |||
| add3fc8f70 | |||
| 0d841bcd05 | |||
| dd1d0e5677 | |||
| 36afa40857 | |||
| 2afbd18233 | |||
| 953496dc37 | |||
| 797491d8b3 | |||
| 2df754459b | |||
| 0896d4089e | |||
| d153d91307 | |||
| ce55af12d5 | |||
| 545800a94e | |||
| bfb309bd0c | |||
| 38d9390eea | |||
| 8d3eca7027 | |||
| 1d6bba4d3f | |||
| 9bb69c0e50 | |||
| 520ec44aec | |||
| 8c6a243ffd | |||
| add6145f1c | |||
| 4e14d08173 | |||
| 8f2f788b6e | |||
| e96d7fd961 | |||
| 2ac7809385 | |||
| 15292ae76c | |||
| 055cf49bd5 | |||
| 8ee7915418 | |||
| e8148bc0a7 | |||
| 625264ba4b | |||
| f34e590bcd | |||
| d208fd7bf5 | |||
| 886c65725b | |||
| 9977af68d7 | |||
| 8e44f07dd4 | |||
| 8c43f13d93 | |||
| 5cf4ff1bd2 | |||
| 6e475e5503 | |||
| 0f8499b141 | |||
| 82e1dc4ae8 | |||
| e70b021cde | |||
| ea90a12846 | |||
| 3b1f4501d6 | |||
| 385fb014fc | |||
| b2160a56aa | |||
| f72658bb50 | |||
| 3180f3f881 | |||
| b0bdc57b37 | |||
| 314b91efc0 | |||
| 53ed3b77c9 | |||
| 070714ec58 | |||
| e4907c2c33 | |||
| 9a142302df | |||
| 3f39f57653 | |||
| 1b86950f10 | |||
| 8b435eb02d | |||
| 7e2004f945 | |||
| 63901d1bde | |||
| e5d96e348f | |||
| 439c24f1b7 | |||
| 2a144bb8d6 | |||
| 197faaaa14 | |||
| 3ffd6dde00 | |||
| e856e924a5 | |||
| ee7f891698 | |||
| 7fde348e2b | |||
| 122135e6dc | |||
| 8563fb0681 | |||
| 0155ab1943 | |||
| d1f94fcd24 | |||
| dd60983f7f | |||
| 096b8bf589 | |||
| 59c4d33adc | |||
| 5f5c28d18d | |||
| b92d1f9343 | |||
| e03c5c194a | |||
| acbb1144b7 | |||
| 028897f38f | |||
| ac0a799cbf | |||
| e3986eb58c | |||
| 6d0d48d585 | |||
| a142c496f7 | |||
| c5b4d11536 | |||
| ed8b4149e7 | |||
| d5ce02d1b8 | |||
| c8af599944 | |||
| 435233a062 | |||
| 785fce3754 | |||
| 01433bae32 | |||
| 6d521a9d8d | |||
| adfae67611 | |||
| 13a37def89 | |||
| 03382e9dd7 | |||
| 9763551656 | |||
| a9c9cb300d | |||
| a619fc1aa2 | |||
| 3d2f3e58ca | |||
| 11afc64ba4 | |||
| 0ee75d393c | |||
| 72b9cc7ed0 | |||
| 5fcf234165 | |||
| 9b1236505f | |||
| 878cd68c1b | |||
| fab1ebf2d6 | |||
| 3f9e89d345 | |||
| 14810a5090 | |||
| 2cfa86e570 | |||
| 36cbf17e10 | |||
| 4459ab2e54 | |||
| 0516bea638 | |||
| 32f067f5bb |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -0,0 +1,22 @@
|
||||
# Remove /add-clidash
|
||||
|
||||
clidash is fully self-contained, so removal is a single directory delete. It
|
||||
made no edits to NanoClaw `src/`, added no dependency, and wired into nothing.
|
||||
|
||||
```bash
|
||||
# Stop the service first if you set one up:
|
||||
systemctl --user disable --now clidash 2>/dev/null || true
|
||||
rm -f ~/.config/systemd/user/clidash.service
|
||||
|
||||
# Remove the tool:
|
||||
rm -rf tools/clidash
|
||||
```
|
||||
|
||||
If you added the config to `.gitignore` in step 2 of the install, remove that
|
||||
line too:
|
||||
|
||||
```
|
||||
tools/clidash/clidash.config.json
|
||||
```
|
||||
|
||||
Nothing else needs reverting.
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: add-clidash
|
||||
description: Add clidash — a zero-dependency, read-only web dashboard that derives its tabs and tables at runtime from any CLI that lists resources as JSON. Ships pre-wired for NanoClaw's ncl CLI (agent groups, sessions, channels, users, roles), plus message-activity charts, a log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.
|
||||
---
|
||||
|
||||
# /add-clidash — CLI-derived read-only dashboard
|
||||
|
||||
clidash is a small, read-only web dashboard. You point it at any CLI that can
|
||||
list resources as JSON (NanoClaw's `ncl`, `docker`, `kubectl`, …) and it builds
|
||||
the dashboard at runtime: one tab per resource, a generic table over whatever
|
||||
columns the rows have. A new `ncl` resource becomes a new tab and a new column
|
||||
becomes a new table column with **zero code changes**.
|
||||
|
||||
It ships pre-wired for NanoClaw's `ncl` CLI and adds three NanoClaw-aware
|
||||
panels driven entirely by config:
|
||||
|
||||
- **Agents overview** — status cards joining groups + sessions + messaging
|
||||
groups + wirings (green <15m / amber <2h / red older).
|
||||
- **Activity** — per-session inbound/outbound message totals and a daily series,
|
||||
read directly from the session DBs (`ncl` has no messages resource).
|
||||
- **Logs** — last N lines of allowlisted host log files.
|
||||
- **Files** — a read-only viewer for group skills, `CLAUDE.md`, and profiles.
|
||||
|
||||
## Why it's safe
|
||||
|
||||
clidash is **read-only by construction**: the server can only `execFile` the
|
||||
argv templates in its config. `{resource}` is the sole substitution and is
|
||||
allowlist-validated against the discovered/static resource set before exec —
|
||||
never a shell, no free-form input reaches argv. There is no auth; **the network
|
||||
is the auth boundary** — it binds `127.0.0.1` by default. Only ever bind a
|
||||
private interface (e.g. a tailnet IP), never a public one.
|
||||
|
||||
It's distinct from `/add-dashboard` (which pushes JSON snapshots to a separate
|
||||
`@nanoco/nanoclaw-dashboard` npm package): clidash has **zero dependencies**, no
|
||||
build step, no push pipeline, and no edits to NanoClaw source — it just reads
|
||||
`ncl` and the session DBs.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Copy the tool into place
|
||||
|
||||
clidash is fully self-contained — copy the whole directory in:
|
||||
|
||||
`tools/` is not a standard NanoClaw directory and `cp -R` won't create it, so
|
||||
make it first:
|
||||
|
||||
```bash
|
||||
mkdir -p tools
|
||||
cp -R .claude/skills/add-clidash/add/tools/clidash tools/clidash
|
||||
```
|
||||
|
||||
That is the only file change this skill makes. Nothing in NanoClaw `src/` is
|
||||
touched, no dependency is added.
|
||||
|
||||
### 2. Create the config
|
||||
|
||||
The example config is pre-wired for NanoClaw with paths relative to the repo
|
||||
root, so it works as-is when you run clidash from `tools/clidash/`:
|
||||
|
||||
```bash
|
||||
cd tools/clidash
|
||||
cp clidash.config.example.json clidash.config.json
|
||||
```
|
||||
|
||||
`clidash.config.json` is your local config — add it to `.gitignore` if you
|
||||
don't want to commit install-specific paths:
|
||||
|
||||
```bash
|
||||
echo 'tools/clidash/clidash.config.json' >> ../../.gitignore
|
||||
```
|
||||
|
||||
The example assumes `ncl` is built at `bin/ncl`. If `bin/ncl` doesn't exist,
|
||||
build it first (`pnpm run build`) or point `clis.ncl.bin` at the right path.
|
||||
|
||||
### 3. Test
|
||||
|
||||
Tests use a stub CLI — no real `ncl` or `docker` needed:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
All tests should pass (Node ≥ 22.5, `node:test`, zero dependencies).
|
||||
|
||||
### 4. Run and verify
|
||||
|
||||
```bash
|
||||
node server.js # serves http://127.0.0.1:4690
|
||||
```
|
||||
|
||||
In another shell, confirm it's live and that `ncl` discovery worked:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:4690/api/clis | head -c 400 # CLIs + discovered resources
|
||||
curl -s http://127.0.0.1:4690/api/r/ncl/groups | head -c 400 # a real resource table
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:4690/` in a browser. You should see the Agents
|
||||
overview plus a tab per `ncl` resource.
|
||||
|
||||
### 5. (Optional) Run as a service
|
||||
|
||||
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
|
||||
private (e.g. tailnet) IP via the `BIND` env var or `bind` in config — never a
|
||||
public interface.
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/clidash.service (Linux)
|
||||
[Unit]
|
||||
Description=clidash read-only CLI dashboard
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=%h/nanoclaw/tools/clidash
|
||||
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
|
||||
Environment=BIND=127.0.0.1
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now clidash
|
||||
```
|
||||
|
||||
On macOS, wrap `node server.js` (with `WorkingDirectory` = `tools/clidash`) in a
|
||||
launchd plist the same way the main NanoClaw service is configured.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
`clidash.config.json` keys (see `tools/clidash/README.md` and
|
||||
`clidash.config.example.json` for the full shape):
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `port`, `bind`, `refreshSeconds` | server bind + UI auto-refresh cadence |
|
||||
| `clis.<name>.bin` / `cwd` / `env` | how to invoke the CLI (`bin` is relative to `cwd`) |
|
||||
| `clis.<name>.discover` or `resources` | runtime discovery (`ncl help`) vs a static resource list |
|
||||
| `clis.<name>.list` | argv template; `{resource}` is the only substitution |
|
||||
| `clis.<name>.output` | `json` or `jsonlines` (docker/kubectl style) |
|
||||
| `clis.<name>.unwrap` | dot-path into a response envelope (e.g. `data`) |
|
||||
| `clis.<name>.enrich`/`badges`/`summary` | table decorations (ID→name joins, status colors, summary cards) |
|
||||
| `activity` | `sessionsRoot` + `days` for the message-activity charts |
|
||||
| `logs` | `dir`, `tailLines`, and an allowlist of `files` to tail |
|
||||
| `docs` | file viewer: `root`, a `deny` glob list, and `collections` of glob patterns |
|
||||
|
||||
Adding a second CLI is config-only — e.g. `docker` is included as a `jsonlines`
|
||||
example. View plugins (`views/<cli>-<view>.js`) are the only per-CLI code and
|
||||
are optional.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`ENOENT` / config not found** — run from `tools/clidash/` and make sure you
|
||||
copied `clidash.config.example.json` to `clidash.config.json` (step 2), or set
|
||||
`CLIDASH_CONFIG=/abs/path.json`.
|
||||
- **No `ncl` resources / discovery empty** — `bin/ncl` isn't built or the path
|
||||
is wrong. Build it (`pnpm run build`) or fix `clis.ncl.bin`.
|
||||
- **docker tab errors** — the docker daemon isn't running, or remove the
|
||||
`docker` CLI from config if you don't need it.
|
||||
- **Can't reach it from another device** — it binds `127.0.0.1`; set
|
||||
`BIND=<private-ip>` (tailnet), never a public interface.
|
||||
- **Empty Activity/Logs/Files** — check that `activity.sessionsRoot`,
|
||||
`logs.dir`, and `docs.root` resolve to your NanoClaw root (relative to where
|
||||
you launch `node server.js`).
|
||||
|
||||
## Removal
|
||||
|
||||
See [REMOVE.md](REMOVE.md).
|
||||
@@ -0,0 +1,109 @@
|
||||
# clidash
|
||||
|
||||
CLI-agnostic **read-only** web dashboard. Point it at any CLI that can list
|
||||
resources as JSON and it derives the dashboard at runtime: one tab per
|
||||
resource, a generic table over whatever columns the rows have. New resource →
|
||||
new tab; new column → new table column; **zero code changes**.
|
||||
|
||||
It ships pre-wired for NanoClaw's `ncl` CLI (agent groups, sessions, messaging
|
||||
groups, wirings, users, roles, …) plus `docker`, but the same config shape
|
||||
works for any list-as-JSON CLI.
|
||||
|
||||
- **Zero dependencies** — Node built-ins only (Node ≥ 22.5, for `node:sqlite`),
|
||||
no build step,
|
||||
vanilla-JS frontend.
|
||||
- **Read-only by construction** — the server can only `execFile` the configured
|
||||
argv templates; `{resource}` is the sole substitution and is validated
|
||||
against the discovered/static resource allowlist. Never a shell.
|
||||
- **Standalone** — no imports from NanoClaw source; the core is extractable to
|
||||
its own repo. The NanoClaw-specific knowledge lives entirely in the config
|
||||
and in the `views/ncl-overview.js` view plugin.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cp clidash.config.example.json clidash.config.json # then edit paths if needed
|
||||
node server.js # uses ./clidash.config.json
|
||||
CLIDASH_CONFIG=/path/to.json node server.js
|
||||
PORT=4690 BIND=127.0.0.1 node server.js # env overrides
|
||||
```
|
||||
|
||||
Run it from `tools/clidash/`; the example config uses paths relative to the
|
||||
NanoClaw root two levels up, so it works out of the box once `ncl` is built.
|
||||
|
||||
## Configure (`clidash.config.json`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"port": 4690,
|
||||
"bind": "127.0.0.1", // never a public interface; a tailnet IP at most
|
||||
"refreshSeconds": 60,
|
||||
"clis": {
|
||||
"ncl": {
|
||||
"bin": "bin/ncl", // relative to cwd below
|
||||
"cwd": "../..", // the NanoClaw root
|
||||
"discover": { "args": ["help"], "parser": "ncl-help" }, // runtime resource discovery
|
||||
"list": ["{resource}", "list", "--json"], // argv template
|
||||
"output": "json", // or "jsonlines" (docker/kubectl style)
|
||||
"unwrap": "data" // dot-path into a response envelope
|
||||
},
|
||||
"docker": {
|
||||
"bin": "docker",
|
||||
"resources": ["ps", "images"], // static alternative to discover
|
||||
"list": ["{resource}", "--format", "{{json .}}"],
|
||||
"output": "jsonlines"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`{resource}` may appear as a whole argv element or inside one — e.g. a remote
|
||||
CLI via ssh: `"list": ["-i", "key.pem", "user@host", "ncl {resource} list --json"]`.
|
||||
|
||||
Per-CLI `env` (merged over the server's env) and `cwd` are supported. See
|
||||
`clidash.config.example.json` for the full NanoClaw config, including the
|
||||
`enrich`/`badges`/`summary` table decorations and the `activity`/`logs`/`docs`
|
||||
sections.
|
||||
|
||||
## API
|
||||
|
||||
| Route | Returns |
|
||||
|---|---|
|
||||
| `GET /api/clis` | configured CLIs + discovered/static resources (discovery cached 60s) |
|
||||
| `GET /api/r/<cli>/<resource>` | `{ok, rows, fetchedAt}` — coalesced, 10s exec timeout |
|
||||
| `GET /api/view/<cli>/<view>` | curated view plugin from `views/<cli>-<view>.js` |
|
||||
|
||||
View plugins are the only per-CLI *code*, and optional: a default-exported
|
||||
async function receiving `{ fetch }` (bound to that CLI) returning JSON.
|
||||
`views/ncl-overview.js` joins groups + sessions + messaging-groups + wirings
|
||||
into per-agent status cards (green <15m / amber <2h / red older).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # unit + integration (node:test, stub CLI — no real CLI needed)
|
||||
./test/smoke.sh # against a running instance
|
||||
```
|
||||
|
||||
## Deploy as a service
|
||||
|
||||
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
|
||||
private (e.g. tailnet) IP — **never a public interface**; the network is the
|
||||
auth boundary. Example systemd user service:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/clidash.service
|
||||
[Unit]
|
||||
Description=clidash read-only CLI dashboard
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=%h/nanoclaw/tools/clidash
|
||||
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
|
||||
Environment=BIND=127.0.0.1
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
Then `systemctl --user enable --now clidash`.
|
||||
@@ -0,0 +1,80 @@
|
||||
// Message-activity reader for clidash.
|
||||
//
|
||||
// ncl has no `messages` resource — message data lives in the per-session SQLite
|
||||
// DBs (`data/v2-sessions/<group>/<session>/{inbound,outbound}.db`). We read them
|
||||
// read-only with Node's built-in `node:sqlite` (no new dependency) and aggregate
|
||||
// per-session in/out totals + a daily time-series for charting.
|
||||
|
||||
import { readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
// Timestamps come in two shapes across tables: SQLite "YYYY-MM-DD HH:MM:SS" (UTC)
|
||||
// and already-ISO "YYYY-MM-DDTHH:MM:SS.sssZ". Normalize to a comparable ISO form
|
||||
// so date-bucketing and max("last") work regardless of which a row used.
|
||||
function normTs(ts) {
|
||||
if (typeof ts !== 'string' || ts.length < 10) return null;
|
||||
if (ts.includes('T')) return ts; // already ISO
|
||||
return `${ts.replace(' ', 'T')}Z`;
|
||||
}
|
||||
|
||||
function readTable(dbPath, table) {
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const rows = db.prepare(`SELECT timestamp FROM ${table}`).all();
|
||||
const byDay = new Map();
|
||||
let last = null;
|
||||
for (const r of rows) {
|
||||
const ts = normTs(r.timestamp);
|
||||
if (!ts) continue;
|
||||
const day = ts.slice(0, 10); // ISO date prefix
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
||||
if (last === null || ts > last) last = ts;
|
||||
}
|
||||
return { total: rows.length, byDay, last };
|
||||
} catch {
|
||||
return { total: 0, byDay: new Map(), last: null }; // missing/locked/corrupt → skip
|
||||
} finally {
|
||||
try { db?.close(); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
|
||||
function listDirs(path) {
|
||||
try {
|
||||
return readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate message activity across all session DBs under `sessionsRoot`.
|
||||
* @returns {{ sessions: Array, series: Array<{date,in,out}> }}
|
||||
* sessions — per session: { agent_group_id, session_id, in, out, lastActivity }
|
||||
* series — one bucket per day for the last `days` days (UTC, newest last)
|
||||
*/
|
||||
export function collectActivity(sessionsRoot, days, now) {
|
||||
const dates = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10));
|
||||
}
|
||||
const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }]));
|
||||
const sessions = [];
|
||||
|
||||
for (const group of listDirs(sessionsRoot)) {
|
||||
for (const session of listDirs(join(sessionsRoot, group))) {
|
||||
const base = join(sessionsRoot, group, session);
|
||||
// a real session dir has at least one of the two message DBs; skip shared
|
||||
// scaffolding dirs like `.claude-shared` that don't.
|
||||
if (!existsSync(join(base, 'inbound.db')) && !existsSync(join(base, 'outbound.db'))) continue;
|
||||
const inb = readTable(join(base, 'inbound.db'), 'messages_in');
|
||||
const out = readTable(join(base, 'outbound.db'), 'messages_out');
|
||||
const lastActivity = [inb.last, out.last].filter(Boolean).sort().at(-1) ?? null;
|
||||
sessions.push({ agent_group_id: group, session_id: session, in: inb.total, out: out.total, lastActivity });
|
||||
for (const [day, n] of inb.byDay) series.get(day)?.in !== undefined && (series.get(day).in += n);
|
||||
for (const [day, n] of out.byDay) series.get(day)?.out !== undefined && (series.get(day).out += n);
|
||||
}
|
||||
}
|
||||
return { sessions, series: dates.map((d) => series.get(d)) };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"port": 4690,
|
||||
"bind": "127.0.0.1",
|
||||
"refreshSeconds": 60,
|
||||
"clis": {
|
||||
"ncl": {
|
||||
"bin": "bin/ncl",
|
||||
"cwd": "../..",
|
||||
"discover": { "args": ["help"], "parser": "ncl-help" },
|
||||
"list": ["{resource}", "list", "--json"],
|
||||
"output": "json",
|
||||
"unwrap": "data",
|
||||
"commands": {
|
||||
"get": ["{resource}", "get", "--id", "{id}", "--json"],
|
||||
"config-get": ["groups", "config", "get", "--id", "{id}", "--json"]
|
||||
},
|
||||
"help": ["{resource}", "help"],
|
||||
"enrich": {
|
||||
"sessions": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
|
||||
},
|
||||
"wirings": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
|
||||
},
|
||||
"roles": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"user_id": { "ref": "users", "label": "display_name" },
|
||||
"granted_by": { "ref": "users", "label": "display_name" }
|
||||
},
|
||||
"members": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"user_id": { "ref": "users", "label": "display_name" }
|
||||
},
|
||||
"destinations": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" }
|
||||
},
|
||||
"user-dms": {
|
||||
"user_id": { "ref": "users", "label": "display_name" },
|
||||
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"container_status": { "running": "green", "idle": "green", "starting": "amber", "stopped": "gray", "error": "red" },
|
||||
"status": { "active": "green", "stopped": "gray", "error": "red", "pending": "amber" }
|
||||
},
|
||||
"summary": {
|
||||
"sessions": "container_status",
|
||||
"messaging-groups": "channel_type",
|
||||
"roles": "role",
|
||||
"users": "kind",
|
||||
"destinations": "target_type",
|
||||
"dropped-messages": "reason"
|
||||
}
|
||||
},
|
||||
"docker": {
|
||||
"bin": "docker",
|
||||
"resources": ["ps", "images"],
|
||||
"list": ["{resource}", "--format", "{{json .}}"],
|
||||
"output": "jsonlines"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"sessionsRoot": "../../data/v2-sessions",
|
||||
"days": 14
|
||||
},
|
||||
"logs": {
|
||||
"dir": "../../logs",
|
||||
"tailLines": 500,
|
||||
"files": [
|
||||
{ "name": "nanoclaw.log", "label": "host log" },
|
||||
{ "name": "nanoclaw.error.log", "label": "errors" }
|
||||
]
|
||||
},
|
||||
"docs": {
|
||||
"root": "../..",
|
||||
"deny": ["node_modules", ".env", "*token*", "*secret*", "*.pem", "*.key", "*.lock", "pnpm-lock.yaml"],
|
||||
"collections": [
|
||||
{
|
||||
"name": "skills",
|
||||
"label": "Skills",
|
||||
"lang": "markdown",
|
||||
"patterns": ["groups/*/skills/*/SKILL.md", "container/skills/*/SKILL.md"]
|
||||
},
|
||||
{
|
||||
"name": "claude-md",
|
||||
"label": "CLAUDE.md",
|
||||
"lang": "markdown",
|
||||
"patterns": ["groups/*/CLAUDE.md", "groups/*/CLAUDE.local.md"]
|
||||
},
|
||||
{
|
||||
"name": "profiles",
|
||||
"label": "Profiles",
|
||||
"lang": "json",
|
||||
"patterns": ["groups/*/profile.json"]
|
||||
},
|
||||
{
|
||||
"name": "conversations",
|
||||
"label": "Conversations",
|
||||
"lang": "markdown",
|
||||
"patterns": ["groups/*/conversations/*.md"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Read-only file viewer for clidash.
|
||||
//
|
||||
// Surfaces on-disk documents (skills, CLAUDE.md, profile.json, conversations)
|
||||
// that are NOT ncl resources. Same security posture as the rest of clidash:
|
||||
// only files matching a configured collection's glob patterns are listable or
|
||||
// readable; a deny-list blocks secrets; path traversal is impossible because a
|
||||
// requested path must be a member of the freshly-globbed allow-set.
|
||||
|
||||
import { readdirSync, realpathSync } from 'node:fs';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
|
||||
// Convert one glob segment to an anchored regex. `*` matches any run of
|
||||
// non-slash chars (so it works both as a whole segment and inside a filename,
|
||||
// e.g. `CLAUDE*.md`). All other regex metacharacters are escaped.
|
||||
function segToRegExp(seg) {
|
||||
const esc = seg.replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '[^/]*');
|
||||
return new RegExp('^' + esc + '$');
|
||||
}
|
||||
|
||||
// A path is denied if any of its segments matches any deny glob.
|
||||
function isDenied(relPath, deny) {
|
||||
const segs = relPath.split('/');
|
||||
return deny.some((d) => {
|
||||
const re = segToRegExp(d);
|
||||
return segs.some((s) => re.test(s));
|
||||
});
|
||||
}
|
||||
|
||||
// Directed walk: descend only entries matching each successive pattern segment.
|
||||
function walk(root, rel, segs, depth, out, deny) {
|
||||
if (depth >= segs.length) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(join(root, rel), { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const re = segToRegExp(segs[depth]);
|
||||
const last = depth === segs.length - 1;
|
||||
for (const e of entries) {
|
||||
if (e.name === '.' || e.name === '..') continue;
|
||||
if (!re.test(e.name)) continue;
|
||||
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (isDenied(childRel, deny)) continue;
|
||||
if (last) {
|
||||
if (e.isFile()) out.add(childRel);
|
||||
} else if (e.isDirectory()) {
|
||||
walk(root, childRel, segs, depth + 1, out, deny);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative paths under `root` matching any of `patterns`, minus `deny` matches.
|
||||
* Sorted, de-duplicated. Patterns use `*` per the segment rules above; no `**`.
|
||||
*/
|
||||
export function globFiles(root, patterns, deny = []) {
|
||||
const out = new Set();
|
||||
for (const pattern of patterns) {
|
||||
walk(root, '', pattern.split('/'), 0, out, deny);
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly grouping/label for a relative path.
|
||||
* `groups/<g>/...` → group `<g>`; `container/...` → group `shared`.
|
||||
*/
|
||||
const CONTAINER_SEGS = new Set(['skills', 'conversations']); // redundant grouping dirs
|
||||
export function describeFile(relPath) {
|
||||
const parts = relPath.split('/');
|
||||
if (parts[0] === 'groups' && parts.length > 2) {
|
||||
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
|
||||
return { group: parts[1], label: `${parts[1]} / ${rest}` };
|
||||
}
|
||||
if (parts[0] === 'container') {
|
||||
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
|
||||
return { group: 'shared', label: `shared / ${rest}` };
|
||||
}
|
||||
return { group: '', label: relPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a requested doc path against a collection and return its absolute
|
||||
* path, or throw. A path is allowed only if it is a member of the collection's
|
||||
* freshly-globbed allow-set — this single check enforces the patterns, the
|
||||
* deny-list, and traversal safety at once.
|
||||
*/
|
||||
export function resolveDoc(root, collection, relPath, deny = []) {
|
||||
const allowed = new Set(globFiles(root, collection.patterns, deny));
|
||||
if (!allowed.has(relPath)) {
|
||||
throw new Error(`Path not allowed: ${relPath}`);
|
||||
}
|
||||
// Defence in depth: the resolved real path must still live under root.
|
||||
const abs = resolve(root, relPath);
|
||||
const rootReal = realpathSync(root);
|
||||
const absReal = realpathSync(abs);
|
||||
if (absReal !== rootReal && !absReal.startsWith(rootReal + sep)) {
|
||||
throw new Error(`Path not allowed: ${relPath}`);
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Log tailing for clidash — reads the last N lines of an allowlisted log file
|
||||
// and strips ANSI color codes (the host logger writes colored output).
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
/**
|
||||
* Last `maxLines` lines of a log file, ANSI-stripped.
|
||||
* @returns {{ lines: string[], text: string }}
|
||||
*/
|
||||
export async function tailFile(path, maxLines) {
|
||||
const raw = (await readFile(path, 'utf8')).replace(ANSI_RE, '');
|
||||
const all = raw.split('\n');
|
||||
if (all.length && all.at(-1) === '') all.pop(); // drop trailing newline's empty field
|
||||
const lines = all.slice(-maxLines);
|
||||
return { lines, text: lines.join('\n') };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "clidash",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI-agnostic read-only web dashboard — derives tabs and tables from any CLI that lists resources as JSON",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node --test 'test/*.test.js'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Pluggable parsers for clidash.
|
||||
//
|
||||
// discoveryParsers — turn a CLI's "help"-style output into a resource list.
|
||||
// parseOutput / unwrapPath — turn a CLI's list output into rows.
|
||||
// All per-CLI knowledge beyond these small functions lives in clidash.config.json.
|
||||
|
||||
/**
|
||||
* Discovery parsers, keyed by the `discover.parser` name in config.
|
||||
* Each receives the raw discovery output and returns
|
||||
* [{ name, description, verbs }] for resources that support `list`.
|
||||
* They must throw loudly on unrecognized formats — silent empty results
|
||||
* would render as silently-stale tabs.
|
||||
*/
|
||||
export const discoveryParsers = {
|
||||
/**
|
||||
* Parses ncl's two-column help format:
|
||||
*
|
||||
* Resources:
|
||||
* sessions Session — the runtime unit. ...
|
||||
* verbs: list, get
|
||||
* Commands:
|
||||
* help ...
|
||||
*/
|
||||
'ncl-help'(text) {
|
||||
const lines = String(text).split('\n');
|
||||
const start = lines.findIndex((l) => l.trim() === 'Resources:');
|
||||
if (start === -1) {
|
||||
throw new Error('ncl-help parser: no "Resources:" section in output — format may have changed');
|
||||
}
|
||||
const resources = [];
|
||||
let current = null;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
if (/^\S/.test(line)) break; // next top-level section, e.g. "Commands:"
|
||||
const verbsMatch = line.match(/^\s+verbs:\s*(.+)$/);
|
||||
if (verbsMatch && current) {
|
||||
current.verbs = verbsMatch[1].split(',').map((v) => v.trim()).filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
const resMatch = line.match(/^ (\S+)\s{2,}(\S.*)$/);
|
||||
if (resMatch) {
|
||||
current = { name: resMatch[1], description: resMatch[2].trim(), verbs: [] };
|
||||
resources.push(current);
|
||||
}
|
||||
}
|
||||
return resources.filter((r) => r.verbs.includes('list'));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a CLI's list output per the config's `output` field.
|
||||
* - 'json' — one JSON document.
|
||||
* - 'jsonlines' — one JSON object per line (docker/kubectl style).
|
||||
* Thrown errors carry the raw output on `err.raw` so the UI can show it.
|
||||
*/
|
||||
export function parseOutput(text, format) {
|
||||
if (format === 'json') {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
const err = new Error(`Invalid JSON output: ${e.message}`);
|
||||
err.raw = text;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (format === 'jsonlines') {
|
||||
const rows = [];
|
||||
const lines = String(text).split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch (e) {
|
||||
const err = new Error(`Invalid JSON on line ${i + 1}: ${e.message}`);
|
||||
err.raw = text;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
throw new Error(`Unknown output format: ${format}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a dot-path into a response envelope (e.g. 'data' for ncl's
|
||||
* {id, ok, data} frame). No path → value passes through unchanged.
|
||||
* Missing path throws — a changed envelope must fail loudly.
|
||||
*/
|
||||
export function unwrapPath(value, path) {
|
||||
if (!path) return value;
|
||||
let cur = value;
|
||||
for (const key of path.split('.')) {
|
||||
if (cur === null || typeof cur !== 'object' || !(key in cur)) {
|
||||
throw new Error(`Unwrap path "${path}" not found in CLI output (missing "${key}")`);
|
||||
}
|
||||
cur = cur[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
// clidash frontend — vanilla JS, no build step.
|
||||
//
|
||||
// Layout: a left sidebar with top-level items (Overview, Activity) and grouped
|
||||
// sections (one per CLI — ncl, docker — and a Files section for on-disk docs).
|
||||
// Each page shows the exact command that produced it. Tables auto-derive from
|
||||
// `ncl <resource> list --json`; rows drill into their `get` detail.
|
||||
//
|
||||
// Refresh UX: on first load every resource of every CLI is prefetched so nav is
|
||||
// instant. 60s auto-refresh + a manual button. Background refreshes diff-and-
|
||||
// inject (the data DOM rebuilds only when the data signature changes).
|
||||
|
||||
import { mdToHtml } from './md.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
clis: [],
|
||||
docCollections: [],
|
||||
activeView: 'overview', // 'overview' | 'activity' | 'r:<cli>:<resource>' | 'doc:<collection>'
|
||||
paused: false,
|
||||
refreshSeconds: 60,
|
||||
lastUpdated: null,
|
||||
refreshing: false,
|
||||
snapshots: new Map(), // "cli/resource" -> { rows, fetchedAt, command }
|
||||
errors: new Map(),
|
||||
activity: null, // { sessions, series }
|
||||
activityConfigured: false,
|
||||
activityCommand: null,
|
||||
logs: [], // [{ name, label }]
|
||||
logCache: new Map(), // name -> { text, command }
|
||||
activeDocPath: null,
|
||||
openDocGroups: new Set(), // which doc groups (e.g. agents) are expanded
|
||||
docCache: new Map(),
|
||||
configCache: new Map(), // groupId -> container config (for the overview page)
|
||||
helpCache: new Map(), // "cli/resource" -> help text | null (prefetched each cycle)
|
||||
detail: null,
|
||||
sidebarOpen: false,
|
||||
renderedSig: null,
|
||||
};
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
function svg(tag, attrs = {}, children = []) {
|
||||
const node = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
||||
for (const c of [].concat(children)) if (c != null) node.append(c);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Lucide-style inline icons (static trusted markup) — crisp, themeable via currentColor.
|
||||
const ICONS = {
|
||||
overview: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
|
||||
activity: '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
|
||||
terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3"/><path d="M13 15h4"/>',
|
||||
box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
|
||||
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
|
||||
logs: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>',
|
||||
};
|
||||
function icon(name) {
|
||||
const s = document.createElementNS(SVG_NS, 'svg');
|
||||
s.setAttribute('viewBox', '0 0 24 24');
|
||||
s.setAttribute('fill', 'none');
|
||||
s.setAttribute('stroke', 'currentColor');
|
||||
s.setAttribute('stroke-width', '1.8');
|
||||
s.setAttribute('stroke-linecap', 'round');
|
||||
s.setAttribute('stroke-linejoin', 'round');
|
||||
s.innerHTML = ICONS[name] ?? '';
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
||||
|
||||
function relTime(iso) {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return iso;
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 0) return new Date(iso).toLocaleString();
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 48) return `${h}h ago`;
|
||||
return `${Math.round(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function coarseAgo(date) {
|
||||
const s = (Date.now() - date.getTime()) / 1000;
|
||||
if (s < 60) return 'less than a minute ago';
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return m === 1 ? '1 minute ago' : `${m} minutes ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return h === 1 ? '1 hour ago' : `${h} hours ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return d === 1 ? '1 day ago' : `${d} days ago`;
|
||||
}
|
||||
|
||||
function staleness(lastActive) {
|
||||
if (!lastActive) return 'gray';
|
||||
const min = (Date.now() - new Date(lastActive).getTime()) / 60000;
|
||||
if (Number.isNaN(min)) return 'gray';
|
||||
return min < 15 ? 'green' : min < 120 ? 'amber' : 'red';
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
|
||||
else node.setAttribute(k, v);
|
||||
}
|
||||
for (const child of [].concat(children)) {
|
||||
if (child == null) continue;
|
||||
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function fmtValue(value) {
|
||||
if (value === null || value === undefined) return { text: 'null', cls: 'null' };
|
||||
if (typeof value === 'string' && ISO_RE.test(value)) return { iso: value };
|
||||
return { text: typeof value === 'object' ? JSON.stringify(value) : String(value) };
|
||||
}
|
||||
|
||||
function cellFor(value) {
|
||||
const f = fmtValue(value);
|
||||
if (f.cls === 'null') return el('td', { class: 'null' }, 'null');
|
||||
if (f.iso) {
|
||||
return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [
|
||||
relTime(f.iso), el('span', { class: 'abs' }, f.iso.slice(0, 16).replace('T', ' ')),
|
||||
]));
|
||||
}
|
||||
if (f.text.length > 42) {
|
||||
const span = el('span', { class: 'trunc', title: f.text }, f.text.slice(0, 39) + '…');
|
||||
span.addEventListener('click', (e) => { e.stopPropagation(); span.textContent = f.text; span.classList.remove('trunc'); });
|
||||
return el('td', {}, span);
|
||||
}
|
||||
return el('td', {}, f.text);
|
||||
}
|
||||
|
||||
function kvRows(obj) {
|
||||
return Object.entries(obj ?? {}).map(([k, v]) => {
|
||||
let valEl;
|
||||
if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2));
|
||||
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${v.slice(0, 16).replace('T', ' ')})`);
|
||||
else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null');
|
||||
else valEl = el('span', {}, String(v));
|
||||
return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRef(cliName, ref, id) {
|
||||
const snap = state.snapshots.get(`${cliName}/${ref.ref}`);
|
||||
const row = snap?.rows?.find((r) => String(r.id) === String(id));
|
||||
return row ? (row[ref.label] ?? null) : null;
|
||||
}
|
||||
|
||||
function badgeChip(value, colorMap) {
|
||||
const color = colorMap[String(value).toLowerCase()] ?? 'gray';
|
||||
return el('span', { class: `badge-status ${color}` }, [el('span', { class: `dot ${color}` }), String(value)]);
|
||||
}
|
||||
|
||||
function buildCell(value, column, ctx) {
|
||||
if (ctx.badges?.[column] && value != null && typeof value !== 'object') {
|
||||
return el('td', {}, badgeChip(value, ctx.badges[column]));
|
||||
}
|
||||
if (ctx.enrich?.[column] && value != null) {
|
||||
const name = resolveRef(ctx.cliName, ctx.enrich[column], value);
|
||||
if (name != null) {
|
||||
return el('td', { class: 'enriched', title: String(value) }, [
|
||||
el('span', {}, String(name)), el('span', { class: 'raw-id' }, String(value)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return cellFor(value);
|
||||
}
|
||||
|
||||
function summaryBar(resource, rows, col, cli) {
|
||||
let label = resource.replace(/-/g, ' ');
|
||||
if (rows.length === 1 && label.endsWith('s')) label = label.slice(0, -1);
|
||||
const bits = [el('span', { class: 'sum-count' }, `${rows.length} ${label}`)];
|
||||
if (col && rows.some((r) => col in r)) {
|
||||
const counts = new Map();
|
||||
for (const r of rows) { const v = r[col] ?? '—'; counts.set(v, (counts.get(v) ?? 0) + 1); }
|
||||
const colorMap = cli.badges?.[col];
|
||||
for (const [v, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
bits.push(el('span', { class: 'sum-sep' }, '·'));
|
||||
const c = colorMap?.[String(v).toLowerCase()] ?? null;
|
||||
bits.push(c
|
||||
? el('span', { class: `badge-status ${c}` }, [el('span', { class: `dot ${c}` }), `${v} ×${n}`])
|
||||
: el('span', { class: 'sum-chip' }, `${v} ×${n}`));
|
||||
}
|
||||
}
|
||||
return el('div', { class: 'summary-bar' }, bits);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
const nclCli = () => state.clis.find((c) => c.name === 'ncl') ?? state.clis[0];
|
||||
function currentView() {
|
||||
const v = state.activeView;
|
||||
if (v === 'overview' || v === 'activity') return { type: v };
|
||||
const m = v.match(/^r:([^:]+):(.+)$/);
|
||||
if (m) return { type: 'resource', cli: m[1], resource: m[2] };
|
||||
if (v.startsWith('doc:')) return { type: 'doc', collection: v.slice(4) };
|
||||
if (v.startsWith('log:')) return { type: 'log', name: v.slice(4) };
|
||||
return { type: 'overview' };
|
||||
}
|
||||
const activeCollection = () => {
|
||||
const v = currentView();
|
||||
return v.type === 'doc' ? state.docCollections.find((c) => c.name === v.collection) : null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- fetching
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url);
|
||||
return res.json().catch(() => ({ ok: false, error: `Bad response from ${url}` }));
|
||||
}
|
||||
|
||||
async function refresh(force = false) {
|
||||
state.refreshing = true;
|
||||
if (force) renderControls();
|
||||
|
||||
const [cliList, docList, logList] = await Promise.all([
|
||||
fetchJson('/api/clis').catch(() => null),
|
||||
fetchJson('/api/docs').catch(() => null),
|
||||
fetchJson('/api/logs').catch(() => null),
|
||||
]);
|
||||
if (cliList?.clis) {
|
||||
state.clis = cliList.clis;
|
||||
state.refreshSeconds = cliList.clis[0]?.refreshSeconds ?? state.refreshSeconds;
|
||||
}
|
||||
if (docList?.collections) state.docCollections = docList.collections;
|
||||
if (logList?.files) state.logs = logList.files;
|
||||
|
||||
render(); // paint sidebar + active view's loading state immediately
|
||||
|
||||
const jobs = [];
|
||||
jobs.push(fetchJson('/api/activity').then((body) => {
|
||||
if (body.ok && body.configured) {
|
||||
state.activity = { sessions: body.sessions, series: body.series };
|
||||
state.activityConfigured = true; state.activityCommand = body.command ?? null;
|
||||
} else state.activityConfigured = false;
|
||||
render();
|
||||
}));
|
||||
for (const lg of state.logs) {
|
||||
jobs.push(fetchJson(`/api/log/${encodeURIComponent(lg.name)}`).then((body) => {
|
||||
if (body.ok) state.logCache.set(lg.name, { text: body.text, command: body.command });
|
||||
render();
|
||||
}));
|
||||
}
|
||||
for (const c of state.clis) {
|
||||
for (const r of c.resources ?? []) {
|
||||
const key = `${c.name}/${r.name}`;
|
||||
jobs.push(fetchJson(`/api/r/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
|
||||
if (body.ok) { state.snapshots.set(key, { rows: body.rows, fetchedAt: body.fetchedAt, command: body.command }); state.errors.set(key, null); }
|
||||
else state.errors.set(key, body.raw ? `${body.error}\n\n${body.raw}` : body.error);
|
||||
render();
|
||||
}));
|
||||
if (c.help) {
|
||||
jobs.push(fetchJson(`/api/help/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
|
||||
state.helpCache.set(key, body.ok ? body.text : null);
|
||||
render();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
|
||||
// per-group container config (for the Overview page) — small, refetched each cycle
|
||||
const groups = state.snapshots.get('ncl/groups')?.rows ?? [];
|
||||
await Promise.all(groups.map(async (g) => {
|
||||
const c = await fetchJson(`/api/cmd/ncl/config-get?id=${encodeURIComponent(g.id)}`);
|
||||
if (c.ok) state.configCache.set(g.id, c.data);
|
||||
}));
|
||||
|
||||
state.lastUpdated = new Date();
|
||||
state.refreshing = false;
|
||||
render();
|
||||
}
|
||||
|
||||
async function openDoc(collectionName, path) {
|
||||
state.activeDocPath = path;
|
||||
const key = `${collectionName}\0${path}`;
|
||||
if (!state.docCache.has(key)) {
|
||||
const body = await fetchJson(`/api/doc?c=${encodeURIComponent(collectionName)}&p=${encodeURIComponent(path)}`);
|
||||
state.docCache.set(key, body.ok ? { lang: body.lang, content: body.content } : { lang: 'error', content: body.error || 'Failed to load' });
|
||||
}
|
||||
state.renderedSig = null;
|
||||
render();
|
||||
}
|
||||
|
||||
async function openDetail(cliName, resource, id) {
|
||||
state.detail = { cli: cliName, resource, id, loading: true };
|
||||
state.renderedSig = null;
|
||||
render();
|
||||
const rec = await fetchJson(`/api/cmd/${cliName}/get?resource=${encodeURIComponent(resource)}&id=${encodeURIComponent(id)}`);
|
||||
let config = null;
|
||||
if (resource === 'groups') {
|
||||
const cg = await fetchJson(`/api/cmd/${cliName}/config-get?id=${encodeURIComponent(id)}`);
|
||||
if (cg.ok) config = cg.data;
|
||||
}
|
||||
if (!state.detail || state.detail.id !== id) return;
|
||||
state.detail = { cli: cliName, resource, id, record: rec.ok ? rec.data : null, error: rec.ok ? null : rec.error, config };
|
||||
state.renderedSig = null;
|
||||
render();
|
||||
}
|
||||
|
||||
function closeDetail() { state.detail = null; state.renderedSig = null; render(); }
|
||||
|
||||
// Help panel: the description (first paragraph) is always visible; the verbs +
|
||||
// fields (everything after the first blank line) sit behind a collapse.
|
||||
function helpPanel(text) {
|
||||
if (text === null) return null; // explicitly no help
|
||||
if (text === undefined) return el('div', { class: 'help-panel' }, el('div', { class: 'help-head dim' }, 'loading help…'));
|
||||
const idx = text.indexOf('\n\n');
|
||||
const head = (idx >= 0 ? text.slice(0, idx) : text).trim();
|
||||
const body = idx >= 0 ? text.slice(idx + 2).trim() : '';
|
||||
return el('div', { class: 'help-panel' }, [
|
||||
el('div', { class: 'help-head' }, head),
|
||||
body ? el('details', { class: 'help-more' }, [
|
||||
el('summary', {}, 'verbs & fields'),
|
||||
el('pre', { class: 'help-text' }, body),
|
||||
]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
function go(view) {
|
||||
state.activeView = view;
|
||||
state.detail = null;
|
||||
state.sidebarOpen = false;
|
||||
state.renderedSig = null;
|
||||
const v = currentView();
|
||||
if (v.type === 'doc') {
|
||||
const coll = state.docCollections.find((c) => c.name === v.collection);
|
||||
const first = coll && (coll.name === 'conversations' ? coll.files.at(-1) : coll.files[0]); // newest conversation
|
||||
state.activeDocPath = state.activeDocPath && coll?.files.some((f) => f.path === state.activeDocPath)
|
||||
? state.activeDocPath : (first?.path ?? null);
|
||||
// expand only the group holding the active doc; the user picks the rest
|
||||
const activeFile = coll?.files.find((f) => f.path === state.activeDocPath);
|
||||
state.openDocGroups = new Set(activeFile ? [activeFile.group] : []);
|
||||
render();
|
||||
if (state.activeDocPath) openDoc(coll.name, state.activeDocPath);
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rendering
|
||||
|
||||
function dataSignature() {
|
||||
const v = currentView();
|
||||
const key = v.type === 'resource' ? `${v.cli}/${v.resource}` : null;
|
||||
const coll = activeCollection();
|
||||
return JSON.stringify({
|
||||
view: state.activeView, clis: state.clis.map((c) => `${c.name}:${(c.resources || []).length}`),
|
||||
activityConfigured: state.activityConfigured,
|
||||
rows: key ? state.snapshots.get(key)?.rows ?? null : null,
|
||||
rowsError: key ? state.errors.get(key) ?? null : null,
|
||||
command: key ? state.snapshots.get(key)?.command ?? null : null,
|
||||
help: key ? state.helpCache.get(key) ?? null : null,
|
||||
overview: v.type === 'overview' ? {
|
||||
groups: state.snapshots.get('ncl/groups')?.rows ?? null,
|
||||
sessions: state.snapshots.get('ncl/sessions')?.rows ?? null,
|
||||
configs: [...state.configCache.entries()],
|
||||
activity: state.activity?.sessions ?? null,
|
||||
} : null,
|
||||
activity: v.type === 'activity' ? state.activity : null,
|
||||
log: v.type === 'log' ? state.logCache.get(v.name)?.text ?? null : null,
|
||||
docFiles: coll ? coll.files.map((f) => f.path) : null,
|
||||
docPath: state.activeDocPath,
|
||||
docGroupsOpen: coll ? [...state.openDocGroups] : null,
|
||||
docContent: coll ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`)?.content ?? null : null,
|
||||
detail: state.detail, paused: state.paused, sidebarOpen: state.sidebarOpen,
|
||||
});
|
||||
}
|
||||
|
||||
function renderControls() {
|
||||
$('updated').textContent = state.lastUpdated
|
||||
? `updated ${coarseAgo(state.lastUpdated)}${state.paused ? ' · paused' : ''}` : '';
|
||||
$('refresh').classList.toggle('spinning', state.refreshing);
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderControls();
|
||||
const sig = dataSignature();
|
||||
if (sig === state.renderedSig) return;
|
||||
state.renderedSig = sig;
|
||||
|
||||
$('sidebar').classList.toggle('open', state.sidebarOpen);
|
||||
$('scrim').hidden = !state.sidebarOpen;
|
||||
|
||||
renderNav();
|
||||
|
||||
const v = currentView();
|
||||
const banner = $('banner');
|
||||
const tabError = v.type === 'resource' ? state.errors.get(`${v.cli}/${v.resource}`) : null;
|
||||
const cli = v.type === 'resource' ? state.clis.find((c) => c.name === v.cli) : null;
|
||||
const bannerMsg = cli?.error ? `Discovery failed for ${v.cli}: ${cli.error}`
|
||||
: (tabError ? `CLI unreachable — showing last good snapshot. ${tabError.split('\n')[0]}` : null);
|
||||
banner.hidden = !bannerMsg;
|
||||
banner.textContent = bannerMsg ?? '';
|
||||
|
||||
renderCmdline(v);
|
||||
if (v.type === 'overview') renderOverviewPage();
|
||||
else if (v.type === 'activity') renderActivity();
|
||||
else if (v.type === 'doc') renderDocs();
|
||||
else if (v.type === 'log') renderLogPage(v.name);
|
||||
else renderTable(v.cli, v.resource);
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
function navItem(label, view, cls = '', iconName = null) {
|
||||
return el('button', {
|
||||
class: `nav-item ${cls}` + (state.activeView === view ? ' active' : ''),
|
||||
onclick: () => go(view),
|
||||
}, [iconName ? icon(iconName) : null, el('span', {}, label)]);
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = $('nav');
|
||||
const items = [navItem('Overview', 'overview', '', 'overview')];
|
||||
if (state.activityConfigured) items.push(navItem('Activity', 'activity', '', 'activity'));
|
||||
|
||||
for (const cli of state.clis) {
|
||||
items.push(el('div', { class: 'nav-section' }, [icon(cli.name === 'docker' ? 'box' : 'terminal'), el('span', {}, cli.name)]));
|
||||
for (const r of cli.resources ?? []) {
|
||||
items.push(navItem(r.name, `r:${cli.name}:${r.name}`, 'nav-sub'));
|
||||
}
|
||||
}
|
||||
if (state.docCollections.length) {
|
||||
items.push(el('div', { class: 'nav-section' }, [icon('folder'), el('span', {}, 'Files')]));
|
||||
for (const coll of state.docCollections) {
|
||||
items.push(navItem(coll.label, `doc:${coll.name}`, 'nav-sub'));
|
||||
}
|
||||
}
|
||||
if (state.logs.length) {
|
||||
items.push(el('div', { class: 'nav-section' }, [icon('logs'), el('span', {}, 'Logs')]));
|
||||
for (const lg of state.logs) {
|
||||
items.push(navItem(lg.label, `log:${lg.name}`, 'nav-sub'));
|
||||
}
|
||||
}
|
||||
nav.replaceChildren(...items);
|
||||
}
|
||||
|
||||
function renderCmdline(v) {
|
||||
const bar = $('cmdline');
|
||||
let cmd = null;
|
||||
if (v.type === 'resource') cmd = state.snapshots.get(`${v.cli}/${v.resource}`)?.command;
|
||||
else if (v.type === 'activity') cmd = state.activityCommand;
|
||||
else if (v.type === 'doc') cmd = state.activeDocPath ? `file · ${state.activeDocPath}` : null;
|
||||
else if (v.type === 'log') cmd = state.logCache.get(v.name)?.command ?? null;
|
||||
else if (v.type === 'overview') cmd = 'derived · ncl groups/sessions/messaging-groups/wirings + config-get + activity';
|
||||
bar.hidden = !cmd;
|
||||
bar.textContent = cmd ? `$ ${cmd}` : '';
|
||||
}
|
||||
|
||||
// ---- Overview page (rich agent cards) ----
|
||||
|
||||
function renderOverviewPage() {
|
||||
const content = $('content');
|
||||
const groups = state.snapshots.get('ncl/groups')?.rows;
|
||||
if (!groups) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
|
||||
const sessions = state.snapshots.get('ncl/sessions')?.rows ?? [];
|
||||
const wirings = state.snapshots.get('ncl/wirings')?.rows ?? [];
|
||||
const mgs = state.snapshots.get('ncl/messaging-groups')?.rows ?? [];
|
||||
const act = state.activity?.sessions ?? [];
|
||||
const mgName = (id) => mgs.find((m) => m.id === id)?.name ?? mgs.find((m) => m.id === id)?.platform_id ?? id;
|
||||
|
||||
const field = (k, v, cls = '') => el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, k), el('span', { class: `v ${cls}` }, v)]);
|
||||
|
||||
const cards = groups.map((g) => {
|
||||
const gs = sessions.filter((s) => s.agent_group_id === g.id);
|
||||
const lastActive = gs.map((s) => s.last_active).filter(Boolean).sort().at(-1) ?? null;
|
||||
const container = gs.some((s) => s.container_status === 'running') ? 'running' : (gs[0]?.container_status ?? 'none');
|
||||
const ga = act.filter((a) => a.agent_group_id === g.id);
|
||||
const msgIn = ga.reduce((a, s) => a + s.in, 0), msgOut = ga.reduce((a, s) => a + s.out, 0);
|
||||
const cfg = state.configCache.get(g.id);
|
||||
const chans = wirings.filter((w) => w.agent_group_id === g.id).map((w) => `${mgs.find((m) => m.id === w.messaging_group_id)?.channel_type ?? '?'}: ${mgName(w.messaging_group_id)}`);
|
||||
const status = staleness(lastActive);
|
||||
const containerColor = container === 'running' ? 'green' : container === 'idle' ? 'green' : container === 'none' ? 'gray' : 'gray';
|
||||
|
||||
const fields = [
|
||||
el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'container'), badgeChip(container, { running: 'green', idle: 'green', stopped: 'gray', none: 'gray' })]),
|
||||
field('sessions', String(gs.length)),
|
||||
field('messages', `${msgIn} in · ${msgOut} out`),
|
||||
field('last active', lastActive ? relTime(lastActive) : '—', lastActive ? '' : 'dim'),
|
||||
];
|
||||
if (cfg) {
|
||||
fields.push(field('provider / model', `${cfg.provider ?? 'claude'} / ${cfg.model ?? 'default'}`));
|
||||
fields.push(el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'cli scope'), badgeChip(cfg.cli_scope ?? 'group', { global: 'amber', group: 'green', disabled: 'gray' })]));
|
||||
const pkgs = (cfg.packages_apt?.length ?? 0) + (cfg.packages_npm?.length ?? 0);
|
||||
const mcp = Object.keys(cfg.mcp_servers ?? {}).length;
|
||||
if (pkgs || mcp) fields.push(field('extras', `${pkgs} pkgs · ${mcp} mcp`));
|
||||
}
|
||||
|
||||
return el('div', { class: 'ov-card' }, [
|
||||
el('div', { class: 'ov-head' }, [
|
||||
el('span', { class: `dot ${status}` }),
|
||||
el('span', { class: 'ov-name' }, g.name),
|
||||
el('span', { class: 'ov-folder' }, g.folder),
|
||||
]),
|
||||
el('div', { class: 'ov-fields' }, fields),
|
||||
el('div', { class: 'ov-chans' }, chans.map((c) => el('span', { class: 'badge' }, c))),
|
||||
]);
|
||||
});
|
||||
|
||||
content.replaceChildren(
|
||||
el('h2', { class: 'page-title' }, 'Agents overview'),
|
||||
el('div', { class: 'ov-cards' }, cards),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Activity ----
|
||||
|
||||
function renderActivity() {
|
||||
const content = $('content');
|
||||
const data = state.activity;
|
||||
if (!data) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
|
||||
const { series, sessions } = data;
|
||||
const totalIn = series.reduce((a, d) => a + d.in, 0);
|
||||
const totalOut = series.reduce((a, d) => a + d.out, 0);
|
||||
|
||||
const W = 720, H = 220, padL = 34, padB = 28, padT = 10;
|
||||
const max = Math.max(1, ...series.map((d) => Math.max(d.in, d.out)));
|
||||
const slot = (W - padL) / series.length;
|
||||
const bw = Math.max(3, slot / 2 - 2);
|
||||
const yOf = (vv) => padT + (H - padT - padB) * (1 - vv / max);
|
||||
const chart = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'activity-chart', preserveAspectRatio: 'none' });
|
||||
for (const frac of [0, 0.5, 1]) {
|
||||
const y = yOf(max * frac);
|
||||
chart.append(svg('line', { x1: padL, y1: y, x2: W, y2: y, class: 'grid' }));
|
||||
chart.append(svg('text', { x: padL - 6, y: y + 3, class: 'axis', 'text-anchor': 'end' }, String(Math.round(max * frac))));
|
||||
}
|
||||
series.forEach((d, i) => {
|
||||
const x = padL + i * slot;
|
||||
chart.append(svg('rect', { x: x + 1, y: yOf(d.in), width: bw, height: yOf(0) - yOf(d.in), class: 'bar-in' }, [svg('title', {}, `${d.date}: ${d.in} in`)]));
|
||||
chart.append(svg('rect', { x: x + 1 + bw, y: yOf(d.out), width: bw, height: yOf(0) - yOf(d.out), class: 'bar-out' }, [svg('title', {}, `${d.date}: ${d.out} out`)]));
|
||||
if (i % 2 === 0) chart.append(svg('text', { x: x + bw, y: H - 8, class: 'axis', 'text-anchor': 'middle' }, d.date.slice(5)));
|
||||
});
|
||||
const legend = el('div', { class: 'activity-legend' }, [
|
||||
el('span', {}, [el('span', { class: 'lg in' }), `inbound (${totalIn})`]),
|
||||
el('span', {}, [el('span', { class: 'lg out' }), `outbound (${totalOut})`]),
|
||||
el('span', { class: 'dim' }, `last ${series.length} days`),
|
||||
]);
|
||||
const sessRows = [...sessions].sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || '')).map((s) => {
|
||||
const groupName = resolveRef('ncl', { ref: 'groups', label: 'name' }, s.agent_group_id) ?? s.agent_group_id;
|
||||
return el('tr', {}, [
|
||||
el('td', {}, groupName),
|
||||
el('td', {}, el('span', { class: 'trunc', title: s.session_id }, s.session_id.slice(0, 22) + '…')),
|
||||
el('td', { class: 'num' }, String(s.in)),
|
||||
el('td', { class: 'num' }, String(s.out)),
|
||||
el('td', {}, s.lastActivity ? el('span', { class: 'reltime', title: s.lastActivity }, relTime(s.lastActivity)) : el('span', { class: 'null' }, '—')),
|
||||
]);
|
||||
});
|
||||
content.replaceChildren(
|
||||
el('h2', { class: 'page-title' }, 'Message activity'),
|
||||
el('div', { class: 'activity-wrap' }, [
|
||||
legend,
|
||||
el('div', { class: 'chart-box' }, chart),
|
||||
el('div', { class: 'table-wrap' }, el('table', { class: 'activity-table' }, [
|
||||
el('thead', {}, el('tr', {}, ['agent', 'session', 'in', 'out', 'last activity'].map((h) => el('th', {}, h)))),
|
||||
el('tbody', {}, sessRows),
|
||||
])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Logs (tail of a log file) ----
|
||||
|
||||
function renderLogPage(name) {
|
||||
const content = $('content');
|
||||
const label = state.logs.find((l) => l.name === name)?.label ?? name;
|
||||
const cached = state.logCache.get(name);
|
||||
if (!cached) { content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'empty' }, 'Loading…')); return; }
|
||||
const view = el('div', { class: 'log-view' });
|
||||
for (const line of cached.text.split('\n')) {
|
||||
const lvl = /\bERROR\b/i.test(line) ? 'err' : /\bWARN(ING)?\b/i.test(line) ? 'warn' : '';
|
||||
view.append(el('div', { class: `log-line ${lvl}` }, line || ' '));
|
||||
}
|
||||
content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'log-box' }, view));
|
||||
// follow the tail — scroll to the newest line
|
||||
requestAnimationFrame(() => { const b = content.querySelector('.log-box'); if (b) b.scrollTop = b.scrollHeight; });
|
||||
}
|
||||
|
||||
// ---- Files (doc viewer) ----
|
||||
|
||||
function renderDocs() {
|
||||
const coll = activeCollection();
|
||||
const content = $('content');
|
||||
if (!coll) { content.replaceChildren(el('div', { class: 'empty' }, 'No documents.')); return; }
|
||||
if (!coll.files.length) { content.replaceChildren(el('div', { class: 'empty' }, `No ${coll.label.toLowerCase()}.`)); return; }
|
||||
// display name: drop the group prefix, the `/SKILL.md` tail (show the skill
|
||||
// dir), and the .md extension — leaving e.g. "meeting-tagger" or "2026-06-13-…"
|
||||
const itemName = (label) => {
|
||||
let n = label.includes('/') ? label.split('/').slice(1).join('/').trim() : label;
|
||||
return n.replace(/\/SKILL\.md$/, '').replace(/\.md$/, '') || label;
|
||||
};
|
||||
const newestFirst = coll.name === 'conversations';
|
||||
const groups = new Map();
|
||||
for (const f of coll.files) { if (!groups.has(f.group)) groups.set(f.group, []); groups.get(f.group).push(f); }
|
||||
const toggleGroup = (g) => {
|
||||
state.openDocGroups.has(g) ? state.openDocGroups.delete(g) : state.openDocGroups.add(g);
|
||||
state.renderedSig = null; render();
|
||||
};
|
||||
const list = el('div', { class: 'doc-list' });
|
||||
for (const [group, files] of groups) {
|
||||
const open = state.openDocGroups.has(group);
|
||||
list.append(el('button', { class: 'doc-group-toggle' + (open ? ' open' : ''), onclick: () => toggleGroup(group) }, [
|
||||
el('span', { class: 'chev' }, open ? '▾' : '▸'),
|
||||
el('span', { class: 'g-name' }, group || '—'),
|
||||
el('span', { class: 'g-count' }, String(files.length)),
|
||||
]));
|
||||
if (open) {
|
||||
const ordered = newestFirst ? [...files].reverse() : files;
|
||||
for (const f of ordered) {
|
||||
list.append(el('button', { class: 'doc-item' + (f.path === state.activeDocPath ? ' active' : ''), title: f.path, onclick: () => openDoc(coll.name, f.path) }, itemName(f.label) || f.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
const pane = el('div', { class: 'doc-content' });
|
||||
const cached = state.activeDocPath ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`) : null;
|
||||
if (!state.activeDocPath) pane.append(el('div', { class: 'empty' }, 'Select a document.'));
|
||||
else if (!cached) pane.append(el('div', { class: 'empty' }, 'Loading…'));
|
||||
else if (cached.lang === 'error') pane.append(el('div', { class: 'tab-error' }, cached.content));
|
||||
else if (cached.lang === 'json') {
|
||||
let pretty = cached.content;
|
||||
try { pretty = JSON.stringify(JSON.parse(cached.content), null, 2); } catch { /* keep raw */ }
|
||||
pane.append(el('pre', { class: 'code json' }, pretty));
|
||||
} else if (cached.lang === 'markdown') {
|
||||
const md = el('div', { class: 'markdown' }); md.innerHTML = mdToHtml(cached.content); pane.append(md);
|
||||
} else pane.append(el('pre', { class: 'code' }, cached.content));
|
||||
content.replaceChildren(el('h2', { class: 'page-title' }, coll.label), el('div', { class: 'doc-viewer' }, [list, pane]));
|
||||
}
|
||||
|
||||
// ---- resource table ----
|
||||
|
||||
function renderTable(cliName, resource) {
|
||||
const content = $('content');
|
||||
const cli = state.clis.find((c) => c.name === cliName);
|
||||
if (!cli) { content.replaceChildren(el('div', { class: 'empty' }, 'No such CLI.')); return; }
|
||||
const key = `${cliName}/${resource}`;
|
||||
const snapshot = state.snapshots.get(key);
|
||||
const error = state.errors.get(key);
|
||||
const canDrill = (cli.commands || []).includes('get');
|
||||
const parts = [el('h2', { class: 'page-title' }, resource)];
|
||||
if (cli.help) parts.push(helpPanel(state.helpCache.get(key)));
|
||||
if (error && snapshot) parts.push(el('div', { class: 'stale-note' }, `⚠ live fetch failing — snapshot from ${new Date(snapshot.fetchedAt).toLocaleTimeString()}`));
|
||||
if (!snapshot) {
|
||||
parts.push(error ? el('div', { class: 'tab-error' }, [`Failed to load ${resource}.`, el('pre', {}, error)]) : el('div', { class: 'empty' }, 'Loading…'));
|
||||
content.replaceChildren(...parts); return;
|
||||
}
|
||||
const rows = snapshot.rows;
|
||||
parts.push(summaryBar(resource, rows, cli.summary?.[resource], cli));
|
||||
if (rows.length === 0) { parts.push(el('div', { class: 'empty' }, `No ${resource}.`)); content.replaceChildren(...parts); return; }
|
||||
const columns = [];
|
||||
for (const row of rows) for (const k of Object.keys(row)) if (!columns.includes(k)) columns.push(k);
|
||||
const ctx = { cliName, enrich: cli.enrich?.[resource], badges: cli.badges };
|
||||
const body = rows.map((row) => {
|
||||
const id = row.id; const canRow = canDrill && id != null;
|
||||
return el('tr', { class: canRow ? 'drillable' : '', ...(canRow ? { onclick: () => openDetail(cliName, resource, String(id)) } : {}) },
|
||||
columns.map((c) => buildCell(row[c], c, ctx)));
|
||||
});
|
||||
parts.push(el('div', { class: 'table-wrap' }, el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, columns.map((c) => el('th', {}, c)))),
|
||||
el('tbody', {}, body),
|
||||
])));
|
||||
content.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
// ---- drill-down detail overlay ----
|
||||
|
||||
function renderDetail() {
|
||||
const overlay = $('detail');
|
||||
if (!state.detail) { overlay.hidden = true; overlay.replaceChildren(); return; }
|
||||
overlay.hidden = false;
|
||||
const d = state.detail;
|
||||
const panel = el('div', { class: 'detail-panel' });
|
||||
panel.append(el('div', { class: 'detail-head' }, [
|
||||
el('div', {}, [el('span', { class: 'detail-res' }, d.resource), ' ', el('span', { class: 'detail-id' }, d.id)]),
|
||||
el('button', { class: 'detail-close', onclick: closeDetail, title: 'Close' }, '✕'),
|
||||
]));
|
||||
const sub = el('div', { class: 'detail-body' });
|
||||
if (d.loading) sub.append(el('div', { class: 'empty' }, 'Loading…'));
|
||||
else if (d.error) sub.append(el('div', { class: 'tab-error' }, d.error));
|
||||
else if (d.record) sub.append(el('div', { class: 'kv' }, kvRows(d.record)));
|
||||
if (d.config) {
|
||||
sub.append(el('div', { class: 'detail-section' }, 'Container config'));
|
||||
sub.append(el('div', { class: 'kv' }, kvRows(d.config)));
|
||||
}
|
||||
panel.append(sub);
|
||||
overlay.replaceChildren(panel);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
|
||||
$('pause').addEventListener('click', () => {
|
||||
state.paused = !state.paused;
|
||||
$('pause').textContent = state.paused ? '▶ resume' : '⏸ pause';
|
||||
$('pause').classList.toggle('paused', state.paused);
|
||||
state.renderedSig = null; render();
|
||||
});
|
||||
$('refresh').addEventListener('click', () => { if (!state.refreshing) refresh(true); });
|
||||
$('hamburger').addEventListener('click', () => { state.sidebarOpen = !state.sidebarOpen; state.renderedSig = null; render(); });
|
||||
$('scrim').addEventListener('click', () => { state.sidebarOpen = false; state.renderedSig = null; render(); });
|
||||
$('detail').addEventListener('click', (e) => { if (e.target === $('detail')) closeDetail(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (state.detail) closeDetail(); else if (state.sidebarOpen) { state.sidebarOpen = false; state.renderedSig = null; render(); } } });
|
||||
|
||||
async function tick() {
|
||||
if (!state.paused) { try { await refresh(); } catch { /* keep snapshots; retry next tick */ } }
|
||||
else renderControls();
|
||||
setTimeout(tick, state.refreshSeconds * 1000);
|
||||
}
|
||||
tick();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#141a24"/>
|
||||
<stop offset="1" stop-color="#0b0e14"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7.5" fill="url(#bg)"/>
|
||||
<rect x="0.5" y="0.5" width="31" height="31" rx="7" fill="none" stroke="#2b3342" stroke-width="1"/>
|
||||
<text x="16.2" y="20.8" font-family="monospace" font-size="15" font-weight="700" letter-spacing="-0.5" text-anchor="middle" fill="#5b9dff">ncl</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 570 B |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>clidash</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="theme-color" content="#0a0c11">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<button id="hamburger" class="hamburger" title="Menu" aria-label="Toggle menu">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div id="scrim" class="scrim" hidden></div>
|
||||
|
||||
<div class="app">
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="brand"><h1>clidash</h1></div>
|
||||
<div class="controls">
|
||||
<button id="refresh" class="refresh" title="Refresh now">↻ refresh</button>
|
||||
<button id="pause" class="pause" title="Pause auto-refresh">⏸ pause</button>
|
||||
</div>
|
||||
<div id="updated" class="updated"></div>
|
||||
<nav id="nav" class="nav"></nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<div id="cmdline" class="cmdline" hidden></div>
|
||||
<section id="content" class="content"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="detail" class="detail-overlay" hidden></div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
// Minimal, dependency-free, XSS-safe markdown → HTML for clidash's file viewer
|
||||
// (SKILL.md / CLAUDE.md). Pure string functions, no DOM — importable in both the
|
||||
// browser (app.js) and node tests.
|
||||
//
|
||||
// Safety model: the ENTIRE source is HTML-escaped first, so no raw markup from a
|
||||
// file can reach innerHTML. Markdown transforms then emit only tags this module
|
||||
// generates. Link hrefs are taken from the URL capture group and gated to an
|
||||
// http(s) scheme, so a `javascript:`/`data:` URL (or one smuggled via link text)
|
||||
// can never become an executable href.
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
|
||||
export function mdToHtml(src) {
|
||||
const lines = escapeHtml(src).split('\n');
|
||||
const out = [];
|
||||
let i = 0;
|
||||
const inline = (t) => t
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, (m, text, url) =>
|
||||
/^https?:\/\//i.test(url) ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>` : m);
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^```/.test(line)) {
|
||||
const buf = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]);
|
||||
i++;
|
||||
out.push(`<pre class="code"><code>${buf.join('\n')}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); i++; continue; }
|
||||
if (/^\s*([-*])\s+/.test(line)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\s*([-*])\s+/.test(lines[i])) {
|
||||
items.push(`<li>${inline(lines[i].replace(/^\s*([-*])\s+/, ''))}</li>`);
|
||||
i++;
|
||||
}
|
||||
out.push(`<ul>${items.join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
|
||||
i++;
|
||||
}
|
||||
out.push(`<ol>${items.join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
|
||||
if (/^\s*>\s?/.test(line)) { out.push(`<blockquote>${inline(line.replace(/^\s*>\s?/, ''))}</blockquote>`); i++; continue; }
|
||||
if (line.trim() === '') { i++; continue; }
|
||||
const para = [line];
|
||||
i++;
|
||||
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>)/.test(lines[i])) {
|
||||
para.push(lines[i++]);
|
||||
}
|
||||
out.push(`<p>${inline(para.join(' '))}</p>`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "clidash",
|
||||
"short_name": "ncl",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#0a0c11",
|
||||
"background_color": "#0a0c11",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/* clidash — refined terminal-ops console.
|
||||
IBM Plex superfamily (Sans for UI, Mono for the CLI identity), a deep layered
|
||||
dark palette, real depth, and precise micro-interactions. */
|
||||
|
||||
:root {
|
||||
/* layered surfaces */
|
||||
--bg: #0a0c11;
|
||||
--bg-grad: #10141d;
|
||||
--panel: #13171f;
|
||||
--panel-2: #1a1f2a;
|
||||
--border: #222834;
|
||||
--border-strong: #2e3644;
|
||||
/* text */
|
||||
--text: #e8edf5;
|
||||
--dim: #98a2b3;
|
||||
--faint: #5c6675;
|
||||
/* accent + semantics */
|
||||
--accent: #5b9dff;
|
||||
--accent-soft: rgba(91, 157, 255, 0.14);
|
||||
--green: #4cc97a;
|
||||
--amber: #e0a93a;
|
||||
--red: #f76d6d;
|
||||
--purple: #c08cff;
|
||||
--gray: #6b7585;
|
||||
/* shape + motion */
|
||||
--radius: 11px;
|
||||
--radius-sm: 8px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow: 0 6px 22px -8px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 18px 44px -12px rgba(0, 0, 0, 0.6);
|
||||
--ease: 160ms cubic-bezier(0.2, 0.6, 0.2, 1);
|
||||
--font-sans: "IBM Plex Sans", -apple-system, system-ui, Segoe UI, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html { scrollbar-color: var(--border-strong) transparent; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(120% 80% at 50% -10%, var(--bg-grad) 0%, transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection { background: var(--accent-soft); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-mono); font-size: 17px; font-weight: 600;
|
||||
letter-spacing: -0.3px; color: var(--text);
|
||||
}
|
||||
h1::before { content: "▍"; color: var(--accent); margin-right: 4px; }
|
||||
|
||||
/* ---- layout ---- */
|
||||
|
||||
.app { display: flex; align-items: stretch; min-height: 100vh; min-height: 100dvh; }
|
||||
|
||||
.sidebar {
|
||||
width: 236px; flex-shrink: 0; height: 100vh; height: 100dvh;
|
||||
position: sticky; top: 0; align-self: flex-start; overflow-y: auto;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 200px), var(--bg);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 18px 12px 40px; display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.sidebar .brand { padding: 2px 8px 6px; }
|
||||
.sidebar .controls { display: flex; gap: 8px; padding: 0 4px; }
|
||||
.sidebar .updated { color: var(--faint); font-size: 11.5px; padding: 0 8px; min-height: 16px; font-family: var(--font-mono); }
|
||||
|
||||
.pause, .refresh {
|
||||
flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
color: var(--dim); padding: 6px 10px; font-size: 12.5px; font-family: var(--font-mono);
|
||||
cursor: pointer; transition: color var(--ease), border-color var(--ease), background var(--ease);
|
||||
}
|
||||
.pause:hover, .refresh:hover { color: var(--text); border-color: var(--border-strong); background: var(--panel-2); }
|
||||
.pause.paused { color: var(--amber); border-color: var(--amber); }
|
||||
.refresh.spinning { color: var(--accent); border-color: var(--accent); animation: pulse 0.85s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
|
||||
.nav { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; }
|
||||
.nav-section {
|
||||
color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px;
|
||||
font-weight: 600; margin: 16px 10px 5px; font-family: var(--font-mono);
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.nav-section svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 9px; text-align: left;
|
||||
background: none; border: none; border-radius: var(--radius-sm);
|
||||
color: var(--dim); padding: 7px 10px; font-size: 13.5px; cursor: pointer; width: 100%;
|
||||
font-family: var(--font-sans); position: relative;
|
||||
transition: color var(--ease), background var(--ease);
|
||||
}
|
||||
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; }
|
||||
.nav-item:hover { background: var(--panel); color: var(--text); }
|
||||
.nav-item.active { background: var(--accent-soft); color: var(--text); }
|
||||
.nav-item.active::before {
|
||||
content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 18px; background: var(--accent); border-radius: 0 3px 3px 0;
|
||||
}
|
||||
.nav-item.nav-sub { padding-left: 16px; font-size: 13px; }
|
||||
.nav-item.nav-sub.active { color: var(--accent); }
|
||||
|
||||
.hamburger {
|
||||
display: none; position: fixed; top: 12px; left: 12px; z-index: 60;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
color: var(--text); width: 40px; height: 40px; cursor: pointer; align-items: center; justify-content: center;
|
||||
}
|
||||
.hamburger svg { width: 20px; height: 20px; }
|
||||
.scrim { display: none; }
|
||||
|
||||
.main { flex: 1; min-width: 0; padding: 26px 28px 64px; max-width: 1500px; }
|
||||
.page-title {
|
||||
font-size: 20px; font-weight: 600; letter-spacing: -0.4px; margin-bottom: 16px;
|
||||
animation: fadeUp 0.3s var(--ease) both;
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: rgba(247, 109, 109, 0.1); border: 1px solid rgba(247, 109, 109, 0.4);
|
||||
border-radius: var(--radius-sm); color: var(--red); padding: 9px 14px; font-size: 13.5px; margin-bottom: 16px;
|
||||
}
|
||||
.cmdline {
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--dim);
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 9px 14px; margin-bottom: 18px; overflow-x: auto; white-space: nowrap; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cmdline::first-letter { color: var(--green); }
|
||||
|
||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(7px); } }
|
||||
|
||||
/* ---- overview cards ---- */
|
||||
|
||||
.ov-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; }
|
||||
.ov-card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 40%), var(--panel);
|
||||
border: 1px solid var(--border); border-radius: var(--radius); padding: 17px 19px;
|
||||
box-shadow: var(--shadow); transition: border-color var(--ease), transform var(--ease);
|
||||
animation: fadeUp 0.4s var(--ease) both;
|
||||
}
|
||||
.ov-card:nth-child(2) { animation-delay: 0.05s; }
|
||||
.ov-card:nth-child(3) { animation-delay: 0.1s; }
|
||||
.ov-card:nth-child(4) { animation-delay: 0.15s; }
|
||||
.ov-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
|
||||
.ov-head { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; }
|
||||
.ov-head .ov-name { font-weight: 600; font-size: 15px; }
|
||||
.ov-head .ov-folder { color: var(--faint); font-size: 12px; font-family: var(--font-mono); }
|
||||
.ov-fields { display: flex; flex-direction: column; gap: 7px; }
|
||||
.ov-field { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
|
||||
.ov-field .k { color: var(--dim); }
|
||||
.ov-field .v { color: var(--text); font-variant-numeric: tabular-nums; } .ov-field .v.dim { color: var(--faint); }
|
||||
.ov-chans { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
|
||||
|
||||
.dot, .ov-head .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.green { background: var(--green); box-shadow: 0 0 8px -1px var(--green); }
|
||||
.dot.amber { background: var(--amber); box-shadow: 0 0 8px -1px var(--amber); }
|
||||
.dot.red { background: var(--red); box-shadow: 0 0 8px -1px var(--red); }
|
||||
.dot.gray { background: var(--gray); }
|
||||
|
||||
.badge {
|
||||
font-size: 11.5px; border: 1px solid var(--border-strong); border-radius: 99px;
|
||||
padding: 2px 10px; color: var(--dim); background: var(--panel-2); font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---- status badges + enriched cells ---- */
|
||||
.badge-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; }
|
||||
.badge-status .dot { width: 7px; height: 7px; }
|
||||
.badge-status.green { color: var(--green); } .badge-status.amber { color: var(--amber); }
|
||||
.badge-status.red { color: var(--red); } .badge-status.gray { color: var(--gray); }
|
||||
td.enriched span:first-child { color: var(--text); }
|
||||
td.enriched .raw-id { display: block; color: var(--faint); font-size: 11px; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- summary bar ---- */
|
||||
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 2px 0 16px; font-size: 13px; }
|
||||
.summary-bar .sum-count { color: var(--text); font-weight: 600; }
|
||||
.summary-bar .sum-sep { color: var(--border-strong); }
|
||||
.summary-bar .sum-chip { color: var(--dim); font-family: var(--font-mono); font-size: 12px; }
|
||||
|
||||
/* ---- per-resource help panel ---- */
|
||||
.help-panel { margin-bottom: 18px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; box-shadow: var(--shadow-sm); }
|
||||
.help-head { color: var(--dim); font-size: 13px; line-height: 1.6; }
|
||||
.help-head.dim { color: var(--faint); }
|
||||
.help-more { margin-top: 9px; }
|
||||
.help-more > summary { cursor: pointer; color: var(--accent); font-size: 12px; list-style: none; user-select: none; font-family: var(--font-mono); }
|
||||
.help-more > summary::-webkit-details-marker { display: none; }
|
||||
.help-more > summary::before { content: "▸ "; }
|
||||
.help-more[open] > summary::before { content: "▾ "; }
|
||||
.help-text {
|
||||
margin: 9px 0 0; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 13px 15px; font-size: 12px; line-height: 1.6; color: var(--dim);
|
||||
white-space: pre-wrap; overflow-x: auto; font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---- document viewer ---- */
|
||||
.doc-viewer { display: grid; grid-template-columns: 264px 1fr; gap: 20px; align-items: start; }
|
||||
.doc-list { display: flex; flex-direction: column; gap: 2px; max-height: 76vh; overflow-y: auto; padding-right: 4px; }
|
||||
.doc-group-toggle {
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: 7px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
|
||||
font-weight: 600; font-family: var(--font-mono); margin: 12px 0 4px; padding: 5px 8px;
|
||||
border-radius: var(--radius-sm); transition: color var(--ease), background var(--ease);
|
||||
}
|
||||
.doc-group-toggle:first-child { margin-top: 0; }
|
||||
.doc-group-toggle:hover { color: var(--text); background: var(--panel); }
|
||||
.doc-group-toggle.open { color: var(--text); }
|
||||
.doc-group-toggle .chev { color: var(--accent); width: 10px; }
|
||||
.doc-group-toggle .g-name { flex: 1; }
|
||||
.doc-group-toggle .g-count {
|
||||
color: var(--faint); background: var(--panel-2); border-radius: 99px;
|
||||
padding: 1px 8px; font-size: 10.5px; letter-spacing: 0;
|
||||
}
|
||||
.doc-item {
|
||||
flex-shrink: 0; text-align: left; background: none; border: none; border-radius: var(--radius-sm);
|
||||
color: var(--dim); padding: 6px 11px; font-size: 13px; line-height: 1.5; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--ease), background var(--ease);
|
||||
}
|
||||
.doc-item:hover { background: var(--panel); color: var(--text); }
|
||||
.doc-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.doc-content {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px 26px; min-height: 220px; max-height: 78vh; overflow-y: auto; box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.markdown { font-size: 14px; line-height: 1.7; color: var(--text); }
|
||||
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { margin: 20px 0 8px; line-height: 1.3; font-family: var(--font-mono); }
|
||||
.markdown h1 { font-size: 21px; } .markdown h2 { font-size: 17px; color: var(--accent); } .markdown h3 { font-size: 15px; }
|
||||
.markdown h1:first-child, .markdown h2:first-child { margin-top: 0; }
|
||||
.markdown p { color: var(--text); margin: 8px 0; }
|
||||
.markdown ul, .markdown ol { margin: 8px 0 8px 22px; }
|
||||
.markdown li { margin: 3px 0; }
|
||||
.markdown a { color: var(--accent); }
|
||||
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
|
||||
.markdown blockquote { border-left: 3px solid var(--border-strong); padding-left: 12px; color: var(--dim); margin: 8px 0; }
|
||||
.markdown code { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; font-size: 12.5px; font-family: var(--font-mono); }
|
||||
.markdown pre.code, .doc-content pre.code {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 14px; overflow-x: auto; font-size: 12.5px; line-height: 1.6; margin: 12px 0; font-family: var(--font-mono);
|
||||
}
|
||||
.markdown pre.code code { background: none; border: none; padding: 0; }
|
||||
.doc-content pre.json { color: var(--text); white-space: pre; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- activity ---- */
|
||||
.activity-wrap { max-width: 780px; }
|
||||
.activity-legend { display: flex; gap: 18px; align-items: center; margin-bottom: 12px; font-size: 13px; color: var(--dim); }
|
||||
.activity-legend .lg { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
|
||||
.activity-legend .lg.in { background: var(--accent); } .activity-legend .lg.out { background: var(--purple); }
|
||||
.chart-box { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; box-shadow: var(--shadow); }
|
||||
.activity-chart { width: 100%; height: 220px; display: block; }
|
||||
.activity-chart .bar-in { fill: var(--accent); } .activity-chart .bar-out { fill: var(--purple); }
|
||||
.activity-chart .grid { stroke: var(--border); stroke-width: 1; }
|
||||
.activity-chart .axis { fill: var(--faint); font-size: 9px; font-family: var(--font-mono); }
|
||||
.activity-table td.num, .activity-table th:nth-child(3), .activity-table th:nth-child(4) { text-align: right; }
|
||||
.activity-table td.num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---- log viewer ---- */
|
||||
.log-box {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
max-height: 78vh; overflow: auto; padding: 12px 0; box-shadow: var(--shadow);
|
||||
}
|
||||
.log-view { font-family: var(--font-mono); font-size: 12px; line-height: 1.65; min-width: max-content; }
|
||||
.log-line { padding: 0 16px; white-space: pre; color: var(--dim); }
|
||||
.log-line:hover { background: var(--panel); }
|
||||
.log-line.err { color: var(--red); background: rgba(247, 109, 109, 0.06); }
|
||||
.log-line.warn { color: var(--amber); }
|
||||
|
||||
/* ---- tables ---- */
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
th { color: var(--dim); font-weight: 600; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.6px; position: sticky; top: 0; background: var(--panel-2); font-family: var(--font-mono); }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
td.null { color: var(--faint); font-style: italic; }
|
||||
tr.drillable { cursor: pointer; transition: background var(--ease); }
|
||||
tr.drillable:hover td { background: var(--panel); }
|
||||
.reltime .abs { color: var(--faint); font-size: 11.5px; margin-left: 6px; font-family: var(--font-mono); }
|
||||
td .trunc { cursor: pointer; border-bottom: 1px dotted var(--faint); }
|
||||
.stale-note { color: var(--amber); font-size: 12.5px; margin-bottom: 8px; }
|
||||
.empty, .tab-error { color: var(--dim); padding: 26px 2px; font-size: 14px; }
|
||||
.tab-error { color: var(--red); }
|
||||
.tab-error pre { margin-top: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; color: var(--dim); font-size: 12px; overflow-x: auto; white-space: pre-wrap; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- drill-down detail overlay ---- */
|
||||
.detail-overlay { position: fixed; inset: 0; background: rgba(2, 4, 8, 0.62); display: flex; justify-content: flex-end; z-index: 50; backdrop-filter: blur(2px); animation: fade 0.18s ease; }
|
||||
@keyframes fade { from { opacity: 0; } }
|
||||
.detail-panel { width: min(580px, 100%); height: 100%; background: var(--bg); border-left: 1px solid var(--border-strong); overflow-y: auto; box-shadow: var(--shadow-lg); animation: slideIn 0.22s var(--ease); }
|
||||
@keyframes slideIn { from { transform: translateX(24px); opacity: 0.6; } }
|
||||
.detail-head { display: flex; justify-content: space-between; align-items: center; padding: 17px 22px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); }
|
||||
.detail-res { font-weight: 600; font-size: 15px; }
|
||||
.detail-id { color: var(--dim); font-family: var(--font-mono); font-size: 13px; }
|
||||
.detail-close { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--dim); width: 30px; height: 30px; cursor: pointer; font-size: 14px; transition: color var(--ease), border-color var(--ease); }
|
||||
.detail-close:hover { color: var(--text); border-color: var(--accent); }
|
||||
.detail-body { padding: 17px 22px 44px; }
|
||||
.detail-section { margin: 24px 0 8px; color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; border-top: 1px solid var(--border); padding-top: 17px; font-family: var(--font-mono); }
|
||||
.kv { display: flex; flex-direction: column; gap: 2px; }
|
||||
.kv-row { display: grid; grid-template-columns: 168px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.kv-key { color: var(--dim); font-family: var(--font-mono); font-size: 12.5px; }
|
||||
.kv-json { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; font-size: 12px; overflow-x: auto; margin: 0; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- mobile ---- */
|
||||
@media (max-width: 640px) {
|
||||
.hamburger { display: flex; }
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; z-index: 70; transform: translateX(-100%);
|
||||
transition: transform 0.22s var(--ease); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.scrim { display: block; position: fixed; inset: 0; background: rgba(2, 4, 8, 0.55); z-index: 65; backdrop-filter: blur(1px); }
|
||||
.scrim[hidden] { display: none; }
|
||||
.main { padding: 58px 16px 48px; }
|
||||
.ov-cards { grid-template-columns: 1fr; }
|
||||
.doc-viewer { grid-template-columns: 1fr; gap: 12px; }
|
||||
.doc-list { max-height: 220px; flex-direction: row; flex-wrap: wrap; }
|
||||
.doc-content { max-height: none; padding: 18px; }
|
||||
.detail-panel { width: 100%; }
|
||||
.kv-row { grid-template-columns: 1fr; gap: 2px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
// clidash — CLI-agnostic read-only web dashboard.
|
||||
// Node built-ins only. All per-CLI knowledge lives in clidash.config.json;
|
||||
// the only per-CLI code is optional view plugins (views/) and discovery
|
||||
// parsers (parsers.js).
|
||||
//
|
||||
// Security model: the server can only exec the configured argv templates.
|
||||
// `{resource}` is the sole substitution and is validated against the
|
||||
// discovered/static resource set before exec. execFile, never a shell.
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve, sep, basename } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { discoveryParsers, parseOutput, unwrapPath } from './parsers.js';
|
||||
import { globFiles, describeFile, resolveDoc } from './docs.js';
|
||||
import { collectActivity } from './activity.js';
|
||||
import { tailFile } from './logs.js';
|
||||
|
||||
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const MAX_DOC_BYTES = 2 * 1024 * 1024; // cap a single served document at 2 MB
|
||||
|
||||
const DEFAULTS = {
|
||||
bind: '127.0.0.1',
|
||||
port: 4690,
|
||||
refreshSeconds: 60,
|
||||
execTimeoutMs: 10_000,
|
||||
discoveryTtlMs: 60_000,
|
||||
};
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
};
|
||||
|
||||
export function createApp(userConfig) {
|
||||
const config = { ...DEFAULTS, ...userConfig };
|
||||
const publicDir = resolve(config.publicDir ?? join(MODULE_DIR, 'public'));
|
||||
const viewsDir = resolve(config.viewsDir ?? join(MODULE_DIR, 'views'));
|
||||
|
||||
// Human-readable form of a command, for display in the UI ("the command run").
|
||||
const displayCmd = (bin, args) => `${basename(bin)} ${args.join(' ')}`;
|
||||
|
||||
// ---- exec --------------------------------------------------------------
|
||||
|
||||
function execCli(cliCfg, args, label) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
execFile(cliCfg.bin, args, {
|
||||
cwd: cliCfg.cwd,
|
||||
timeout: config.execTimeoutMs,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: { ...process.env, ...cliCfg.env },
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const timedOut = error.killed || error.signal === 'SIGTERM';
|
||||
const detail = stderr.trim() || error.message;
|
||||
const msg = timedOut
|
||||
? `${label} timed out after ${config.execTimeoutMs}ms`
|
||||
: `${label} failed: ${detail}`;
|
||||
rejectPromise(new Error(msg));
|
||||
return;
|
||||
}
|
||||
resolvePromise(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- resource discovery (cached, coalesced, keeps last good) -----------
|
||||
|
||||
const discoveryCache = new Map(); // cli -> { at, resources }
|
||||
const discoveryInflight = new Map(); // cli -> Promise
|
||||
|
||||
async function discoverResources(cliName) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
if (cliCfg.resources) {
|
||||
return cliCfg.resources.map((name) =>
|
||||
typeof name === 'string' ? { name, description: '' } : name,
|
||||
);
|
||||
}
|
||||
const cached = discoveryCache.get(cliName);
|
||||
if (cached && Date.now() - cached.at < config.discoveryTtlMs) return cached.resources;
|
||||
if (discoveryInflight.has(cliName)) return discoveryInflight.get(cliName);
|
||||
|
||||
const parser = discoveryParsers[cliCfg.discover.parser];
|
||||
if (!parser) throw new Error(`Unknown discovery parser: ${cliCfg.discover.parser}`);
|
||||
const promise = execCli(cliCfg, cliCfg.discover.args, `${cliName} discovery`)
|
||||
.then((stdout) => {
|
||||
const resources = parser(stdout);
|
||||
discoveryCache.set(cliName, { at: Date.now(), resources });
|
||||
return resources;
|
||||
})
|
||||
.finally(() => discoveryInflight.delete(cliName));
|
||||
discoveryInflight.set(cliName, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- row fetching (coalesced per cli+resource) --------------------------
|
||||
|
||||
const listInflight = new Map(); // "cli\0resource" -> Promise
|
||||
|
||||
async function fetchRows(cliName, resourceName) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
const resources = await discoverResources(cliName);
|
||||
if (!resources.some((r) => r.name === resourceName)) {
|
||||
const err = new Error(`Unknown resource "${resourceName}" for CLI "${cliName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const key = `${cliName}\0${resourceName}`;
|
||||
if (listInflight.has(key)) return listInflight.get(key);
|
||||
|
||||
// {resource} may appear as a whole arg or inside one (e.g. an ssh remote
|
||||
// command). Safe either way — the value is allowlist-validated above.
|
||||
const args = cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName));
|
||||
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} list`)
|
||||
.then((stdout) => {
|
||||
const parsed = parseOutput(stdout, cliCfg.output ?? 'json');
|
||||
const rows = unwrapPath(parsed, cliCfg.unwrap);
|
||||
if (!Array.isArray(rows)) {
|
||||
const err = new Error(`${cliName} ${resourceName}: expected an array of rows`);
|
||||
err.raw = stdout;
|
||||
throw err;
|
||||
}
|
||||
return rows;
|
||||
})
|
||||
.finally(() => listInflight.delete(key));
|
||||
listInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- detail commands (drill-down: get, config-get, …) -------------------
|
||||
|
||||
const cmdInflight = new Map();
|
||||
const ID_RE = /^[A-Za-z0-9:_.-]+$/; // ncl ids / uuids; no shell metas (and execFile never shells)
|
||||
|
||||
async function runCommand(cliName, cmdName, resourceName, id) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
const template = cliCfg.commands?.[cmdName];
|
||||
if (!template) {
|
||||
const err = new Error(`Unknown command "${cmdName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const needsResource = template.includes('{resource}');
|
||||
if (needsResource) {
|
||||
const resources = await discoverResources(cliName);
|
||||
if (!resources.some((r) => r.name === resourceName)) {
|
||||
const err = new Error(`Unknown resource "${resourceName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (template.includes('{id}') && !ID_RE.test(id ?? '')) {
|
||||
const err = new Error('Invalid id');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const key = `${cliName}\0${cmdName}\0${resourceName}\0${id}`;
|
||||
if (cmdInflight.has(key)) return cmdInflight.get(key);
|
||||
const args = template.map((a) => a.replaceAll('{resource}', resourceName ?? '').replaceAll('{id}', id ?? ''));
|
||||
const promise = execCli(cliCfg, args, `${cliName} ${cmdName}`)
|
||||
.then((stdout) => unwrapPath(parseOutput(stdout, cliCfg.output ?? 'json'), cliCfg.unwrap))
|
||||
.finally(() => cmdInflight.delete(key));
|
||||
cmdInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- per-resource help (raw text from `<cli> <resource> help`) -----------
|
||||
|
||||
const helpInflight = new Map();
|
||||
async function runHelp(cliName, resourceName) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
if (!cliCfg.help) { const e = new Error(`No help for "${cliName}"`); e.statusCode = 404; throw e; }
|
||||
const resources = await discoverResources(cliName);
|
||||
if (!resources.some((r) => r.name === resourceName)) {
|
||||
const e = new Error(`Unknown resource "${resourceName}"`); e.statusCode = 404; throw e;
|
||||
}
|
||||
const key = `${cliName}\0${resourceName}`;
|
||||
if (helpInflight.has(key)) return helpInflight.get(key);
|
||||
const args = cliCfg.help.map((a) => a.replaceAll('{resource}', resourceName));
|
||||
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} help`).finally(() => helpInflight.delete(key));
|
||||
helpInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- view plugins --------------------------------------------------------
|
||||
|
||||
async function listViews(cliName) {
|
||||
try {
|
||||
const files = await readdir(viewsDir);
|
||||
return files
|
||||
.filter((f) => f.startsWith(`${cliName}-`) && f.endsWith('.js'))
|
||||
.map((f) => f.slice(cliName.length + 1, -3));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function runView(cliName, viewName) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(viewName)) {
|
||||
const err = new Error(`Invalid view name`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const file = join(viewsDir, `${cliName}-${viewName}.js`);
|
||||
let mod;
|
||||
try {
|
||||
mod = await import(pathToFileURL(file).href);
|
||||
} catch (e) {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND') {
|
||||
const err = new Error(`No view "${viewName}" for CLI "${cliName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return mod.default({ fetch: (resource) => fetchRows(cliName, resource) });
|
||||
}
|
||||
|
||||
// ---- http ----------------------------------------------------------------
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function sendError(res, err) {
|
||||
const status = err.statusCode ?? 502;
|
||||
const body = { ok: false, error: err.message };
|
||||
if (err.raw !== undefined) body.raw = String(err.raw).slice(0, 64 * 1024);
|
||||
sendJson(res, status, body);
|
||||
}
|
||||
|
||||
async function serveStatic(res, urlPath) {
|
||||
const relative = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath.slice(1));
|
||||
const file = resolve(publicDir, relative);
|
||||
if (file !== publicDir && !file.startsWith(publicDir + sep)) {
|
||||
sendJson(res, 403, { ok: false, error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await readFile(file);
|
||||
const ext = file.slice(file.lastIndexOf('.'));
|
||||
// always revalidate so a redeploy is picked up immediately (no stale JS/CSS)
|
||||
res.writeHead(200, { 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
||||
res.end(content);
|
||||
} catch {
|
||||
sendJson(res, 404, { ok: false, error: 'Not found' });
|
||||
}
|
||||
}
|
||||
|
||||
return createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'GET') {
|
||||
sendJson(res, 405, { ok: false, error: 'Read-only dashboard: GET only' });
|
||||
return;
|
||||
}
|
||||
const urlPath = req.url.split('?')[0];
|
||||
const segments = urlPath.split('/').map((s) => decodeURIComponent(s));
|
||||
|
||||
if (urlPath === '/api/clis') {
|
||||
const clis = await Promise.all(Object.keys(config.clis).map(async (name) => {
|
||||
const entry = {
|
||||
name,
|
||||
refreshSeconds: config.refreshSeconds,
|
||||
views: await listViews(name),
|
||||
commands: Object.keys(config.clis[name].commands ?? {}),
|
||||
enrich: config.clis[name].enrich ?? null,
|
||||
badges: config.clis[name].badges ?? null,
|
||||
summary: config.clis[name].summary ?? null,
|
||||
help: !!config.clis[name].help,
|
||||
};
|
||||
try {
|
||||
entry.resources = await discoverResources(name);
|
||||
} catch (e) {
|
||||
// keep last good discovery (≤TTL old) if we have one; always surface the error
|
||||
entry.resources = discoveryCache.get(name)?.resources ?? [];
|
||||
entry.error = e.message;
|
||||
}
|
||||
return entry;
|
||||
}));
|
||||
sendJson(res, 200, { clis });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'r' && segments.length === 5) {
|
||||
const [, , , cliName, resourceName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const rows = await fetchRows(cliName, resourceName);
|
||||
const cliCfg = config.clis[cliName];
|
||||
const command = displayCmd(cliCfg.bin, cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName)));
|
||||
sendJson(res, 200, { ok: true, rows, command, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'cmd' && segments.length === 5) {
|
||||
const [, , , cliName, cmdName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const q = new URL(req.url, 'http://localhost').searchParams;
|
||||
const data = await runCommand(cliName, cmdName, q.get('resource'), q.get('id'));
|
||||
const tmpl = config.clis[cliName].commands?.[cmdName] ?? [];
|
||||
const command = displayCmd(config.clis[cliName].bin,
|
||||
tmpl.map((a) => a.replaceAll('{resource}', q.get('resource') ?? '').replaceAll('{id}', q.get('id') ?? '')));
|
||||
sendJson(res, 200, { ok: true, data, command, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'help' && segments.length === 5) {
|
||||
const [, , , cliName, resourceName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const text = await runHelp(cliName, resourceName);
|
||||
sendJson(res, 200, { ok: true, text });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'view' && segments.length === 5) {
|
||||
const [, , , cliName, viewName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const result = await runView(cliName, viewName);
|
||||
sendJson(res, 200, { ok: true, result, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Log tails (allowlisted files under logs.dir).
|
||||
if (urlPath === '/api/logs') {
|
||||
sendJson(res, 200, { files: (config.logs?.files ?? []).map((f) => ({ name: f.name, label: f.label ?? f.name })) });
|
||||
return;
|
||||
}
|
||||
if (segments[1] === 'api' && segments[2] === 'log' && segments.length === 4) {
|
||||
const name = segments[3];
|
||||
const file = config.logs?.files?.find((f) => f.name === name);
|
||||
if (!file) { sendJson(res, 404, { ok: false, error: `Unknown log "${name}"` }); return; }
|
||||
const lines = config.logs.tailLines ?? 400;
|
||||
const { text } = await tailFile(join(config.logs.dir, name), lines);
|
||||
sendJson(res, 200, { ok: true, text, command: `tail -n ${lines} ${join(config.logs.dir, name)}`, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Message activity (read per-session DBs; ncl has no messages resource).
|
||||
if (urlPath === '/api/activity') {
|
||||
if (!config.activity) { sendJson(res, 200, { ok: true, configured: false }); return; }
|
||||
const days = config.activity.days ?? 14;
|
||||
const { sessions, series } = collectActivity(config.activity.sessionsRoot, days, new Date());
|
||||
const command = `node:sqlite · ${config.activity.sessionsRoot}/*/*/{inbound,outbound}.db (last ${days}d)`;
|
||||
sendJson(res, 200, { ok: true, configured: true, sessions, series, command, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read-only file viewer (skills, CLAUDE.md, profiles, conversations).
|
||||
if (urlPath === '/api/docs') {
|
||||
const docs = config.docs;
|
||||
const collections = (docs?.collections ?? []).map((coll) => ({
|
||||
name: coll.name,
|
||||
label: coll.label ?? coll.name,
|
||||
lang: coll.lang ?? 'text',
|
||||
files: globFiles(docs.root, coll.patterns, docs.deny ?? []).map((path) => ({
|
||||
path,
|
||||
...describeFile(path),
|
||||
})),
|
||||
}));
|
||||
sendJson(res, 200, { collections });
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlPath === '/api/doc') {
|
||||
const docs = config.docs;
|
||||
const query = new URL(req.url, 'http://localhost').searchParams;
|
||||
const collName = query.get('c');
|
||||
const relPath = query.get('p') ?? '';
|
||||
const collection = docs?.collections?.find((c) => c.name === collName);
|
||||
if (!collection) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown collection "${collName}"` });
|
||||
return;
|
||||
}
|
||||
let abs;
|
||||
try {
|
||||
abs = resolveDoc(docs.root, collection, relPath, docs.deny ?? []);
|
||||
} catch {
|
||||
sendJson(res, 404, { ok: false, error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
const content = await readFile(abs, 'utf8');
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
path: relPath,
|
||||
lang: collection.lang ?? 'text',
|
||||
content: content.length > MAX_DOC_BYTES ? content.slice(0, MAX_DOC_BYTES) : content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlPath.startsWith('/api/')) {
|
||||
sendJson(res, 404, { ok: false, error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(res, urlPath);
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- standalone entry point ------------------------------------------------
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
||||
if (isMain) {
|
||||
const configPath = process.env.CLIDASH_CONFIG ?? join(MODULE_DIR, 'clidash.config.json');
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
if (process.env.PORT) config.port = Number(process.env.PORT);
|
||||
if (process.env.BIND) config.bind = process.env.BIND;
|
||||
const finalConfig = { ...DEFAULTS, ...config };
|
||||
const server = createApp(finalConfig);
|
||||
server.listen(finalConfig.port, finalConfig.bind, () => {
|
||||
console.log(`clidash listening on http://${finalConfig.bind}:${finalConfig.port}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
let root;
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-'));
|
||||
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
|
||||
const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); };
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]);
|
||||
});
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp({ port: 0, bind: '127.0.0.1', clis: {}, ...config });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/activity: returns per-session totals + a daily series', async () => {
|
||||
await withServer({ activity: { sessionsRoot: root, days: 14 } }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/activity`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.configured, true);
|
||||
assert.equal(body.series.length, 14);
|
||||
assert.equal(body.sessions[0].in, 2);
|
||||
assert.equal(body.sessions[0].out, 1);
|
||||
assert.equal(body.series.at(-1).in, 2); // today
|
||||
assert.equal(body.series.at(-1).out, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/activity: not configured → configured:false, no crash', async () => {
|
||||
await withServer({}, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/activity`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.configured, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { collectActivity } from '../activity.js';
|
||||
|
||||
let root;
|
||||
const NOW = new Date('2026-06-14T12:00:00Z');
|
||||
|
||||
function makeDb(path, table, timestamps) {
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec(`CREATE TABLE ${table} (id TEXT, timestamp TEXT)`);
|
||||
const ins = db.prepare(`INSERT INTO ${table} (id, timestamp) VALUES (?, ?)`);
|
||||
timestamps.forEach((t, i) => ins.run(String(i), t));
|
||||
db.close();
|
||||
}
|
||||
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-act-'));
|
||||
// session 1 (group ag-1): 3 inbound across 2 days, 2 outbound today
|
||||
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
|
||||
makeDb(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in',
|
||||
['2026-06-14 09:01:23', '2026-06-14 10:00:00', '2026-06-13 08:00:00']);
|
||||
makeDb(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out',
|
||||
['2026-06-14 09:05:00', '2026-06-14 10:05:00']);
|
||||
// session 2 (group ag-2): 1 inbound 20 days ago (outside 14d window), 0 outbound
|
||||
mkdirSync(join(root, 'ag-2', 'sess-2'), { recursive: true });
|
||||
makeDb(join(root, 'ag-2', 'sess-2', 'inbound.db'), 'messages_in', ['2026-05-25 08:00:00']);
|
||||
makeDb(join(root, 'ag-2', 'sess-2', 'outbound.db'), 'messages_out', []);
|
||||
});
|
||||
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
test('collectActivity: per-session in/out totals + last activity', () => {
|
||||
const { sessions } = collectActivity(root, 14, NOW);
|
||||
const s1 = sessions.find((s) => s.session_id === 'sess-1');
|
||||
assert.equal(s1.agent_group_id, 'ag-1');
|
||||
assert.equal(s1.in, 3);
|
||||
assert.equal(s1.out, 2);
|
||||
assert.equal(s1.lastActivity, '2026-06-14T10:05:00Z'); // normalized to ISO
|
||||
const s2 = sessions.find((s) => s.session_id === 'sess-2');
|
||||
assert.equal(s2.in, 1);
|
||||
assert.equal(s2.out, 0);
|
||||
});
|
||||
|
||||
test('collectActivity: series has one bucket per day for `days`, newest last', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
assert.equal(series.length, 14);
|
||||
assert.equal(series[0].date, '2026-06-01');
|
||||
assert.equal(series[13].date, '2026-06-14');
|
||||
});
|
||||
|
||||
test('collectActivity: counts land in the right day buckets', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
const byDate = Object.fromEntries(series.map((d) => [d.date, d]));
|
||||
assert.equal(byDate['2026-06-14'].in, 2);
|
||||
assert.equal(byDate['2026-06-14'].out, 2);
|
||||
assert.equal(byDate['2026-06-13'].in, 1);
|
||||
assert.equal(byDate['2026-06-13'].out, 0);
|
||||
});
|
||||
|
||||
test('collectActivity: messages outside the window are counted in totals but not the series', () => {
|
||||
const { series, sessions } = collectActivity(root, 14, NOW);
|
||||
const total = series.reduce((a, d) => a + d.in + d.out, 0);
|
||||
assert.equal(total, 5); // the 20-day-old message is excluded from series
|
||||
assert.equal(sessions.find((s) => s.session_id === 'sess-2').in, 1); // but still in the total count
|
||||
});
|
||||
|
||||
test('collectActivity: a dir with no message DBs is not a session (skipped)', () => {
|
||||
mkdirSync(join(root, 'ag-1', '.claude-shared'), { recursive: true }); // scaffolding, no db files
|
||||
const { sessions } = collectActivity(root, 14, NOW);
|
||||
assert.ok(!sessions.some((s) => s.session_id === '.claude-shared'));
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clidash-cmd-'));
|
||||
after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
function cli(extra = {}) {
|
||||
return {
|
||||
bin: process.execPath,
|
||||
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
|
||||
list: [STUB, '{resource}', 'list', '--json'],
|
||||
output: 'json',
|
||||
unwrap: 'data',
|
||||
commands: {
|
||||
get: [STUB, '{resource}', 'get', '{id}', '--json'],
|
||||
'config-get': [STUB, 'groups', 'config', 'get', '--id', '{id}', '--json'],
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(clis, fn, extra = {}) {
|
||||
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis, ...extra });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/cmd: runs an allowlisted command with {resource} + {id}', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=sess-123`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.data.id, 'sessions-detail');
|
||||
assert.match(body.data.args, /sessions get sess-123/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: config-get needs no resource', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/cmd/ncl/config-get?id=ag-1`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.match(body.data.args, /groups config get --id ag-1/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: unknown command name → 404 (allowlist)', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const res = await fetch(`${base}/api/cmd/ncl/delete?resource=groups&id=ag-1`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: a {resource} not in the discovered set is rejected without exec', async () => {
|
||||
const countFile = join(tmp, 'cmd-count.txt');
|
||||
const c = cli();
|
||||
c.env = { STUB_COUNT_FILE: countFile };
|
||||
await withServer({ ncl: c }, async (base) => {
|
||||
const res = await fetch(`${base}/api/cmd/ncl/get?resource=evil&id=x`);
|
||||
assert.equal(res.status, 404);
|
||||
// only discovery ran, never a get for the bogus resource
|
||||
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
|
||||
assert.deepEqual(calls, ['help']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: an id with illegal characters is rejected', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const res = await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=${encodeURIComponent('a b;rm -rf')}`);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: unknown cli → 404', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/cmd/nope/get?resource=sessions&id=x`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: a cli without a commands map → 404', async () => {
|
||||
const c = cli();
|
||||
delete c.commands;
|
||||
await withServer({ ncl: c }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=x`)).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../public/style.css', import.meta.url)), 'utf8');
|
||||
|
||||
// Regression: the `hidden` attribute must override author `display` rules.
|
||||
// `.detail-overlay` and `.cli-switcher` set `display:flex`, which beats the
|
||||
// browser's default `[hidden]{display:none}` — without this reset a hidden
|
||||
// overlay stays on top of the page and silently eats every click.
|
||||
test('style.css forces [hidden] to display:none with !important', () => {
|
||||
assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important;?\s*\}/);
|
||||
});
|
||||
|
||||
// Guard the premise: if these stop using display:flex the reset is less load-
|
||||
// bearing, but this documents WHY the reset exists.
|
||||
test('the overlays that motivated the reset still use display:flex', () => {
|
||||
assert.match(css, /\.detail-overlay\s*\{[^}]*display:\s*flex/);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
let root;
|
||||
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-docsrv-'));
|
||||
const w = (rel, body) => {
|
||||
const abs = join(root, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
};
|
||||
w('groups/alpha/skills/tagger/SKILL.md', '# tagger\nhello');
|
||||
w('container/skills/welcome/SKILL.md', '# welcome');
|
||||
w('groups/alpha/profile.json', '{"name":"Alpha"}');
|
||||
w('groups/alpha/.env', 'SECRET=nope');
|
||||
});
|
||||
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function docsConfig() {
|
||||
return {
|
||||
port: 0,
|
||||
bind: '127.0.0.1',
|
||||
clis: {},
|
||||
docs: {
|
||||
root,
|
||||
deny: ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'],
|
||||
collections: [
|
||||
{ name: 'skills', label: 'Skills', lang: 'markdown', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] },
|
||||
{ name: 'profiles', label: 'Profiles', lang: 'json', patterns: ['groups/*/profile.json'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp(config);
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
}
|
||||
|
||||
test('/api/docs: lists collections with their files', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/docs`)).json();
|
||||
const skills = body.collections.find((c) => c.name === 'skills');
|
||||
assert.equal(skills.label, 'Skills');
|
||||
assert.equal(skills.lang, 'markdown');
|
||||
const paths = skills.files.map((f) => f.path);
|
||||
assert.ok(paths.includes('groups/alpha/skills/tagger/SKILL.md'));
|
||||
assert.ok(paths.includes('container/skills/welcome/SKILL.md'));
|
||||
// each file carries a readable label + group
|
||||
const f = skills.files.find((x) => x.path.includes('tagger'));
|
||||
assert.equal(f.group, 'alpha');
|
||||
assert.match(f.label, /tagger/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: returns file content + lang', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const url = `${base}/api/doc?c=skills&p=${encodeURIComponent('groups/alpha/skills/tagger/SKILL.md')}`;
|
||||
const body = await (await fetch(url)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.lang, 'markdown');
|
||||
assert.match(body.content, /# tagger/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: a denied file is not readable even though it sits under root', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
// .env is excluded by the deny-list and not in any collection pattern
|
||||
const coll = docsConfig();
|
||||
coll.docs.collections.push({ name: 'all', label: 'All', lang: 'text', patterns: ['groups/*/*'] });
|
||||
await withServer(coll, async (base2) => {
|
||||
const res = await fetch(`${base2}/api/doc?c=all&p=${encodeURIComponent('groups/alpha/.env')}`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).ok, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: path traversal is rejected', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const res = await fetch(`${base}/api/doc?c=skills&p=${encodeURIComponent('../../../../etc/passwd')}`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: unknown collection → 404', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const res = await fetch(`${base}/api/doc?c=nope&p=x`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/docs: absent docs config → empty collections, no crash', async () => {
|
||||
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/docs`)).json();
|
||||
assert.deepEqual(body.collections, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { globFiles, describeFile, resolveDoc } from '../docs.js';
|
||||
|
||||
let root;
|
||||
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-docs-'));
|
||||
const w = (rel, body = 'x') => {
|
||||
const abs = join(root, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
};
|
||||
w('groups/alpha/skills/example-skill/SKILL.md', '# example-skill\nbody');
|
||||
w('groups/alpha/skills/tagger/SKILL.md');
|
||||
w('groups/alpha/CLAUDE.md', '# Alpha');
|
||||
w('groups/alpha/CLAUDE.local.md');
|
||||
w('groups/alpha/profile.json', '{"name":"Alpha"}');
|
||||
w('groups/alpha/conversations/2026-06-01.md');
|
||||
w('groups/bravo/skills/tagger/SKILL.md');
|
||||
w('groups/bravo/profile.json');
|
||||
w('container/skills/agent-browser/SKILL.md');
|
||||
w('container/skills/welcome/SKILL.md');
|
||||
// things that must NEVER be served
|
||||
w('groups/alpha/.env', 'SECRET=1');
|
||||
w('groups/alpha/skills/example-skill/node_modules/dep/SKILL.md');
|
||||
w('groups/alpha/notion-token.txt', 'ntn_xxx');
|
||||
});
|
||||
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const DENY = ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'];
|
||||
|
||||
// --------------------------------------------------------------- globFiles
|
||||
|
||||
test('globFiles: matches a nested *-segment pattern', () => {
|
||||
const files = globFiles(root, ['groups/*/skills/*/SKILL.md'], DENY);
|
||||
assert.deepEqual(files, [
|
||||
'groups/alpha/skills/example-skill/SKILL.md',
|
||||
'groups/alpha/skills/tagger/SKILL.md',
|
||||
'groups/bravo/skills/tagger/SKILL.md',
|
||||
]);
|
||||
});
|
||||
|
||||
test('globFiles: multiple patterns union, sorted', () => {
|
||||
const files = globFiles(root, ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'], DENY);
|
||||
assert.ok(files.includes('container/skills/agent-browser/SKILL.md'));
|
||||
assert.ok(files.includes('groups/alpha/skills/example-skill/SKILL.md'));
|
||||
});
|
||||
|
||||
test('globFiles: wildcard inside a filename segment', () => {
|
||||
const files = globFiles(root, ['groups/*/CLAUDE*.md'], DENY);
|
||||
assert.deepEqual(files, ['groups/alpha/CLAUDE.local.md', 'groups/alpha/CLAUDE.md']);
|
||||
});
|
||||
|
||||
test('globFiles: deny list excludes node_modules and secret-ish files', () => {
|
||||
const files = globFiles(root, ['groups/*/skills/*/**', 'groups/*/*'], DENY);
|
||||
assert.ok(!files.some((f) => f.includes('node_modules')));
|
||||
assert.ok(!files.some((f) => f.endsWith('.env')));
|
||||
assert.ok(!files.some((f) => f.includes('token')));
|
||||
});
|
||||
|
||||
test('globFiles: no match returns empty array', () => {
|
||||
assert.deepEqual(globFiles(root, ['nope/*/x.md'], DENY), []);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- describeFile
|
||||
|
||||
test('describeFile: per-group skill → group + readable label', () => {
|
||||
const d = describeFile('groups/alpha/skills/tagger/SKILL.md');
|
||||
assert.equal(d.group, 'alpha');
|
||||
assert.match(d.label, /alpha/);
|
||||
assert.match(d.label, /tagger/);
|
||||
});
|
||||
|
||||
test('describeFile: container skill → shared', () => {
|
||||
const d = describeFile('container/skills/agent-browser/SKILL.md');
|
||||
assert.equal(d.group, 'shared');
|
||||
assert.match(d.label, /agent-browser/);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- resolveDoc
|
||||
|
||||
const SKILLS = { name: 'skills', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] };
|
||||
|
||||
test('resolveDoc: returns an absolute path for an allowed file', () => {
|
||||
const abs = resolveDoc(root, SKILLS, 'groups/alpha/skills/example-skill/SKILL.md', DENY);
|
||||
assert.ok(abs.endsWith('/groups/alpha/skills/example-skill/SKILL.md'));
|
||||
assert.ok(abs.startsWith(root));
|
||||
});
|
||||
|
||||
test('resolveDoc: rejects a path not matching the collection patterns', () => {
|
||||
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/profile.json', DENY), /not allowed/i);
|
||||
});
|
||||
|
||||
test('resolveDoc: rejects path traversal', () => {
|
||||
assert.throws(() => resolveDoc(root, SKILLS, '../../etc/passwd', DENY), /not allowed/i);
|
||||
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/skills/../../../.env', DENY), /not allowed/i);
|
||||
});
|
||||
|
||||
test('resolveDoc: rejects an absolute path', () => {
|
||||
assert.throws(() => resolveDoc(root, SKILLS, '/etc/passwd', DENY), /not allowed/i);
|
||||
});
|
||||
|
||||
test('resolveDoc: a denied file is not resolvable even if pattern-shaped', () => {
|
||||
const coll = { name: 'all', patterns: ['groups/*/*'] };
|
||||
assert.throws(() => resolveDoc(root, coll, 'groups/alpha/.env', DENY), /not allowed/i);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
Resources:
|
||||
approvals Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.
|
||||
verbs: list, get
|
||||
destinations Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.
|
||||
verbs: list, add, remove
|
||||
dropped-messages Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn't fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).
|
||||
verbs: list
|
||||
groups Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.
|
||||
verbs: list, get, create, update, delete, restart, config get, config update, config add-mcp-server, config remove-mcp-server, config add-package, config remove-package
|
||||
members Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".
|
||||
verbs: list, add, remove
|
||||
messaging-groups Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.
|
||||
verbs: list, get, create, update, delete
|
||||
roles User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).
|
||||
verbs: list, grant, revoke
|
||||
sessions Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.
|
||||
verbs: list, get
|
||||
user-dms User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.
|
||||
verbs: list
|
||||
users User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).
|
||||
verbs: list, get, create, update
|
||||
wirings Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.
|
||||
verbs: list, get, create, update, delete
|
||||
|
||||
Commands:
|
||||
help List available resources and commands.
|
||||
|
||||
Run `ncl <resource> help` for detailed field information.
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
// Stub CLI for clidash tests. Impersonates ncl (envelope json) or a
|
||||
// jsonlines CLI, with failure/slowness/garbage modes driven by env vars.
|
||||
import { readFileSync, appendFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (process.env.STUB_COUNT_FILE) {
|
||||
appendFileSync(process.env.STUB_COUNT_FILE, args.join(' ') + '\n');
|
||||
}
|
||||
|
||||
const sleepMs = Number(process.env.STUB_SLEEP_MS || 0);
|
||||
|
||||
setTimeout(() => {
|
||||
if (process.env.STUB_FAIL) {
|
||||
process.stderr.write('boom: socket down\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (args[0] === 'help') {
|
||||
process.stdout.write(
|
||||
readFileSync(fileURLToPath(new URL('./ncl-help.txt', import.meta.url)), 'utf8'),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[1] === 'help') { // `<resource> help` → raw per-resource help text
|
||||
process.stdout.write(`${args[0]}: help for ${args[0]}\n\nVerbs:\n list\n get <id>\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.env.STUB_RAW) {
|
||||
process.stdout.write(process.env.STUB_RAW + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const resource = args[0];
|
||||
// `get`/detail commands → single-object envelope
|
||||
if (args.includes('get') || args.includes('config')) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
id: 'req-1', ok: true,
|
||||
data: { id: `${resource}-detail`, args: args.join(' '), extra: 'field' },
|
||||
}) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.env.STUB_JSONLINES) {
|
||||
process.stdout.write(JSON.stringify({ id: `${resource}-1`, name: 'row one' }) + '\n');
|
||||
process.stdout.write(JSON.stringify({ id: `${resource}-2`, name: 'row two' }) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({
|
||||
id: 'req-1',
|
||||
ok: true,
|
||||
data: [
|
||||
{ id: `${resource}-1`, name: 'row one' },
|
||||
{ id: `${resource}-2`, name: 'row two' },
|
||||
],
|
||||
}) + '\n');
|
||||
process.exit(0);
|
||||
}, sleepMs);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
|
||||
|
||||
function cli(extra = {}) {
|
||||
return {
|
||||
bin: process.execPath,
|
||||
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
|
||||
list: [STUB, '{resource}', 'list', '--json'],
|
||||
output: 'json', unwrap: 'data',
|
||||
help: [STUB, '{resource}', 'help'],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(clis, fn) {
|
||||
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/help: returns raw per-resource help text', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/help/ncl/sessions`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.match(body.text, /sessions: help for sessions/);
|
||||
assert.match(body.text, /Verbs:/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/help: undiscovered resource → 404', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/help/ncl/evil`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/help: a cli without a help template → 404', async () => {
|
||||
const c = cli(); delete c.help;
|
||||
await withServer({ ncl: c }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/help/ncl/sessions`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/help: unknown cli → 404', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/help/nope/sessions`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/clis: reports help availability per cli', async () => {
|
||||
const noHelp = cli(); delete noHelp.help;
|
||||
await withServer({ ncl: cli(), docker: noHelp }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/clis`)).json();
|
||||
assert.equal(body.clis.find((c) => c.name === 'ncl').help, true);
|
||||
assert.equal(body.clis.find((c) => c.name === 'docker').help, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { tailFile } from '../logs.js';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
let dir;
|
||||
before(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'clidash-logs-'));
|
||||
// 10 lines, some with ANSI color codes
|
||||
const lines = Array.from({ length: 10 }, (_, i) =>
|
||||
`[12:00:0${i}] \x1b[32mINFO\x1b[39m line ${i}`);
|
||||
writeFileSync(join(dir, 'app.log'), lines.join('\n') + '\n');
|
||||
writeFileSync(join(dir, 'error.log'), 'boom\n');
|
||||
});
|
||||
after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
test('tailFile: returns the last N lines, ANSI stripped, no trailing blank', async () => {
|
||||
const { lines, text } = await tailFile(join(dir, 'app.log'), 3);
|
||||
assert.equal(lines.length, 3);
|
||||
assert.deepEqual(lines, ['[12:00:07] INFO line 7', '[12:00:08] INFO line 8', '[12:00:09] INFO line 9']);
|
||||
assert.ok(!text.includes('\x1b'));
|
||||
});
|
||||
|
||||
test('tailFile: maxLines larger than file returns all lines', async () => {
|
||||
const { lines } = await tailFile(join(dir, 'app.log'), 100);
|
||||
assert.equal(lines.length, 10);
|
||||
});
|
||||
|
||||
// ---- server endpoints ----
|
||||
|
||||
function cfg() {
|
||||
return {
|
||||
port: 0, bind: '127.0.0.1', clis: {},
|
||||
logs: { dir, tailLines: 5, files: [{ name: 'app.log', label: 'app' }, { name: 'error.log', label: 'errors' }] },
|
||||
};
|
||||
}
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp(config);
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/logs: lists the configured log files', async () => {
|
||||
await withServer(cfg(), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/logs`)).json();
|
||||
assert.deepEqual(body.files.map((f) => f.name), ['app.log', 'error.log']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/logs: absent logs config → empty list', async () => {
|
||||
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
|
||||
assert.deepEqual((await (await fetch(`${base}/api/logs`)).json()).files, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/log: returns the tail text + a tail command', async () => {
|
||||
await withServer(cfg(), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/log/app.log`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.match(body.text, /line 9$/);
|
||||
assert.equal(body.text.split('\n').length, 5); // tailLines
|
||||
assert.match(body.command, /tail -n 5 .*app\.log/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/log: a name not in the allowlist is rejected (no traversal)', async () => {
|
||||
await withServer(cfg(), async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/log/${encodeURIComponent('../../etc/passwd')}`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/api/log/secrets.log`)).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { escapeHtml, mdToHtml } from '../public/md.js';
|
||||
|
||||
// ---- escaping -------------------------------------------------------------
|
||||
|
||||
test('escapeHtml: neutralizes all HTML metacharacters', () => {
|
||||
assert.equal(escapeHtml(`<script>"&'`), '<script>"&'');
|
||||
});
|
||||
|
||||
test('mdToHtml: raw HTML in source is escaped, never passed through', () => {
|
||||
const html = mdToHtml('a <script>alert(1)</script> b');
|
||||
assert.ok(!html.includes('<script>'));
|
||||
assert.ok(html.includes('<script>'));
|
||||
});
|
||||
|
||||
// ---- the security-sensitive part: links -----------------------------------
|
||||
|
||||
test('mdToHtml: link href comes from the URL, label from the text', () => {
|
||||
const html = mdToHtml('see [the docs](https://example.com/x)');
|
||||
assert.match(html, /<a href="https:\/\/example\.com\/x" target="_blank" rel="noopener noreferrer">the docs<\/a>/);
|
||||
});
|
||||
|
||||
test('mdToHtml: javascript: smuggled in link TEXT stays inert (never an href)', () => {
|
||||
const html = mdToHtml('[javascript:alert(1)](https://safe.com)');
|
||||
// href is the safe URL; the js string is only visible label text
|
||||
assert.match(html, /href="https:\/\/safe\.com"/);
|
||||
assert.ok(!/href="javascript:/i.test(html));
|
||||
});
|
||||
|
||||
test('mdToHtml: a non-http(s) URL is not turned into a link', () => {
|
||||
// javascript:/data: never match the (https?:...) capture, so the literal
|
||||
// (escaped) markdown is left as-is — no anchor, no executable href.
|
||||
const html = mdToHtml('[click](javascript:alert(1))');
|
||||
assert.ok(!/<a /.test(html));
|
||||
assert.ok(!/href="javascript:/i.test(html));
|
||||
});
|
||||
|
||||
test('mdToHtml: an attribute-breakout attempt in the URL cannot escape the href', () => {
|
||||
// The double-quote is escaped to " before the regex runs, so it can never
|
||||
// close an attribute. (Here the URL also has a space, so no anchor even forms.)
|
||||
// The security property: no REAL attribute (with a literal quote) is injected.
|
||||
const html = mdToHtml('[x](https://a" onmouseover="alert(1))');
|
||||
assert.ok(!/<a/.test(html), 'malformed link must not produce an anchor');
|
||||
assert.ok(!/onmouseover="/.test(html), 'no real (unescaped-quote) attribute injected');
|
||||
});
|
||||
|
||||
test('mdToHtml: an escaped quote inside a matched URL stays inside the href, inert', () => {
|
||||
// Even when a URL matches, any " in it is already " (an entity), which
|
||||
// does not terminate an HTML attribute value — so no breakout.
|
||||
const html = mdToHtml('[x](https://a"onmouseover=alert)');
|
||||
assert.ok(!/onmouseover="/.test(html));
|
||||
if (/<a/.test(html)) assert.match(html, /href="https:\/\/a"onmouseover=alert"/);
|
||||
});
|
||||
|
||||
// ---- basic rendering sanity ----------------------------------------------
|
||||
|
||||
test('mdToHtml: headings, code fences, lists render', () => {
|
||||
const html = mdToHtml('# Title\n\n```\ncode\n```\n\n- a\n- b');
|
||||
assert.match(html, /<h1>Title<\/h1>/);
|
||||
assert.match(html, /<pre class="code"><code>code<\/code><\/pre>/);
|
||||
assert.match(html, /<ul><li>a<\/li><li>b<\/li><\/ul>/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import overview from '../views/ncl-overview.js';
|
||||
|
||||
const minutesAgo = (m) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
|
||||
// Shapes mirror real `ncl <resource> list --json` output.
|
||||
function makeFixtures({ alphaLastActive, bravoLastActive }) {
|
||||
return {
|
||||
groups: [
|
||||
{ id: 'ag-1', name: 'Alpha', folder: 'alpha', created_at: '2026-05-31T11:14:48.793Z' },
|
||||
{ id: 'ag-2', name: 'Bravo Team', folder: 'bravo', created_at: '2026-05-31T11:14:48.796Z' },
|
||||
{ id: 'ag-3', name: 'Orphan', folder: 'orphan', created_at: '2026-05-31T11:14:48.799Z' },
|
||||
],
|
||||
sessions: [
|
||||
{ id: 'sess-1', agent_group_id: 'ag-1', messaging_group_id: 'mg-1', thread_id: null, status: 'active', container_status: 'stopped', last_active: alphaLastActive, created_at: '2026-05-31T11:14:51.911Z' },
|
||||
{ id: 'sess-2', agent_group_id: 'ag-2', messaging_group_id: 'mg-2', thread_id: null, status: 'active', container_status: 'running', last_active: bravoLastActive, created_at: '2026-05-31T11:14:51.973Z' },
|
||||
],
|
||||
'messaging-groups': [
|
||||
{ id: 'mg-1', channel_type: 'telegram', platform_id: 'telegram:1', name: 'Alpha', is_group: 0 },
|
||||
{ id: 'mg-2', channel_type: 'telegram', platform_id: 'telegram:2', name: 'Bravo Team', is_group: 0 },
|
||||
],
|
||||
wirings: [
|
||||
{ id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', session_mode: 'shared' },
|
||||
{ id: 'mga-2', messaging_group_id: 'mg-2', agent_group_id: 'ag-2', session_mode: 'shared' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fetchFrom(fixtures) {
|
||||
return async (resource) => {
|
||||
if (!(resource in fixtures)) throw new Error(`unexpected fetch: ${resource}`);
|
||||
return fixtures[resource];
|
||||
};
|
||||
}
|
||||
|
||||
test('overview: one card per agent group with joined session + wiring data', async () => {
|
||||
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
|
||||
const result = await overview({ fetch: fetchFrom(fixtures) });
|
||||
assert.equal(result.cards.length, 3);
|
||||
|
||||
const alpha = result.cards.find((c) => c.title === 'Alpha');
|
||||
assert.equal(alpha.subtitle, 'alpha');
|
||||
assert.equal(alpha.fields.container, 'stopped');
|
||||
assert.equal(alpha.fields.sessions, 1);
|
||||
assert.deepEqual(alpha.badges, ['telegram: Alpha']);
|
||||
|
||||
const bravo = result.cards.find((c) => c.title === 'Bravo Team');
|
||||
assert.equal(bravo.fields.container, 'running');
|
||||
assert.deepEqual(bravo.badges, ['telegram: Bravo Team']);
|
||||
});
|
||||
|
||||
test('overview: staleness thresholds — green <15m, amber <2h, red older, gray never', async () => {
|
||||
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
|
||||
const result = await overview({ fetch: fetchFrom(fixtures) });
|
||||
assert.equal(result.cards.find((c) => c.title === 'Alpha').status, 'green');
|
||||
assert.equal(result.cards.find((c) => c.title === 'Bravo Team').status, 'amber');
|
||||
assert.equal(result.cards.find((c) => c.title === 'Orphan').status, 'gray');
|
||||
|
||||
const stale = makeFixtures({ alphaLastActive: minutesAgo(300), bravoLastActive: minutesAgo(30) });
|
||||
const result2 = await overview({ fetch: fetchFrom(stale) });
|
||||
assert.equal(result2.cards.find((c) => c.title === 'Alpha').status, 'red');
|
||||
});
|
||||
|
||||
test('overview: last_active is exposed for relative-time rendering', async () => {
|
||||
const ts = minutesAgo(5);
|
||||
const fixtures = makeFixtures({ alphaLastActive: ts, bravoLastActive: minutesAgo(30) });
|
||||
const result = await overview({ fetch: fetchFrom(fixtures) });
|
||||
assert.equal(result.cards.find((c) => c.title === 'Alpha').fields['last active'], ts);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { discoveryParsers, parseOutput, unwrapPath } from '../parsers.js';
|
||||
|
||||
const fixture = readFileSync(
|
||||
fileURLToPath(new URL('./fixtures/ncl-help.txt', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- ncl-help
|
||||
|
||||
test('ncl-help: parses all listable resources from real captured output', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
assert.deepEqual(
|
||||
resources.map((r) => r.name),
|
||||
[
|
||||
'approvals', 'destinations', 'dropped-messages', 'groups', 'members',
|
||||
'messaging-groups', 'roles', 'sessions', 'user-dms', 'users', 'wirings',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('ncl-help: every parsed resource has a non-empty description and a list verb', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
for (const r of resources) {
|
||||
assert.ok(r.description.length > 0, `${r.name} has empty description`);
|
||||
assert.ok(r.verbs.includes('list'), `${r.name} missing list verb`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ncl-help: parses verbs correctly, including multi-word verbs', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
const groups = resources.find((r) => r.name === 'groups');
|
||||
assert.deepEqual(groups.verbs, [
|
||||
'list', 'get', 'create', 'update', 'delete', 'restart',
|
||||
'config get', 'config update', 'config add-mcp-server',
|
||||
'config remove-mcp-server', 'config add-package', 'config remove-package',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ncl-help: excludes resources without a list verb', () => {
|
||||
const input = [
|
||||
'Resources:',
|
||||
' alpha Has list.',
|
||||
' verbs: list, get',
|
||||
' beta No list here.',
|
||||
' verbs: grant, revoke',
|
||||
'',
|
||||
].join('\n');
|
||||
const resources = discoveryParsers['ncl-help'](input);
|
||||
assert.deepEqual(resources.map((r) => r.name), ['alpha']);
|
||||
});
|
||||
|
||||
test('ncl-help: ignores the Commands section (help is not a resource)', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
assert.ok(!resources.some((r) => r.name === 'help'));
|
||||
});
|
||||
|
||||
test('ncl-help: throws loudly on unrecognized format', () => {
|
||||
assert.throws(() => discoveryParsers['ncl-help']('totally not help output'), /Resources/);
|
||||
assert.throws(() => discoveryParsers['ncl-help'](''), /Resources/);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- parseOutput
|
||||
|
||||
test('parseOutput json: parses a single document', () => {
|
||||
assert.deepEqual(parseOutput('{"a": 1}', 'json'), { a: 1 });
|
||||
});
|
||||
|
||||
test('parseOutput json: throws on malformed input with raw output preserved', () => {
|
||||
assert.throws(() => parseOutput('not json', 'json'), (err) => {
|
||||
assert.match(err.message, /JSON/i);
|
||||
assert.equal(err.raw, 'not json');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('parseOutput jsonlines: one object per line, blank lines skipped', () => {
|
||||
const text = '{"id":1}\n\n{"id":2}\n{"id":3}\n';
|
||||
assert.deepEqual(parseOutput(text, 'jsonlines'), [{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
});
|
||||
|
||||
test('parseOutput jsonlines: throws on a malformed line', () => {
|
||||
assert.throws(() => parseOutput('{"ok":1}\ngarbage\n', 'jsonlines'), /line 2/i);
|
||||
});
|
||||
|
||||
test('parseOutput: rejects unknown format', () => {
|
||||
assert.throws(() => parseOutput('{}', 'xml'), /format/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- unwrapPath
|
||||
|
||||
test('unwrapPath: extracts the ncl {id, ok, data} envelope', () => {
|
||||
const doc = { id: 'x', ok: true, data: [{ id: 'sess-1' }] };
|
||||
assert.deepEqual(unwrapPath(doc, 'data'), [{ id: 'sess-1' }]);
|
||||
});
|
||||
|
||||
test('unwrapPath: supports nested dot paths', () => {
|
||||
assert.deepEqual(unwrapPath({ a: { b: [1, 2] } }, 'a.b'), [1, 2]);
|
||||
});
|
||||
|
||||
test('unwrapPath: throws when the path is missing', () => {
|
||||
assert.throws(() => unwrapPath({ ok: true }, 'data'), /data/);
|
||||
});
|
||||
|
||||
test('unwrapPath: no path returns the value unchanged', () => {
|
||||
const rows = [{ id: 1 }];
|
||||
assert.equal(unwrapPath(rows, undefined), rows);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clidash-test-'));
|
||||
|
||||
function stubCli(extra = {}) {
|
||||
return {
|
||||
bin: process.execPath,
|
||||
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
|
||||
list: [STUB, '{resource}', 'list', '--json'],
|
||||
output: 'json',
|
||||
unwrap: 'data',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfig(clis, extra = {}) {
|
||||
return { port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, refreshSeconds: 10, clis, ...extra };
|
||||
}
|
||||
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp(config);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
// ----------------------------------------------------------------- /api/clis
|
||||
|
||||
test('/api/clis: lists configured CLIs with discovered resources', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/api/clis`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.clis.length, 1);
|
||||
assert.equal(body.clis[0].name, 'stub');
|
||||
assert.equal(body.clis[0].refreshSeconds, 10);
|
||||
const names = body.clis[0].resources.map((r) => r.name);
|
||||
assert.ok(names.includes('sessions'));
|
||||
assert.ok(names.includes('groups'));
|
||||
assert.equal(names.length, 11);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/clis: static resource list needs no discovery', async () => {
|
||||
const cli = stubCli({ resources: ['alpha', 'beta'] });
|
||||
delete cli.discover;
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/clis`)).json();
|
||||
assert.deepEqual(body.clis[0].resources.map((r) => r.name), ['alpha', 'beta']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/clis: discovery failure reports a loud error', async () => {
|
||||
const cli = stubCli();
|
||||
cli.env = { STUB_FAIL: '1' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/clis`)).json();
|
||||
assert.equal(body.clis[0].resources.length, 0);
|
||||
assert.match(body.clis[0].error, /boom/);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ /api/r/cli/res
|
||||
|
||||
test('/api/r: returns unwrapped rows with fetchedAt', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/stub/sessions`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.deepEqual(body.rows.map((r) => r.id), ['sessions-1', 'sessions-2']);
|
||||
assert.ok(body.fetchedAt);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: rejects a resource not in the discovered set without exec', async () => {
|
||||
const countFile = join(tmp, 'count-reject.txt');
|
||||
const cli = stubCli();
|
||||
cli.env = { STUB_COUNT_FILE: countFile };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/stub/evil%20--rm`);
|
||||
assert.equal(res.status, 404);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, false);
|
||||
// only the discovery exec ran — never a list exec for the bogus resource
|
||||
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
|
||||
assert.deepEqual(calls, ['help']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: unknown cli → 404', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/nope/sessions`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: jsonlines CLI with static resources works', async () => {
|
||||
const cli = {
|
||||
bin: process.execPath,
|
||||
resources: ['ps'],
|
||||
list: [STUB, '{resource}'],
|
||||
output: 'jsonlines',
|
||||
env: { STUB_JSONLINES: '1' },
|
||||
};
|
||||
await withServer(makeConfig({ docker: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/docker/ps`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.deepEqual(body.rows.map((r) => r.id), ['ps-1', 'ps-2']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: exec failure returns ok:false with stderr', async () => {
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_FAIL: '1' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/stub/sessions`);
|
||||
assert.equal(res.status, 502);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.match(body.error, /boom: socket down/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: exec timeout returns ok:false naming the resource', async () => {
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_SLEEP_MS: '5000' };
|
||||
await withServer(makeConfig({ stub: cli }, { execTimeoutMs: 200 }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.match(body.error, /sessions/);
|
||||
assert.match(body.error, /timed out/i);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: malformed CLI output returns the raw output', async () => {
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_RAW: 'this is not json' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.match(body.raw, /this is not json/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: concurrent requests for the same resource coalesce into one exec', async () => {
|
||||
const countFile = join(tmp, 'count-coalesce.txt');
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_COUNT_FILE: countFile, STUB_SLEEP_MS: '150' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const bodies = await Promise.all(
|
||||
Array.from({ length: 5 }, () => fetch(`${base}/api/r/stub/sessions`).then((r) => r.json())),
|
||||
);
|
||||
for (const body of bodies) assert.equal(body.ok, true);
|
||||
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- /api/view
|
||||
|
||||
test('/api/view: runs a view plugin with a bound fetch helper', async () => {
|
||||
const viewsDir = join(tmp, 'views');
|
||||
writeFileSync(join(viewsDir, '..', 'placeholder'), ''); // ensure tmp exists
|
||||
const { mkdirSync } = await import('node:fs');
|
||||
mkdirSync(viewsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(viewsDir, 'stub-overview.js'),
|
||||
'export default async function ({ fetch }) {\n' +
|
||||
' const rows = await fetch("sessions");\n' +
|
||||
' return { count: rows.length, first: rows[0].id };\n' +
|
||||
'}\n',
|
||||
);
|
||||
await withServer(makeConfig({ stub: stubCli() }, { viewsDir }), async (base) => {
|
||||
const res = await fetch(`${base}/api/view/stub/overview`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.deepEqual(body.result, { count: 2, first: 'sessions-1' });
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/view: missing view → 404; bad view name → 404', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }, { viewsDir: join(tmp, 'views') }), async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/view/stub/nope`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/api/view/stub/..%2F..%2Fserver`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- static files
|
||||
|
||||
test('GET /: serves the dashboard index.html', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get('content-type'), /text\/html/);
|
||||
assert.match(await res.text(), /clidash/i);
|
||||
});
|
||||
});
|
||||
|
||||
test('static: path traversal outside public/ is rejected', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/..%2Fserver.js`);
|
||||
assert.notEqual(res.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: {resource} substitutes inside a larger argv string (ssh-remote pattern)', async () => {
|
||||
const cli = {
|
||||
bin: process.execPath,
|
||||
resources: ['sessions'],
|
||||
list: [STUB, 'wrapped-{resource}-arg', 'list'],
|
||||
output: 'json',
|
||||
unwrap: 'data',
|
||||
env: { STUB_COUNT_FILE: join(tmp, 'count-embed.txt') },
|
||||
};
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
const calls = readFileSync(join(tmp, 'count-embed.txt'), 'utf8').trim();
|
||||
assert.equal(calls, 'wrapped-sessions-arg list');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test against a running clidash instance (run on the VM after deploy).
|
||||
# Usage: ./test/smoke.sh [base-url] (default http://127.0.0.1:4690)
|
||||
set -euo pipefail
|
||||
BASE="${1:-http://127.0.0.1:4690}"
|
||||
|
||||
check() {
|
||||
local label="$1" url="$2" pattern="$3"
|
||||
if curl -fsS --max-time 15 "$url" | grep -q "$pattern"; then
|
||||
echo "OK $label"
|
||||
else
|
||||
echo "FAIL $label ($url did not match $pattern)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check "/api/clis" "$BASE/api/clis" '"resources"'
|
||||
check "/api/r/ncl/sessions" "$BASE/api/r/ncl/sessions" '"ok":true'
|
||||
check "/api/view/ncl/overview" "$BASE/api/view/ncl/overview" '"ok":true'
|
||||
check "GET / (static UI)" "$BASE/" 'clidash'
|
||||
echo "smoke: all good"
|
||||
@@ -0,0 +1,60 @@
|
||||
// Curated "Agents overview" view for ncl: joins groups + sessions +
|
||||
// messaging-groups + wirings into per-agent cards. Returns the generic
|
||||
// card shape the frontend renders, so the UI itself stays CLI-agnostic:
|
||||
// { title, cards: [{ title, subtitle, status, fields, badges }] }
|
||||
// status: green <15m since last_active, amber <2h, red older, gray never.
|
||||
|
||||
const GREEN_MAX_MIN = 15;
|
||||
const AMBER_MAX_MIN = 120;
|
||||
|
||||
function staleness(lastActive) {
|
||||
if (!lastActive) return 'gray';
|
||||
const ageMin = (Date.now() - new Date(lastActive).getTime()) / 60_000;
|
||||
if (ageMin < GREEN_MAX_MIN) return 'green';
|
||||
if (ageMin < AMBER_MAX_MIN) return 'amber';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export default async function overview({ fetch }) {
|
||||
const [groups, sessions, messagingGroups, wirings] = await Promise.all([
|
||||
fetch('groups'),
|
||||
fetch('sessions'),
|
||||
fetch('messaging-groups'),
|
||||
fetch('wirings'),
|
||||
]);
|
||||
|
||||
const mgById = new Map(messagingGroups.map((mg) => [mg.id, mg]));
|
||||
|
||||
const cards = groups.map((group) => {
|
||||
const groupSessions = sessions.filter((s) => s.agent_group_id === group.id);
|
||||
const lastActive = groupSessions
|
||||
.map((s) => s.last_active)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
const container = groupSessions.some((s) => s.container_status === 'running')
|
||||
? 'running'
|
||||
: groupSessions[0]?.container_status ?? 'none';
|
||||
|
||||
const badges = wirings
|
||||
.filter((w) => w.agent_group_id === group.id)
|
||||
.map((w) => {
|
||||
const mg = mgById.get(w.messaging_group_id);
|
||||
return mg ? `${mg.channel_type}: ${mg.name ?? mg.platform_id}` : w.messaging_group_id;
|
||||
});
|
||||
|
||||
return {
|
||||
title: group.name,
|
||||
subtitle: group.folder,
|
||||
status: staleness(lastActive),
|
||||
fields: {
|
||||
container,
|
||||
sessions: groupSessions.length,
|
||||
'last active': lastActive,
|
||||
},
|
||||
badges,
|
||||
};
|
||||
});
|
||||
|
||||
return { title: 'Agents overview', cards };
|
||||
}
|
||||
@@ -1,83 +1,78 @@
|
||||
# Remove Codex provider
|
||||
# Remove the Codex agent provider
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, plus the Dockerfile CLI install.
|
||||
Reverses every change `/add-codex` makes and returns every group to the default provider. Safe to run when partially installed — skip any step whose target is already absent.
|
||||
|
||||
## 1. Delete the barrel import lines (both trees)
|
||||
## 1. Switch codex groups back to the default
|
||||
|
||||
Delete (do not comment out) the `import './codex.js';` line from each barrel:
|
||||
List groups still on codex and switch each one (each group's `memory/` tree stays on disk and readable; run `/migrate-memory` per group if its memory should carry back to Claude — see [docs/provider-migration.md](../../docs/provider-migration.md)):
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
# for each group whose config shows provider=codex:
|
||||
ncl groups config update --id <group-id> --provider claude
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
## 2. Delete the barrel imports
|
||||
|
||||
Delete (do not comment out) the `import './codex.js';` line from each of:
|
||||
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
|
||||
|
||||
## 2. Delete the copied files (both trees)
|
||||
## 3. Delete every copied file
|
||||
|
||||
```bash
|
||||
rm -f src/providers/codex.ts \
|
||||
src/providers/codex-agents-md.ts \
|
||||
src/providers/codex-registration.test.ts \
|
||||
src/providers/codex-host-contribution.test.ts \
|
||||
src/providers/codex-agents-md.test.ts \
|
||||
container/agent-runner/src/providers/codex.ts \
|
||||
container/agent-runner/src/providers/codex-app-server.ts \
|
||||
container/agent-runner/src/providers/codex.factory.test.ts \
|
||||
container/agent-runner/src/providers/exchange-archive.ts \
|
||||
container/agent-runner/src/providers/exchange-archive.test.ts \
|
||||
container/agent-runner/src/providers/codex-registration.test.ts \
|
||||
container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
container/agent-runner/src/providers/codex.factory.test.ts \
|
||||
container/agent-runner/src/providers/codex.turns.test.ts \
|
||||
container/agent-runner/src/providers/codex-app-server.test.ts \
|
||||
container/agent-runner/src/providers/codex-cli-tools.test.ts \
|
||||
setup/providers/codex.ts \
|
||||
setup/providers/codex.test.ts \
|
||||
setup/providers/codex-registration.test.ts
|
||||
```
|
||||
|
||||
## 3. Revert the Dockerfile CLI install
|
||||
This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so the provider can be re-added later.
|
||||
|
||||
In `container/Dockerfile`, remove both Codex edits (skip whichever is already gone):
|
||||
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
|
||||
|
||||
**(a)** Delete the version ARG from the "Pin CLI versions" block:
|
||||
## 4. Remove the CLI manifest entry
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Delete the standalone Codex install layer:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
|
||||
|
||||
## 4. Dependency
|
||||
|
||||
Codex is a CLI binary installed via the Dockerfile — there is no agent-runner package dependency to uninstall. Step 3 removes the only install surface; no `bun remove` / `pnpm uninstall` is needed.
|
||||
|
||||
## 5. Unset Codex env vars
|
||||
|
||||
Remove any Codex-specific lines you added to `.env` (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `CODEX_MODEL`) if no other integration uses them, then re-sync to the container:
|
||||
Delete the `@openai/codex` entry from `container/cli-tools.json`:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8")).filter((t) => t.name !== "@openai/codex");
|
||||
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
'
|
||||
```
|
||||
|
||||
Switch any group still on Codex back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
|
||||
## 5. Vault secret (optional)
|
||||
|
||||
## 6. Rebuild and restart
|
||||
The ChatGPT/OpenAI secret in the OneCLI vault grants nothing once the provider is gone. To remove it: `onecli secrets list`, then `onecli secrets delete --id <id>` for the `chatgpt.com` / `api.openai.com` entry.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
## 6. Rebuild and verify
|
||||
|
||||
```bash
|
||||
pnpm run build && ./container/build.sh
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
pnpm test
|
||||
cd container/agent-runner && bun test
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
|
||||
|
||||
```bash
|
||||
grep -R "codex.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
|
||||
grep "@openai/codex" container/Dockerfile # no output
|
||||
```
|
||||
|
||||
In a wired agent, requesting `agent_provider = 'codex'` should fall back to the default provider since `codex` is no longer in the registry.
|
||||
All suites green and `ncl groups list` showing no codex groups means the removal is complete. Restart the service (`launchctl kickstart -k gui/$(id -u)/<label>` on macOS, `systemctl --user restart <unit>` on Linux).
|
||||
|
||||
@@ -1,186 +1,144 @@
|
||||
---
|
||||
name: add-codex
|
||||
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
description: Use Codex (OpenAI's codex app-server) as a full agent provider — planning, tool orchestration, MCP tools, server-side history, session resume — alongside or instead of Claude. ChatGPT subscription or OpenAI API key, vault-only via OneCLI. Per-group via `ncl groups config update --provider codex`. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
---
|
||||
|
||||
# Codex agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
|
||||
> Shortcut: `pnpm exec tsx setup/index.ts --step provider-auth codex` performs this whole install (manifest-driven from the providers branch: files, barrels, CLI manifest entry, image rebuild) plus auth in one command. The steps below are the same operations, for agent-driven or manual application.
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
|
||||
NanoClaw selects each group's agent backend from `container_configs.provider` (default `claude`). This skill installs the Codex provider: copy the payload from the `providers` branch, append one import to each of the three provider barrels, add the pinned Codex CLI to the container manifest (`container/cli-tools.json`), rebuild, then run the vault auth walk-through.
|
||||
|
||||
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
|
||||
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
|
||||
If all of the following are already present, skip to **Configuration**:
|
||||
Check whether the payload is already wired (a prior apply, or a trunk that still carries it). All of these present means installed — skip to **Authenticate**:
|
||||
|
||||
- `src/providers/codex.ts`
|
||||
- `src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex-app-server.ts`
|
||||
- `container/agent-runner/src/providers/codex.factory.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-dockerfile.test.ts`
|
||||
- `import './codex.js';` line in `src/providers/index.ts`
|
||||
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
|
||||
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
|
||||
- `src/providers/codex.ts` and `src/providers/codex-agents-md.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts` and `codex-app-server.ts`
|
||||
- `setup/providers/codex.ts`
|
||||
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
|
||||
- an `@openai/codex` entry in `container/cli-tools.json`
|
||||
|
||||
Missing pieces — continue below. All steps are idempotent; re-running is safe.
|
||||
|
||||
### 1. Fetch the providers branch
|
||||
### Fetch and copy
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
```
|
||||
|
||||
### 2. Copy the Codex source files and tests
|
||||
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
|
||||
|
||||
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
|
||||
Host (`src/providers/`):
|
||||
- `codex.ts` — provider contribution: per-group `.codex-shared` state dir, AGENTS.md compose, skill links
|
||||
- `codex-agents-md.ts` — AGENTS.md composition (32KB Codex cap: degrades by dropping the largest instruction sections, never blocks a spawn)
|
||||
- `codex-registration.test.ts` — barrel-driven host registration guard
|
||||
- `codex-host-contribution.test.ts` — drives the real contribution against a real test DB (the "consumes core" leg)
|
||||
- `codex-agents-md.test.ts` — cap-degradation behavior
|
||||
|
||||
Container (`container/agent-runner/src/providers/`):
|
||||
- `codex.ts` — the provider (turn loop, steering, memory scaffold + `onExchangeComplete` archiving)
|
||||
- `codex-app-server.ts` — JSON-RPC child-process wrapper
|
||||
- `exchange-archive.ts` — per-exchange markdown writer the `onExchangeComplete` hook uses (provider-owned, not runner code)
|
||||
- `exchange-archive.test.ts` — writer behavior
|
||||
- `codex-registration.test.ts` — barrel-driven container registration guard
|
||||
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
|
||||
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
|
||||
|
||||
Setup (`setup/providers/`):
|
||||
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
|
||||
- `codex.test.ts` — install-check coverage
|
||||
- `codex-registration.test.ts` — barrel-driven setup registration guard
|
||||
|
||||
Shared base (skip if present):
|
||||
- `container/AGENTS.md` — the runtime-contract base the composed AGENTS.md embeds
|
||||
|
||||
### Wire the barrels
|
||||
|
||||
Append `import './codex.js';` to each of:
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
### CLI manifest
|
||||
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
|
||||
|
||||
```bash
|
||||
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
|
||||
git show origin/providers:src/providers/codex-registration.test.ts > src/providers/codex-registration.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-registration.test.ts > container/agent-runner/src/providers/codex-registration.test.ts
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!tools.some((t) => t.name === "@openai/codex")) {
|
||||
tools.push({ name: "@openai/codex", version: "0.138.0" });
|
||||
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
}
|
||||
'
|
||||
```
|
||||
|
||||
The two `codex-registration.test.ts` files are the **registration guards**. Each imports only the real barrel — the host one calls `listProviderContainerConfigNames()` from `src/providers/index.ts`, the container one calls `listProviderNames()` from `container/agent-runner/src/providers/index.ts` — and asserts `codex` is present. They go red the instant a barrel import line is deleted or drifts. (`codex.factory.test.ts` imports `./codex.js` directly and self-registers, so it stays green even if the barrel line is gone — keep it as a unit test of provider behavior, but it is **not** the registration guard.)
|
||||
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
|
||||
If `git show origin/providers:.../codex-registration.test.ts` errors with `path ... does not exist`, the registration tests have not landed on `origin/providers` yet. Run `git fetch origin providers` again; once the branch carries them, the copies above succeed. The rest of the install proceeds regardless — the Dockerfile and factory tests still run.
|
||||
|
||||
Copy the Dockerfile structural test that ships with this skill into the container provider tree:
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-codex/codex-dockerfile.test.ts container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
`codex-dockerfile.test.ts` reads the real `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` line and the `pnpm install -g "@openai/codex@${CODEX_VERSION}"` line are both present. The Codex CLI is a binary, not an importable package, so the registration tests cannot see it — this structural test is what guards the Dockerfile edits in step 4.
|
||||
### Restart the host
|
||||
|
||||
### 3. Append the self-registration imports
|
||||
|
||||
Each barrel gets one line — alphabetical placement keeps diffs small.
|
||||
|
||||
`src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
`container/agent-runner/src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
### 4. Add the Codex CLI to the container Dockerfile
|
||||
|
||||
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
|
||||
|
||||
### 5. Build and validate
|
||||
The image rebuild does not reload the **host**. Codex's host contribution
|
||||
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
|
||||
passthrough, and the running host only picks it up on restart. Skip this and the
|
||||
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml` —
|
||||
with no mount, Docker auto-creates the dir root-owned and the non-root container
|
||||
user can't write to it.
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec vitest run src/providers/codex-registration.test.ts # host registration guard
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
cd container/agent-runner && bun test src/providers/codex-registration.test.ts && cd - # container registration guard
|
||||
cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts && cd - # Dockerfile structural guard
|
||||
./container/build.sh # agent image
|
||||
# macOS (launchd)
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
# Linux (systemd)
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
All must be clean before proceeding.
|
||||
|
||||
- The **host** `codex-registration.test.ts` imports the real host barrel (`src/providers/index.ts`) and asserts `listProviderContainerConfigNames()` contains `codex`. It goes red if the `import './codex.js';` line is deleted or drifts, or if the barrel fails to evaluate.
|
||||
- The **container** `codex-registration.test.ts` imports the real container barrel (`container/agent-runner/src/providers/index.ts`) and asserts `listProviderNames()` contains `codex`. Same failure surface for the container-side import line.
|
||||
- The **Dockerfile** `codex-dockerfile.test.ts` reads `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` and `@openai/codex@${CODEX_VERSION}` install lines are present — red if either edit is dropped.
|
||||
|
||||
The `@openai/codex` CLI binary is guarded by the Dockerfile structural test plus the container build (`./container/build.sh` fails if the install line is bad), **not** by the registration test — Codex is a CLI binary, not an importable package, so nothing imports it for the registration guard to trip on. To confirm the binary is actually present after the image rebuild, probe it inside a running container with `docker exec <container> codex --version`.
|
||||
|
||||
The host-side provider also consumes core APIs (per-session `~/.codex` mount, env passthrough); that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
|
||||
|
||||
### Option A — ChatGPT subscription (recommended for individuals)
|
||||
|
||||
On the host (not inside the container), run Codex's OAuth login:
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
codex login
|
||||
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
|
||||
cd container/agent-runner && bun test src/providers/
|
||||
```
|
||||
|
||||
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
|
||||
The registration tests import only the real barrels — they go red if a barrel line is missing, a barrel fails to evaluate, or the payload is broken.
|
||||
|
||||
No `.env` variables required for this mode.
|
||||
## Authenticate
|
||||
|
||||
### Option B — API key (recommended for CI or API billing)
|
||||
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-...
|
||||
CODEX_MODEL=gpt-5.4-mini
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
```
|
||||
|
||||
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
|
||||
The same walk-through fresh installs get from the setup picker: ChatGPT subscription (browser login or device pairing) or an OpenAI API key, landed in the OneCLI vault. Idempotent — it short-circuits when a matching secret already exists. It finishes with the install check.
|
||||
|
||||
### Option C — BYO OpenAI-compatible endpoint (experimental)
|
||||
## Use it
|
||||
|
||||
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
|
||||
Per group:
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=...
|
||||
OPENAI_BASE_URL=https://api.groq.com/openai/v1
|
||||
CODEX_MODEL=llama-3.3-70b-versatile
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider codex
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
|
||||
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
|
||||
|
||||
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
|
||||
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
|
||||
|
||||
### Per group / per session
|
||||
## Troubleshooting
|
||||
|
||||
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
|
||||
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
|
||||
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
|
||||
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
|
||||
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
|
||||
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
|
||||
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in **Build and validate** confirm the wiring. For a live end-to-end check, set `agent_provider = 'codex'` on a test group and send a message after the image rebuild. A successful round-trip looks like:
|
||||
|
||||
- `init` event with a stable thread ID as continuation
|
||||
- One or more `activity` / `progress` events during the turn
|
||||
- `result` event with the model's reply
|
||||
|
||||
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. To confirm the CLI binary itself landed in the image, `docker exec <container> codex --version`.
|
||||
|
||||
To back this provider out, follow [REMOVE.md](REMOVE.md).
|
||||
- **Container dies at boot, channel silent:** `grep 'Container exited non-zero' logs/nanoclaw.error.log` — the `stderrTail` carries the reason (e.g. `Unknown provider: codex. Registered: claude` means the barrels aren't wired in the running build).
|
||||
- **In-channel `Error: spawn codex ENOENT` on every message:** the image predates the manifest entry — re-run `./container/build.sh`.
|
||||
- **Auth errors mid-conversation:** the vault secret is missing or stale — re-run `pnpm exec tsx setup/index.ts --step provider-auth codex` (subscription re-login updates the vault copy).
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Structural guard for the Codex CLI install in container/cli-tools.json.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
|
||||
// json-merge seam), not an importable package, so the barrel-driven
|
||||
// registration tests cannot see it. This test reads the real cli-tools.json
|
||||
// and asserts the @openai/codex entry is present and pinned to an exact
|
||||
// version. It goes red if the manifest entry is dropped or unpins.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/cli-tools.json
|
||||
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
|
||||
const manifestPresent = existsSync(MANIFEST);
|
||||
|
||||
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
|
||||
// read has to be guarded for the bare-branch (no manifest) case.
|
||||
const tools: Array<{ name: string; version: string }> = manifestPresent
|
||||
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
: [];
|
||||
const codex = tools.find((t) => t.name === '@openai/codex');
|
||||
|
||||
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
|
||||
// so skip there. In an installed tree (trunk + this payload) it must carry the
|
||||
// pinned @openai/codex entry.
|
||||
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
|
||||
it('includes the @openai/codex entry', () => {
|
||||
expect(codex).toBeDefined();
|
||||
});
|
||||
|
||||
it('pins it to an exact semver (no latest, no ranges)', () => {
|
||||
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
// Structural guard for the Codex CLI install in container/Dockerfile.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed via the Dockerfile, not an
|
||||
// importable package, so the barrel-driven registration tests cannot see it.
|
||||
// This test reads the real Dockerfile and asserts the version ARG and the
|
||||
// `pnpm install -g` line for @openai/codex are both present. It goes red if
|
||||
// either Dockerfile edit is dropped or drifts.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/Dockerfile
|
||||
const DOCKERFILE = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
|
||||
|
||||
describe('container/Dockerfile codex CLI install', () => {
|
||||
const dockerfile = readFileSync(DOCKERFILE, 'utf8');
|
||||
|
||||
it('declares the CODEX_VERSION ARG', () => {
|
||||
expect(dockerfile).toMatch(/ARG\s+CODEX_VERSION=/);
|
||||
});
|
||||
|
||||
it('installs the @openai/codex CLI pinned to that ARG', () => {
|
||||
expect(dockerfile).toMatch(/pnpm install -g\s+"@openai\/codex@\$\{CODEX_VERSION\}"/);
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -50,7 +50,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -59,7 +59,7 @@ import './linear.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -9,13 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider. The provider is the materialized `provider` key in each group's `container.json` (absent or `claude` = default Claude provider). Confirm it before applying:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
grep -H '"provider"' groups/*/container.json 2>/dev/null # no match, or "provider": "claude" = Claude
|
||||
```
|
||||
|
||||
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
If a group sets a different provider (e.g. `"provider": "opencode"`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: add-opencode
|
||||
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
description: Use OpenCode as an agent provider. OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per group via `ncl groups config update --provider opencode`; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
---
|
||||
|
||||
# OpenCode agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected per agent group by the **`provider`** key in that group's `container.json` (materialized from the `container_configs` table) — set it with `ncl groups config update --provider opencode`. Default is `claude`.
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
|
||||
|
||||
@@ -148,7 +148,7 @@ done
|
||||
|
||||
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
|
||||
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the group still needs `provider` set to `opencode` (see [Select the provider](#select-the-provider) below).
|
||||
|
||||
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
|
||||
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
|
||||
@@ -215,7 +215,7 @@ OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
|
||||
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
|
||||
|
||||
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
**Naming:** NanoClaw's **`provider: opencode`** (the `container.json` key, set via `ncl groups config update --provider opencode`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
|
||||
**Host `.env` (typical Zen shape):**
|
||||
|
||||
@@ -236,9 +236,16 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
--header-name "x-api-key" --value-format "{value}"
|
||||
```
|
||||
|
||||
### Per group / per session
|
||||
### Select the provider
|
||||
|
||||
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
Per group, from the host:
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider opencode
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
`ncl groups config update --provider` writes the `provider` value into the `container_configs` table; the host materializes it into `groups/<folder>/container.json` at spawn time and the in-container runner reads `provider` from there (defaulting to `claude`). The restart picks up the change. Switching is an operator action — run it from the host. Memory does NOT carry over automatically between providers — run `/migrate-memory` to carry it across.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
||||
|
||||
@@ -250,6 +257,6 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, switch a test group with `ncl groups config update --id <group-id> --provider opencode && ncl groups restart --id <group-id>`, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
|
||||
To remove this provider, see [REMOVE.md](REMOVE.md).
|
||||
|
||||
@@ -46,7 +46,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -60,7 +60,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -71,6 +71,8 @@ Parse the `PAIR_TELEGRAM_ISSUED` status block for `CODE` and follow the `REMINDE
|
||||
|
||||
## 4. Run the init script
|
||||
|
||||
First, pick the agent provider. Read `src/providers/index.ts` and collect the installed providers from its `import './<name>.js';` lines — `claude` is always available as the built-in default. If a non-default provider is installed (e.g. codex), ask the user which one this agent should run on; if only claude is available, skip the question and omit the flag.
|
||||
|
||||
```bash
|
||||
npx tsx scripts/init-first-agent.ts \
|
||||
--channel "${CHANNEL}" \
|
||||
@@ -80,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
|
||||
--agent-name "${AGENT_NAME}"
|
||||
```
|
||||
|
||||
Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
|
||||
The script:
|
||||
1. Upserts the `users` row and grants `owner` role if no owner exists.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: learn
|
||||
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
|
||||
---
|
||||
|
||||
# Learn — Distill a Skill from Anything
|
||||
|
||||
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
|
||||
|
||||
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
|
||||
|
||||
## When to use
|
||||
|
||||
Invoke when the user wants to *capture* a workflow as a reusable skill:
|
||||
|
||||
- `/learn <path>` — read a project/dir and build a skill for working with it
|
||||
- `/learn <url>` — read docs / an API page and build a usage skill
|
||||
- `/learn what we just did` — distill the current conversation's workflow
|
||||
- `/learn` + pasted notes — turn notes into a structured skill
|
||||
|
||||
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the source — and whether this is a new skill or a refine
|
||||
- A **path** → read the code/files.
|
||||
- A **URL** → fetch and read the page.
|
||||
- **"what we just did" / "this"** → use the current conversation as the source.
|
||||
- **Pasted text** → use it directly.
|
||||
|
||||
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
|
||||
|
||||
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
|
||||
|
||||
### 2. Gather the material
|
||||
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
|
||||
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
|
||||
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
|
||||
|
||||
### 3. Distill — find the reusable procedure
|
||||
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
|
||||
|
||||
- the trigger / when-to-use,
|
||||
- the step-by-step procedure (commands, file paths, decision points),
|
||||
- the non-obvious **gotchas** that were hit — usually the most valuable part,
|
||||
- any scripts or templates worth shipping alongside.
|
||||
|
||||
### 4. Author the SKILL.md
|
||||
|
||||
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
|
||||
- Keep what is still correct; weave the new learnings into the right sections.
|
||||
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
|
||||
- Correct anything the new source proves stale (a changed path, command, or flag).
|
||||
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
|
||||
|
||||
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
|
||||
|
||||
**Frontmatter (required):**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <kebab-case, matches the folder>
|
||||
description: "<what it does + when to use it + likely trigger phrases>"
|
||||
---
|
||||
```
|
||||
|
||||
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
|
||||
|
||||
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
|
||||
|
||||
**House authoring rules (from `docs/skill-guidelines.md`):**
|
||||
|
||||
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
|
||||
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
|
||||
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
|
||||
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
|
||||
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
|
||||
|
||||
### 5. Place and verify
|
||||
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
|
||||
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
|
||||
- Tell the user the skill exists and how to invoke it (`/<name>`).
|
||||
|
||||
## Example
|
||||
|
||||
`/learn what we just did` after a multi-step setup:
|
||||
|
||||
1. Re-read the conversation's commands and gotchas.
|
||||
2. Distill the repeatable procedure.
|
||||
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
|
||||
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
|
||||
- The most valuable content is the **gotchas**, not the happy path.
|
||||
- This skill is prose and safe to re-run — use it again to refine an existing skill.
|
||||
@@ -67,6 +67,8 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
|
||||
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
## Add Channel Group
|
||||
|
||||
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
|
||||
```
|
||||
|
||||
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
|
||||
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
|
||||
|
||||
## Add Directories
|
||||
|
||||
Ask which directories the user wants agents to access. For each path:
|
||||
- Validate the path exists
|
||||
- Ask if it should be read-only for non-main agents (default: yes)
|
||||
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
|
||||
|
||||
Build the JSON config and write it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
Use `--force` to overwrite the existing config.
|
||||
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
|
||||
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
## Reset to Empty
|
||||
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automatically — no service restart needed.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
To apply the new config to a group that already has a running container, restart just that group:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: migrate-memory
|
||||
description: Carry an agent group's memory across a provider switch, in either direction (e.g. Claude ↔ Codex, or any provider to/from another). Run after the operator switches a group's provider with `ncl groups config update --provider`. The coding agent reads the source provider's memory store, distills it into the target provider's store, and restarts the group. Triggers on "migrate memory", "carry memory over", "the agent forgot everything after the switch".
|
||||
---
|
||||
|
||||
# Migrate memory across a provider switch
|
||||
|
||||
NanoClaw does not migrate memory at runtime — each provider keeps its own store, and carrying content across is the operator's move, executed by you (the coding agent). This skill is the whole mechanism: read the source store, **infer** what is durable, write it into the target store, restart.
|
||||
|
||||
You translate between **store shapes**, not provider names. There are two:
|
||||
|
||||
- **Flat file** — `CLAUDE.local.md` at the group workspace root (the Claude provider; may reference satellite files in the workspace).
|
||||
- **Scaffold tree** — `memory/` (any provider with `usesMemoryScaffold`, e.g. Codex). `memory/index.md` is the index; durable notes live under `memory/memories/`; `memory/memories/imported-agent-memory.md` is the conventional landing file for imported memory.
|
||||
|
||||
A switch only needs migration when it **crosses shapes**. Two providers that both use the scaffold share the same `memory/` tree, so switching between them carries nothing — the memory is already there. The work is always one of: flat → scaffold, or scaffold → flat.
|
||||
|
||||
Principles: **copy, never move** (the source store stays intact — it IS the rollback), **idempotent** (re-running must not duplicate), **distill, don't dump** (you are the inference step: keep identity/seed instructions, user preferences, durable facts; drop conversational residue).
|
||||
|
||||
## Step 1: Identify the group, both providers, and the direction
|
||||
|
||||
- `ncl groups list`, then `ncl groups config get --id <group-id>` — note the current (target) `provider`. Ask the operator which group, and which provider it switched *from*, if either is ambiguous.
|
||||
- Map each provider to its store shape (flat `CLAUDE.local.md` vs `memory/` scaffold), then inspect `groups/<folder>/`:
|
||||
- **Same shape on both sides** (e.g. scaffold → scaffold) → the store is shared; nothing to migrate. Tell the operator and stop.
|
||||
- **Flat → scaffold** (source has `CLAUDE.local.md` content, target uses the scaffold) → Step 2.
|
||||
- **Scaffold → flat** (source has a `memory/` tree, target is Claude) → Step 3.
|
||||
- Source missing or empty → nothing to migrate; tell the operator and stop.
|
||||
|
||||
## Step 2: flat → scaffold (`CLAUDE.local.md` → `memory/`)
|
||||
|
||||
1. Read `groups/<folder>/CLAUDE.local.md` and any workspace files it references.
|
||||
2. If `memory/memories/imported-agent-memory.md` already exists, a previous import happened — show the operator what's there and ask before overwriting; integrate only what's new.
|
||||
3. Distill the content into `groups/<folder>/memory/memories/imported-agent-memory.md` (create the directories if missing — the container scaffolds the rest of the tree at boot and never clobbers your files). Lead with anything that defines who the agent is or how it must behave; references to satellite files keep their workspace-root paths.
|
||||
4. If `memory/index.md` exists, add the following: `- [Imported agent memory](memories/imported-agent-memory.md) — seed instructions and memory carried over from a previous provider. Read it first and treat it as binding; it may define who you are and how to behave. Integrate its facts into your memory as you work; never modify files that belong to another provider's memory system.`
|
||||
5. Leave the source store exactly as it is.
|
||||
|
||||
## Step 3: scaffold → flat (`memory/` → `CLAUDE.local.md`)
|
||||
|
||||
1. Read `memory/index.md`, then the files it points to under `memory/memories/` (and `memory/data/` where durable).
|
||||
2. Integrate the durable facts into `groups/<folder>/CLAUDE.local.md` under a clearly marked section (e.g. `## Imported from memory/ (<date>)`), deduplicating against what's already there. If the section already exists, update it instead of appending a second one.
|
||||
3. Leave the source store exactly as it is.
|
||||
|
||||
## Step 4: Restart and verify
|
||||
|
||||
```bash
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Tell the operator to send the group a quick test message that depends on a migrated fact (a preference, a project name). If the agent doesn't know it, re-check that the target file landed in the right group folder.
|
||||
|
||||
Note: switching the provider is an operator action — `ncl groups config update --id <group-id> --provider <name>` from the host. See [docs/provider-migration.md](../../../docs/provider-migration.md) for what carries over automatically.
|
||||
@@ -121,6 +121,7 @@ Bucket the upstream changed files:
|
||||
- **Host source** (`src/`): may conflict if user modified the same files
|
||||
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
|
||||
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
|
||||
- **Version pins** (`versions.json`): a changed `onecli-gateway` / `onecli-cli` value requires upgrading the OneCLI gateway/CLI to match — see Step 5.5
|
||||
- **Other**: docs, tests, setup scripts, misc
|
||||
|
||||
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
|
||||
@@ -215,6 +216,11 @@ If build fails:
|
||||
- Do not refactor unrelated code.
|
||||
- If unclear, ask the user before making changes.
|
||||
|
||||
# Step 5.5: OneCLI upgrade (if pins moved)
|
||||
The OneCLI gateway and CLI are external components pinned in `versions.json`; when a pin moves, the running version must be upgraded to match or the new code may fail against it.
|
||||
|
||||
If `git diff <backup-tag-from-step-1>..HEAD -- versions.json` shows the `onecli-gateway` or `onecli-cli` value changed, follow `docs/onecli-upgrades.md` before the service restart (Step 8). Otherwise skip.
|
||||
|
||||
# Step 6: Breaking changes check
|
||||
After validation succeeds, check if the update introduced any breaking changes.
|
||||
|
||||
@@ -240,30 +246,40 @@ If one or more `[BREAKING]` lines are found:
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
Updating your installed skills is **part of** updating NanoClaw, not an optional
|
||||
extra. Channel and provider code ships on long-lived branches (`channels`,
|
||||
`providers`) that the host merge above doesn't touch — so stopping here leaves
|
||||
that code on whatever version you installed, which is how an important upstream
|
||||
fix gets silently left behind. The default is to continue into `/update-skills`,
|
||||
which re-applies your installed channels/providers to pull their latest code.
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
Detect whether anything is installed: read `src/channels/index.ts` and
|
||||
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
|
||||
`cli`).
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
|
||||
- If nothing is installed: skip silently and proceed to Step 7.9.
|
||||
- If one or more are installed: continue into skill updates.
|
||||
|
||||
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
|
||||
- List the installed channels/providers.
|
||||
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
|
||||
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
|
||||
- "Skip — I'll update them later"
|
||||
- Set `multiSelect: true`
|
||||
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
|
||||
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
|
||||
Name the installed skills in the question so the choice is concrete:
|
||||
- Question: "Skill updates are part of this NanoClaw update — your installed
|
||||
channels/providers (<list the detected ones>) ride separate branches the host
|
||||
update didn't touch. Continue into `/update-skills` to bring them up to date?"
|
||||
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
|
||||
`/update-skills`, which re-applies your installed channels/providers to pull
|
||||
their latest upstream code. You pick which ones there."
|
||||
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
|
||||
installed skill code stays as-is and may be behind upstream."
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
Keep it to these two options — the per-skill selection lives inside
|
||||
`/update-skills`, not here.
|
||||
|
||||
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
|
||||
touches container code, `/update-skills` rebuilds the agent image itself — see
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ For each selected skill (process one at a time):
|
||||
After all selected skills are re-applied:
|
||||
- `pnpm run build`
|
||||
- `pnpm test` (do not fail the flow if tests are not configured)
|
||||
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
|
||||
|
||||
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
||||
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
|
||||
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
|
||||
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
|
||||
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
|
||||
- **Slash commands now interrupt an in-flight turn.** A runner-handled command (`/clear`, `/compact`, `/cost`, …) arriving mid-turn aborts the active stream and runs immediately instead of waiting out the turn.
|
||||
|
||||
## [2.1.0] - 2026-06-07
|
||||
|
||||
- [BREAKING] **Startup now requires an upgrade marker.** The host refuses to boot unless `data/upgrade-state.json` records that this install reached the current version through a sanctioned path (`/setup`, `/update-nanoclaw`, `/migrate-nanoclaw`). After this update completes — and before restarting the service — stamp the marker by running `pnpm exec tsx scripts/upgrade-state.ts set`. If the host has already tripped on restart with "update did not go through the supported path", that same command clears it. See [docs/upgrade-recovery.md](docs/upgrade-recovery.md).
|
||||
|
||||
@@ -69,9 +69,9 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
|
||||
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
@@ -79,8 +79,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
|
||||
@@ -152,7 +152,7 @@ Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer
|
||||
|
||||
## Secrets / Credentials / OneCLI
|
||||
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/modules/approvals/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
|
||||
### Secret modes
|
||||
|
||||
@@ -182,7 +182,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
@@ -193,6 +193,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
| `/debug` | Container issues, logs, troubleshooting |
|
||||
| `/update-nanoclaw` | Bring upstream updates into a customized install |
|
||||
| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials |
|
||||
| `/migrate-memory` | Carry a group's agent memory across a provider switch (operator-run, both directions) |
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -275,9 +276,11 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
|
||||
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
|
||||
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
|
||||
| [docs/provider-migration.md](docs/provider-migration.md) | Switching a live agent group between providers (e.g. Claude → Codex) — what carries over, rollback |
|
||||
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
|
||||
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
|
||||
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
|
||||
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
|
||||
**Not accepted:** Features, capabilities, compatibility, enhancements. These should be skills.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Breaking changes are allowed; **silent** ones are not. NanoClaw does not migrate user installs at runtime — the user's coding agent is the migrator, so every breaking change must ship a migration path that agent can execute without a human reverse-engineering the diff:
|
||||
|
||||
1. **Every `[BREAKING]` CHANGELOG entry must reference its migration path** — either a skill to run (`Run /<skill-name> to <action>`) or a `docs/` page covering **detect / why / fix / verify / rollback** (see [docs/onecli-upgrades.md](docs/onecli-upgrades.md) for the shape). `/update-nanoclaw` surfaces these entries after every update and walks the user through them.
|
||||
2. **If the change moves an external component's sanctioned version** (gateway, pinned CLI binary, …), update its pin in [`versions.json`](versions.json). The changelog stays human-narrative; `versions.json` is the machine-checkable signal — `/update-nanoclaw` diffs it across the update and routes the user to the linked doc for any pin that moved.
|
||||
|
||||
## Skills
|
||||
|
||||
NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) — markdown files with optional supporting files that teach Claude how to do something. There are four types of skills in NanoClaw, each serving a different purpose.
|
||||
@@ -118,6 +125,10 @@ Instructions here...
|
||||
- Put code in separate files, not inline in the markdown
|
||||
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
|
||||
|
||||
## Templates
|
||||
|
||||
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Testing
|
||||
|
||||
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">docs</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
@@ -79,8 +80,9 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -163,7 +165,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">ドキュメント</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
<p align="center">
|
||||
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
에이전트를 각자의 컨테이너에서 안전하게 실행하는 AI 어시스턴트입니다. 가볍고, 쉽게 이해할 수 있으며, 여러분의 필요에 맞게 완전히 커스터마이즈할 수 있도록 만들어졌습니다.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">문서</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## NanoClaw를 만든 이유
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)는 인상적인 프로젝트지만, 제가 이해하지 못하는 복잡한 소프트웨어에 제 삶 전체에 대한 접근 권한을 줬다면 저는 잠을 이루지 못했을 것입니다. OpenClaw는 거의 50만 줄에 달하는 코드, 53개의 설정 파일, 70개 이상의 의존성을 가지고 있습니다. 보안은 진정한 OS 수준의 격리가 아니라 애플리케이션 수준(허용 목록, 페어링 코드)에 의존합니다. 모든 것이 메모리를 공유하는 하나의 Node 프로세스에서 실행됩니다.
|
||||
|
||||
NanoClaw는 그와 동일한 핵심 기능을 제공하지만, 이해할 수 있을 만큼 작은 코드베이스로 구현합니다. 하나의 프로세스와 몇 개의 파일뿐입니다. Claude 에이전트는 단순한 권한 검사 뒤가 아니라, 파일시스템이 격리된 각자의 Linux 컨테이너에서 실행됩니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
`nanoclaw.sh`는 갓 준비한 머신에서 시작해 메시지를 보낼 수 있는 이름 붙은 에이전트까지 안내합니다. 누락된 경우 Node, pnpm, Docker를 설치하고, Anthropic 자격 증명을 OneCLI에 등록하며, 에이전트 컨테이너를 빌드하고, 첫 채널(Telegram, Discord, WhatsApp 또는 로컬 CLI)을 페어링합니다. 어떤 단계가 실패하면 Claude Code가 자동으로 호출되어 원인을 진단하고 중단된 지점부터 재개합니다.
|
||||
|
||||
<details>
|
||||
<summary><strong>NanoClaw v1에서 마이그레이션하시나요?</strong></summary>
|
||||
|
||||
기존 v1 설치 옆에 새로운 v2 체크아웃을 만들어 실행하세요:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
`migrate-v2.sh`는 v1 설치(형제 디렉터리, 또는 `NANOCLAW_V1_PATH=/path/to/nanoclaw`)를 찾아 상태를 v2 체크아웃으로 마이그레이션한 다음, 판단이 필요한 부분(소유자 시딩, CLAUDE.local.md 정리, 포크 커스터마이징 재적용)을 마무리하기 위해 Claude Code로 `exec`합니다.
|
||||
|
||||
이 스크립트는 Claude 세션 내부가 아니라 직접 실행하세요. 결정론적인 부분에서 Node/pnpm 부트스트랩, Docker, OneCLI, 컨테이너 빌드를 위해 대화형 프롬프트와 실제 셸 I/O가 필요합니다.
|
||||
|
||||
**무엇을 하는가:** `.env`를 병합하고, `registered_groups`로부터 v2 DB를 시딩하며, 그룹 폴더 + 세션 데이터 + 예약 작업을 복사하고, 선택한 채널 어댑터를 설치하며, 채널 인증 상태(WhatsApp의 Baileys 키스토어 + LID 매핑 포함)를 복사하고, 에이전트 컨테이너를 빌드합니다.
|
||||
|
||||
**무엇을 하지 않는가:** 시스템 서비스를 전환하지 않습니다. 프롬프트에서 *"switch to v2"*를 선택하거나, 테스트 후 수동으로 전환하세요. 기존 v1 설치는 그대로 유지됩니다.
|
||||
|
||||
무엇이 달라졌는지는 [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md)를, 개발 노트는 [docs/migration-dev.md](docs/migration-dev.md)를 참고하세요.
|
||||
|
||||
</details>
|
||||
|
||||
## 철학
|
||||
|
||||
**이해할 수 있을 만큼 작게.** 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. NanoClaw 코드베이스 전체를 이해하고 싶다면 Claude Code에게 안내해 달라고 요청하기만 하면 됩니다.
|
||||
|
||||
**격리를 통한 보안.** 에이전트는 Linux 컨테이너에서 실행되며 명시적으로 마운트된 것만 볼 수 있습니다. 명령이 호스트가 아니라 컨테이너 안에서 실행되기 때문에 Bash 접근도 안전합니다.
|
||||
|
||||
**개별 사용자를 위해 설계.** NanoClaw는 거대한 단일 프레임워크가 아니라, 각 사용자의 정확한 필요에 맞는 소프트웨어입니다. 비대한 소프트웨어가 되는 대신, NanoClaw는 맞춤형이 되도록 설계되었습니다. 직접 포크를 만들고 Claude Code가 여러분의 필요에 맞게 수정하도록 합니다.
|
||||
|
||||
**커스터마이징 = 코드 변경.** 설정의 난립이 없습니다. 다른 동작을 원하시나요? 코드를 수정하세요. 코드베이스가 충분히 작아서 안전하게 변경할 수 있습니다.
|
||||
|
||||
**AI 네이티브, 설계상 하이브리드.** 설치와 온보딩 흐름은 최적화된 스크립트 경로로, 빠르고 결정론적입니다. 어떤 단계에 판단이 필요할 때 — 설치 실패, 안내가 필요한 결정, 커스터마이징 등 — 제어권이 Claude Code로 매끄럽게 넘어갑니다. 설정 이후에도 모니터링 대시보드나 디버깅 UI가 없습니다. 채팅으로 문제를 설명하면 Claude Code가 처리합니다.
|
||||
|
||||
**기능보다 스킬.** 트렁크는 특정 채널 어댑터나 대체 에이전트 프로바이더가 아니라 레지스트리와 인프라를 제공합니다. 채널(Discord, Slack, Telegram, WhatsApp, …)은 오래 유지되는 `channels` 브랜치에, 대체 프로바이더(OpenCode, Ollama)는 `providers` 브랜치에 있습니다. `/add-telegram`, `/add-opencode` 등을 실행하면 스킬이 여러분이 필요로 하는 모듈만 정확히 포크로 복사합니다. 요청하지 않은 기능은 없습니다.
|
||||
|
||||
**최고의 하니스, 최고의 모델.** NanoClaw는 Anthropic의 공식 Claude Agent SDK를 통해 Claude Code를 네이티브로 사용하므로, 최신 Claude 모델과 Claude Code의 전체 도구 세트를 누릴 수 있습니다. 여기에는 자신의 NanoClaw 포크를 직접 수정하고 확장하는 능력도 포함됩니다. 다른 프로바이더는 드롭인 옵션입니다. OpenAI의 Codex는 `/add-codex`(ChatGPT 구독 또는 API 키), OpenRouter·Google·DeepSeek 등은 OpenCode를 통한 `/add-opencode`, 로컬 오픈 웨이트 모델은 `/add-ollama-provider`로 추가합니다. 프로바이더는 에이전트 그룹별로 설정할 수 있습니다.
|
||||
|
||||
## 지원 기능
|
||||
|
||||
- **멀티 채널 메시징** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, 그리고 Resend를 통한 이메일. `/add-<channel>` 스킬로 필요할 때 설치합니다. 하나 또는 여러 개를 동시에 실행할 수 있습니다.
|
||||
- **유연한 격리** — 완전한 프라이버시를 위해 각 채널을 자체 에이전트에 연결하거나, 대화는 분리하되 메모리는 통합하기 위해 하나의 에이전트를 여러 채널에서 공유하거나, 여러 채널을 하나의 공유 세션으로 묶어 하나의 대화가 여러 채널에 걸쳐 이어지도록 할 수 있습니다. `/manage-channels`로 채널별로 선택하세요. [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
|
||||
- **에이전트별 작업 공간** — 각 에이전트 그룹은 자체 `CLAUDE.md`, 자체 메모리, 자체 컨테이너, 그리고 여러분이 허용한 마운트만 갖습니다. 직접 연결하지 않는 한 경계를 넘는 것은 아무것도 없습니다.
|
||||
- **예약 작업** — Claude를 실행하고 여러분에게 다시 메시지를 보낼 수 있는 반복 작업
|
||||
- **웹 접근** — 웹에서 검색하고 콘텐츠를 가져오기
|
||||
- **컨테이너 격리** — 에이전트는 Docker(macOS/Linux/WSL2)에서 샌드박스화되며, 선택적으로 [Docker Sandboxes](docs/docker-sandboxes.md) 마이크로 VM 격리나 macOS 네이티브 런타임인 Apple Container를 사용할 수 있습니다
|
||||
- **자격 증명 보안** — 에이전트는 원시 API 키를 절대 보유하지 않습니다. 아웃바운드 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 요청 시점에 자격 증명을 주입하고 에이전트별 정책과 속도 제한을 적용합니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
트리거 단어(기본값: `@Andy`)로 어시스턴트에게 말을 거세요:
|
||||
|
||||
```
|
||||
@Andy 매주 평일 오전 9시에 영업 파이프라인 개요를 보내줘 (내 Obsidian 보관함 폴더에 접근 가능)
|
||||
@Andy 매주 금요일에 지난 한 주간의 git 히스토리를 검토하고, 내용이 어긋나면 README를 업데이트해줘
|
||||
@Andy 매주 월요일 오전 8시에 Hacker News와 TechCrunch에서 AI 관련 소식을 모아 브리핑을 보내줘
|
||||
```
|
||||
|
||||
여러분이 소유하거나 관리하는 채널에서는 그룹과 작업을 관리할 수 있습니다:
|
||||
```
|
||||
@Andy 모든 그룹에 걸친 예약 작업을 전부 나열해줘
|
||||
@Andy 월요일 브리핑 작업을 일시 정지해줘
|
||||
@Andy Family Chat 그룹에 참여해줘
|
||||
```
|
||||
|
||||
## 커스터마이징
|
||||
|
||||
NanoClaw는 설정 파일을 사용하지 않습니다. 변경하려면 Claude Code에게 원하는 것을 말하기만 하면 됩니다:
|
||||
|
||||
- "트리거 단어를 @Bob으로 바꿔줘"
|
||||
- "앞으로는 응답을 더 짧고 직접적으로 하도록 기억해줘"
|
||||
- "내가 좋은 아침이라고 인사하면 맞춤 인사를 추가해줘"
|
||||
- "매주 대화 요약을 저장해줘"
|
||||
|
||||
또는 안내형 변경을 위해 `/customize`를 실행하세요.
|
||||
|
||||
코드베이스가 충분히 작아서 Claude가 안전하게 수정할 수 있습니다.
|
||||
|
||||
## 기여하기
|
||||
|
||||
**기능을 추가하지 마세요. 스킬을 추가하세요.**
|
||||
|
||||
새로운 채널이나 에이전트 프로바이더를 추가하고 싶다면 트렁크에 추가하지 마세요. 새 채널 어댑터는 `channels` 브랜치에, 새 에이전트 프로바이더는 `providers` 브랜치에 들어갑니다. 사용자는 `/add-<name>` 스킬로 자신의 포크에 설치하며, 이 스킬은 관련 모듈을 표준 경로로 복사하고, 등록을 연결하며, 의존성을 고정합니다.
|
||||
|
||||
이를 통해 트렁크는 순수한 레지스트리이자 인프라로 유지되고, 모든 포크는 가벼운 상태를 유지합니다. 사용자는 요청한 채널과 프로바이더만 얻고 그 외에는 아무것도 얻지 않습니다.
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
저희가 보고 싶은 스킬:
|
||||
|
||||
**커뮤니케이션 채널**
|
||||
- `/add-signal` — Signal을 채널로 추가
|
||||
|
||||
## 요구 사항
|
||||
|
||||
- macOS 또는 Linux (Windows는 WSL2 경유)
|
||||
- Node.js 20+ 및 pnpm 10+ (설치 프로그램이 누락 시 둘 다 설치합니다)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) 또는 Docker Engine (Linux)
|
||||
- `/customize`, `/debug`, 설정 중 오류 복구, 그리고 모든 `/add-<channel>` 스킬을 위한 [Claude Code](https://claude.ai/download)
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
메시징 앱 → 호스트 프로세스(라우터) → inbound.db → 컨테이너(Bun, Claude Agent SDK) → outbound.db → 호스트 프로세스(전송) → 메시징 앱
|
||||
```
|
||||
|
||||
하나의 Node 호스트가 세션별 에이전트 컨테이너를 오케스트레이션합니다. 메시지가 도착하면 호스트는 엔티티 모델(사용자 → 메시징 그룹 → 에이전트 그룹 → 세션)을 통해 라우팅하고, 세션의 `inbound.db`에 기록한 뒤 컨테이너를 깨웁니다. 컨테이너 내부의 에이전트 러너는 `inbound.db`를 폴링하고, Claude를 실행하며, 응답을 `outbound.db`에 기록합니다. 호스트는 `outbound.db`를 폴링하여 채널 어댑터를 통해 다시 전송합니다.
|
||||
|
||||
세션당 두 개의 SQLite 파일이 있으며 각각 정확히 하나의 작성자만 갖습니다. 교차 마운트 경합이 없고, IPC가 없으며, stdin 파이핑이 없습니다. 채널과 대체 프로바이더는 시작 시 자체 등록됩니다. 트렁크는 레지스트리와 Chat SDK 브리지를 제공하고, 어댑터 자체는 포크별로 스킬을 통해 설치됩니다.
|
||||
|
||||
전체 아키텍처 설명은 [docs/architecture.md](docs/architecture.md)를, 3단계 격리 모델은 [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
|
||||
|
||||
핵심 파일:
|
||||
- `src/index.ts` — 진입점: DB 초기화, 채널 어댑터, 전송 폴링, 스윕
|
||||
- `src/router.ts` — 인바운드 라우팅: 메시징 그룹 → 에이전트 그룹 → 세션 → `inbound.db`
|
||||
- `src/delivery.ts` — `outbound.db` 폴링, 어댑터를 통한 전송, 시스템 액션 처리
|
||||
- `src/host-sweep.ts` — 60초 스윕: 정체 감지, 예정 메시지 깨우기, 반복 처리
|
||||
- `src/session-manager.ts` — 세션 확인, `inbound.db` / `outbound.db` 열기
|
||||
- `src/container-runner.ts` — 에이전트 그룹별 컨테이너 생성, OneCLI 자격 증명 주입
|
||||
- `src/db/` — 중앙 DB (사용자, 역할, 에이전트 그룹, 메시징 그룹, 연결, 마이그레이션)
|
||||
- `src/channels/` — 채널 어댑터 인프라 (어댑터는 `/add-<channel>` 스킬로 설치)
|
||||
- `src/providers/` — 호스트 측 프로바이더 설정 (`claude`는 기본 내장, 그 외는 스킬 경유)
|
||||
- `container/agent-runner/` — Bun 에이전트 러너: 폴 루프, MCP 도구, 프로바이더 추상화
|
||||
- `groups/<folder>/` — 에이전트 그룹별 파일시스템 (`CLAUDE.md`, 스킬, 컨테이너 설정)
|
||||
|
||||
## FAQ
|
||||
|
||||
**왜 Docker인가요?**
|
||||
|
||||
Docker는 크로스 플랫폼 지원(macOS, Linux, 그리고 WSL2 경유 Windows)과 성숙한 생태계를 제공합니다. macOS에서는 더 가벼운 네이티브 런타임인 Apple Container도 지원됩니다. 추가 격리를 위해 [Docker Sandboxes](docs/docker-sandboxes.md)는 각 컨테이너를 마이크로 VM 안에서 실행합니다.
|
||||
|
||||
**Linux나 Windows에서 실행할 수 있나요?**
|
||||
|
||||
네. Docker가 기본 런타임이며 macOS, Linux, Windows(WSL2 경유)에서 작동합니다. `bash nanoclaw.sh`를 실행하기만 하면 됩니다.
|
||||
|
||||
**이것은 안전한가요?**
|
||||
|
||||
에이전트는 애플리케이션 수준의 권한 검사 뒤가 아니라 컨테이너에서 실행됩니다. 명시적으로 마운트된 디렉터리만 접근할 수 있습니다. 자격 증명은 컨테이너에 들어가지 않습니다. 아웃바운드 API 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 프록시 수준에서 인증을 주입하고 속도 제한과 접근 정책을 지원합니다. 여전히 실행하는 것을 검토해야 하지만, 코드베이스가 충분히 작아서 실제로 검토할 수 있습니다. 전체 보안 모델은 [보안 문서](https://docs.nanoclaw.dev/concepts/security)를 참고하세요.
|
||||
|
||||
**왜 설정 파일이 없나요?**
|
||||
|
||||
설정의 난립을 원하지 않습니다. 모든 사용자는 일반적인 시스템을 설정하는 대신, 코드가 정확히 원하는 대로 동작하도록 NanoClaw를 커스터마이즈해야 합니다. 설정 파일을 선호한다면 Claude에게 추가해 달라고 할 수 있습니다.
|
||||
|
||||
**서드파티나 오픈소스 모델을 사용할 수 있나요?**
|
||||
|
||||
네. 지원되는 경로는 `/add-opencode`(OpenCode 설정을 통한 OpenRouter, OpenAI, Google, DeepSeek 등) 또는 `/add-ollama-provider`(Ollama를 통한 로컬 오픈 웨이트 모델)입니다. 둘 다 에이전트 그룹별로 설정할 수 있으므로, 같은 설치 내에서 서로 다른 에이전트가 서로 다른 백엔드에서 실행될 수 있습니다.
|
||||
|
||||
일회성 실험의 경우, Claude API 호환 엔드포인트라면 `.env`를 통해서도 작동합니다:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
**문제를 어떻게 디버깅하나요?**
|
||||
|
||||
Claude Code에게 물어보세요. "스케줄러가 왜 실행되지 않지?" "최근 로그에 뭐가 있지?" "이 메시지는 왜 응답을 받지 못했지?" 그것이 NanoClaw의 바탕에 깔린 AI 네이티브 접근 방식입니다.
|
||||
|
||||
**설정이 왜 작동하지 않나요?**
|
||||
|
||||
어떤 단계가 실패하면 `nanoclaw.sh`는 진단하고 재개하기 위해 Claude Code로 넘깁니다. 그래도 해결되지 않으면 `claude`를 실행한 뒤 `/debug`를 실행하세요. Claude가 다른 사용자에게도 영향을 줄 만한 문제를 발견하면, 관련 설정 단계나 스킬에 대한 PR을 열어주세요.
|
||||
|
||||
**NanoClaw를 어떻게 제거하나요?**
|
||||
|
||||
```bash
|
||||
bash nanoclaw.sh --uninstall
|
||||
```
|
||||
|
||||
모든 설치는 체크아웃별 ID로 태깅되므로, 제거 프로그램은 해당 사본에 속한 것만 제거합니다: 백그라운드 서비스, 컨테이너와 이미지, 앱 데이터와 로그, 에이전트 파일, 그리고 이 사본의 OneCLI 볼트 에이전트입니다. 공유되는 것 — OneCLI 앱과 여러분의 자격 증명, 머신의 다른 NanoClaw 사본 — 은 그대로 둡니다. 무엇을 발견했는지 정확히 보여주고 그룹별로 확인을 요청합니다. 여러분이 동의하기 전까지는 아무것도 삭제되지 않습니다. 변경 없이 미리 보려면 `--dry-run`을, 프롬프트를 건너뛰려면 `--yes`를 사용하세요. `.env`는 제거 전에 백업됩니다. 마무리하려면 체크아웃 폴더 자체를 삭제하세요.
|
||||
|
||||
**어떤 변경이 코드베이스에 받아들여지나요?**
|
||||
|
||||
기본 구성에는 보안 수정, 버그 수정, 명확한 개선만 받아들여집니다. 그게 전부입니다.
|
||||
|
||||
그 외의 모든 것(새로운 기능, OS 호환성, 하드웨어 지원, 향상)은 스킬로 기여해야 합니다. 채널과 프로바이더 코드는 `channels`/`providers` 레지스트리 브랜치에, 그 외에는 자체 완결형 스킬로 기여합니다. [docs/customizing.md](docs/customizing.md)와 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
||||
|
||||
이를 통해 기본 시스템을 최소한으로 유지하고, 모든 사용자가 원하지 않는 기능을 떠안지 않으면서 자신의 설치를 커스터마이즈할 수 있습니다.
|
||||
|
||||
## 커뮤니티
|
||||
|
||||
질문이 있나요? 아이디어가 있나요? [Discord에 참여하세요](https://discord.gg/VDdww8qS42).
|
||||
|
||||
## 변경 이력
|
||||
|
||||
호환성을 깨는 변경 사항은 [CHANGELOG.md](CHANGELOG.md)를, 또는 문서 사이트의 [전체 릴리스 히스토리](https://docs.nanoclaw.dev/changelog)를 참고하세요.
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">文档</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
+11
-15
@@ -16,12 +16,11 @@ FROM node:22-slim
|
||||
# CJK fonts add ~200MB. Opt in only if you render Chinese/Japanese/Korean text.
|
||||
ARG INSTALL_CJK_FONTS=false
|
||||
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.170
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
# Pin versions for reproducibility. Bump deliberately — unpinned installs mean
|
||||
# every rebuild silently picks up the latest and can break in lockstep across
|
||||
# all users. The global Node CLIs (claude-code, agent-browser, vercel) are
|
||||
# pinned in cli-tools.json so a skill can add one with a json-merge; Bun (the
|
||||
# runtime) is pinned here because it installs from a different source.
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
# ---- System dependencies -----------------------------------------------------
|
||||
@@ -99,16 +98,13 @@ ENV PATH="$PNPM_HOME:$PATH"
|
||||
ARG PNPM_VERSION=10.33.0
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
|
||||
# Global Node CLIs the agent invokes at runtime live in cli-tools.json so a
|
||||
# skill can add one with a json-merge instead of editing this Dockerfile.
|
||||
# install-cli-tools.sh installs each via pnpm (pinned), writing the per-tool
|
||||
# only-built-dependencies opt-ins it reads from the manifest.
|
||||
COPY cli-tools.json install-cli-tools.sh /tmp/
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \
|
||||
pnpm install -g "vercel@${VERCEL_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
sh /tmp/install-cli-tools.sh /tmp/cli-tools.json
|
||||
|
||||
# ---- ncl CLI wrapper ----------------------------------------------------------
|
||||
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
@@ -19,25 +19,25 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.197", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.197" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.108.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-XBnl7Nszpbzg0aLnOCmdBi0bOU5goAsQ/L+NPNiuUPowDj8Mbzx0vlIIc1M79BjIvmw5nUM5G3jbrCBStT/0fQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
|
||||
// so the singleton survives for the rest of the test.
|
||||
if (_testMode && _inbound) {
|
||||
const db = _inbound;
|
||||
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
|
||||
return {
|
||||
prepare: (sql: string) => db.prepare(sql),
|
||||
exec: (sql: string) => db.exec(sql),
|
||||
close: () => {},
|
||||
} as unknown as Database;
|
||||
}
|
||||
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
|
||||
_outbound?.close();
|
||||
_outbound = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInboundDb() / getOutboundDb() instead.
|
||||
* Kept for backward compatibility during migration.
|
||||
*/
|
||||
export function getSessionDb(): Database {
|
||||
return getInboundDb();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
getInboundDb,
|
||||
getOutboundDb,
|
||||
getSessionDb,
|
||||
initTestSessionDb,
|
||||
closeSessionDb,
|
||||
touchHeartbeat,
|
||||
|
||||
@@ -56,7 +56,7 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
|
||||
* to skip messages already picked up by this or a previous container run.
|
||||
*
|
||||
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
|
||||
* Returns the most recent `maxMessagesPerPrompt` pending rows in
|
||||
* chronological order, regardless of their `trigger` flag: accumulated
|
||||
* context (trigger=0) rides along with the wake-eligible rows so the agent
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
@@ -163,4 +163,3 @@ export function findQuestionResponse(questionId: string): MessageInRow | undefin
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The a2a reply stamp: the id of the first inbound message in the batch the
|
||||
* agent is currently processing. The poll loop publishes it at batch start;
|
||||
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
|
||||
* rows so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This lives in outbound.db rather than module state because the MCP server
|
||||
* runs as a separate stdio subprocess from the poll loop — module state set
|
||||
* by the poll loop is invisible to it. Both processes open outbound.db
|
||||
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
|
||||
*/
|
||||
const IN_REPLY_TO_KEY = 'current_in_reply_to';
|
||||
|
||||
/**
|
||||
* Ignore a stamp older than this. The poll loop clears the stamp in a
|
||||
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
|
||||
* the guard stops a later out-of-batch read from picking up a dead stamp.
|
||||
* Generous so a long-running batch's late sends still stamp correctly.
|
||||
*/
|
||||
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
if (id === null) {
|
||||
clearCurrentInReplyTo();
|
||||
return;
|
||||
}
|
||||
setValue(IN_REPLY_TO_KEY, id);
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
deleteValue(IN_REPLY_TO_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
const row = getOutboundDb()
|
||||
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
|
||||
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
|
||||
if (!row) return null;
|
||||
const age = Date.now() - new Date(row.updated_at).getTime();
|
||||
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
|
||||
return row.value;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
import { loadConfig } from './config.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
// Providers barrel — each enabled provider self-registers on import.
|
||||
// Provider skills append imports to providers/index.ts.
|
||||
import './providers/index.js';
|
||||
@@ -95,6 +96,12 @@ async function main(): Promise<void> {
|
||||
effort: config.effort,
|
||||
});
|
||||
|
||||
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
|
||||
// the runner creates a persistent memory/ tree in its host-backed workspace at
|
||||
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
|
||||
// and keeps its native memory untouched.
|
||||
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { getContinuation, setContinuation } from './db/session-state.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import type { ProviderExchange } from './providers/types.js';
|
||||
import { runPollLoop } from './poll-loop.js';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -304,6 +305,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
signal,
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
@@ -324,6 +326,86 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('poll loop — exchange hook (onExchangeComplete)', () => {
|
||||
// A provider that declares the per-exchange hook. The hook call is the
|
||||
// wiring under test — these tests go red if the poll-loop seam is severed.
|
||||
// What the provider DOES with an exchange (e.g. write markdown into
|
||||
// conversations/) ships with the provider, not the runner.
|
||||
class HookedMockProvider extends MockProvider {
|
||||
readonly exchanges: ProviderExchange[] = [];
|
||||
onExchangeComplete(exchange: ProviderExchange): void {
|
||||
this.exchanges.push(exchange);
|
||||
}
|
||||
}
|
||||
|
||||
it('reports each exchange to a provider that declares the hook', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'please archive this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new HookedMockProvider({}, () => '<message to="discord-test">archived answer</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => provider.exchanges.length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.exchanges.length).toBe(1);
|
||||
const exchange = provider.exchanges[0];
|
||||
expect(exchange.prompt).toContain('please archive this');
|
||||
expect(exchange.result).toContain('archived answer');
|
||||
expect(exchange.continuation).toStartWith('mock-session-');
|
||||
expect(exchange.status).toBe('completed');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('does not report the internal wrapping-retry nudge as a user prompt', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'wrap this later' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
let calls = 0;
|
||||
const provider = new HookedMockProvider({}, () => {
|
||||
calls += 1;
|
||||
// First result is unwrapped (triggers the retry nudge), second is wrapped.
|
||||
return calls === 1 ? 'unwrapped text' : '<message to="discord-test">wrapped now</message>';
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);
|
||||
|
||||
await waitFor(() => provider.exchanges.length >= 2, 3000);
|
||||
controller.abort();
|
||||
|
||||
// Both exchanges attribute themselves to the real user prompt, never the nudge.
|
||||
for (const exchange of provider.exchanges) {
|
||||
expect(exchange.prompt).not.toContain('Your response was not delivered');
|
||||
expect(exchange.prompt).toContain('wrap this later');
|
||||
}
|
||||
expect(provider.exchanges.map((e) => e.status)).toEqual(['undelivered', 'completed']);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('a throwing hook never breaks delivery', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'still deliver this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
class ThrowingHookProvider extends MockProvider {
|
||||
onExchangeComplete(): void {
|
||||
throw new Error('hook exploded');
|
||||
}
|
||||
}
|
||||
const provider = new ThrowingHookProvider({}, () => '<message to="discord-test">delivered anyway</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].content).toContain('delivered anyway');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — provider error recovery', () => {
|
||||
it('writes error to outbound and continues loop on provider throw', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
@@ -462,3 +544,76 @@ class InvalidSessionProvider {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('poll loop — slash command during active query', () => {
|
||||
it('aborts the active query when /clear arrives as a follow-up', async () => {
|
||||
insertMessage('m-active', { sender: 'Alice', text: 'long running request' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new BlockingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 3000);
|
||||
|
||||
await waitFor(() => provider.queries === 1, 2000);
|
||||
insertMessage('m-clear-active', { sender: 'Alice', text: '/clear' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
await waitFor(() => provider.aborts === 1, 2000);
|
||||
await waitFor(
|
||||
() => getUndeliveredMessages().some((msg) => JSON.parse(msg.content).text === 'Session cleared.'),
|
||||
2000,
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.ends).toBe(0);
|
||||
expect(getContinuation('mock')).toBeUndefined();
|
||||
expect(getPendingMessages()).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider whose query never completes until ended/aborted — for testing how
|
||||
* the loop interrupts an active stream.
|
||||
*/
|
||||
class BlockingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
queries = 0;
|
||||
aborts = 0;
|
||||
ends = 0;
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query() {
|
||||
const owner = this;
|
||||
this.queries += 1;
|
||||
let wake: (() => void) | null = null;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
|
||||
return {
|
||||
push() {},
|
||||
end: () => {
|
||||
owner.ends += 1;
|
||||
ended = true;
|
||||
wake?.();
|
||||
},
|
||||
abort: () => {
|
||||
owner.aborts += 1;
|
||||
aborted = true;
|
||||
wake?.();
|
||||
},
|
||||
events: (async function* () {
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'init' as const, continuation: 'blocking-session' };
|
||||
while (!ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = null;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,31 @@
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*
|
||||
* The stamp is published through session_state in outbound.db, not module
|
||||
* state — the MCP server runs as a separate stdio subprocess from the poll
|
||||
* loop, so it can only see the stamp through the shared DB. These tests seed
|
||||
* it the same way the poll-loop process does (a direct DB write) rather than
|
||||
* via any in-memory helper, so they exercise the real process boundary.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
/**
|
||||
* Publish the a2a reply stamp the way the poll loop does: a direct write to
|
||||
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
|
||||
* staleness guard MCP tools apply when reading it.
|
||||
*/
|
||||
function publishInReplyTo(id: string, ageMs = 0): void {
|
||||
const updatedAt = new Date(Date.now() - ageMs).toISOString();
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('current_in_reply_to', id, updatedAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
@@ -24,13 +41,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
|
||||
publishInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stale stamp left behind by a killed container', async () => {
|
||||
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getCurrentInReplyTo } from '../db/session-state.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
|
||||
describe('ensureMemoryScaffold', () => {
|
||||
it('deterministically creates the memory tree', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never touches workspace memory it did not create — CLAUDE.local.md stays untouched', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(base, 'CLAUDE.local.md'), '# group memory\nuser prefers terse replies\n');
|
||||
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
// Migration between memory stores is the operator's move (/migrate-memory),
|
||||
// never a boot side effect.
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'memories', 'imported-agent-memory.md'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(base, 'CLAUDE.local.md'), 'utf-8')).toContain('terse replies');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent and never clobbers the agent edits', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
const indexFile = path.join(base, 'memory', 'index.md');
|
||||
fs.writeFileSync(indexFile, '# my own index\n');
|
||||
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Create the agent's persistent memory scaffold, container-side, at boot.
|
||||
*
|
||||
* The runner owns its own workspace: it writes the memory tree straight into
|
||||
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
|
||||
* ephemeral container). No host-side step, nothing mounted in.
|
||||
*
|
||||
* The default `definition.md` / `index.md` live as real markdown templates next
|
||||
* to this module (under `memory-templates/`) — not as strings in code — so the
|
||||
* doctrine is editable as markdown and the agent receives an unescaped copy.
|
||||
* They ship in the mounted `/app/src` tree, so no image change is needed.
|
||||
*
|
||||
* Idempotent — only writes what's missing, so the agent's own edits and
|
||||
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
|
||||
* the runner makes no assumption about which harness is running — a provider
|
||||
* opts in via `usesMemoryScaffold`.
|
||||
*/
|
||||
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
|
||||
|
||||
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
|
||||
const memoryDir = path.join(baseDir, 'memory');
|
||||
const systemDir = path.join(memoryDir, 'system');
|
||||
|
||||
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
|
||||
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
|
||||
}
|
||||
|
||||
function copyTemplateIfMissing(template: string, dest: string): void {
|
||||
if (fs.existsSync(dest)) return;
|
||||
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
|
||||
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
|
||||
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
|
||||
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
|
||||
// test goes red. main() can't be driven in-process (it reads
|
||||
// /workspace/agent/container.json and enters the poll loop), so the guard is
|
||||
// structural: gate + import must both be present in the real entry point.
|
||||
describe('memory scaffold boot wiring', () => {
|
||||
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
|
||||
|
||||
it('gates the scaffold on the provider capability in main()', () => {
|
||||
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
|
||||
});
|
||||
|
||||
it('imports ensureMemoryScaffold from the seam module', () => {
|
||||
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Memory System
|
||||
|
||||
This editable file defines how your persistent memory works. It is a starting
|
||||
point, not a contract — reorganize it as the work demands. If the user or another
|
||||
memory system replaces this definition, follow the replacement.
|
||||
|
||||
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
|
||||
Treat indexes as core data: keep them accurate and concise.
|
||||
Every folder of durable memory has its own `index.md` describing its contents.
|
||||
When an index grows past roughly 20 entries, group related items into subfolders,
|
||||
and give each new subfolder its own `index.md` linked from the parent.
|
||||
|
||||
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
|
||||
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
|
||||
Use entity folders for things that matter: projects, people, places, organizations, decisions.
|
||||
|
||||
When the user shares something that should survive future turns, store it in the
|
||||
smallest useful file; prefer updating an existing file over creating duplicates.
|
||||
Write concise, source-aware notes; include dates when timing matters.
|
||||
If a fact is corrected, update the memory and keep only useful history.
|
||||
When you add, move, or remove memory, update the nearest index.
|
||||
Before answering from memory, read the relevant index or file instead of guessing;
|
||||
if memory is missing or uncertain, say so and verify when it matters.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Memory Index
|
||||
|
||||
- [Memory system definition](system/definition.md)
|
||||
- [Memories](memories/) - durable facts, people, projects, decisions
|
||||
- [Data](data/) - structured reference data
|
||||
@@ -4,8 +4,9 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
|
||||
import { getPendingMessages, markCompleted } from './db/messages-in.js';
|
||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { formatMessages, extractRouting } from './formatter.js';
|
||||
import { isCorruptionError } from './poll-loop.js';
|
||||
import { isCorruptionError, processQuery } from './poll-loop.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import type { AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
@@ -379,6 +380,64 @@ describe('end-to-end with mock provider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a one-shot stub query that yields init + a single result event, then
|
||||
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
|
||||
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
|
||||
*/
|
||||
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
|
||||
const pushes: string[] = [];
|
||||
async function* events(): AsyncGenerator<ProviderEvent> {
|
||||
yield { type: 'init', continuation: 'sess-1' };
|
||||
yield result;
|
||||
}
|
||||
return {
|
||||
pushes,
|
||||
query: {
|
||||
push: (m: string) => {
|
||||
pushes.push(m);
|
||||
},
|
||||
end: () => {},
|
||||
events: events(),
|
||||
abort: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ERR_ROUTING = {
|
||||
platformId: 'chan-1',
|
||||
channelType: 'discord',
|
||||
threadId: null,
|
||||
inReplyTo: 'm1',
|
||||
};
|
||||
|
||||
describe('error result with no <message> envelope', () => {
|
||||
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
|
||||
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
|
||||
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
|
||||
|
||||
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe(budgetText);
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
expect(out[0].channel_type).toBe('discord');
|
||||
// No re-wrap nudge — an error result must not re-hammer the gateway.
|
||||
expect(pushes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
|
||||
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
|
||||
|
||||
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
|
||||
|
||||
expect(getUndeliveredMessages()).toHaveLength(0);
|
||||
expect(pushes).toHaveLength(1);
|
||||
expect(pushes[0]).toContain('was not delivered');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCorruptionError', () => {
|
||||
it('matches the Docker Desktop macOS torn-read symptom', () => {
|
||||
expect(isCorruptionError('database disk image is malformed')).toBe(true);
|
||||
|
||||
@@ -2,8 +2,13 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
clearCurrentInReplyTo,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
setCurrentInReplyTo,
|
||||
} from './db/session-state.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
@@ -14,7 +19,7 @@ import {
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const ACTIVE_POLL_INTERVAL_MS = 500;
|
||||
@@ -63,6 +68,12 @@ export interface PollLoopConfig {
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
};
|
||||
/**
|
||||
* Optional stop signal. In production the loop runs until the container
|
||||
* dies; tests pass a signal so an abandoned loop actually exits instead of
|
||||
* polling forever and stealing messages from the next test's DB.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +118,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
let pollCount = 0;
|
||||
let isFirstPoll = true;
|
||||
while (true) {
|
||||
if (config.signal?.aborted) return;
|
||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
||||
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
|
||||
isFirstPoll = false;
|
||||
@@ -232,7 +244,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
setCurrentInReplyTo(routing.inReplyTo);
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
const result = await processQuery(
|
||||
query,
|
||||
routing,
|
||||
processingIds,
|
||||
config.providerName,
|
||||
config.provider.onExchangeComplete?.bind(config.provider),
|
||||
prompt,
|
||||
continuation,
|
||||
);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setContinuation(config.providerName, continuation);
|
||||
@@ -308,15 +328,23 @@ interface QueryResult {
|
||||
continuation?: string;
|
||||
}
|
||||
|
||||
async function processQuery(
|
||||
export async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
onExchangeComplete: ((exchange: ProviderExchange) => void) | undefined,
|
||||
initialPrompt: string,
|
||||
initialContinuation: string | undefined,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
let unwrappedNudged = false;
|
||||
// Prompt queue for the exchange hook — each result event consumes the
|
||||
// oldest unanswered prompt, except a wrapping-retry result, which answers
|
||||
// the same prompt again. Unused (and unmaintained) when the provider
|
||||
// doesn't implement `onExchangeComplete`.
|
||||
const archivePrompts: string[] = [initialPrompt];
|
||||
|
||||
// Concurrent polling: push follow-ups into the active query as they arrive.
|
||||
// We do NOT force-end the stream on silence — keeping the query open avoids
|
||||
@@ -342,13 +370,16 @@ async function processQuery(
|
||||
// resume id (fixed at sdkQuery() time); admin/passthrough commands
|
||||
// (/compact, /cost, …) only dispatch when they're the first input
|
||||
// of a query — pushed mid-stream they arrive as plain text and
|
||||
// the SDK never runs them. End the stream and leave the rows
|
||||
// pending; the outer loop handles them on next iteration via the
|
||||
// canonical command path + formatMessagesWithCommands.
|
||||
// the SDK never runs them. Abort the active stream and leave the
|
||||
// rows pending; the outer loop handles them on next iteration via
|
||||
// the canonical command path + formatMessagesWithCommands. Abort,
|
||||
// not end: end() lets an in-flight turn run to completion, which
|
||||
// can block the command (e.g. /clear during a long task) for as
|
||||
// long as the turn takes.
|
||||
if (pending.some((m) => isRunnerCommand(m))) {
|
||||
log('Pending slash command — ending stream so outer loop can process');
|
||||
log('Pending slash command — aborting active stream so outer loop can process');
|
||||
endedForCommand = true;
|
||||
query.end();
|
||||
query.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,6 +424,7 @@ async function processQuery(
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
unwrappedNudged = false;
|
||||
query.push(prompt);
|
||||
archivePrompts.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
// Without this catch the rejection escapes the void IIFE and Node
|
||||
@@ -455,21 +487,57 @@ async function processQuery(
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
if (hasUnwrapped && !unwrappedNudged) {
|
||||
unwrappedNudged = true;
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
||||
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
||||
`Your destinations: ${names}. ` +
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
if (sent === 0 && event.isError === true) {
|
||||
// Non-retryable error turn (e.g. a 403 billing_error) with no
|
||||
// <message> envelope: deliver the notice instead of dropping it as
|
||||
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
|
||||
// the failing gateway turn after turn.
|
||||
deliverErrorResult(event.text, routing);
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: event.text,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: 'error',
|
||||
});
|
||||
archivePrompts.shift();
|
||||
} else {
|
||||
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: event.text,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: hasUnwrapped ? 'undelivered' : 'completed',
|
||||
});
|
||||
if (willRetryWrapping) {
|
||||
unwrappedNudged = true;
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
||||
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
||||
`Your destinations: ${names}. ` +
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
}
|
||||
// The wrapping-retry result answers the SAME user prompt — keep it
|
||||
// queued so the retry archives against it, not the nudge text.
|
||||
if (!willRetryWrapping) archivePrompts.shift();
|
||||
}
|
||||
} else {
|
||||
archivePrompts.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: `Error: ${errMsg}`,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: 'error',
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
done = true;
|
||||
clearInterval(pollHandle);
|
||||
@@ -478,6 +546,18 @@ async function processQuery(
|
||||
return { continuation: queryContinuation };
|
||||
}
|
||||
|
||||
function notifyExchangeComplete(
|
||||
hook: ((exchange: ProviderExchange) => void) | undefined,
|
||||
exchange: ProviderExchange,
|
||||
): void {
|
||||
if (!hook) return;
|
||||
try {
|
||||
hook(exchange);
|
||||
} catch (err) {
|
||||
log(`onExchangeComplete failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
switch (event.type) {
|
||||
case 'init':
|
||||
@@ -497,6 +577,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a turn's text straight to the channel the batch arrived on. Used when
|
||||
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
|
||||
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
|
||||
* This is the same user-facing write the outer catch block does, minus the
|
||||
* `Error:` prefix — the provider's text is already a user-facing message.
|
||||
*/
|
||||
function deliverErrorResult(text: string, routing: RoutingContext): void {
|
||||
log('Error result with no <message> envelope — delivering to channel');
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||
|
||||
@@ -440,8 +440,13 @@ export class ClaudeProvider implements AgentProvider {
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
yield { type: 'init', continuation: message.session_id };
|
||||
} else if (message.type === 'result') {
|
||||
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
|
||||
yield { type: 'result', text };
|
||||
// `result` text exists only on subtype:"success"; error subtypes
|
||||
// (e.g. a non-retryable 403 billing_error) carry their message in
|
||||
// `errors[]` instead. Surface either so the poll-loop can deliver a
|
||||
// billing/quota notice to the user rather than dropping the turn.
|
||||
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
|
||||
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
|
||||
yield { type: 'result', text, isError: m.is_error === true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
|
||||
yield { type: 'error', message: 'API retry', retryable: true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './mock.js';
|
||||
|
||||
@@ -6,6 +6,25 @@ export interface AgentProvider {
|
||||
*/
|
||||
readonly supportsNativeSlashCommands: boolean;
|
||||
|
||||
/**
|
||||
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
|
||||
* agent's workspace at boot. Providers with their own native memory (e.g.
|
||||
* Claude's `CLAUDE.local.md`) omit this and get nothing — memory is opt-in per
|
||||
* provider, never gated on a provider name.
|
||||
*/
|
||||
readonly usesMemoryScaffold?: boolean;
|
||||
|
||||
/**
|
||||
* Optional. Called by the poll-loop after each completed exchange (a
|
||||
* result, a wrapping retry, or an error). Providers whose harness keeps no
|
||||
* on-disk transcript implement this to persist exchanges themselves (e.g.
|
||||
* markdown into the agent's `conversations/` dir); providers that persist
|
||||
* and archive their own transcript (e.g. the Claude Agent SDK's `.jsonl`)
|
||||
* omit it. Best-effort: the loop catches and logs anything it throws. The
|
||||
* implementation lives with the provider, never in the runner.
|
||||
*/
|
||||
onExchangeComplete?(exchange: ProviderExchange): void;
|
||||
|
||||
/** Start a new query. Returns a handle for streaming input and output. */
|
||||
query(input: QueryInput): AgentQuery;
|
||||
|
||||
@@ -31,6 +50,16 @@ export interface AgentProvider {
|
||||
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
|
||||
}
|
||||
|
||||
/** One prompt/result round-trip, as reported to `onExchangeComplete`. */
|
||||
export interface ProviderExchange {
|
||||
/** The user prompt this exchange answers (never an internal retry nudge). */
|
||||
prompt: string;
|
||||
result: string | null;
|
||||
/** Continuation/thread id in effect for the exchange, if any. */
|
||||
continuation?: string;
|
||||
status: 'completed' | 'undelivered' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to provider constructors. Fields are common to most
|
||||
* providers; individual providers may ignore any they don't need.
|
||||
@@ -96,7 +125,13 @@ export interface AgentQuery {
|
||||
|
||||
export type ProviderEvent =
|
||||
| { type: 'init'; continuation: string }
|
||||
| { type: 'result'; text: string | null }
|
||||
/**
|
||||
* A completed turn. `isError` is set when the underlying SDK flagged the
|
||||
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
|
||||
* poll-loop uses it to surface the result text to the user instead of
|
||||
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
|
||||
*/
|
||||
| { type: 'result'; text: string | null; isError?: boolean }
|
||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||
| { type: 'progress'; message: string }
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "name": "vercel", "version": "52.2.1" },
|
||||
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.197", "onlyBuilt": true }
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
// Guards the cli-tools.json seam: the global CLIs the agent invokes at runtime
|
||||
// are installed from the manifest (a skill adds one with a json-merge), not
|
||||
// hand-edited into the Dockerfile. These go red on a bad merge that drops a
|
||||
// baseline tool, or on dewiring the Dockerfile / switching the installer off
|
||||
// the pnpm supply-chain path.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'cli-tools.json'), 'utf8')) as Array<{
|
||||
name: string;
|
||||
version: string;
|
||||
onlyBuilt?: boolean;
|
||||
}>;
|
||||
const dockerfile = readFileSync(join(here, 'Dockerfile'), 'utf8');
|
||||
const installer = readFileSync(join(here, 'install-cli-tools.sh'), 'utf8');
|
||||
|
||||
describe('cli-tools manifest', () => {
|
||||
it('is a non-empty array of { name, version }', () => {
|
||||
expect(Array.isArray(manifest)).toBe(true);
|
||||
expect(manifest.length).toBeGreaterThan(0);
|
||||
for (const tool of manifest) {
|
||||
expect(typeof tool.name).toBe('string');
|
||||
expect(tool.name.length).toBeGreaterThan(0);
|
||||
expect(typeof tool.version).toBe('string');
|
||||
expect(tool.version.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('has unique tool names (json-merge is keyed on name)', () => {
|
||||
const names = manifest.map((t) => t.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('pins every version to an exact semver (no latest, no ranges — supply-chain policy)', () => {
|
||||
for (const tool of manifest) {
|
||||
expect(tool.version, `${tool.name} must be an exact semver, not "${tool.version}"`).toMatch(
|
||||
/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the baseline CLIs the agent depends on', () => {
|
||||
const names = manifest.map((t) => t.name);
|
||||
for (const required of ['vercel', 'agent-browser', '@anthropic-ai/claude-code']) {
|
||||
expect(names).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
it('is wired into the Dockerfile build (COPY manifest + run installer)', () => {
|
||||
expect(dockerfile).toMatch(/COPY cli-tools\.json install-cli-tools\.sh/);
|
||||
expect(dockerfile).toMatch(/install-cli-tools\.sh \/tmp\/cli-tools\.json/);
|
||||
});
|
||||
|
||||
it('installs via pnpm and writes only-built opt-ins (preserves the supply-chain path)', () => {
|
||||
expect(installer).toMatch(/pnpm install -g/);
|
||||
expect(installer).toMatch(/only-built-dependencies\[\]=/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# Install the global Node CLIs the agent invokes at runtime, from cli-tools.json.
|
||||
#
|
||||
# A skill adds a tool by appending a { "name", "version" } entry to that
|
||||
# manifest (a json-merge) instead of editing the Dockerfile — the reach-in
|
||||
# becomes the safest change shape, deterministic and removable.
|
||||
#
|
||||
# Every tool is installed via `pnpm install -g`, pinned to an exact version, so
|
||||
# the pnpm supply-chain policy still applies. Tools with a native postinstall
|
||||
# set "onlyBuilt": true to opt in to running build scripts (pnpm skips them by
|
||||
# default). Run as root before `USER node`, so /root/.npmrc is the right home.
|
||||
set -eu
|
||||
|
||||
MANIFEST="${1:-/tmp/cli-tools.json}"
|
||||
|
||||
# Write the per-tool only-built-dependencies opt-ins pnpm reads at install time.
|
||||
node -e '
|
||||
const tools = require(process.argv[1]);
|
||||
const optIns = tools.filter((t) => t.onlyBuilt).map((t) => "only-built-dependencies[]=" + t.name);
|
||||
require("fs").writeFileSync("/root/.npmrc", optIns.join("\n") + (optIns.length ? "\n" : ""));
|
||||
' "$MANIFEST"
|
||||
|
||||
# Install every tool, pinned. name@version specs never contain spaces, so the
|
||||
# unquoted expansion word-splits cleanly into positional args.
|
||||
# shellcheck disable=SC2046
|
||||
set -- $(node -e 'require(process.argv[1]).forEach((t) => console.log(t.name + "@" + t.version))' "$MANIFEST")
|
||||
if [ "$#" -gt 0 ]; then
|
||||
pnpm install -g "$@"
|
||||
fi
|
||||
@@ -1,90 +0,0 @@
|
||||
# Apple Container Networking Setup (macOS 26)
|
||||
|
||||
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
|
||||
|
||||
## Quick Setup
|
||||
|
||||
Run these two commands (requires `sudo`):
|
||||
|
||||
```bash
|
||||
# 1. Enable IP forwarding so the host routes container traffic
|
||||
sudo sysctl -w net.inet.ip.forwarding=1
|
||||
|
||||
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
|
||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
|
||||
|
||||
## Making It Persistent
|
||||
|
||||
These settings reset on reboot. To make them permanent:
|
||||
|
||||
**IP Forwarding** — add to `/etc/sysctl.conf`:
|
||||
```
|
||||
net.inet.ip.forwarding=1
|
||||
```
|
||||
|
||||
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
|
||||
```
|
||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
||||
```
|
||||
|
||||
Then reload: `sudo pfctl -f /etc/pf.conf`
|
||||
|
||||
## IPv6 DNS Issue
|
||||
|
||||
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
|
||||
|
||||
The container image and runner are configured to prefer IPv4 via:
|
||||
```
|
||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
```
|
||||
|
||||
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check IP forwarding is enabled
|
||||
sysctl net.inet.ip.forwarding
|
||||
# Expected: net.inet.ip.forwarding: 1
|
||||
|
||||
# Test container internet access
|
||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
||||
# Expected: 404
|
||||
|
||||
# Check bridge interface (only exists when a container is running)
|
||||
ifconfig bridge100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
||||
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
||||
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
|
||||
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Container VM (192.168.64.x)
|
||||
│
|
||||
├── eth0 → gateway 192.168.64.1
|
||||
│
|
||||
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
|
||||
│
|
||||
├── IP forwarding (sysctl) routes packets from bridge100 → en0
|
||||
│
|
||||
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
|
||||
│
|
||||
en0 (your WiFi/Ethernet) → Internet
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
|
||||
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
|
||||
@@ -6,8 +6,5 @@ The files in this directory are original design documents and developer referenc
|
||||
|
||||
| This directory | Documentation site |
|
||||
|---|---|
|
||||
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
|
||||
+102
-60
@@ -2,69 +2,101 @@
|
||||
|
||||
## Trust Model
|
||||
|
||||
Privilege is **user-level**, persisted in the `user_roles` table (owner /
|
||||
admin, global or scoped to an agent group) plus `agent_group_members` (the
|
||||
unprivileged access gate).
|
||||
|
||||
| Entity | Trust Level | Rationale |
|
||||
|--------|-------------|-----------|
|
||||
| Main group | Trusted | Private self-chat, admin control |
|
||||
| Non-main groups | Untrusted | Other users may be malicious |
|
||||
| Container agents | Sandboxed | Isolated execution environment |
|
||||
| Incoming messages | User input | Potential prompt injection |
|
||||
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
|
||||
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
|
||||
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
|
||||
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
|
||||
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
### 1. Container Isolation (Primary Boundary)
|
||||
|
||||
Agents execute in containers (lightweight Linux VMs), providing:
|
||||
- **Process isolation** - Container processes cannot affect the host
|
||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
||||
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
|
||||
Agents execute in containers (Docker), providing:
|
||||
- **Process isolation** — container processes cannot affect the host
|
||||
- **Filesystem isolation** — only explicitly mounted directories are visible
|
||||
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
|
||||
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
|
||||
|
||||
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
|
||||
This is the primary security boundary. Rather than relying on application-level
|
||||
permission checks, the attack surface is limited by what's mounted.
|
||||
|
||||
### 2. Mount Security
|
||||
|
||||
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
|
||||
- Outside project root
|
||||
- Never mounted into containers
|
||||
- Cannot be modified by agents
|
||||
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
|
||||
spawn. For the default (Claude) provider these are:
|
||||
|
||||
**Default Blocked Patterns:**
|
||||
| Container path | Host source | Mode | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
|
||||
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
|
||||
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
|
||||
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
|
||||
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
|
||||
| `/workspace/global` | `groups/global/` | RO | Shared global memory |
|
||||
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
|
||||
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
|
||||
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
|
||||
| `/app/skills` | `container/skills/` | RO | Shared container skills |
|
||||
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
|
||||
|
||||
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
|
||||
**nested read-only mounts on top of the read-write group dir** — the agent can
|
||||
read its config but cannot modify it. The project root is **never mounted**: the
|
||||
container only ever sees the paths above plus any provider-contributed mounts
|
||||
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
|
||||
`package.json`) is not reachable.
|
||||
|
||||
**Additional-mount allowlist** — extra mounts from a group's container config
|
||||
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
|
||||
which is:
|
||||
- Outside the project root
|
||||
- Never mounted into containers
|
||||
- Not modifiable by agents
|
||||
|
||||
Its schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedRoots": [
|
||||
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
|
||||
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
|
||||
],
|
||||
"blockedPatterns": ["password", "secret", "token"]
|
||||
}
|
||||
```
|
||||
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
|
||||
|
||||
**Default blocked patterns** (merged with any in the file):
|
||||
```
|
||||
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
|
||||
private_key, .secret
|
||||
```
|
||||
|
||||
**Protections:**
|
||||
- Symlink resolution before validation (prevents traversal attacks)
|
||||
- Container path validation (rejects `..` and absolute paths)
|
||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
||||
|
||||
**Read-Only Project Root:**
|
||||
|
||||
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
|
||||
**Enforcement** (`src/modules/mount-security/index.ts`):
|
||||
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
|
||||
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
|
||||
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
|
||||
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
|
||||
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
|
||||
|
||||
### 3. Session Isolation
|
||||
|
||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
||||
- Groups cannot see other groups' conversation history
|
||||
- Session data includes full message history and file contents read
|
||||
- Prevents cross-group information disclosure
|
||||
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
|
||||
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
|
||||
(`.claude-shared`) and the working folder are scoped to the agent group, so:
|
||||
- Different agent groups cannot see each other's conversation history or files.
|
||||
- A group's sessions share that group's memory but keep separate message DBs.
|
||||
|
||||
### 4. IPC Authorization
|
||||
This prevents cross-group information disclosure.
|
||||
|
||||
Messages and task operations are verified against group identity:
|
||||
|
||||
| Operation | Main Group | Non-Main Group |
|
||||
|-----------|------------|----------------|
|
||||
| Send message to own chat | ✓ | ✓ |
|
||||
| Send message to other chats | ✓ | ✗ |
|
||||
| Schedule task for self | ✓ | ✓ |
|
||||
| Schedule task for others | ✓ | ✗ |
|
||||
| View all tasks | ✓ | Own only |
|
||||
| Manage other groups | ✓ | ✗ |
|
||||
|
||||
### 5. Credential Isolation (OneCLI Agent Vault)
|
||||
### 4. Credential Isolation (OneCLI Agent Vault)
|
||||
|
||||
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
|
||||
|
||||
@@ -77,13 +109,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
|
||||
**Per-agent policies:**
|
||||
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
|
||||
|
||||
**NOT Mounted:**
|
||||
- Channel auth sessions (`store/auth/`) — host only
|
||||
- Mount allowlist — external, never mounted
|
||||
- Any credentials matching blocked patterns
|
||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||
**Never on the container filesystem:**
|
||||
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
|
||||
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
|
||||
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
|
||||
|
||||
### 6. Egress Lockdown (Forced Proxy)
|
||||
### 5. Egress Lockdown (Forced Proxy)
|
||||
|
||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
||||
ignores it (or a raw socket) could reach the internet directly and bypass
|
||||
@@ -111,31 +142,42 @@ no `host-gateway` route).
|
||||
exception: a heal failure there is logged but not fatal, since already-running
|
||||
agents stay on the internal net (no leak) until the gateway returns.
|
||||
|
||||
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
|
||||
the agent reaches the OneCLI gateway over the host-gateway path and outbound
|
||||
traffic is not confined to the internal network.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
|
||||
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
||||
|
||||
These variables are read from the **host process** environment (the service's
|
||||
environment / `.env`), not from inside the container. The agent container is
|
||||
started with only `TZ` and any provider-declared variables — host environment
|
||||
variables, including secrets, are never forwarded into the agent.
|
||||
|
||||
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
||||
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
||||
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
||||
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
|
||||
## Privilege Comparison
|
||||
## Resource Limits
|
||||
|
||||
| Capability | Main Group | Non-Main Group |
|
||||
|------------|------------|----------------|
|
||||
| Project root access | `/workspace/project` (ro) | None |
|
||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||
| Additional mounts | Configurable | Read-only unless allowed |
|
||||
| Network access | Unrestricted | Unrestricted |
|
||||
| MCP tools | All | All |
|
||||
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
|
||||
agent is not throttled unless the operator configures a limit:
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
|
||||
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
|
||||
|
||||
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
|
||||
the host having no swap (a deployment concern). On a swapless host a runaway is
|
||||
OOM-killed at the limit.
|
||||
|
||||
## Security Architecture Diagram
|
||||
|
||||
@@ -149,7 +191,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ HOST PROCESS (TRUSTED) │
|
||||
│ • Message routing │
|
||||
│ • IPC authorization │
|
||||
│ • Role / access checks (user_roles, agent_group_members) │
|
||||
│ • Mount validation (external allowlist) │
|
||||
│ • Container lifecycle │
|
||||
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NanoClaw Specification
|
||||
|
||||
> **⚠️ Historical v1 spec.** This document describes the original NanoClaw v1 architecture — the single `store/messages.db`, the file-based IPC watcher, the `task-scheduler.ts` loop, the `MAX_CONCURRENT_CONTAINERS` cap, and the `groups/{channel}_{name}/` folder convention. **None of these exist in v2.** v2 replaced them with the two-DB session split (`inbound.db`/`outbound.db`), the entity model (users → messaging groups → agent groups → sessions), and the system-action delivery path. Kept for reference only. For the current architecture start at [architecture.md](architecture.md) and the root [CLAUDE.md](../CLAUDE.md); the v1→v2 diff is in [v1-to-v2-changes.md](v1-to-v2-changes.md).
|
||||
|
||||
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
|
||||
|
||||
---
|
||||
@@ -341,6 +343,12 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:la
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps → `docker run --cpus/--memory`. Empty default =
|
||||
// no flag = unbounded (today's behavior). Opt in to bound a fleet sharing one
|
||||
// host: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g. Swap is a host concern
|
||||
// (run the host swapless to make --memory a hard cap); not managed here.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||
```
|
||||
|
||||
@@ -596,7 +596,7 @@ Schedule a one-shot or recurring task.
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_in` row (to self) with `kind: 'task'`, `process_after`, and optionally `recurrence`. The host sweep picks it up when due.
|
||||
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts` → `insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
|
||||
|
||||
#### list_tasks
|
||||
|
||||
@@ -609,7 +609,7 @@ List active scheduled/recurring tasks.
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
|
||||
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
|
||||
|
||||
#### cancel_task / pause_task / resume_task / update_task
|
||||
|
||||
@@ -625,7 +625,7 @@ Modify a scheduled task.
|
||||
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
|
||||
```
|
||||
|
||||
Implementation: cancel/pause/resume update the live row(s) directly. update_task is sent as a system action — the host reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
|
||||
#### register_agent_group
|
||||
|
||||
@@ -712,9 +712,9 @@ These are ephemeral to the container's lifetime. When the container is killed an
|
||||
|
||||
The agent-runner receives configuration via:
|
||||
|
||||
- **Environment variables:** `AGENT_PROVIDER` (claude/codex/opencode), `NANOCLAW_ADMIN_USER_ID`, provider-specific vars (API keys, model overrides), `TZ`
|
||||
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
|
||||
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
|
||||
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
|
||||
- **Optional startup config:** Some config may be passed as a JSON file at a fixed path (e.g., `/workspace/config.json`) for things like the session ID to resume, assistant name, and admin user ID. This avoids overloading environment variables.
|
||||
|
||||
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
|
||||
|
||||
@@ -731,7 +731,7 @@ function createProvider(name: ProviderName, config: ProviderConfig): AgentProvid
|
||||
}
|
||||
```
|
||||
|
||||
The provider name comes from the container's environment (`AGENT_PROVIDER` env var), set by the host based on `agent_groups.agent_provider` or `sessions.agent_provider`.
|
||||
The provider name comes from the `provider` key in `/workspace/agent/container.json` (defaulting to `'claude'`), which the host materializes from the `container_configs` table — set it with `ncl groups config update --provider`. It is not an environment variable.
|
||||
|
||||
`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ flowchart TB
|
||||
subgraph Session["Per-Session Container (Docker / Apple Container)"]
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
|
||||
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NanoClaw Architecture (Draft)
|
||||
|
||||
> **Draft — design intent, not a line-by-line spec.** Some passages predate the current implementation and can drift from it. The root [CLAUDE.md](../CLAUDE.md) and the cited source files (`src/`, `container/agent-runner/src/`) are the source of truth; when this doc and the code disagree, trust the code. Notably, scheduling MCP tools do **not** write `inbound.db` directly — they emit `messages_out` system actions that the host applies (see [agent-runner-details.md](agent-runner-details.md) and `src/modules/scheduling/`).
|
||||
|
||||
## Core Idea
|
||||
|
||||
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
|
||||
@@ -128,7 +130,6 @@ Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) impleme
|
||||
The host is an orchestrator:
|
||||
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
|
||||
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
|
||||
3. **Limits** — MAX_CONCURRENT_CONTAINERS caps active containers
|
||||
|
||||
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
|
||||
|
||||
@@ -244,7 +245,7 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
|
||||
|
||||
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
|
||||
|
||||
**Agent-runner creates schedules** by writing messages_in (to itself) or messages_out (reminders/notifications) with `process_after` and optionally `recurrence`.
|
||||
**Agent-runner creates schedules** by emitting a `messages_out` row with `kind: 'system'` and an `action` (`schedule_task`, `cancel_task`, …) — it cannot write host-owned `inbound.db` directly. The host applies the action during delivery (`src/modules/scheduling/actions.ts`), inserting/updating the `kind: 'task'` `messages_in` row with `process_after` and optionally `recurrence`.
|
||||
|
||||
### messages_in content by kind
|
||||
|
||||
@@ -829,7 +830,7 @@ Mixed batches (e.g., a chat message + a system result both pending) are combined
|
||||
|
||||
### MCP Tools
|
||||
|
||||
MCP tools write directly to the session DB.
|
||||
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
|
||||
|
||||
**Core tools:**
|
||||
|
||||
@@ -837,9 +838,9 @@ MCP tools write directly to the session DB.
|
||||
|------|-------------|
|
||||
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
|
||||
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
|
||||
| `schedule_task` | Write `messages_in` row (to self) with `process_after` + `recurrence`. Or `messages_out` with `deliver_after` for outbound reminders. |
|
||||
| `list_tasks` | Query `messages_in WHERE recurrence IS NOT NULL` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Modify `messages_in` rows (update status, clear/set recurrence) |
|
||||
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
|
||||
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
|
||||
|
||||
**New tools:**
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ data/v2-sessions/<agent_group_id>/<session_id>/
|
||||
outbox/<message_id>/ ← attachments the agent produced
|
||||
```
|
||||
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`, `agent-runner-src/`) that is shared across every session of that agent group.
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
|
||||
|
||||
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
|
||||
|
||||
@@ -55,7 +55,7 @@ CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||
|
||||
Content shapes: see [api-details.md §Session DB Schema Details](api-details.md#session-db-schema-details).
|
||||
|
||||
**Writers (host):** `insertMessage()`, `insertTask()`, `insertRecurrence()` — all in `src/db/session-db.ts`. Each calls `nextEvenSeq()`.
|
||||
**Writers (host):** `insertMessage()` (and `nextEvenSeq()`) in `src/db/session-db.ts`; `insertTask()` and `insertRecurrence()` in `src/modules/scheduling/db.ts`. Each calls `nextEvenSeq()`.
|
||||
**Reader (container):** `container/agent-runner/src/db/messages-in.ts` — polls `status='pending' AND (process_after IS NULL OR process_after <= now)`.
|
||||
|
||||
### 2.2 `delivered`
|
||||
|
||||
@@ -35,7 +35,6 @@ data/
|
||||
v2-sessions/
|
||||
<agent_group_id>/
|
||||
.claude-shared/ ← shared Claude state for the agent group
|
||||
agent-runner-src/ ← per-group agent-runner overlay
|
||||
<session_id>/
|
||||
inbound.db ← host writes, container reads
|
||||
outbound.db ← container writes, host reads
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user