Compare commits

..

487 Commits

Author SHA1 Message Date
gavrielc 803f3413ec Merge branch 'main' into cleanup/mount-allowlist-readonly-and-cache 2026-07-04 19:41:26 +03:00
github-actions[bot] a8b7da7bcf docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:40:39 +00:00
github-actions[bot] 0b6ad5550d chore: bump version to 2.1.35 2026-07-04 16:40:37 +00:00
gavrielc c1965cfcaf Merge pull request #2942 from nanocoai/cleanup/a2a-batch-stamp-crossprocess
Fix the agent-to-agent in_reply_to stamp (cross-process no-op)
2026-07-04 19:40:23 +03:00
gavrielc 3cefbfccf4 Merge branch 'main' into cleanup/a2a-batch-stamp-crossprocess 2026-07-04 19:38:09 +03:00
github-actions[bot] 6f22c73aac docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:38:00 +00:00
gavrielc 31dd37b3a8 Merge pull request #2940 from nanocoai/cleanup/remove-onedb-shims
Delete one-DB-era @deprecated shims and dead exports
2026-07-04 19:37:47 +03:00
gavrielc 0dfde3aa5b Merge branch 'main' into cleanup/remove-onedb-shims 2026-07-04 19:37:36 +03:00
github-actions[bot] 33d2366252 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:35:58 +00:00
github-actions[bot] 9bf1514f26 chore: bump version to 2.1.34 2026-07-04 16:35:56 +00:00
gavrielc be3502c23b Merge pull request #2937 from nanocoai/cleanup/resolve-session-reprovision
Re-provision a missing session folder so the documented reset works
2026-07-04 19:35:44 +03:00
gavrielc e3f38dbed9 Merge branch 'main' into cleanup/resolve-session-reprovision 2026-07-04 19:35:26 +03:00
github-actions[bot] d8a6b99f0e chore: bump version to 2.1.33 2026-07-04 16:35:06 +00:00
gavrielc 8ca7564fbc Merge pull request #2936 from nanocoai/cleanup/cli-protocol-vocab
Clean up dead ncl CLI protocol vocabulary
2026-07-04 19:34:51 +03:00
gavrielc afa07f0566 Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:42 +03:00
github-actions[bot] 8059ee4eec docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:34:08 +00:00
gavrielc 41c486cacc Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:02 +03:00
gavrielc 190a7d4f43 Merge pull request #2935 from nanocoai/cleanup/remove-dead-v1-config
Delete dead v1 config knobs and the broken pnpm auth script
2026-07-04 19:33:53 +03:00
gavrielc e3d2d43b0e Merge branch 'main' into cleanup/remove-dead-v1-config 2026-07-04 19:33:11 +03:00
github-actions[bot] afde23bbe6 chore: bump version to 2.1.32 2026-07-04 16:32:51 +00:00
gavrielc 776bc14ca0 Merge pull request #2934 from nanocoai/cleanup/reachable-perimeter-env
Make the security-perimeter env vars reachable under the shipped service
2026-07-04 19:32:37 +03:00
gavrielc a9461afef1 Merge branch 'main' into cleanup/reachable-perimeter-env 2026-07-04 19:32:28 +03:00
github-actions[bot] ca81558ec8 chore: bump version to 2.1.31 2026-07-04 16:30:56 +00:00
github-actions[bot] 2f88e4fa15 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:30:54 +00:00
gavrielc 9b0d1dd044 Merge pull request #2933 from nanocoai/feat/approval-button-styles
feat(approvals): colored buttons on approval cards (Slack primary/danger)
2026-07-04 19:30:42 +03:00
gavrielc 79e490dfcf Merge branch 'main' into feat/approval-button-styles 2026-07-04 19:29:58 +03:00
gavrielc 01b07d6652 Merge pull request #2795 from leetwito/feat/add-clidash-skill
feat: add /add-clidash — read-only CLI-derived dashboard skill
2026-07-04 16:53:44 +03:00
gavrielc be20e9f723 Merge branch 'main' into feat/add-clidash-skill 2026-07-04 16:53:25 +03:00
gavrielc e3d156f800 test(channels): cover ask_question option style normalization
Reviewers on #2933 asked for tests on the button-style plumbing. Two layers:

- src/channels/ask-question.test.ts (new): normalizeOption/normalizeOptions
  style whitelist — primary/danger/default pass through; unknown strings,
  case variants, and non-string values drop to undefined; string-shorthand
  options carry no style; style coexists with the label/selectedLabel/value
  defaulting. This is the load-bearing gate: an invalid style reaching
  Slack Block Kit fails the whole card with invalid_blocks, which in the
  approval flow is an effective auto-deny.

- src/channels/chat-sdk-bridge.test.ts: ask_question delivery passes each
  normalized option style into Button() and omits it when unset; an invalid
  style in the raw payload is stripped before the card is built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:04 +03:00
github-actions[bot] b4da018d8c chore: bump version to 2.1.30 2026-07-04 13:33:48 +00:00
gavrielc 4b11079007 Merge pull request #2932 from nanocoai/cleanup/dispatch-longest-prefix
Fix ncl positional IDs for generated (dashed) identifiers
2026-07-04 16:33:36 +03:00
gavrielc 31cc35ea32 Merge branch 'main' into cleanup/dispatch-longest-prefix 2026-07-04 16:33:27 +03:00
github-actions[bot] bf0a3d2612 chore: bump version to 2.1.29 2026-07-04 13:31:46 +00:00
gavrielc 6dc25a9b8a Merge pull request #2930 from nanocoai/cleanup/command-gate-start-and-failopen
command-gate: restore the /start filter and remove the fail-open admin check
2026-07-04 16:31:32 +03:00
gavrielc cafba7fb18 Merge branch 'main' into cleanup/command-gate-start-and-failopen 2026-07-04 16:31:05 +03:00
github-actions[bot] 10d400ec64 chore: bump version to 2.1.28 2026-07-04 13:29:49 +00:00
gavrielc 1a9643cfa3 Merge pull request #2929 from nanocoai/feat/onecli-approval-card-summary
feat(approvals): render OneCLI approval requests from the gateway's structured summary
2026-07-04 16:29:38 +03:00
gavrielc 2ba2555032 Merge branch 'main' into feat/onecli-approval-card-summary 2026-07-04 16:28:01 +03:00
github-actions[bot] 687d7d13ac chore: bump version to 2.1.27 2026-07-04 13:26:56 +00:00
gavrielc 2938bbf94a Merge pull request #2928 from nanocoai/cleanup/remove-dead-global-mount
Remove the dead /workspace/global mount and untrack v1 group seed files
2026-07-04 16:26:42 +03:00
gavrielc d1a2b04d32 Merge branch 'main' into cleanup/remove-dead-global-mount 2026-07-04 16:26:10 +03:00
github-actions[bot] 455014e7b9 chore: bump version to 2.1.26 2026-07-04 13:25:56 +00:00
gavrielc 5032c431ae Merge pull request #2927 from nanocoai/cleanup/unregister-mock-provider
Unregister the mock provider from the production container barrel
2026-07-04 16:25:40 +03:00
gavrielc ac8273c698 Merge branch 'main' into cleanup/unregister-mock-provider 2026-07-04 16:24:37 +03:00
gavrielc ed9a3e330d Mount allowlist: honor the readOnly key and stop caching parse errors
Translate the per-root `readOnly` key (and tolerate the top-level
`nonMainReadOnly` key) that /manage-mounts and setup actually write, so
read-write grants are no longer silently forced read-only. Read+validate
the allowlist per call (mtime-keyed cache) instead of caching it — and its
parse errors — for the whole process lifetime. Add tests and fix the
skill docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:08:46 +03:00
gavrielc 102ce80fda Fix a2a in_reply_to stamp: publish through outbound.db, not module state
The active batch's inReplyTo lived in module-level state in current-batch.ts,
but the nanoclaw MCP server runs as a separate stdio subprocess from the poll
loop, so getCurrentInReplyTo() always read null there. The a2a reply stamp was
therefore dead — only the host peer-affinity fallback kept replies routing.

Publish the stamp through session_state in outbound.db (both processes already
open it): the poll loop writes it at batch start and clears it in the existing
finally; the MCP tools read it with an updated_at staleness guard so a stamp
left behind by a killed container isn't reused. Delete current-batch.ts and
reseed core.test.ts via the DB so it reflects the real process boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:49 +03:00
gavrielc 0626dcec92 Delete one-DB-era @deprecated shims and dead exports
These are leftovers from the April-2026 inbound/outbound session-DB
split, marked @deprecated "kept temporarily for test compatibility":
sessionDbPath, openSessionDb, writeSystemResponse (session-manager),
getStuckProcessingIds (session-db, superseded by getProcessingClaims),
getSessionDb (container connection + barrel re-export), and the dead
registerResponseHandler/onShutdown re-exports from index.ts. The only
real callers are two dev scripts, repointed to inboundDbPath /
getInboundDb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:02 +03:00
gavrielc b42329551d Re-provision a missing session folder so the documented reset works
The /debug skill tells operators to rm -rf a session folder to reset a
stuck session, but the sessions row survives. The next message then takes
the existing-session path and opens inbound.db in a directory that no
longer exists — better-sqlite3 throws and the message is dropped forever.

writeSessionMessage now calls the idempotent initSessionFolder when the
inbound.db path is missing, so the documented reset actually re-provisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:55 +03:00
gavrielc 855f204d8a Clean up dead ncl CLI protocol vocabulary
Remove scaffold-era vocabulary in the ncl protocol that no code produces:
the duplicate `Access` union in crud.ts (now imported from registry.ts),
the unused `'hidden'` access level, and the never-emitted `'permission-denied'`
and `'not-found'` error codes. Also refresh three stale docstrings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:43 +03:00
gavrielc d4ca8a4ea6 chore(config): remove dead v1 config knobs and broken pnpm auth script
These config exports in src/config.ts have zero readers and advertise
controls that v2 removed:

- CONTAINER_TIMEOUT / CONTAINER_MAX_OUTPUT_SIZE / IDLE_TIMEOUT /
  MAX_CONCURRENT_CONTAINERS / MAX_MESSAGES_PER_PROMPT
- the trigger block (escapeRegex, buildTriggerPattern, DEFAULT_TRIGGER,
  getTriggerPattern, TRIGGER_PATTERN)

Also remove the `auth` script from package.json — it pointed at the
deleted src/whatsapp-auth.ts, so `pnpm run auth` only errored — and fix
the stale MAX_MESSAGES_PER_PROMPT reference in the container's
messages-in.ts comment (the value comes from container.json's
maxMessagesPerPrompt).

Grep confirms no importers in src/, container/, scripts/, setup/, or
tests. Precedent: 0283391.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:34 +03:00
gavrielc 7615d7d846 Make security-perimeter env vars reachable under the shipped service
Egress-lockdown and CPU/memory limits were read from process.env only, but
the shipped launchd/systemd service sets just PATH+HOME and the host never
loads .env into process.env — so these knobs could not be turned on the
supported way. Route them through the same readEnvFile path as the other
config keys, keeping process.env precedence so dev-mode/nohup installs are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:27 +03:00
gavrielc f094f56bf6 Fix ncl positional IDs for generated (dashed) identifiers
The dispatcher trimmed exactly one trailing dash-segment to recover the
target id, so any generated id containing dashes (UUIDs, sess-*, appr-*)
never matched a command and failed unknown-command; only --id worked.
Resolve by longest registered prefix instead: split the dash-joined
command, take the longest prefix that lookup() resolves as the command,
and treat the re-joined remainder as args.id. Server-side only, no wire
or client change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:02 +03:00
gavrielc fcee39ea14 command-gate: restore the /start filter and remove the fail-open admin check
Add '/start' back to the host FILTERED set (a Telegram fix from 866b791
was silently undone when host gating was added) so it is dropped instead
of reaching the agent as a normal message. Replace the inline isAdmin
query and its hasTable('user_roles') fail-open guard with a call to
hasAdminPrivilege; user_roles always exists (core migration 001-initial),
so the guard only ever masked a missing check. Add a focused command-gate
test covering both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:58 +03:00
gavrielc 2bce84781b feat(approvals): colored buttons on approval cards (Slack primary/danger)
Approval cards render every button with the same neutral style, so Approve
and Reject read identically at a glance. The chat SDK's Button already
accepts style ('primary' | 'danger' | 'default') and @chat-adapter/slack
maps primary→green and danger→red Block Kit styles (Telegram ignores it),
but the ask_question pipeline dropped the field.

- ask-question.ts: add OptionStyle and an optional style field to
  OptionInput/NormalizedOption; normalizeOption whitelists the value.
  Bare-string options stay unstyled.
- chat-sdk-bridge.ts: forward opt.style into Button() on ask_question
  cards. Persisted options_json tolerates the extra key
  (getAskQuestionRender only reads label/selectedLabel/value).
- Producers: module approvals (Approve/Reject), sender approvals
  (Allow/Deny), channel approvals (Connect/Reject), and OneCLI credential
  cards (Approve/Reject) annotate primary/danger. Multi-choice picker
  options stay unstyled — a list of equals.

Purely additive: options without style render exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:24 +03:00
gavrielc f4be00e6ed Remove dead /workspace/global mount and untrack v1 group seed files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:14 +03:00
gavrielc c9aa69dea9 Unregister the mock provider from the production container barrel
The container self-registration barrel imported mock.js, so every container
registered a 'mock' provider returning canned text. A typo'd --provider mock
would silently 'work' instead of failing loudly. Drop the import (mock.ts stays
for direct test use) and tidy the now-stale host comments and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:09 +03:00
gavrielc 3731e26769 feat(approvals): render OneCLI approval requests from the gateway's structured summary
The hosted OneCLI gateway (api.onecli.sh) sends a structured summary field
on ApprovalRequest — { action, details: [{label, value}] } — that the SDK's
TypeScript type doesn't declare yet. When present, render it as the approval
card body (*Action:* plus labeled fields, fencing multi-line values) instead
of the raw METHOD host/path + bodyPreview, so approvers see what the agent
is doing (e.g. To / Subject / Body of an email send) rather than an HTTP
trace.

Rendering is defensive: non-string values are coerced via JSON.stringify (a
render throw would turn into a deny via handleRequest's catch), each value is
capped at 900 chars, and a 2600-char running budget keeps the card under
Slack's 3000-char section block limit — overflow is reported with an
explicit "…N field(s) omitted for length" line instead of a failed
delivery. Without a summary, the old bodyPreview fallback remains, now
capped and with the method/host/path as a footnote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:08 +03:00
leetwito d472d9d32b Merge remote-tracking branch 'upstream/main' into feat/add-clidash-skill 2026-07-04 15:38:41 +03:00
leetwito 6c2d26836f fix(add-clidash): mkdir -p tools in install step; require Node >=22.5
Address review feedback on PR #2795:
- SKILL.md Step 1 now creates tools/ before cp -R (cp does not create
  intermediate parents; tools/ is not a standard NanoClaw dir, so the copy
  failed on a fresh checkout).
- package.json engines bumped to >=22.5 — server.js statically imports
  activity.js -> node:sqlite (DatabaseSync), which lands in Node 22.5, so
  the server crashed at module load on Node 20/21 LTS.
- Doc references (SKILL.md, README.md) updated to Node >=22.5 for consistency.

Verified 87/87 tests pass on Node 22.14.
2026-07-04 15:38:38 +03:00
github-actions[bot] b28c917997 docs: update token count to 207k tokens · 104% of context window 2026-07-04 08:08:53 +00:00
github-actions[bot] a00a5610bd chore: bump version to 2.1.25 2026-07-04 08:08:50 +00:00
gavrielc 05dc1b0a3c Merge pull request #2611 from Hinotoi-agent/fix/approval-cli-caller-context
[security] fix(cli): preserve caller context after approval
2026-07-04 11:08:39 +03:00
glifocat a7b34bc872 Merge branch 'main' into fix/approval-cli-caller-context 2026-07-04 09:11:27 +02:00
github-actions[bot] aecad864e6 chore: bump version to 2.1.24 2026-07-02 11:57:00 +00:00
gavrielc c87f2e55dc Merge pull request #2890 from amit-shafnir/worktree-nanoclaw-templates
feat(templates): local template loader, ncl --template, and docs
2026-07-02 14:56:45 +03:00
Amit Shafnir 411f5e71df feat(templates): local template loader, ncl --template, provider-agnostic persona and skills seams
Agent templates: folder-only templates under templates/ (context/instructions.md +
optional context extras, .mcp.json, skills/). Stamping via ncl groups create
--template writes the provider-neutral instructions.prepend.md (inlined at the top
of CLAUDE.md/AGENTS.md every spawn), copies context extras preserving their
template-relative layout, writes MCP servers to container config, and installs the
per-group skills overlay. Includes docs (docs/templates.md, templates/README.md).

Setup-wizard wiring ships separately on top of this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:36:33 +03:00
gavrielc cb6e3d117c Merge pull request #2885 from PartridgeNet/fix/slack-setup-socket-mode
fix(setup): offer Slack Socket Mode in the guided setup flow
2026-06-30 23:05:45 +03:00
gavrielc ed7e3f70da Merge branch 'main' into fix/slack-setup-socket-mode 2026-06-30 23:04:38 +03:00
github-actions[bot] 557e073c2f chore: bump version to 2.1.23 2026-06-30 19:28:09 +00:00
gavrielc 91ebc9def2 chore(container): bump claude-code, agent SDK to latest
- @anthropic-ai/claude-code (cli-tools.json): 2.1.170 → 2.1.197
- @anthropic-ai/claude-agent-sdk: ^0.3.170 → ^0.3.197
- @anthropic-ai/sdk: ^0.100.0 → ^0.108.0

Typecheck and all 112 agent-runner tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:27:46 +03:00
github-actions[bot] 14c89e9716 docs: update token count to 204k tokens · 102% of context window 2026-06-30 15:49:15 +00:00
github-actions[bot] 549c424a38 chore: bump version to 2.1.22 2026-06-30 15:49:12 +00:00
gavrielc 186b9befcd Merge pull request #2880 from johnmathews/fix/2828-inbox-symlink-containment-upstream
fix(security): contain inbox symlink escapes in attachment writes (#2828)
2026-06-30 18:48:59 +03:00
John Mathews 863d413d32 Merge branch 'main' into fix/2828-inbox-symlink-containment-upstream 2026-06-29 18:27:07 +02:00
Rob Stevenson cf8478ffbb fix(setup): offer Slack Socket Mode in the guided setup flow
PR #2837 added Slack Socket Mode end-to-end ("adapter + guided setup") but
was merged into the `channels` branch, not `main`. As a result the
`setup:auto` Slack flow on main is webhook-only: it always collects a
signing secret and pushes the user toward a public Request URL, with no
Socket Mode option — even though setup/verify.ts already recognizes
SLACK_APP_TOKEN and the channels-branch adapter supports it.

Forward-port the setup-side of #2837 onto current main, re-authored on top
of main's current flow (back-nav, inline agent wiring, operator-role prompt,
welcome DM all preserved):

- setup/channels/slack.ts: add a mode picker (askSlackMode), mode-specific
  app-creation steps, collectAppToken() for the xapp- app-level token,
  conditional credential collection + env, and a mode-aware post-install
  checklist (Socket Mode skips the public-URL guidance).
- setup/add-slack.sh: require/persist either SLACK_APP_TOKEN (Socket Mode)
  or SLACK_SIGNING_SECRET (webhook) instead of mandating the signing secret.

Scope is setup-side only: the adapter's socket support already lives on
`channels` (#2837/#2839) and reaches users via /add-slack; the add-slack
SKILL.md doc is handled by #2700.
2026-06-29 14:45:31 +01:00
github-actions[bot] 8be5be93ba docs: update token count to 203k tokens · 101% of context window 2026-06-29 05:58:05 +00:00
omri-maya add3fc8f70 Merge pull request #2882 from nanocoai/fix/ncl-messaging-group-instance
fix(ncl): default messaging-groups create instance to channel_type
2026-06-29 08:57:53 +03:00
Omri Maya 0d841bcd05 fix(ncl): default messaging-groups create instance to channel_type
`ncl messaging-groups create` failed with a NOT NULL violation on the
`instance` column (migration 016). The generic CRUD insert builds its
column list from the resource definition, and `instance` wasn't declared
there — so the INSERT omitted the column entirely. The router path never
hit this because it goes through `createMessagingGroup`, which has its own
`instance ?? channel_type` fallback.

There is no operator-facing reason to require `--instance`: the default
instance IS the channel type (migration 016, `createMessagingGroup`, and
the default-instance resolver all encode this). So rather than force a
flag, default it.

- crud: add `defaultFrom` to ColumnDef — default a column to another
  already-resolved column's value on create. Generic, reusable.
- messaging-groups: declare `instance` with `defaultFrom: 'channel_type'`
  (placed after channel_type so it resolves first), still overridable via
  `--instance` for multi-instance setups.
- test: drive the real dispatch('messaging-groups-create') path; asserts
  omitted -> channel_type and explicit --instance preserved. Goes red if
  the column/defaultFrom wiring is deleted (insert fails NOT NULL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:38:22 +03:00
John Mathews dd1d0e5677 fix(security): contain channel-inbound attachments via shared inbox guard (#2828)
extractAttachmentFiles (the channel-inbound attachment path) hardened only
the per-message inbox subdir, not the `inbox` root itself. A compromised
container can write inside its own session dir, so it could replace `inbox`
with a symlink: mkdirSync({recursive}) then followed it, and the realpath
containment check passed because it compared against realpathSync(inboxRoot)
— which had already followed the symlink. A brand-new attachment file (the
`wx` flag only blocks an existing dst) therefore landed outside the session
sandbox. This is the same symlink-follow class fixed for the A2A path in
#2828 (CWE-59), but reachable from ordinary inbound messages.

Extract the guard both inbound paths need into src/inbox-safety.ts
(ensureContainedInboxDir + isPathInside): lstat-reject a pre-placed symlink
or non-dir at the inbox root AND the per-message subdir before mkdir, then a
realpath containment check. forwardAttachedFiles and extractAttachmentFiles
now share it, removing the duplicated guard logic. Callers still write with
an exclusive flag (COPYFILE_EXCL / wx) and skip-with-warn on failure.

Adds src/session-manager.attachments.test.ts (red before this change, green
after) covering the symlinked inbox-root vector on the channel path.
2026-06-29 00:27:58 +02:00
John Mathews 36afa40857 fix(agent-to-agent): containment-check target inbox in forwardAttachedFiles (#2828)
forwardAttachedFiles hardened only the source side of A2A attachment
forwarding; the target side called fs.mkdirSync({recursive}) and
fs.copyFileSync without any symlink or containment checks. A compromised
target agent that can write inside its own session dir could pre-place
`inbox` (or `inbox/<future-msgId>`) as a symlink pointing anywhere
host-writable — mkdirSync silently follows it and copyFileSync lands
attacker-influenced bytes outside the sandbox (CWE-59, GHSA #2828). This
mirrors the existing defensive pattern in src/session-manager.ts
saveAttachments(): lstat-reject a pre-existing symlink/non-dir at the
inbox root and the per-message subdir before mkdir, realpath + isPathInside
containment check, and an exclusive (COPYFILE_EXCL) copy that refuses to
follow or overwrite a pre-placed symlinked destination. Failures log.warn
with structured context and skip rather than throw, so one bad attachment
never kills a batch. Tests cover a symlinked inbox dir, a symlinked
inbox/<msgId> subdir, a pre-existing symlinked destination file, and a
normal end-to-end forward regression.
2026-06-29 00:27:58 +02:00
gavrielc 2afbd18233 Merge pull request #2859 from cben0ist/fix/migrate-v2-is-main
fix(migrate-v2): don't SELECT is_main from v1 registered_groups
2026-06-26 13:38:42 +03:00
gavrielc 953496dc37 Merge branch 'main' into fix/migrate-v2-is-main 2026-06-26 13:38:27 +03:00
Christophe Benoist 797491d8b3 fix(migrate-v2): don't SELECT is_main from v1 registered_groups
The v2 DB seed queried `is_main` from the v1 `registered_groups` table, but
that column was a later v1 addition — older v1 installs (e.g. 1.1.0) don't have
it, so the migration's `1b-db` step crashes with `no such column: is_main` and
v2.db is never created, cascading into the sessions and tasks steps failing.

`is_main` was selected into the V1Group interface but never read anywhere, so
this just drops it from the SELECT and the interface. The accompanying comment
already states the intent ("Query only the columns we know exist in all v1
installs") — the code now matches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:43:19 -04:00
github-actions[bot] 2df754459b chore: bump version to 2.1.21 2026-06-25 18:59:54 +00:00
gavrielc 0896d4089e Merge pull request #2832 from nanocoai/feat/reject-with-reason
feat(approvals): reject with reason
2026-06-25 21:59:42 +03:00
gavrielc d153d91307 Merge branch 'main' into feat/reject-with-reason 2026-06-25 21:45:12 +03:00
gavrielc ce55af12d5 Merge pull request #2843 from robbyczgw-cla/feat/learn-skill
feat: add /learn skill — distill or refine a reusable skill from anything
2026-06-25 21:41:23 +03:00
gavrielc 545800a94e Merge branch 'main' into feat/learn-skill 2026-06-25 21:40:55 +03:00
github-actions[bot] bfb309bd0c chore: bump version to 2.1.20 2026-06-25 18:34:34 +00:00
gavrielc 38d9390eea Merge pull request #2856 from nanocoai/container-limits
feat(container): per-container CPU/memory limits (opt-in)
2026-06-25 21:34:16 +03:00
gavrielc 8d3eca7027 Merge branch 'main' into container-limits 2026-06-25 21:34:00 +03:00
Omri Maya 1d6bba4d3f feat(container): per-container CPU/memory limits (opt-in)
Pass CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT through to `docker run`
as --cpus / --memory in buildContainerArgs. Both default to empty, so spawn
args are byte-identical to today unless an operator opts in — no risk of
OOM-ing existing workloads. Caps an agent container's CPU/memory so one agent
can't monopolize the host. Swap is a deployment concern (--memory is a hard
cap on a swapless host); not managed here.

Structural tests assert each flag is pushed and guarded by its env knob,
matching the existing buildContainerArgs structural-test convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:39:16 +03:00
amit-shafnir 9bb69c0e50 Merge pull request #2830 from amit-shafnir/fix/peer-dead-plist-reaper
fix(setup): reap dead peer service registrations whose binary is gone
2026-06-25 11:27:54 +03:00
robbyczgw-cla 520ec44aec feat: add /learn skill — distill or refine a reusable skill from anything
Instruction-only skill that distills a reusable skill from any source
(directory, URL, pasted notes, or the current conversation) or refines an
existing skill in place. Uses existing agent tools (Read/Grep/Glob/WebFetch/
Write) and injects the project's skill-authoring guidelines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:49 +02:00
gavrielc 8c6a243ffd Merge branch 'main' into feat/reject-with-reason 2026-06-23 15:46:02 +03:00
gavrielc add6145f1c Merge pull request #2826 from nanocoai/fix/skill-updates-nudge-and-container-rebuild
fix(update-skills): nudge into skill updates, rebuild container on re-apply
2026-06-23 15:41:05 +03:00
gavrielc 4e14d08173 Merge pull request #2834 from nanocoai/chore/bump-chat-sdk-4.29.0
chore(deps): move chat SDK + channel-adapter pins to 4.29.0
2026-06-23 15:26:50 +03:00
Gabi Simons 8f2f788b6e chore(deps): bump channel adapter install pins to 4.29.0 (skills + setup)
The prior commit moves `chat` to 4.29.0, but main's own install pins were left
behind — and were inconsistent: the 8 /add-<channel> SKILL.md steps pinned
@chat-adapter/*@4.27.0 while the 12 setup/*.sh scripts pinned @4.26.0. Unify all
to @4.29.0 so `/add-<channel>` (and setup:auto) on a main install fetch an
adapter whose ChatInstance matches the bridge.

20 files, version-string only. Shell scripts pass `bash -n`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:47:31 +03:00
Gabi Simons e96d7fd961 chore(deps): pin chat SDK to 4.29.0
`chat` and the `@chat-adapter/*` channel adapters are version-locked — the
adapter's ChatInstance must match the bridge's, so the pair must move together.
Pin `chat` exactly to 4.29.0 (was 4.26.0 via `^4.24.0`); a caret range floats to
4.31.0 and reintroduces the skew.

Host build + full test suite green at 4.29.0 (chat is consumed only as type
imports by the Chat SDK bridge). The channels-branch adapters bump to 4.29.0 in
lockstep; CHANGELOG notes the reinstall migration for existing channel installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:03:48 +03:00
Moshe Krupper 2ac7809385 feat(agent-to-agent): clarify the a2a gate approval prompt
Replace the terse "Approve delivery?" with a one-line legend that names all
three buttons and notes that "Reject with reason…" prompts the approver to
type a reason relayed back to the sender. The longer line also widens the
card bubble, easing the three-button-row truncation on platforms that size
buttons to the message width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:16:03 +03:00
Amit Shafnir 15292ae76c fix(setup): reap dead peer service registrations whose binary is gone
The setup preflight unloads *crash-looping* peers but ignores a more common
leftover: a launchd plist (or systemd unit) whose program no longer exists,
left behind when a NanoClaw checkout is deleted without running the
uninstaller. The health probe can't see these because an unloaded/inactive job
doesn't report via `launchctl print` / `systemctl show`, so they accumulate —
the OS keeps retrying a missing binary forever.

Detect a registration as dead when its `dist/index.js` target is absent on
disk, then unload (best-effort) and delete the orphaned config file. Own-label
and still-valid registrations are never touched.

Adds peer-cleanup.test.ts (the file previously had no tests) covering both
platforms: dead target removed, live target kept, own registration spared,
unrecognized config ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:58:51 +03:00
Koshkoshinsk 055cf49bd5 fix(update-skills): nudge into skill updates, rebuild container on re-apply
/update-nanoclaw Step 7 framed skill updates as an optional, "safe to skip"
extra, so an important channel/provider fix — shipped on the channels/providers
branches the host merge never touches — could be silently missed. Reframe it as
part of the update: default into /update-skills, name the installed skills, and
leave one minimal opt-out.

Move the container image rebuild into /update-skills Step 4: when a re-apply
changes files under container/ (e.g. a provider's runtime), rebuild so new
sessions actually run the new code. Living in update-skills covers both the
standalone and via-update-nanoclaw paths; the update-nanoclaw Step 7.5 that
briefly owned this is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHaa6bp25E62AuUJyW1V5J
2026-06-21 17:08:51 +03:00
Lee Twito 8ee7915418 Merge branch 'main' into feat/add-clidash-skill 2026-06-21 16:19:48 +03:00
Moshe Krupper e8148bc0a7 feat(approvals): reject-with-reason — relay an optional decline reason to the agent
Add a third "Reject with reason…" button to module approval cards. Plain
Reject stays the instant fast path; the new option holds the row
(status='awaiting_reason'), DM-prompts the approver, and captures their
next DM (≤280 chars, truncated) as a one-line reason relayed to the
requesting agent as a single combined message. A ghosted hold is
finalized as a plain reject by the host sweep after ~5 min — restart-safe
via the durable DB row.

- Generalize the router message-interceptor to a list
  (registerMessageInterceptor) so approvals can capture replies alongside
  the permissions agent-naming flow.
- Share reject finalization across the instant, captured, and swept paths
  via finalizeReject.
- Scope: all module approvals (create_agent, install_packages,
  add_mcp_server); OneCLI credential cards are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:55:01 +03:00
amit-shafnir 625264ba4b Merge pull request #2811 from amit-shafnir/setup-agent-provider-flag
fix(setup): allow env-selected agent provider
2026-06-18 22:08:37 +03:00
github-actions[bot] f34e590bcd docs: update token count to 199k tokens · 100% of context window 2026-06-18 15:19:05 +00:00
github-actions[bot] d208fd7bf5 chore: bump version to 2.1.19 2026-06-18 15:18:56 +00:00
Moshe Krupper 886c65725b Merge pull request #2793 from nanocoai/feat/a2a-approval-policies
feat(agent-to-agent): per-message approval policies on connected agents
2026-06-18 18:18:43 +03:00
Moshe Krupper 9977af68d7 chore(migrations): number the new migration files (017, 018)
Rename the two new migration files to the numbered convention used by the core
migrations (001–016), with matching migrationNNN exports, instead of the
module- prefix. Versions (17, 18) and stable migration `name`s are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8e44f07dd4 refactor(approvals): carry approver on a pending_approvals column, not the payload
Per review: move the assigned approver from the approval payload to a dedicated
`approver_user_id` column on pending_approvals.

- New migration adds the column; createPendingApproval + requestApproval write it.
- isAuthorizedApprovalClick reads approval.approver_user_id directly (drops the
  payload-parsing helper); when set, only that exact user may resolve.
- The gate no longer stuffs `approver` into the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8c43f13d93 refactor(approvals): assigned approver is strict — only the named user may resolve
Per review: drop the owner/global-admin override on assigned approvals. When an
approval names an approver, only that exact user can resolve it. (Non-assigned
approvals are unchanged — still group/owner authorized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 5cf4ff1bd2 refactor: destructure approverUserId / policy.approver instead of repeated access
Per review: pull `approverUserId` into the `opts` destructure in requestApproval,
and `approver` out of `policy` in the gate, instead of accessing the property
twice. (policies.ts already binds args.* to locals.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 6e475e5503 chore(agent-to-agent): drop self-explanatory comments
Remove redundant doc/inline comments where the code speaks for itself; keep only
the non-obvious notes (return-vs-throw consume, ghost-gate cleanup, caller-does-
auth, reject-handled-elsewhere, stored-vs-click payload). Also drops a couple of
now-stale "target admin" descriptions. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 0f8499b141 refactor(agent-to-agent): drop set-time admin check on policy approver
With payload-based click-auth (clicker === approver), the approver no longer
needs to be a group admin — the operator (operator-only command) designates
whoever should approve, and only that user (or an owner) can resolve the card.
Removes the now-redundant hasAdminPrivilege validation and its import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 82e1dc4ae8 feat(approvals): authorize by approver named in payload; policy approver may be source or target
Per review (no new pending_approvals column): the gate carries `approver` inside
the existing approval `payload`, and isAuthorizedApprovalClick authorizes the
named approver (or an owner/global admin) when an approval names one — reading
the real value at click time, no group re-derivation.

- `ncl policies set --approver` validates the user is an admin/owner of the
  source OR target.
- Drops `approverAgentGroupId` and the agent_group_id stamp; `requestApproval`
  keeps `approverUserId` only for delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper e70b021cde refactor(agent-to-agent): destructure payload in applyA2aMessageGate
Per review: destructure the approval payload once instead of repeating
`payload.x`, and narrow `platform_id` up front so it's used directly (drops the
separate `targetAgentGroupId` local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper ea90a12846 feat(agent-to-agent): make policy approver mandatory
Per review, the policy approver is now required, not optional. Every policy
names one specific admin/owner of the target who approves.

- `approver` column is NOT NULL; `AgentMessagePolicy.approver` is non-nullable.
- `ncl policies set --approver <user-id>` is required and validated to be an
  admin/owner of the target.
- The gate always delivers the card to `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3b1f4501d6 feat(agent-to-agent): optional single approver per policy
Per review, add an optional `approver` to a policy: a specific admin/owner of
the target who receives the approval card (instead of all target admins). NULL
keeps the default (all target admins/owners).

- `approver` column on agent_message_policies; carried on AgentMessagePolicy.
- `ncl policies set --approver <user-id>` validates the user is an admin/owner
  of the target at set-time, so the existing click-auth gate is unchanged.
- `requestApproval` gains `approverUserId` (single) to deliver the card to that
  one user; the gate passes `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 385fb014fc refactor(agent-to-agent): split content parsing out of buildGateQuestion
Address PR review: extract `parseMessageContent` (text + attachment names from
the message content JSON) so `buildGateQuestion` reads as pure formatting, and
name the body-length cap (`GATE_CARD_BODY_MAX`) instead of a bare 1500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b2160a56aa refactor(agent-to-agent): drop named-approver list from v1
Address PR review: remove the `approvers` option entirely for v1 — the
approver is always the target group's admins/owners. Drops the `approvers`
DB column, the `--approvers` flag + its set-time validation, the now-unused
`approverUserIds` param on requestApproval, and the related tests. The
target-scoped approver pick (`approverAgentGroupId`) stays. Named approvers
can be re-added later via a migration when needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper f72658bb50 refactor(agent-to-agent): extract sourceAgentGroupId in routeAgentMessage
Address PR review: hoist session.agent_group_id into a named local
`sourceAgentGroupId`, mirroring `targetAgentGroupId`, and use it throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3180f3f881 chore(agent-to-agent): trim comments to match repo convention
Shorten the verbose doc/inline comments added with the approval-policy gate
down to terse one-liners, matching the surrounding style (e.g. agent-destinations,
write-destinations). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b0bdc57b37 refactor(agent-to-agent): align policy files with resource conventions
- policies.ts: drop the 10-line top banner. Sibling resource files carry no
  descriptive header (only destinations.ts, and only for a non-obvious
  side-effect); the prose already lives in the resource `description`.
- agent-message-policies.ts: remove `listMessagePolicies` — no production
  caller (the `ncl policies list` op uses the generic table-based CRUD); only
  its own test referenced it.
- message-gate.test.ts: assert the upsert-no-duplicate invariant via a direct
  row count instead of the removed helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Moshe Krupper 314b91efc0 feat(agent-to-agent): per-message approval policies on connected agents
Add an optional, directed, per-message require-approval gate on top of an
existing agent-to-agent connection. No policy = today's free flow (fully
backward compatible). When a policy exists for A→B, each message A sends to B
is held, an approval card showing the message goes to B's admins, and the
message is delivered on approve / declined on reject. Rejecting one message
never blocks the connection.

- New `agent_message_policies` table (directed from→to; row exists = require
  approval; `approvers` JSON, NULL = target admins). Deleted alongside its
  connection so a stale rule can't reactivate on re-wire.
- Gate inside `routeAgentMessage` after the self/`hasDestination` checks:
  holds the message via `requestApproval` and returns to consume it (like a
  system action); the held message rides in the approval payload and is
  re-routed by `applyA2aMessageGate` on approve. Self/internal messages are
  never gated.
- `requestApproval` gains `approverAgentGroupId` / `approverUserIds` and stamps
  `agent_group_id` on the pending row so the target's admins pass the
  click-auth gate.
- `ncl policies list/set/remove`, operator-only (not in the container cli_scope
  allowlist); `set` validates named approvers are admins/owners of the target.

Reuses the existing requestApproval / pending_approvals / approval-handler
spine (same shape as create_agent). Host-only; no container changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Amit Shafnir 53ed3b77c9 fix(setup): allow env-selected agent provider 2026-06-18 17:49:13 +03:00
Daniel M 070714ec58 Merge pull request #2810 from nanocoai/refactor/agents-claude-symlinks
refactor: mirror .claude skills + CLAUDE.md into .agents via symlinks
2026-06-18 17:11:26 +03:00
exe.dev user e4907c2c33 refactor: mirror .claude into .agents via symlinks
.agents/skills -> ../.claude/skills and AGENTS.md -> CLAUDE.md so the
agents-convention paths resolve to the canonical ones. Drops the
host/skills indirection in favor of .claude as the single source of truth.
2026-06-18 14:03:38 +00:00
gavrielc 9a142302df Merge branch 'main' into feat/add-clidash-skill 2026-06-18 09:18:10 +03:00
github-actions[bot] 3f39f57653 chore: bump version to 2.1.18 2026-06-18 06:15:49 +00:00
gavrielc 1b86950f10 Merge pull request #2803 from sturdy4days/refactor/remove-dead-resolvegroupipcpath
refactor: remove dead resolveGroupIpcPath
2026-06-18 09:15:36 +03:00
gavrielc 8b435eb02d Merge branch 'main' into refactor/remove-dead-resolvegroupipcpath 2026-06-18 09:15:23 +03:00
gavrielc 7e2004f945 Merge pull request #2806 from arkjun/docs/add-korean-readme
docs: add Korean README
2026-06-18 09:14:52 +03:00
gavrielc 63901d1bde Merge branch 'main' into docs/add-korean-readme 2026-06-18 09:14:35 +03:00
gavrielc e5d96e348f Merge pull request #2805 from amit-shafnir/fix/setup-token-pty-parsing
fix(setup): parse Claude OAuth token from wrapped PTY capture
2026-06-18 09:12:56 +03:00
Juntai Park 439c24f1b7 docs: link Korean README in language switchers 2026-06-18 11:21:51 +09:00
Juntai Park 2a144bb8d6 docs: add Korean README 2026-06-18 11:21:50 +09:00
Amit Shafnir 197faaaa14 fix(setup): parse Claude OAuth token from wrapped PTY capture
`claude setup-token` runs under script(1) so the browser OAuth flow keeps a
TTY while we capture the printed token. On terminals that wrap long lines
(e.g. sbx), the token lands split across lines with padding spaces, and the
old parser — which stripped only ANSI codes and newlines — matched just the
first fragment and failed the trailing `AA` check. Login succeeded; only our
parse of the human-oriented output failed (`No sk-ant-oat…AA token found`).

Add setup/lib/captured-token.ts: normalize the capture (strip ANSI/control
bytes and all whitespace, un-wrapping the token) then extract. The TS caller
(claude-assist.ts) and the bash registration script now share it, so the
normalization rules can't drift. Placeholder lines like
`export CLAUDE_CODE_OAUTH_TOKEN=<token>` are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:20:21 +03:00
sturdy4days 3ffd6dde00 refactor: remove dead resolveGroupIpcPath
resolveGroupIpcPath has no production callers (only its own test); IPC was
removed in the v2 architecture (host<->container communicate solely via the two
session DBs). Drop the function, the now-unused DATA_DIR import, and its tests.
2026-06-17 15:19:29 -04:00
leetwito e856e924a5 feat: add /add-clidash — read-only CLI-derived dashboard skill
clidash is 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 plus docker, with message-activity charts, a
log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.

Packaged as a utility skill per CONTRIBUTING.md: code under the skill dir,
copied into tools/clidash on install. No edits to NanoClaw src, no new deps.
2026-06-17 15:40:39 +03:00
github-actions[bot] ee7f891698 docs: update token count to 196k tokens · 98% of context window 2026-06-16 11:15:10 +00:00
github-actions[bot] 7fde348e2b chore: bump version to 2.1.17 2026-06-16 11:15:04 +00:00
Gabi Simons 122135e6dc Merge pull request #2759 from assapin/fix/budget-error-surfaced-to-user
fix(agent-runner): deliver budget/billing error turns instead of dropping them
2026-06-16 14:14:48 +03:00
Gabi Simons 8563fb0681 Merge remote-tracking branch 'origin/main' into fix/budget-error-surfaced-to-user
# Conflicts:
#	CHANGELOG.md
2026-06-16 11:35:45 +03:00
omri-maya 0155ab1943 Merge pull request #2775 from nanocoai/docs/onecli-gateway-upgrade-notice
docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
2026-06-16 09:55:25 +03:00
Koshkoshinsk d1f94fcd24 docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
The breaking notice said the onecli setup step enforces the pinned versions, which is only true for fresh installs — on an existing install, updating does not upgrade the running gateway. Clarify that the gateway is separate: /update-nanoclaw upgrades it when the pin moves, otherwise upgrade manually per docs/onecli-upgrades.md.
2026-06-15 20:25:42 +03:00
gavrielc dd60983f7f Merge pull request #2774 from nanocoai/feat/update-nanoclaw-onecli-pin
feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
2026-06-15 20:09:01 +03:00
Koshkoshinsk 096b8bf589 feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
When an update moves the onecli-gateway/onecli-cli pin in versions.json, the running gateway must be upgraded to match — otherwise the new code's @onecli-sh/sdk calls fail (404 on /v1/agents) and agents can't spawn. update-nanoclaw never detected this, so the upgrade was silently skipped. Add a conditional step that follows docs/onecli-upgrades.md before restart when the pin moves.
2026-06-15 19:37:23 +03:00
Gabi Simons 59c4d33adc Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 17:42:01 +03:00
omri-maya 5f5c28d18d Merge pull request #2773 from nanocoai/docs/codex-fix-docs
docs(add-codex): drop redundant TTY warning in auth note
2026-06-15 16:04:28 +03:00
Koshkoshinsk b92d1f9343 docs(add-codex): drop redundant TTY warning in auth note
The 'don't run via `!` prefix or Bash tool' sentence was redundant with
the leading 'Run this in a separate, real terminal — it is interactive.'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:32:04 +03:00
Gabi Simons e03c5c194a Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 12:17:20 +03:00
Daniel M acbb1144b7 Merge pull request #2769 from nanocoai/docs/codex-interactive-host-restart
docs(add-codex): flag interactive auth step + add host-restart step
2026-06-15 02:24:06 +03:00
Koshkoshinsk 028897f38f docs(add-codex): flag interactive auth step + add host-restart step
- Authenticate: run in a separate real terminal, not Claude Code's `!`
  prefix or an agent Bash tool — the provider-auth picker + browser/device
  login need an interactive TTY, so those prompts stall otherwise (CDX-002).
- add a "Restart the host" step after the image rebuild so the host
  reloads Codex's /home/node/.codex mount + env; skipping it left the dir
  root-owned and the container hit EACCES writing config.toml (CDX-003).

Refs CDX-002, CDX-003.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:58:30 +03:00
gavrielc ac0a799cbf refactor(add-codex): install Codex CLI via cli-tools.json, not the Dockerfile
adfae67 moved the agent's global Node CLIs into container/cli-tools.json so a
skill adds one with a json-merge instead of editing the Dockerfile. The Codex
provider install was left behind — add-codex.sh still awk'd an ARG + RUN into
the Dockerfile and its test guarded that shape.

Migrate add-codex to the seam:
- add-codex.sh appends { name: "@openai/codex", version } to cli-tools.json
  (idempotent json-merge); install/idempotency gates read the manifest.
- SKILL.md / REMOVE.md document the manifest append/removal, not Dockerfile edits.
- codex-dockerfile.test.ts -> codex-cli-tools.test.ts, asserting the manifest
  entry (skips when the manifest is absent, e.g. the bare providers branch).

Pairs with the providers-branch commit that drops the codex Dockerfile lines,
renames the payload test, and points the setup install-check at the manifest.

Verified end-to-end: full add-codex install into a clean worktree leaves the
Dockerfile codex-free, the manifest correctly appended and idempotent; vitest
cli-tools.test.ts (6) and bun codex-cli-tools.test.ts (2) green; host tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:40:44 +03:00
github-actions[bot] e3986eb58c chore: bump version to 2.1.16 2026-06-14 18:29:28 +00:00
github-actions[bot] 6d0d48d585 docs: update token count to 195k tokens · 98% of context window 2026-06-14 18:29:25 +00:00
gavrielc a142c496f7 Merge pull request #2756 from nanocoai/provider-selection
feat(providers): operator-driven provider selection, switching, and memory migration
2026-06-14 21:29:12 +03:00
gavrielc c5b4d11536 Apply suggestion from @gavrielc 2026-06-14 21:16:19 +03:00
Daniel M ed8b4149e7 Merge pull request #2764 from glifocat/docs/fix-claude-md-relocated-paths
docs(CLAUDE.md): fix two relocated Key Files paths
2026-06-14 18:13:31 +03:00
glifocat d5ce02d1b8 docs(CLAUDE.md): fix two relocated Key Files paths
The Key Files table and the Secrets/OneCLI section referenced
src/onecli-approvals.ts and src/user-dm.ts, but both files were moved
under src/modules/ (src/modules/approvals/onecli-approvals.ts and
src/modules/permissions/user-dm.ts). onecli-approvals.ts is already
cited at its correct new path elsewhere in the same doc, so this was a
partial-rename miss. Docs only — no code changes.

Closes #2763

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:01:40 +02:00
omri-maya c8af599944 Merge branch 'main' into provider-selection 2026-06-14 15:17:13 +03:00
github-actions[bot] 435233a062 chore: bump version to 2.1.15 2026-06-14 11:04:33 +00:00
gavrielc 785fce3754 Merge pull request #2758 from nanocoai/feat/cli-tools-manifest
feat(container): data-drive global CLI installs from cli-tools.json
2026-06-14 14:04:16 +03:00
assafpin 01433bae32 fix(agent-runner): deliver budget/billing error turns instead of dropping them
A turn that ends in a non-retryable provider error (e.g. an Anthropic
403 billing_error) comes back from the streaming SDK as a result with
is_error=true and no <message> envelope. dispatchResultText treated it
as scratchpad and dropped it, then the poll-loop pushed a re-wrap nudge
-> new turn -> same error, re-hammering the gateway until idle-kill. The
user saw silence.

- providers/claude.ts: surface is_error on the result event, and fall
  back to errors[] for the message text (error subtypes carry no result).
- poll-loop.ts: when a result has no <message> blocks and is_error, deliver
  the notice verbatim to the originating channel and skip the nudge.

Verified live (real agent image + SDK, 403 mock): the notice is delivered
to the channel and the retry loop is gone.

Refs #2751
2026-06-14 12:56:02 +03:00
Omri Maya 6d521a9d8d refactor(memory): scope imported-memory doctrine to /migrate-memory
The "read imported-agent-memory.md, treat it as binding" doctrine sat in the
memory definition that every group loads, but it only matters when an import
actually happened. Move it into the /migrate-memory skill — the step that
writes the imported file and its index pointer (which the agent inlines into
its prompt each turn) — and drop the always-on block from definition.md.

Addresses review feedback on #2756.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:12:41 +03:00
gavrielc adfae67611 feat(container): data-drive global CLI installs from cli-tools.json
The agent's global Node CLIs (claude-code, agent-browser, vercel) were each
a hardcoded ARG + RUN layer in the Dockerfile, so adding or bumping one meant
editing the Dockerfile — a code reach-in every tool-installing skill had to make.

Move the tool list into container/cli-tools.json. A skill now adds a CLI by
appending a {name, version} entry (a json-merge) — the safest change shape:
deterministic, idempotent, removable. install-cli-tools.sh parses the manifest
with node (no new jq dep), writes the per-tool only-built-dependencies opt-ins,
and runs one pinned `pnpm install -g`, so the pnpm supply-chain path is unchanged.

Behavior is byte-for-byte: same opt-ins, same pinned installs. agent-browser is
now pinned (0.27.1, what `latest` last resolved to) instead of floating.

container/cli-tools.test.ts guards the seam: red if a baseline tool is dropped,
a version unpins, or the Dockerfile wiring / pnpm path is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:07:14 +03:00
Omri Maya 13a37def89 feat(providers): operator-driven provider selection, switching, and memory migration
Make the agent provider a first-class, operator-chosen property instead of a
Claude-only assumption. Trunk gains the seams; the actual non-default payloads
(Codex first) install from the `providers` branch.

Setup
- A provider registry feeds a hard-wired setup picker (Claude | Codex). Picking
  a non-default provider installs its payload (setup/add-codex.sh, channel-style),
  runs a vault-only auth walkthrough (--step provider-auth), and records the pick
  on the first agent before its first spawn.
- Picking Claude changes nothing — default installs are byte-for-byte unaffected.

Provider as a DB property
- Provider lives on container_configs.provider (materialized to container.json,
  read by resolveProviderName). Creation stays provider-agnostic; the picked
  provider is applied via the picked-provider seam. The deprecated
  agent_groups.agent_provider path is not used.

Switching + memory
- Switch a live group with `ncl groups config update --provider` + restart.
- Memory never migrates at runtime — each provider keeps its own store. The
  /migrate-memory skill carries a group's memory across a switch in either
  direction (flat CLAUDE.local.md <-> memory/ scaffold). group-init seeds an
  imported-agent-memory note for non-default providers; the runner's memory
  definition reads it first turn. See docs/provider-migration.md.

No install-wide default, no runtime provider guard — switching is operator-by-
convention, consistent with the no-install-gating posture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:49:39 +03:00
github-actions[bot] 03382e9dd7 chore: bump version to 2.1.14 2026-06-13 13:05:30 +00:00
github-actions[bot] 9763551656 docs: update token count to 194k tokens · 97% of context window 2026-06-13 13:05:27 +00:00
gavrielc a9c9cb300d Merge pull request #2754 from nanocoai/oss/exchange-hook
feat(runner): onExchangeComplete provider hook + slash-command interruption
2026-06-13 16:05:14 +03:00
gavrielc a619fc1aa2 Apply suggestion from @gavrielc 2026-06-13 16:03:02 +03:00
Omri Maya 3d2f3e58ca feat(runner): onExchangeComplete provider hook + slash-command interruption
Inverts conversation archiving into an optional onExchangeComplete provider
hook: the runner never archives on a provider's behalf, and the markdown
writer ships with the provider that needs it. Dormant for the default
provider.

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:56:43 +03:00
gavrielc 11afc64ba4 Merge pull request #2747 from nanocoai/oss/onecli-sdk-v2
feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
2026-06-13 15:49:40 +03:00
github-actions[bot] 0ee75d393c chore: bump version to 2.1.13 2026-06-13 12:27:29 +00:00
github-actions[bot] 72b9cc7ed0 docs: update token count to 192k tokens · 96% of context window 2026-06-13 12:27:24 +00:00
gavrielc 5fcf234165 Merge pull request #2746 from nanocoai/oss/agent-surfaces
feat(providers): agent-surfaces capability seam
2026-06-13 15:27:12 +03:00
github-actions[bot] 9b1236505f chore: bump version to 2.1.12 2026-06-13 12:25:58 +00:00
github-actions[bot] 878cd68c1b docs: update token count to 191k tokens · 96% of context window 2026-06-13 12:25:52 +00:00
gavrielc fab1ebf2d6 Merge pull request #2745 from nanocoai/oss/memory-scaffold
feat(memory): opt-in persistent memory scaffold for providers
2026-06-13 15:25:39 +03:00
Omri Maya 3f9e89d345 feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
Injects credentials as request-time stubs so no credential is ever written
into a container or to disk. Gateway and CLI versions move to versions.json
(machine-checkable pins); breaking upgrades are documented in
docs/onecli-upgrades.md as an agent-executable runbook (detect / why / fix /
verify / rollback), and the update flow follows linked docs and diffs the
pins.

BREAKING: requires a gateway upgrade; the doc carries the steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:11 +03:00
Omri Maya 14810a5090 feat(providers): agent-surfaces capability seam
Host-side registry where a provider can declare, by capability rather than
by name, that it owns its agent surfaces (project doc, skills). Default
providers keep the standard surfaces; a surfaces-owning provider suppresses
them. Dormant until a provider registers — no change for existing installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:10 +03:00
Omri Maya 2cfa86e570 feat(memory): opt-in persistent memory scaffold for providers
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:09 +03:00
github-actions[bot] 36cbf17e10 chore: bump version to 2.1.11 2026-06-11 17:16:51 +00:00
gavrielc 4459ab2e54 Merge pull request #2739 from nanocoai/feat/raw-webhook-registry
feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
2026-06-11 20:16:33 +03:00
gavrielc 9e6238d28f Merge main (channel instances): keep both webhook suites as separate files
The instance route-split suite (from #2733) keeps src/webhook-server.test.ts;
this branch's raw-route suite moves to src/webhook-server-raw.test.ts —
incompatible lifecycle setups (fixed port + afterEach vs random port +
afterAll) make a single merged file wrong. webhook-server.ts auto-merge
verified: raw routes take dispatch priority, stop clears both maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:07:30 +03:00
github-actions[bot] d1bda5d15b chore: bump version to 2.1.10 2026-06-11 16:42:59 +00:00
gavrielc 7eddc7d8c9 Merge pull request #2738 from nanocoai/fix/write-outbound-direct-rw
fix(session-manager): writeOutboundDirect opens outbound.db read-only — command-gate denials never deliver
2026-06-11 19:42:39 +03:00
github-actions[bot] 991ef986f8 docs: update token count to 190k tokens · 95% of context window 2026-06-11 16:42:36 +00:00
github-actions[bot] 0f2557e2bc chore: bump version to 2.1.9 2026-06-11 16:42:32 +00:00
gavrielc 4e6552ed55 Merge pull request #2737 from nanocoai/feat/approval-resolved-hook
feat(approvals): approval-resolved callback registry — modules observe resolution additively
2026-06-11 19:42:12 +03:00
github-actions[bot] 978b998ee6 chore: bump version to 2.1.8 2026-06-11 16:41:56 +00:00
gavrielc 83951d7c01 Merge pull request #2736 from nanocoai/fix/host-sweep-wake-grace
fix(host-sweep): grace period for freshly-woken containers with stale processing claims
2026-06-11 19:41:38 +03:00
github-actions[bot] 76ef097521 chore: bump version to 2.1.7 2026-06-11 16:41:14 +00:00
gavrielc 1c85fd6e50 Merge pull request #2735 from nanocoai/fix/approval-card-actor-byline
fix(chat-sdk-bridge): record the acting user on resolved approval cards
2026-06-11 19:40:59 +03:00
github-actions[bot] 42275ede1f chore: bump version to 2.1.6 2026-06-11 16:40:40 +00:00
gavrielc 53e1989529 Merge pull request #2734 from nanocoai/feat/delivery-action-getter
feat(delivery): getDeliveryAction read side for the action registry
2026-06-11 19:40:20 +03:00
github-actions[bot] 6f2142d7c7 docs: update token count to 189k tokens · 95% of context window 2026-06-11 16:39:51 +00:00
github-actions[bot] 79a0226962 chore: bump version to 2.1.5 2026-06-11 16:39:41 +00:00
gavrielc 0b31695e92 Merge pull request #2733 from nanocoai/feat/channel-instances
feat(channels): native channel-instance dimension — multi-bot substrate
2026-06-11 19:39:19 +03:00
gavrielc 421f8707d2 Merge pull request #2741 from nanocoai/setup-handoff-kickoff-prompt
fix(setup): auto-submit handoff context as Claude's first prompt
2026-06-11 17:36:04 +03:00
gavrielc 67ccd9e74c fix(setup): auto-submit handoff context as Claude's first prompt
Interactive setup handoffs (mid-flow `?` escape and on-failure) spawned
claude with all context in --append-system-prompt and no user message,
so Claude sat at an empty REPL until the user re-explained themselves.

Move the context into a positional prompt that auto-submits as the
first user message: Claude starts orienting immediately, the context
stays visible in the transcript, and it survives --resume.

Also:
- Share one session across all handoffs in a setup run: pin a
  generated UUID via --session-id on the first spawn, --resume it on
  later ones (stdio is inherited, so Claude's own id is never visible).
- Switch --permission-mode from acceptEdits to auto.
- Dedupe the two spawn blocks into spawnInteractiveClaude().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:48 +03:00
gavrielc f69af07c57 feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
Add a RawWebhookHandler registry alongside the Chat SDK adapter routes
so modules can mount plain Node handlers at /webhook/{path} on the
shared server instead of editing webhook-server.ts or standing up a
second HTTP server on another port. Raw routes dispatch ahead of
adapter routes, handler throws surface as a 500, and stopWebhookServer
clears the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:58:51 +03:00
gavrielc 93a302b5db feat(approvals): add approval-resolved callback registry
Modules can already register to handle an approval (registerApprovalHandler),
but nothing lets a module observe that an approval was resolved — e.g. to
clear an "awaiting approval" status indicator it set when the card went out.
Today that observation is only possible by core importing module code.

Add registerApprovalResolvedHandler/notifyApprovalResolved to the approvals
primitive and fire it at the three resolution exits in the response handler
(reject, approve-with-no-handler, approve-after-handler). Callback errors are
logged and isolated so one bad callback never blocks resolution or other
callbacks. The hook only fires for authorized clicks (it sits behind the
isAuthorizedApprovalClick gate) and carries the namespaced user id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:54:21 +03:00
gavrielc eef285ba3b fix(session-manager): open outbound.db read-write in writeOutboundDirect
writeOutboundDirect opened the session's outbound DB through
openOutboundDb, which sets readonly: true. The INSERT it then runs threw
SQLITE_READONLY on every call, so the command-gate denial path
(router.ts) never delivered its 'Permission denied' response — the
sender just got silence, and the throw aborted routing for that inbound
event.

Switch to the openOutboundDbRw wrapper, which opens the same path with
write access (DELETE journal + busy_timeout). The host-side write to the
container-owned outbound.db is safe: both sides use DELETE journal mode,
and the even host seq stays out of the container's odd-seq space.

Adds a guard test that drives writeOutboundDirect against a real session
folder and asserts the denial rows land in messages_out with even seqs;
it goes red if the open call reverts to the readonly form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:54:17 +03:00
gavrielc a806534199 fix(host-sweep): grace period for freshly-woken containers with stale processing claims
The sweep tick that wakes a container for due messages also ran the
running-container SLA check in the same iteration. A fresh container that
inherits stale processing_ack rows from a previous crash hasn't had a chance
to run its startup cleanup (clearStaleProcessingAcks) yet, so the per-claim
stuck rule saw an hours-old claim, concluded the just-spawned container was
stuck, and SIGKILL'd it — an immediate spawn-kill loop.

Carry a justWoke flag from the wake step into the SLA gate and skip the
check for that one tick. The next tick (60s later) enforces the SLA
normally, so a genuinely stuck container is still killed.

Guarded by src/host-sweep-grace.test.ts, which drives two real sweep ticks
against on-disk session DBs: the wake tick must not kill, a later tick with
the claim still stale must kill claim-stuck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:53:15 +03:00
gavrielc 0ac8073e34 fix(chat-sdk-bridge): record the acting user on resolved approval cards
When a button on an approval/question card is clicked, the bridge edits
the card down to the title and the selected answer — but not who clicked
it. In shared channels every member sees the same resolved card, so the
audit trail of which user approved or rejected is lost the moment the
buttons disappear.

Append an actor byline (" — <userName>", falling back to fullName) to
the edited card markdown. The shared chat.onAction handler covers every
Chat SDK webhook platform; cards edited for actors with no resolvable
name stay byline-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:52:45 +03:00
gavrielc 539a2b3c63 feat(delivery): getDeliveryAction read side for the action registry
registerDeliveryAction had no read side, so module registrations could
not be verified through the registry itself. Add a getter beside it and
a guard test covering lookup, miss, and overwrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:52:00 +03:00
gavrielc fccaadf24c fix(channels,db): exact instance dispatch, FK-check scoping, migration-safe skill snippets
Review-round fixes on the instance dimension:
- delivery/typing resolve adapters by exact registry key, never the
  channelType fallback — a named instance with an offline adapter gets
  offline handling, not a cross-identity send through a sibling bot;
  the fallback scan (channelType-only callers) now warns when it
  resolves through a differently-keyed instance
- migration runner only fails on FK violations a migration introduced:
  pre-existing latent orphans (FK-OFF CLI surgery) are logged and
  carried, not turned into a boot crash-loop
- typing re-trigger updates the full address (channelType, platformId,
  threadId, instance) together — no torn entries on agent-shared
  sessions spanning instances
- bridge rejects empty/whitespace instance names (URL-route and
  state-namespace safety)
- add-github / add-linear SKILL.md wiring inserts include the NOT NULL
  instance column
- drop the 10s same-platform boot stagger: operational policy, not
  substrate — reintroducible skill-side for gateway-mode installs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:43:18 +03:00
Hinotobi 0516bea638 Merge branch 'main' into fix/approval-cli-caller-context 2026-06-11 11:29:11 +08:00
github-actions[bot] 3329270c67 docs: update token count to 185k tokens · 93% of context window 2026-06-10 20:02:28 +00:00
Daniel M f16ea0c783 Merge pull request #2719 from amit-shafnir/feat/uninstall-script
feat: add uninstall.sh — per-copy uninstaller with confirmation, dry-run, and OneCLI agent cleanup
2026-06-10 23:02:11 +03:00
gavrielc 1c024bc976 docs: document the channel-instance dimension
- CLAUDE.md entity model: instance on messaging_groups.
- db-central.md: updated messaging_groups DDL (instance NOT NULL, triple
  UNIQUE, denied_at), instance semantics (default = channel_type via
  migration 016 backfill; inbound exact-on-instance, outbound
  default-first), and the user_dms per-platform (not per-instance)
  cold-DM note.
- architecture.md: same DDL update in the schema appendix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:07:22 +03:00
gavrielc 6c26f3ef08 feat(host): thread the channel instance through router, delivery, and typing
Inbound: src/index.ts onInbound stamps `instance: adapter.instance ??
adapter.channelType` — the single host-side stamping seam; adapters stay
instance-blind and onInboundEvent (CLI) passes events through unchanged.
The router resolves the thread-policy adapter and the messaging group by
the receiving instance (exact-only — an unknown named instance auto-creates
its own group, persisting the instance, instead of hijacking a sibling's
row).

Outbound: ChannelDeliveryAdapter.deliver/setTyping grow a trailing
`instance` param (host-internal interface only — messages_out, destinations
and session_routing schemas are untouched; containers never see instance).
deliverMessage resolves the messaging group ORIGIN-SESSION-FIRST, so a
named instance's session replies through its own adapter even when a
sibling default row shares the same (channel_type, platform_id); dispatch
goes through getChannelAdapter(instance ?? channelType).

Typing: TypingTarget stores the instance and all three tick sites
(immediate, 4s interval, re-trigger) forward it, so the indicator fires
through the bot that owns the chat.

Also updates a raw-SQL fixture in groups.test.ts for the NOT NULL instance
column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:05:50 +03:00
gavrielc ab6ab6936c feat(channels): per-instance Chat SDK state namespaces and webhook routes
ChatSdkBridgeConfig gains `instance`. The bridge keeps channelType =
adapter.name (semantic platform identity is untouched) and threads the
instance into three places:

- Registry identity: bridge.name / bridge.instance follow config.instance.
- Chat SDK state: SqliteStateAdapter takes an optional namespace and
  prefixes every key at a single choke point (k()). All bridges share the
  chat_sdk_* tables and two same-platform instances see identical
  thread/message ids — without the namespace, the SDK's
  dedupe:${adapter.name}:${message.id} key makes the second bot silently
  drop every message the first processed, locks serialize across bots, and
  subscriptions leak engagement. The namespace applies ONLY when instance
  is set AND differs from adapter.name: the default instance stays on the
  legacy UNPREFIXED keyspace byte-identically, so live installs' existing
  subscriptions/kv/locks/lists rows are never orphaned. enqueue does not
  prefix (appendToList does) — layout is ns:queue:<tid>; acquireLock
  returns the raw threadId and release/extend re-apply k() at their SQL
  sites.
- Webhook route: registerWebhookAdapter(chat, adapterName, routingPath =
  adapterName) splits the URL segment from the chat.webhooks handler key,
  so each same-platform instance gets its own URL (and signing secret).
  Signature adopted verbatim from PR #2617 (credit @davekim917's #1804
  prototype); the handler body needed zero change — dispatch already read
  entry.adapterName, not the route key.

Instance names are validated URL-safe (no '/', '?', ':' or whitespace) at
bridge construction: the route regex is [^/?]+ and ':' is the namespace
delimiter. The Chat instance's inner adapters map stays keyed adapter.name
(the SDK resolves adapters via channelId.split(':')[0] and serializes by
adapter.name) — instance identity lives entirely outside the Chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:57:33 +03:00
gavrielc 501afb4beb feat(channels): key the adapter registry by instance with channelType fallback
ChannelAdapter and InboundEvent gain an optional `instance` field — the
host-side routing identity for N adapters of one platform. channelType
stays the semantic platform key (user ids, formatting, container config).

Registry changes:
- activeAdapters keys by `adapter.instance ?? adapter.channelType`, so the
  default instance keeps today's channelType key byte-identically. A
  duplicate instance key warns loudly and overwrites (today's boot
  semantics, made visible).
- getChannelAdapter(key) resolves the exact instance key first, then falls
  back to the first-registered adapter of that channel type — channelType-
  only callers (cold DMs, user-id prefix resolution, approval delivery)
  still resolve deterministically when every instance of a platform is
  named.
- initChannelAdapters staggers same-channelType setups by 10s so two
  gateway bots of one platform don't identify simultaneously from one IP.
  Inert when no two registrations share a channelType.

No adapter sets `instance` today, so every existing install boots
identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:53:01 +03:00
gavrielc 9040dbb86e feat(db): add messaging_groups.instance with FK-safe recreate migration
Adds the channel-instance dimension to the schema: an `instance` column
(NOT NULL, default instance = channel_type) on messaging_groups, relaxing
UNIQUE(channel_type, platform_id) to the triple so N adapter instances of
one platform can each own a row per chat.

SQLite can't relax a table-level UNIQUE in place, and DROP TABLE fails FK
integrity on live DBs with child rows (the failure that forced migration
011 to abandon its rebuild) — so the migration runner grows an opt-in
`disableForeignKeys` flag: foreign_keys=OFF around the transaction (the
pragma is a no-op inside one), PRAGMA foreign_key_check inside it so a
violating recreate rolls back atomically.

Query semantics (deliberately asymmetric, both documented):
- getMessagingGroupWithAgentCount (router fast path): exact-on-instance,
  no fallback — an unknown named instance returns null so the router
  auto-creates a per-instance group instead of hijacking a sibling's row.
  Default param (= channelType) keeps existing callers identical.
- getMessagingGroupByPlatform (outbound/cold-DM/setup): unset instance
  resolves default-instance-first with a deterministic ORDER BY; set
  instance is exact-only.

Existing rows are backfilled instance = channel_type, so single-instance
installs see zero behavior change and need no operator action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:49:02 +03:00
Amit Shafnir d8748e3a45 fix: address uninstaller review findings
- .env backup and removal are now one atomic action: a failed backup
  throws into executePlan's catch and the deletion never runs (the bash
  original's set -e gave the same guarantee; the port had lost it)
- containers are re-listed by install label at removal time instead of
  removed from scan-time ids — the live host can spawn containers during
  the confirm phase
- uninstall telemetry no longer creates data/install-id (persistId:false
  on emit), so --dry-run truly changes nothing and the already-clean
  exit can fire
- runtime-tail failure notes are printed before the Done line instead
  of being discarded
- uninstall.sh translates the old short flags (-n/-y) instead of
  silently dropping them (-n used to fall through to a real interactive
  uninstall)
- nanoclaw.sh gates the TS uninstaller on node (tsx's interpreter), not
  pnpm, which the direct-exec path never uses
- detectExistingInstall also checks the system-level systemd unit
- a delete-onecli-agent spawn failure now notes the manual command
  instead of claiming the agent was already gone
- setupLog.userInput is skipped when logs/ is absent so the uninstall
  doesn't recreate it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
Amit Shafnir 41a720dd59 feat: port uninstaller to TS, wire nanoclaw.sh --uninstall, detect existing installs in setup
Replaces the standalone bash uninstall.sh with a TypeScript flow inside the
setup driver (setup/uninstall/): scan (slug-scoped inventory), plan (pure
ordered removal actions), remove (per-action executor that absorbs failures
into notes), and flow (clack UI). uninstall.sh is now a 3-line pointer that
execs nanoclaw.sh --uninstall.

- nanoclaw.sh --uninstall short-circuits before diagnostics/bootstrap; with
  no node_modules it prints manual cleanup commands and exits 1
- setup:auto routes --uninstall before initProgressionLog so an uninstall
  never resets logs/setup.log
- fresh setup runs detect an existing install (service registration or
  data/v2.db) and offer keep-and-continue (default) or uninstall-and-exit;
  suppressed on fail()-retry and sg re-exec resumes
- self-deletion safety: static imports only, dist/ + node_modules/ removed
  dead last, nothing but console.log after the runtime tail
- --yes never deletes orphan ag-* vault agents; their manual delete
  commands (by vault uuid) are printed instead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
Amit Shafnir 6ae83f48ac feat: add uninstall.sh — per-copy uninstaller with confirmation, dry-run, and OneCLI agent cleanup
Removes only what belongs to this checkout (slug-scoped): background
service, containers + image, data/, logs/, groups/, ncl symlink, and
this copy's OneCLI vault agents. Shared tools (OneCLI app, credentials,
other copies) are left alone. Interactive per-group confirmation with
--dry-run and --yes modes; .env is backed up before removal.

Documented in README FAQ and the CLAUDE.md key-files table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
gavrielc dc34ceb83d Merge pull request #2721 from nanocoai/docs/skills-model
docs: customizing intro, skills model, and skill guidelines
2026-06-10 11:41:40 +03:00
gavrielc ad3dfad3f5 docs: align CONTRIBUTING and README with the registry-branch install model
CONTRIBUTING still described feature skills as installed by merging a
skill/* branch, a design the shipped skills no longer use: /add-slack,
/add-telegram and the rest install by additive fetch from the channels
and providers registry branches (git fetch + git show per file), with
registration tests and a REMOVE.md. Rewrite the skill-type section to
match, point the authoring bar at docs/skill-guidelines.md, fix the
README FAQ line that sent every contribution to the registry branches,
and delete docs/skills-as-branches.md (the superseded merge-based
design, including a marketplace flow that was never the shipped path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:27:51 +03:00
gavrielc 0bdc6d2bb2 docs: customizing intro, skills model, and skill guidelines
Three public docs establishing the skills-based customization contract:

- docs/customizing.md: the short doorway. The problem (merge fights on
  update), the idea (every change is a skill), how to work (edit first,
  skillify after), the one rule (/update-nanoclaw, never raw git pull),
  and the two-sided deal.
- docs/skills-model.md: the full model. Recipes, skill anatomy, the
  two kinds of skills, registry branches (additive fetch, never merge),
  a test for every integration point, upgrading, migrations and the
  startup tripwire, the maintainer commitments, and the registry
  review rule.
- docs/skill-guidelines.md: the authoritative checklist for writing a
  skill. Two principles (minimal integration surface; a test per
  functional integration point), anatomy, change shapes, testing
  doctrine with archetypes, anti-patterns, worked examples.

Also: CLAUDE.md docs index rows for the three docs, and .gitignore
entries for local-only working artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:12:17 +03:00
github-actions[bot] 820cd8ece6 docs: update token count to 185k tokens · 92% of context window 2026-06-09 19:31:53 +00:00
github-actions[bot] e44d497cdf chore: bump version to 2.1.4 2026-06-09 19:31:49 +00:00
gavrielc ac37ecbfd6 Merge pull request #2720 from nanocoai/security/authorize-create-agent
security: authorize create_agent host-side (approval for confined groups)
2026-06-09 22:31:36 +03:00
gavrielc c6627d32e2 security: authorize create_agent host-side (approval for confined groups)
create_agent writes central-DB state (agent_groups, container_configs,
agent_destinations) and scaffolds host filesystem state, but the only
gate lived inside the untrusted container and is bypassed by writing the
outbound system row directly (the "host re-checks permission" comment was
false). Authorize host-side by CLI scope: trusted owner agent groups
(global scope) create sub-agents directly; confined groups require admin
approval via requestApproval. Adds regression tests for the branch.

Alternative to #2383 (which denies confined groups outright); co-authored
from that work.

Co-Authored-By: hinotoi-agent <paperlantern.agent@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:29:57 +03:00
github-actions[bot] 51bf403b22 chore: bump version to 2.1.3 2026-06-09 19:29:33 +00:00
github-actions[bot] 265953ffec docs: update token count to 184k tokens · 92% of context window 2026-06-09 19:29:29 +00:00
gavrielc 6227bd1a5b Merge pull request #2478 from Hinotoi-agent/security/approval-response-admin-authz
[security] fix(approvals): require admin for approval responses
2026-06-09 22:29:07 +03:00
gavrielc 28032bc0ec Merge pull request #2468 from Hinotoi-agent/security/a2a-attachment-symlink-guard
[security] fix(agent-route): reject unsafe forwarded attachments
2026-06-09 22:29:03 +03:00
github-actions[bot] 3e3a2945a5 chore: bump version to 2.1.2 2026-06-09 18:04:39 +00:00
gavrielc f3fc18e56e chore: bump claude-code to 2.1.170 and agent SDK to 0.3.170
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:04:13 +03:00
github-actions[bot] d85efea229 chore: bump version to 2.1.1 2026-06-08 12:10:39 +00:00
github-actions[bot] c5b22cb308 docs: update token count to 183k tokens · 92% of context window 2026-06-08 12:10:36 +00:00
gavrielc 1592369201 Merge pull request #2713 from nanocoai/feat/egress-lockdown
feat(security): egress lockdown (opt-in, off by default)
2026-06-08 15:10:22 +03:00
Omri Maya 6420c0e254 feat(security): egress lockdown (opt-in) — agent egress only via OneCLI
Place agent containers on a Docker `--internal` network (no internet route)
with the OneCLI gateway attached, aliased host.docker.internal. The injected
proxy URL resolves only to the gateway, so a non-proxy-aware client or raw
socket has nowhere to go — closing the HTTPS_PROXY-bypass hole. The agent is
non-root with no NET_ADMIN, so it cannot undo this. Self-healing: the gateway
is re-attached at every spawn and on each host-sweep tick.

Fail-fast: when lockdown is enabled but the network/gateway can't be
established, refuse to spawn and surface a clear EgressLockdownError rather
than silently falling back to open egress. The host-sweep re-heal is the lone
exception — a heal failure there is logged, not fatal, since running agents
stay on the internal net (no leak) until the gateway returns.

Off by default — opt in with NANOCLAW_EGRESS_LOCKDOWN=true (so OSS users get
the prior behavior unchanged on pull). Also NANOCLAW_EGRESS_NETWORK and
ONECLI_GATEWAY_CONTAINER.

The lockdown logic lives in its own src/egress-lockdown.ts; container-runtime.ts
keeps only the generic runtime surface. Documented in docs/SECURITY.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:23:17 +03:00
gavrielc aef8d38b36 Merge pull request #2710 from markbala/docs/ollama-prefix-cache
docs(ollama): allow prompt caching by filtering the cache-busting hash
2026-06-07 23:21:45 +03:00
gavrielc 6d6f813deb Merge branch 'main' into docs/ollama-prefix-cache 2026-06-07 22:01:26 +03:00
markbala f9c86d0af2 docs(ollama): allow prompt caching by filtering the cache-busting hash
The Claude Agent SDK adds a per-request cch=<hash> to the front of every
prompt; it changes each turn, and Ollama's prompt cache only reuses a
prompt whose start is unchanged, so it re-reads the whole prompt every
time (slow). A tiny proxy filters the hash out (pins cch to a constant) so
caching kicks in. In our setup (31B on Apple Silicon) follow-up replies
went ~80s -> ~4s; numbers vary by model/hardware. Ollama ignores the hash,
so output is unchanged.

Scope: only the Claude-Code-CLI -> Ollama path; Codex/OpenCode emit no cch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 23:20:11 +08:00
github-actions[bot] 9edb33dd3a docs: update token count to 182k tokens · 91% of context window 2026-06-07 14:06:19 +00:00
gavrielc 8ba5261ae8 Merge pull request #2707 from nanocoai/feat/upgrade-tripwire
feat(upgrade): startup tripwire + upgrade marker
2026-06-07 17:06:03 +03:00
gavrielc 8c84dec8e9 Merge remote-tracking branch 'origin/main' into feat/upgrade-tripwire
# Conflicts:
#	.claude/skills/migrate-nanoclaw/SKILL.md
2026-06-07 17:05:24 +03:00
gavrielc 092487d7ad chore: release 2.1.0; guard auto-bump against deliberate version changes
Set package.json to 2.1.0 to match the CHANGELOG entry for the upgrade
tripwire (a [BREAKING] change warrants a minor bump). The startup
tripwire reads package.json as the source of truth, so this is the
version the gate will enforce.

bump-version.yml previously ran `pnpm version patch` on every push to
main, which would patch a deliberate 2.1.0 up to 2.1.1. It now skips the
auto-bump when the pushed commits already changed package.json
themselves. fetch-depth: 0 so the before/after diff has both tips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:03:02 +03:00
gavrielc 87850aa7f8 docs(changelog): release the upgrade-tripwire entry as 2.1.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:59:30 +03:00
gavrielc 526170fd47 feat(upgrade): add human-addressed guidance to tripwire banner
The startup tripwire message was written for a coding agent and gave a
human no direction — only the bare `set` override (which skips the
migrations the gate guards). Add one human-addressed stanza pointing to
/update-nanoclaw as the correct fix. The tested CODING AGENT block is
left byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:57:13 +03:00
gavrielc 2d9375531b Merge pull request #2698 from nanocoai/feat/skill-exemplars
Skills conformance: exemplars + fleet retrofit (upgrade-maintainable skills)
2026-06-06 20:16:24 +03:00
gavrielc cee19ad300 feat(skills): rewrite use-native-credential-proxy, add-ollama-tool, migrate-from-openclaw for v2
Three skills that were broken on v2 (branch-merge installs of stale branches,
SKILL.md-only with no shipped code, dead v1 schema targets) rewritten to the
additive standard:
- use-native-credential-proxy: a skill-owned .env credential proxy + one-line
  seam reach-in (behavior + wiring tests, REMOVE.md). Explicit OneCLI opt-out;
  the credential-home inversion is flagged in docs/skill-smells.md.
- add-ollama-tool: an atomic-chat-shaped MCP-tool skill — bun stdio server,
  container registration + wiring tests, idempotent REMOVE.md.
- migrate-from-openclaw: retargeted at v2 (data/v2.db, ncl, OneCLI SecretRef,
  v2 recurrence) with a transform unit test and a REMOVE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:09:12 +03:00
gavrielc 14dba9b73f feat(skills): MCP-tool / capability / operational conformance cleanup
Anatomy + correctness pass driven by the audit: REMOVE.md added to every
file-creating skill (gmail/gcal/rtk/mnemon/vercel/macos-statusbar/karpathy);
dead/fabricated reach-ins removed (mnemon's nonexistent OpenCode path and
migration-doc reach-in; migrate-from-v1's nonexistent scanForV1Patterns);
structural Dockerfile dep-tests where a CLI binary was unguarded; debug and
customize rewritten off stale v1 architecture onto v2 (data/v2.db, ncl,
two-DB sessions); update-skills + migrate-nanoclaw's branch-merge reapply
converted to additive re-runs; diff-against-past framing and non-step callouts
stripped throughout. Direct-DB / credential / telemetry smells flagged, not
actioned (see docs/skill-smells.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:59 +03:00
gavrielc 83c245722e feat(skills): provider conformance for opencode and codex
The provider-multipoint archetype: each skill now fetches and runs a
barrel-driven registration test in BOTH trees (host listProviderContainerConfigNames,
container listProviderNames) — pushed to origin/providers — instead of relying on
the shipped *.factory.test.ts, which imports the provider module directly,
self-registers, and stays green when a barrel line is deleted. Adds a structural
Dockerfile dep-test for codex's @openai/codex CLI binary, and a cross-runtime
REMOVE.md that reverses both barrels, the copied files, the dependency, and the
Dockerfile edits. Drops the grep-based "Verify" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:42 +03:00
gavrielc 2908ffccf7 feat(skills): channel-family conformance retrofit
All 15 channel skills brought to the slack/deltachat standard: SKILL.md fetches
and runs the behavior registration test (pushed to origin/channels) in a
"Build and validate" step; REMOVE.md rewritten to delete every copied file and
remove the channel's actual env vars + package (each was individually wrong —
e.g. discord falsely claimed "no package to uninstall"); the VERIFY.md
anti-pattern deleted across the fleet; REMOVE.md created for emacs and whatsapp.
linear drops its stale bridge-patch step and relies on the bridge default +
the wiring's engage mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:31 +03:00
gavrielc 28e814d70c chore(skills): drop 4 skills broken on v2 + fix dangling refs
claw, x-integration, add-parallel, and convert-to-apple-container target
removed v1 architecture (v1 DB schema, file-IPC) or install via a forbidden
branch-merge of a stale branch — they can't be made conformant and are retired.
Cleans up the references to them in README.md, docs/SPEC.md, CONTRIBUTING.md,
and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:09 +03:00
gavrielc ff22a2bcfb skill(add-dashboard): document that build guards the @nanoco dependency
The behavior test mocks @nanoco/nanoclaw-dashboard (its startDashboard binds a
real port), so the test alone passes with the dependency missing. The build
step is what catches a missing dep (TS2307 on the `await import(...)`), so the
validate step must run `pnpm run build` before the tests. Make that explicit so
it survives edits — a dependency install is an integration point that needs a
red-on-missing check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:09:45 +03:00
gavrielc 4578b58a0b skill(add-deltachat,add-slack): behavior registration test, implicit dep check
The registration tests now import the real barrel and assert the registry
contains the channel (not a structural source parse). Update the validate-step
prose accordingly: the test also goes red if the adapter package isn't
installed (the unmocked barrel import throws), so it implicitly verifies the
dependency-install step — and a structural check would falsely pass when the
barrel can't evaluate or the dep is missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:05:36 +03:00
gavrielc b23c25fa19 skill(add-slack): make conformant — integration test, drop VERIFY.md
Retrofits the Slack skill to the two core principles and establishes the
template for the Chat SDK channel family (discord, telegram, teams, gchat,
webex, linear, github, …), which all share this single-barrel-import shape:

- Fetch and run a new src/channels/slack-registration.test.ts (lives on the
  channels branch next to the adapter, copied in via git show). Structural
  barrel parse asserting the `import './slack.js';` line — the one reach-in
  that fires the adapter's top-level registerChannelAdapter. Hermetic (does
  not import @chat-adapter/slack); red-on-delete. SKILL.md gains a
  build+validate step that also notes the build leg guards the adapter's
  createChatSdkBridge core-API consumption.
- REMOVE.md now deletes the import line and rm's the adapter and its test
  (was a soft comment-out), and re-syncs .env to the container.
- Drop VERIFY.md — tests are the verification; its manual check is covered by
  Next Steps / webhook setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:56:09 +03:00
gavrielc c6afef1fe6 skill(add-deltachat): make conformant — integration test, drop VERIFY.md
Retrofits the existing deltachat channel skill to the two core principles
(minimal integration surface + a test for every functional integration point):

- Fetch and run a new src/channels/deltachat-registration.test.ts (lives on
  the channels branch next to the adapter, copied in via git show like the
  adapter itself). It guards the skill's one reach-in — the
  `import './deltachat.js';` line in the channel barrel that fires the
  adapter's top-level registerChannelAdapter. Structural barrel parse rather
  than importing it, so the native @deltachat/stdio-rpc-server isn't pulled
  into the host test process; the build leg covers that the import resolves.
  Red-on-delete of the barrel line. SKILL.md gains a build+validate step.
- REMOVE.md now deletes the import line and rm's the adapter and its test
  (was a soft comment-out), per the anatomy rule that remove reverses every
  change including copied test files.
- Drop VERIFY.md — tests are the verification; its manual log/connectivity/
  e2e checks were operational and already covered in Troubleshooting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:21:23 +03:00
gavrielc 3ddfbfabd2 feat(add-atomic-chat-tool): make conformant — integration tests + standalone remove
- Add red-on-delete integration tests for both reach-ins:
  - index.ts mcpServers registration (AST, container/Bun tree)
  - buildContainerArgs env-forward call (AST, host/Node tree)
- Extract env forwarding into src/atomic-chat-env.ts so the container-runner
  reach-in is a single call (minimal integration point)
- Drop the redundant providers/claude.ts allowlist edit — the allow-pattern is
  derived from registered MCP server names
- Move removal into a standalone REMOVE.md; SKILL.md reads as a self-contained,
  present-tense artifact
- Document the shared stderr logger reach-in as a known hotspot (registry candidate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:25:06 +03:00
gavrielc 6592e8461c feat(add-dashboard): conformant skill (minimal integration point + tests)
First exemplar of the skills upgradeability model, built example-first.
Giving the skill real tests surfaced — and the build caught — two silent
drifts that would break any adopter today.

What conformance means here:
- Minimal integration point: all startup logic lives in startDashboard() in
  the skill's own file; the edit to src/index.ts is a single colocated block
  (dynamic import + await call) in main() — no top-of-file import.
- Behavior test (dashboard-pusher.test.ts): real in-memory DB + real pusher
  + fake dashboard HTTP endpoint; asserts the /api/ingest snapshot on both
  the enabled and disabled paths.
- Wiring test (dashboard-wiring.test.ts): TS-AST assertion that index.ts
  dynamically imports the pusher and awaits startDashboard() colocated in
  main(), after DB init and before the boot-complete log — catches deletion
  AND misplacement, which a grep can't.
- Build catches drift: fixed imports of five DB modules that moved into
  src/modules/, and a stale g.container_config (now getContainerConfig()).

apply copies all three files and runs the tests; remove deletes them and the
single index.ts block. apply/remove stay markdown prose; the tests are the
verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:25:06 +03:00
gavrielc e734e5cddd feat(upgrade): startup tripwire + upgrade marker
Refuse to start unless this install reached the current version through a
sanctioned path (setup / update / migrate). A raw `git pull` that skips
migrations now fails loudly with a self-healing message instead of
silently breaking.

- src/upgrade-state.ts: marker at data/upgrade-state.json, getCodeVersion,
  isUpgradeCurrent, enforceUpgradeTripwire (fails closed on missing /
  corrupt / mismatched marker)
- src/index.ts: gate wired in at startup step 0.5, before DB init
- scripts/upgrade-state.ts: get/set CLI (also the override / recovery cmd)
- setup/service.ts, /update-nanoclaw, /migrate-nanoclaw: stamp on success;
  update/migrate also self-update their own skill first
- CHANGELOG [BREAKING] entry bridges existing installs via the skills'
  breaking-change check
- docs/upgrade-recovery.md: clearing the tripwire

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:02:12 +03:00
github-actions[bot] d14472142d chore: bump version to 2.0.76 2026-06-05 08:04:24 +00:00
gavrielc 0c1897ad12 fix: blank the secret_url path instead of /*
A bare * in the pre-filled secret_url path doesn't survive (the gateway
URL-encodes everything, so an unencoded * collapses to just /, which only
exact-matches the path /). Leave the path blank instead so the created
secret matches all of huggingface.co, not a single endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:04:06 +03:00
github-actions[bot] d16b24d5b4 docs: update token count to 181k tokens · 91% of context window 2026-06-05 07:56:50 +00:00
github-actions[bot] d0de64b999 chore: bump version to 2.0.75 2026-06-05 07:56:45 +00:00
gavrielc f3fde69536 fix: trim the upload-trace not-signed-in message
Drop "(host pattern pre-filled)" and "— no restart needed" from the HF
setup instructions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:56:30 +03:00
github-actions[bot] 20140c84be chore: bump version to 2.0.74 2026-06-05 07:55:13 +00:00
gavrielc 33c36842fa Merge pull request #2691 from nanocoai/upload-trace-gateway-setup-url
feat: show OneCLI's own setup URL when HF token is missing
2026-06-05 10:54:58 +03:00
gavrielc 0435736314 feat: show OneCLI's own setup URL when HF token is missing
The not-signed-in message hardcoded both a local and a hosted OneCLI
dashboard URL because the container can't tell which gateway it's behind.
But the gateway already tells us: a credential-less proxied request comes
back with the right URL in its error body —

  - credential_not_found → secret_url (pre-filled "new secret" form)
  - access_restricted     → manage_url (grant this agent access)
  - app_not_connected     → connect_url

Capture whoami's body + status (drop -f so the JSON survives the 401),
extract that URL, and present it. It's always the correct gateway, local
or hosted, with zero extra wiring. The secret_url's pre-filled `path`
defaults to the failing request path (/api/whoami-v2), so broaden it to
/* — otherwise the created secret wouldn't cover the upload endpoints.
Falls back to generic text when there's no gateway JSON to read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:48:02 +03:00
github-actions[bot] 384f9c29e3 chore: bump version to 2.0.73 2026-06-05 07:37:40 +00:00
github-actions[bot] aa8597acf8 docs: update token count to 181k tokens · 90% of context window 2026-06-05 07:37:35 +00:00
gavrielc 3ae4ba18c3 Merge pull request #2690 from nanocoai/fix-upload-trace-secret-mode-docs
fix: simplify HF token setup + correct secret-mode docs
2026-06-05 10:37:20 +03:00
gavrielc de88be8a7a fix: simplify HF token setup + correct secret-mode docs
The default OneCLI secret mode for auto-created agents is `all`, not
`selective` — a fresh agent created via ensureAgent({name, identifier})
comes back with secretMode "all", so matching vault secrets inject
automatically. Drop the now-unnecessary per-agent assignment step.

- upload-trace.ts: remove step 3 (set-secret-mode) from the not-authed
  message; creating the token and adding it to the vault is enough
- CLAUDE.md: trim the secret-mode gotcha to reflect `all` as the default
- init-onecli skill: replace stale `onecli start` (gone in 1.4.x) and the
  `ps aux | grep onecli` check with the real Docker Compose start path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:35:42 +03:00
github-actions[bot] b9141218ad docs: update token count to 181k tokens · 91% of context window 2026-05-31 20:17:59 +00:00
github-actions[bot] 341b5950e1 chore: bump version to 2.0.72 2026-05-31 20:17:55 +00:00
gavrielc 8cb4ed27ef Merge pull request #2648 from nanocoai/share-session-command 2026-05-31 23:17:43 +03:00
gavrielc 729cd8d2a6 feat: add /upload-trace command to upload session trace to Hugging Face
Adds a runner-handled /upload-trace slash command (admin-gated, like /clear)
that uploads the current session's Claude Code transcript to the user's own
private {hf_user}/nanoclaw-traces dataset, browsable in the HF Agent Trace
Viewer. The transcript is already in the format the viewer auto-detects, so
the command just locates the newest one and pushes it via the Hub commit API.

Auth is handled by the OneCLI gateway: curl goes out through the injected
HTTPS_PROXY, which adds the user's HF token — no credential ever touches
agent code. A missing/unassigned token yields a clear setup message.

- container/agent-runner/src/upload-trace.ts: isUploadTraceCommand() + uploadTrace()
- poll-loop.ts: recognize and handle /upload-trace in the runner
- command-gate.ts: admin-gate /upload-trace on the host
- upload-trace.test.ts: unit + integration coverage for the command

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:42:36 +03:00
github-actions[bot] 3601a8a1fe chore: bump version to 2.0.71 2026-05-28 19:41:34 +00:00
gavrielc 991969085e Merge pull request #2637 from nanocoai/bump-claude-code-2.1.154
chore: bump claude-code to 2.1.154 and claude-agent-sdk to 0.3.154
2026-05-28 22:41:19 +03:00
gavrielc 81d99e1dc9 chore: bump claude-code to 2.1.154 and claude-agent-sdk to 0.3.154
claude-code CLI 2.1.128 -> 2.1.154 (Dockerfile build-arg). agent-runner SDK 0.2.128 -> 0.3.154: the 0.3 major moved @anthropic-ai/sdk and @modelcontextprotocol/sdk from regular deps to peer deps, so add @anthropic-ai/sdk ^0.100.0 as a direct dep and raise @modelcontextprotocol/sdk to ^1.29.0. Regenerate bun.lock. Typecheck + agent-runner tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:35:34 +03:00
hinotoi-agent 32f067f5bb fix(cli): preserve caller context after approval 2026-05-25 19:51:54 +08:00
github-actions[bot] 24922593e3 docs: update token count to 179k tokens · 89% of context window 2026-05-25 08:41:50 +00:00
github-actions[bot] b142055a1f chore: bump version to 2.0.70 2026-05-25 08:41:46 +00:00
glifocat 0c5104df68 Merge pull request #2526 from glifocat/fix/2525-groups-delete-cascade
fix(cli): cascade dependent rows on groups delete (#2525)
2026-05-25 10:41:31 +02:00
github-actions[bot] cabc7c0f82 docs: update token count to 178k tokens · 89% of context window 2026-05-23 17:06:48 +00:00
github-actions[bot] 3e533413e5 chore: bump version to 2.0.69 2026-05-23 17:06:44 +00:00
gavrielc c76ecb43f8 Merge pull request #2597 from kartast/fix/db-malformed-self-restart
fix(agent-runner): exit on persistent inbound.db corruption errors
2026-05-23 20:06:33 +03:00
gavrielc 9dc9efa3bf Merge branch 'main' into fix/db-malformed-self-restart 2026-05-23 20:06:10 +03:00
github-actions[bot] 8f332e0f29 chore: bump version to 2.0.68 2026-05-23 17:05:03 +00:00
gavrielc 5443ca8b7f Merge pull request #2595 from IamAdamJowett/fix/transcript-rotate-age-zero-disable
fix(agent-runner): honor zero/negative transcript rotate-age override
2026-05-23 20:04:50 +03:00
gavrielc ecca637fb3 Merge branch 'main' into fix/transcript-rotate-age-zero-disable 2026-05-23 20:04:27 +03:00
github-actions[bot] 6a2e34463d chore: bump version to 2.0.67 2026-05-23 17:03:10 +00:00
gavrielc 4d92b6dd47 Merge pull request #2596 from IamAdamJowett/fix/formatter-test-drop-messages-envelope
test(agent-runner): update formatter test for dropped <messages> envelope
2026-05-23 20:02:59 +03:00
gavrielc 136cb4d198 Merge pull request #2598 from jonnychesthair-crypto/fix/load-claude-local-settingsources
fix: load per-group CLAUDE.local.md by adding 'local' to settingSources
2026-05-23 19:59:26 +03:00
jonnychesthair-crypto c727bb638c fix: load per-group CLAUDE.local.md by adding 'local' to settingSources
The agent-runner runs the Agent SDK with settingSources: ['project', 'user'], which omits 'local'. Per the SDK docs the 'local' source is what loads CLAUDE.local.md (the 'project' source loads CLAUDE.md). So every group's CLAUDE.local.md is silently never read, even though container/CLAUDE.md tells each agent to use it as per-group memory.

Closes #2185.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 02:45:51 +00:00
karta 3df30475ed fix(agent-runner): exit on persistent inbound.db corruption errors
The follow-up poll catches and logs SQLite errors but never recovers
from them. On Docker Desktop macOS, the kernel page cache for the
inbound.db bind mount can latch a torn snapshot mid-host-write (a known
virtiofs / gRPC-FUSE coherency issue), after which every fresh
openInboundDb() in the same process sees the same broken view and
emits 'database disk image is malformed' at the poll rate (2/sec).

Reopening the DB handle inside the container does not recover — only
a fresh container mount does. The fix: after CORRUPTION_STREAK_EXIT
consecutive corruption errors (~5s), log a clear message and
process.exit(75) so host-sweep respawns the container with a fresh
mount. Transient single torn reads are still tolerated.

- Add isCorruptionError() helper covering the three SQLite read-side
  corruption symptoms (disk image malformed, SQLITE_CORRUPT, file is
  not a database).
- Add streak counter scoped to processQuery's pollHandle so it resets
  on any successful or non-corruption error.
- Add unit tests for the matcher.

Refs the cross-mount invariants documented in db/connection.ts:11-18.
2026-05-23 10:10:09 +08:00
Adam df90f9952c test(agent-runner): update formatter test for dropped <messages> envelope
fe2e881b (#2556) removed the <messages> wrapper from formatChatMessages
so the Claude Agent SDK calls the API instead of emitting a synthetic
stub, but poll-loop.test.ts still asserted the wrapper. The test has
failed on every PR built against main since. Assert the current shape:
no envelope, one self-contained <message> block per message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:09:42 +10:00
Adam f00f8637a3 fix(agent-runner): honor zero/negative transcript rotate-age override
CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS=0 (or negative) is documented to
disable age-based rotation, but transcriptRotateAgeMs() routed it
into the same branch as an unset var and returned the 14-day default.
Sessions intentionally configured to stay long-lived were still
rotated at 14 days, causing unexpected resets and context loss.

Distinguish unset/non-numeric (default 14d) from an explicit
non-positive override (Infinity = disabled; size alone governs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:58:47 +10:00
github-actions[bot] 68448c40c0 chore: bump version to 2.0.66 2026-05-22 20:07:03 +00:00
gavrielc 30f2b6e553 Merge pull request #2553 from IamAdamJowett/feat/whatsapp-formatting-skill
feat(skills): add whatsapp-formatting container skill
2026-05-22 23:06:48 +03:00
github-actions[bot] cea78a7832 docs: update token count to 177k tokens · 89% of context window 2026-05-22 20:06:06 +00:00
gavrielc 650b0449fa Merge pull request #2556 from IamAdamJowett/fix/agent-runner-drop-messages-envelope
fix(agent-runner): drop <messages> envelope so claude-agent-sdk calls API
2026-05-22 23:05:51 +03:00
gavrielc 77b5ee4897 Merge branch 'main' into fix/agent-runner-drop-messages-envelope 2026-05-22 23:05:40 +03:00
gavrielc 4f63ef67a7 Merge pull request #2551 from claudiopostinghel/fix/add-whatsapp-qr-browser-wrapper
fix(add-whatsapp): correct removed --method refs, ship QR-browser wrapper
2026-05-22 23:02:51 +03:00
gavrielc 8901fcc23f Merge branch 'main' into fix/add-whatsapp-qr-browser-wrapper 2026-05-22 23:02:40 +03:00
gavrielc e794223968 Merge pull request #2558 from guyb1/main
fix(setup): correct OneCLI default URL from app to api subdomain
2026-05-22 22:58:45 +03:00
github-actions[bot] d9868449c2 chore: bump version to 2.0.65 2026-05-22 19:57:43 +00:00
gavrielc 0eef8fafdd Merge pull request #2566 from Hinotoi-agent/fix/channel-approval-target-authz
[security] fix(permissions): scope channel approval targets
2026-05-22 22:57:28 +03:00
gavrielc 1204440266 Merge pull request #2571 from ira-at-work/skill/add-rtk
feat: add add-rtk skill
2026-05-22 22:56:53 +03:00
github-actions[bot] bef362e324 docs: update token count to 176k tokens · 88% of context window 2026-05-22 19:54:16 +00:00
gavrielc 13eb53f64e Merge pull request #2586 from IamAdamJowett/fix/rotate-oversized-transcripts
fix(agent-runner): rotate oversized/old session transcripts before resume
2026-05-22 22:54:01 +03:00
gavrielc b6d5f76f87 Merge pull request #2592 from mmahmed/docs/add-teams-cli-path
docs(add-teams): document Teams CLI as an auto credentials path
2026-05-22 22:52:10 +03:00
gavrielc d2b63308a3 Merge branch 'main' into docs/add-teams-cli-path 2026-05-22 22:51:57 +03:00
gavrielc 5466109104 Merge pull request #2563 from kky/pr/setup-register-scope
fix(setup-register): scope --assistant-name to the registered group only
2026-05-22 22:50:22 +03:00
gavrielc ea06453bcb fix: correct Photon URL from photon.im to photon.codes
The chat-adapter-imessage docs use photon.codes — our setup flow
and skill had the wrong domain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 22:41:28 +03:00
gavrielc edbb9c3686 Merge pull request #2584 from snymanpaul/fix/signal-auth-number-field
fix(signal-auth): read 'number' field from signal-cli 0.13+ listAccounts JSON
2026-05-22 22:22:09 +03:00
Mohammed Mansoor Ahmed ed3c56aa67 docs(add-teams): map CLI credential names to TEAMS_APP_* env keys
- teams app create prints CLIENT_ID/CLIENT_SECRET/TENANT_ID; the existing Configure environment section expects TEAMS_APP_ID/TEAMS_APP_PASSWORD/TEAMS_APP_TENANT_ID, so without the mapping a user pasting verbatim would silently end up with an adapter that can't authenticate
2026-05-22 22:55:03 +05:30
Mohammed Mansoor Ahmed d365728372 docs(add-teams): add CLI path as auto setup option
- @microsoft/teams.cli registers bots via the Teams Developer Portal, skipping the Azure subscription requirement that blocks users on locked-down corporate tenants
2026-05-22 22:48:31 +05:30
Adam 6686315a10 fix(agent-runner): rotate oversized/old session transcripts before resume
A long-lived hub session never rotates its continuation, so the on-disk
.jsonl grows without bound — days of history plus base64 image blocks the
agent Read (screenshots from QA lanes, etc.). The SDK reloads the whole
transcript on every --resume, and past a threshold the first turn alone
exceeds the host's 30-min idle ceiling: the container is SIGKILLed before
it can reply, then the next message repeats the cycle forever. Symptom:
a hub that was responsive for days suddenly goes silent on a heavy turn.

Before resuming, the Claude provider now checks the transcript backing the
stored continuation; if it exceeds a size cap (default 12MB) or age cap
(default 14 days, from the first entry's timestamp) it archives a markdown
summary to conversations/ and starts a fresh session. Both caps are
operator-overridable via CLAUDE_TRANSCRIPT_ROTATE_BYTES /
CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS. The PreCompact archiver is refactored
into a shared archiveTranscriptFile() reused by the rotation path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:40:47 +10:00
Paul Snyman 3a87953bc9 fix(signal-auth): read 'number' field from signal-cli 0.13+ listAccounts
signal-cli >= 0.13 emits the account identifier as `number` in JSON
output, not `account`. The skip-if-already-linked path in signal-auth
always returned an empty list, so re-runs of setup unconditionally
tried `signal-cli link`, which fails when the data directory already
exists.

Read `number` first, fall back to `account` for older signal-cli.
2026-05-21 13:28:48 -07:00
Ira Abramov 0ec51d440f feat: add add-rtk skill for token-efficient CLI proxy
Installs rtk (60–90% token savings on dev commands) into agent containers
via host binary mount + Claude Code PreToolUse hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:45:08 +03:00
hinotoi-agent 7d15dbceeb fix(permissions): scope channel approval targets
Filter channel registration target options to the approver's authorized agent groups and re-check target authorization before applying a pending approval. Add regression coverage for scoped admins attempting to connect channels to out-of-scope groups.
2026-05-20 10:12:26 +08:00
Claw 6db6919086 fix(setup-register): scope --assistant-name to the registered group only
Previously, passing --assistant-name <Name> when registering an agent
did a project-wide find-replace of "Andy" → <Name> across every
groups/*/CLAUDE.md file, and overwrote .env's ASSISTANT_NAME.

Two unintended consequences:

  - Registering a second agent (e.g. "Homie") clobbered an unrelated
    primary agent's CLAUDE.md. Real-world hit when wiring Homie's
    Signal group on an install that already had Diddyclaw set up —
    groups/diddyclaw/CLAUDE.md ended up with "Homie" references it
    shouldn't have had.
  - The install-wide .env ASSISTANT_NAME flipped to the most recently-
    registered name, becoming the default trigger pattern for any
    subsequent group registered without an explicit --assistant-name.

Both were a per-agent operation accidentally exercising project-wide
state. Now only groups/<folder>/CLAUDE.md of the agent being
registered is touched. .env is left alone — it represents the
install-wide default and shouldn't be flipped by per-agent registers.

If the install's primary-default name needs to change, that's an
explicit one-line .env edit, not a side-effect of registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:55:24 -04:00
Guy Ben Aharon 1b29a60358 fix(setup): correct OneCLI default URL from app to api subdomain
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 21:10:40 +03:00
Adam fe2e881b37 fix(agent-runner): drop <messages> envelope so claude-agent-sdk calls API
When 2+ pending messages were bundled into <messages>...</messages> at
container/agent-runner/src/formatter.ts:162-167, the Claude Agent SDK
responded with a synthetic stub (model="<synthetic>", stop_reason=
"stop_sequence", content="No response requested.") instead of calling
the real API. The poll loop never yielded a `result` event, so the
inbound message was never marked completed; the container exited; the
next sweep tick respawned it with the same batch; same synthetic; the
transcript file ballooned with each retry until tries=5 → failed.

Single-message turns (which skipped the wrapper) worked normally — the
SDK's heuristic appears to treat the wrapped envelope as a context dump
rather than a real user turn. Each `<message id=... from=...>...</message>`
block is already self-contained, so dropping the outer wrapper lets the
N>1 case work the same way the N=1 case always has.

Fix:

  function formatChatMessages(messages: MessageInRow[]): string {
    return messages.map(formatSingleChat).join('\n');
  }

Updates one existing test that asserted on the envelope, and adds two
regression tests: one negative (no `<messages>` wrapper), one positive
(each inbound row produces a `<message>` block in order).

Confirmed working in a real install: two stuck lanes recovered after
reducing their pending queue to 1 message, and both produced normal
replies from claude after the wipe + this fix were both applied (the
wipe alone wasn't enough — a fresh session given the same batch shape
hit the same synthetic loop).

Refs nanocoai/nanoclaw#2555 for full repro + transcript evidence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:47:41 +10:00
Adam 7f92f17669 feat(skills): add whatsapp-formatting container skill
Teaches agents WhatsApp's mention syntax (@<phone-digits>, never display
names) and where to find the sender's phone JID in inbound metadata
(content.sender). Without this, agents default to @<displayName>, which
WhatsApp can't tag — it just renders as plain text with no notification.

Two files:

- SKILL.md — frontmatter + description so the Claude Agent SDK can
  discover it via skill metadata for ad-hoc lookups.
- instructions.md — always-on guidance. claude-md-compose.ts inlines
  any skill that ships an instructions.md into every group's CLAUDE.md
  on container spawn, so the rule is in the agent's context for every
  reply (not just when the agent decides to invoke the Skill tool).

Mirrors the existing container/skills/slack-formatting/ layout for the
analogous Slack mrkdwn rules. Pairs with the adapter-side fix on the
`channels` branch that wires `mentions` through to Baileys' contextInfo
— both layers are needed for tags to render end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:21:54 +10:00
claudiopostinghel e5e8e9bca2 fix(add-whatsapp): correct removed --method refs, ship QR-browser wrapper
The SKILL.md recommends `--method qr-browser` and references `--method qr-terminal`, but `setup/whatsapp-auth.ts` on `channels` only accepts `qr` and `pairing-code`. Running the recommended path errors out with `Unknown --method: qr-browser (expected 'qr' or 'pairing-code')`.

Add `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` — a small wrapper that spawns the existing `--method qr` step, parses its `WHATSAPP_AUTH_QR` status blocks, and serves the rotating QR as a PNG on a local HTTP server with the default browser auto-opened. Restores the 'QR in browser' UX the skill already promises.

Update SKILL.md to invoke the wrapper for the browser method and to call `--method qr` (not `qr-terminal`) for the terminal method. Also expand the 'pairing code keeps failing' troubleshooting with the 'Couldn't link device — An error happened' server-side rejection seen on fresh dedicated numbers.

No source changes (`setup/`, `src/`) — preserves the 'browser method dropped' decision in `setup/whatsapp-auth.ts`. No new npm deps — uses `qrcode` (already pinned by this skill) and Node's built-in `http`.
2026-05-19 13:13:35 +02:00
glifocat 0683c6ec58 Merge pull request #2536 from glifocat/docs/v2.0.64-release-notes
docs(changelog): add v2.0.64 entry
2026-05-18 18:55:06 +02:00
glifocat 8dbe8c1de8 docs(changelog): add v2.0.64 entry
Documents the fix from #2510 (closes #2465) in user-facing prose
following the RELEASING.md style guide. Single-bullet release —
no rollup opener since this is a clean one-bump cycle.
2026-05-18 12:56:51 +02:00
glifocat 4635c406e7 review(cli): explicit container_configs delete in cascade
migration-014 has ON DELETE CASCADE on container_configs.agent_group_id,
so the row was already being removed by the final DELETE FROM agent_groups.
Doing the delete explicitly here mirrors the shape of every other table
in the cascade and lets the handler surface a container_configs count in
the `removed` response, matching the rest of the breakdown.
2026-05-18 11:43:45 +02:00
glifocat d1a53a0deb review(cli): count deletes inside the transaction
Move the row-count queries out of a separate pre-flight pass and source
the `removed` counts from each DELETE's `.changes` instead, so the
response describes exactly what the transaction did rather than a
snapshot from before it ran.

Also drops the two double-quoted SQL strings (the `'agent'` literal is
now a bound parameter) so quoting is consistent with the rest of the
file.
2026-05-18 09:26:55 +02:00
glifocat cdc4db596d chore(cli): pnpm run format
Apply prettier formatting to groups.ts and groups.test.ts.
2026-05-18 09:19:22 +02:00
glifocat 289b99444c fix(cli): cascade dependent rows on groups delete (#2525)
The generic single-table DELETE handler for `ncl groups delete` always
failed with SQLITE_CONSTRAINT_FOREIGNKEY when any session, destination,
approval, role grant, membership, or channel wiring still pointed at the
group — which is approximately always.

Replace with a `customOperations.delete` handler on the `groups`
resource that runs a single sync better-sqlite3 transaction and deletes
the dependent rows in FK-respecting order before the final DELETE on
`agent_groups`. Polymorphic `agent_destinations` rows with
`target_type='agent'` and `target_id` pointing at the deleted group
are also cleaned up so they don't dangle.

Module tables (`agent_destinations`, `pending_approvals`) are guarded
with `hasTable(getDb(), ...)` so installs without the agent-to-agent or
approvals modules degrade silently.

`container_configs.agent_group_id` already has ON DELETE CASCADE, so
that row is removed automatically by the final DELETE.

Out of scope (filed separately): killing any running container for the
group, and on-disk cleanup of `groups/<folder>/` and
`data/v2-sessions/<group-id>/`. The DB cascade is the load-bearing
fix; the filesystem leak is cosmetic.
2026-05-18 01:54:25 +02:00
github-actions[bot] 78bb6cb087 chore: bump version to 2.0.64 2026-05-17 11:50:33 +00:00
gavrielc ce804afb73 Merge pull request #2510 from nanocoai/fix/2465-approval-destinations-inbound-sync
fix(cli): hydrate receiver inbound.db on approval-path destinations add
2026-05-17 14:50:20 +03:00
glifocat 898f4b5f66 Merge branch 'main' into fix/2465-approval-destinations-inbound-sync 2026-05-16 10:49:16 +02:00
glifocat 4b7bfb0a11 fix(cli): hydrate receiver inbound.db on approval-path destinations add/remove
The `destinations add` and `destinations remove` custom ops in the admin
CLI INSERT/DELETE rows in the central `agent_destinations` table, but
did not project the change into running sessions' `inbound.db`. The
agent-runner container reads its destination map from the per-session
projection, so until the next container spawn (`container-runner.ts`
hydrates on every wake), the running agent saw a stale map — explaining
the "dropped: unknown destination" symptom after a fresh `ncl
destinations add` even though the central row was clearly committed.

Same handler runs for both the direct-host path and the approval-execution
path because the `cli_command` approval handler in `dispatch.ts` re-enters
`dispatch()` as `caller: 'host'`, so the fix at the handler level covers
both surfaces.

Helper iterates over `getSessionsByAgentGroup(agentGroupId)` (every
active session for the affected agent), guarded by `hasTable('agent_destinations')`
and a lazy dynamic import of `writeDestinations` to keep the agent-to-agent
module optional. Per-session try/catch keeps one bad session from killing
the whole projection; failures are logged at WARN with session id + error.

Regression test invokes the dispatcher with `caller: 'host'` (the same
re-entry the approval handler uses after admin approves), with two active
sessions on the source agent group, and asserts the `destinations` row
lands in every session's inbound.db after `add` and is cleared after `remove`.

Fixes #2465
2026-05-16 10:47:13 +02:00
glifocat 2ab69269ce Merge pull request #2509 from nanocoai/docs/v2.0.63-release-notes
docs(changelog): align v2.0.63 rollup line with RELEASING.md voice
2026-05-16 10:46:35 +02:00
glifocat 6418dda3da docs(changelog): align v2.0.63 rollup line with RELEASING.md voice
RELEASING.md frames the per-bump release policy as a goal that is cut
manually, not as automation. The v2.0.63 CHANGELOG rollup line still
asserted the stronger claim ("NanoClaw publishes a GitHub Release on
every package.json version bump"), which contradicts the policy doc.
Soften to match RELEASING.md so the two land consistently on main.
2026-05-15 21:04:17 +02:00
glifocat 975a2f0f5b Merge pull request #2502 from nanocoai/docs/v2.0.63-release-notes
docs: add v2.0.63 CHANGELOG entry and RELEASING.md
2026-05-15 20:51:36 +02:00
glifocat d2a015074d docs(changelog): drop stale docs.nanoclaw.dev link from header
The "For detailed release notes, see the full changelog on the
documentation site" line pointed at a docs portal that does not exist.
CHANGELOG.md is the canonical record, so the header now says only what
is true: all notable changes are documented in this file.
2026-05-15 20:49:53 +02:00
glifocat 8ea451aced docs(releasing): soften per-bump policy and document release channels
Two revisions in RELEASING.md based on review feedback:

1. Soften the "release per bump" claim. The policy is aspirational and
   release publication is manual, so the opening now states the goal
   ("publish a GitHub Release for every package.json version bump that
   lands on main") and acknowledges that there can be lag between a bump
   merging and the release being cut. Intent: timeliness, not strict 1:1.

2. Add a "Channels and stability" section that explicitly states NanoClaw
   ships a single channel today, distinguishes latest/stable/pinned for
   consumers, and reserves space for a future pre-release channel without
   inventing structure that does not yet exist. Folds the previous Pinning
   section into the new structure as the Pinned bullet.
2026-05-15 20:24:47 +02:00
glifocat 5b14ae249a docs: add v2.0.63 CHANGELOG entry and RELEASING.md
CHANGELOG.md gets a rollup entry covering v2.0.55..v2.0.63 in the
project voice (bold lead-ins, [BREAKING] prefix with inline workaround,
doc links to setup/lib/install-slug.sh, no PR numbers).

RELEASING.md is new and documents the per-bump release policy starting
with v2.0.63: tag every package.json bump, mirror the CHANGELOG entry
into the GitHub Release body, append Contributors and (when relevant)
New Contributors sections, and use rollup framing when multiple bumps
collapsed into one release.
2026-05-15 19:51:01 +02:00
github-actions[bot] 06711b5e47 chore: bump version to 2.0.63 2026-05-15 17:15:22 +00:00
glifocat d0139a7c0f Merge pull request #2493 from nanocoai/fix/2484-2485-v1-name-hardcoding
fix(cli,skills): use per-install slug for service names
2026-05-15 19:15:05 +02:00
glifocat 2abb34bc78 docs(skills): apply v1-name fix to gmail/gcal tools
The gmail/gcal Phase 4 restart blocks and uninstall one-liners
still hardcoded `com.nanoclaw` / `restart nanoclaw`, so on a v2
install they would fail with "no such service" or kick the
wrong unit.

Phase 4 restart now uses the canonical
`source setup/lib/install-slug.sh` + `$(launchd_label)` /
`$(systemd_unit)` pattern with the standalone `Run from your
NanoClaw project root:` lead-in. Uninstall one-liners switch
to the inline-subshell form
`"$(. setup/lib/install-slug.sh && systemd_unit)"`.

(Folds in #2489's v2-alignment changes to the same two files;
the deferral noted in the original PR body is no longer needed
now that #2489 has merged.)
2026-05-15 18:25:46 +02:00
glifocat b8d7777740 docs(skills): standardize project-root lead-in to its own line
Split the embedded forms ("... — run from your NanoClaw project root:")
into a separate `Run from your NanoClaw project root:` line directly
above the code block, so the lead-in pattern is uniform across all
restart blocks.
2026-05-15 18:05:14 +02:00
glifocat 43ff3a4644 docs(skills): consolidate project-root reminder into prose lead-ins
Replace inline `# run from your NanoClaw project root` annotations on
`source setup/lib/install-slug.sh` lines with one standalone prose
lead-in per code block. Also drop parenthetical "(run from the project
root...)" mentions where the same convention is already obvious.
2026-05-15 18:02:29 +02:00
glifocat 34b9b259ea Merge branch 'main' into fix/2484-2485-v1-name-hardcoding 2026-05-15 17:48:05 +02:00
glifocat f3d5b82899 docs(skills): tighten install-slug usage per #2493 review
- swap remaining inline subshells from `; helper` to `&& helper` so source
  failures surface as the source error instead of a downstream 'command not
  found' on the helper call
- fix two service-status checks that still grepped for the bare v1 name
  (init-first-agent, add-emacs) — same canonical inline form as the rest of
  the sweep, scoped to the per-install slug
- collapse add-parallel's verify block to the inline form so it stops
  shadowing the canonical pattern
- note 'run from your NanoClaw project root' beside every restart snippet
  that sources `setup/lib/install-slug.sh` (inline as a bash comment on
  the source line, plus parenthetical lead-ins where the snippet is
  prose-form) so the relative-path dependency is loud at the spot it
  matters
2026-05-15 17:47:29 +02:00
glifocat e603236223 Merge pull request #2489 from nanocoai/fix/2488-gmail-gcal-skills-stale
docs(skill): align add-gmail-tool/add-gcal-tool with v2 architecture
2026-05-15 17:39:10 +02:00
glifocat 5fff2d2728 fix(cli,skills): use per-install slug for service names
The `ncl` transport-error message and ~20 skill docs hardcoded v1's
`com.nanoclaw` / `nanoclaw` for launchd labels and systemd units. Under
v2 the names are slug-suffixed per checkout (`com.nanoclaw.<slug>`,
`nanoclaw-<slug>.service`), so those commands no longer match a real
service on the host.

- `src/cli/client.ts` — extract `formatTransportError` into
  `src/cli/transport-errors.ts` so it can read `install-slug` and call
  `getLaunchdLabel()` / `getSystemdUnit()`.
- `src/cli/transport-errors.test.ts` — regression test for #2484: the
  error string must not contain the bare v1 names.
- `.claude/skills/**/*.md` — replace hardcoded restart snippets with
  the canonical `source setup/lib/install-slug.sh` + `$(systemd_unit)` /
  `$(launchd_label)` pattern (or the inline subshell form where the
  snippet is a one-liner).

Closes #2484
Closes #2485
2026-05-15 17:11:12 +02:00
glifocat 529d2db8e2 docs(skill): fix sqlite3/json invocations in gmail/gcal mount steps
Three issues with the DB-edit steps that ship in #2489:

- `'$[#]'` was double-quoted in the surrounding bash string, so bash
  arith-expanded `$#` (positional-arg count, 0 in interactive shell)
  before sqlite ever saw it — silently overwrote index 0 instead of
  appending. Now escaped as `'\$[#]'`.

- `sqlite3` CLI replaced with `pnpm exec tsx scripts/q.ts` — clean
  installs have no sqlite3 binary; setup/verify.ts:5 codifies that
  NanoClaw avoids depending on it.

- `strftime('%s','now')` replaced with `datetime('now')` — the column
  stores ISO strings everywhere else; mixing epoch ints made any
  consumer doing `datetime(updated_at)` parse those rows as 1970.

Also: reworded the "approval-gated" wording to distinguish container
vs host-operator-shell invocation, and added the "Why this can't be
container.json" note to add-gcal-tool (gmail had it, gcal didn't).
2026-05-15 17:03:54 +02:00
glifocat 26eb89c771 docs(skill): align add-gmail-tool/add-gcal-tool with v2 architecture
Two pieces of post-v1 drift in the gmail/gcal skills made the instructions
either dead-code edits or silently broken installs:

1. The TOOL_ALLOWLIST edit step is redundant. claude.ts derives
   mcp__<name>__* allow-patterns dynamically from each group's
   mcpServers map (claude.ts:294-297), so registering the MCP server in
   Phase 3 already authorizes the tools. Removed the edit step, its
   pre-check, its troubleshooting attribution, and its uninstall mirror;
   replaced with an explanatory note pointing at the dynamic derivation.

2. The "edit groups/<folder>/container.json" step doesn't stick.
   materializeContainerJson rewrites that file from the central DB on
   every spawn (post-migration 014-container-configs), so hand edits are
   silently overwritten on next restart. Rewrote Phase 3 to use
   `ncl groups config add-mcp-server` (which persists to DB) for the
   MCP-server entry, and a sqlite3 json_insert workaround for the mount
   entry — with a note to switch to `ncl groups config add-mount` once
   #2395 lands. Removal step rewritten the same way using
   `remove-mcp-server` and a sqlite3 json_group_array filter.

Fixes #2488
2026-05-15 16:50:07 +02:00
hinotoi-agent 728c6a641b fix(approvals): require admin for approval responses 2026-05-15 10:34:46 +08:00
github-actions[bot] fa945a1d0c chore: bump version to 2.0.62 2026-05-14 17:22:20 +00:00
Daniel M bec10fe4e3 Merge pull request #2473 from nanocoai/fix/destinations-remove-scratchpad-clause
fix(destinations): remove misleading scratchpad clause from internal-tag description
2026-05-14 20:22:07 +03:00
Daniel Milliner cbdebe55fc fix(destinations): remove misleading scratchpad clause from internal-tag description
Follow-up to #2467. The trailing "anything outside these tags is also
treated as scratchpad" clause contradicted the rest of the system prompt,
which requires bare text to be wrapped in `<message>` blocks. Removing it
keeps the description focused on what `<internal>` actually does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 20:20:43 +03:00
hinotoi-agent 8385236c30 fix(agent-route): reject unsafe forwarded attachments 2026-05-14 21:04:04 +08:00
github-actions[bot] 8f30a7aad3 chore: bump version to 2.0.61 2026-05-14 11:58:02 +00:00
Daniel M b2894bf44c Merge pull request #2467 from nanocoai/Koshkoshinsk/fix/welcome-duplicate-message
fix(welcome): stop emitting the greeting twice
2026-05-14 14:57:46 +03:00
Koshkoshinsk ca52d2c6c1 fix(welcome): stop emitting the greeting twice
The welcome skill told the agent to send the greeting via `send_message`,
but the destinations system prompt also requires the final response to
be wrapped in `<message to="…">` blocks (since 1d4d920). The agent
followed both, sending the greeting once via the MCP tool and once via
the wrapped final output.

- welcome/SKILL.md: drop the mechanism — "send a short, warm greeting"
  lets the system prompt steer how it's delivered.
- destinations.ts: reframe `<message>` blocks and `send_message` as the
  same delivery surface, with the explicit note that each call/block
  lands as its own message — so they compose into a sequence rather than
  reading as additive duplicates of the same content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:12:38 +00:00
glifocat b779a0b5c6 Merge pull request #2460 from madevizslove183/madevizslove183/setup/slack-files-scope
setup: add files:read and files:write to Slack scope checklist
2026-05-13 17:51:06 +02:00
madevizslove183 4d81dc4e0e setup: add files:read and files:write to Slack scope checklist
Without files:read, @chat-adapter/slack cannot download attachments —
Slack returns an HTML login page in place of file bytes and the adapter
throws a NetworkError. Bundles files:write for symmetric outbound
(files.uploadV2).

Closes #2457

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:43:15 +02:00
github-actions[bot] e263352aed chore: bump version to 2.0.60 2026-05-13 07:43:11 +00:00
Gabi Simons d27b1bb291 Merge pull request #2442 from Koshkoshinsk/fix/core-instructions-message-wrapping
fix(core-instructions): require message wrapping for single-destination agents
2026-05-13 00:42:57 -07:00
Koshkoshinsk 1d4d920629 fix(core-instructions): require message wrapping for single-destination agents
The parenthetical "(single-destination: just write)" was stale after
9db39b2 removed the bare-text routing fallback. Agents following this
hint had their responses silently dropped to scratchpad.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 07:27:07 +00:00
gavrielc c9c5ffadc9 fix(setup): pin OneCLI gateway version to 1.23.0
The upstream install script supports ONECLI_VERSION; use it to avoid
pulling an untested gateway release during setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-13 01:16:33 +03:00
github-actions[bot] 001c62c2e4 docs: update token count to 174k tokens · 87% of context window 2026-05-12 17:17:43 +00:00
github-actions[bot] 7334feb8dc chore: bump version to 2.0.59 2026-05-12 17:17:38 +00:00
gavrielc 2eb6a1c62e fix(permissions): skip channel-type prefix for userIds that already contain a colon
Platforms like Teams send userIds in "29:xxx" format which already
include a colon. Blindly prefixing with channelType produced double-
namespaced ids (e.g. "teams:29:xxx") that never matched the users
table, causing all approval clicks to be rejected. Mirror the
resolveOrCreateUser logic: only prefix when the raw id has no colon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 20:17:17 +03:00
github-actions[bot] 61d7ca6bba chore: bump version to 2.0.58 2026-05-11 21:44:24 +00:00
gavrielc 1baea6b9e9 Merge pull request #2414 from nanocoai/fix/unwrapped-output-nudge
fix(poll-loop): nudge agent when output lacks message wrapping
2026-05-12 00:44:10 +03:00
gavrielc 7f4fa65f3c fix(poll-loop): nudge agent when output lacks message wrapping
When the agent outputs bare text without <message to="..."> blocks,
nothing gets delivered — silent failure. Now the poll-loop pushes a
one-shot correction back into the active query telling the agent to
re-send with proper wrapping. Capped at once per user turn to avoid
loops; resets when a new follow-up message arrives.

Also updates destination instructions to require explicit <internal>
wrapping for scratchpad instead of treating bare text as implicit
scratchpad.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 00:30:23 +03:00
github-actions[bot] e0f5967128 docs: update token count to 173k tokens · 87% of context window 2026-05-11 21:25:29 +00:00
github-actions[bot] c1fd830add chore: bump version to 2.0.57 2026-05-11 21:25:10 +00:00
gavrielc 74744599d3 Merge pull request #2413 from nanocoai/fix/compact-instructions-reminder
fix(compact): place destination reminder at end of compaction summary
2026-05-12 00:25:05 +03:00
gavrielc fcbc204a24 Merge pull request #2412 from nanocoai/revert/compaction-destination-reminder
revert: remove compaction destination reminder (PR #2327)
2026-05-12 00:24:50 +03:00
gavrielc 00ddb3b169 fix(compact): place destination reminder at end of compaction summary
Tell the compactor to include the <message to="name"> wrapping reminder
verbatim at the END of the summary so it's the last thing the agent sees
after compaction. Previously the instruction just asked to "preserve"
routing info, which the compactor could place anywhere or summarize away.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 12:49:28 +03:00
gavrielc a760da7fef revert: remove compaction destination reminder (PR #2327)
The compacted event handler injected a system-tagged reminder into the
live query after SDK auto-compaction, which caused the agent to send
an unintended message. Reverts the four changes from #2327:

- Remove `compacted` variant from ProviderEvent union
- Restore `result` yield for compact_boundary in ClaudeProvider
- Remove compacted event handler and getAllDestinations import in poll-loop
- Remove compaction integration tests and CompactingProvider helper

Closes #2325 differently — the reminder approach is not viable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 12:38:49 +03:00
github-actions[bot] 48dfb1b1e0 chore: bump version to 2.0.56 2026-05-11 08:19:03 +00:00
gavrielc 9dfd68d14a Merge pull request #2410 from nanocoai/fix/on-wake-graceful-degrade
fix(container): gracefully handle missing on_wake column
2026-05-11 11:18:48 +03:00
gavrielc 8ac3cf2912 fix(container): gracefully handle missing on_wake column in pre-migration session DBs
The container opens inbound.db read-only, so it can't ALTER TABLE.
If the host hasn't run migrateMessagesInTable yet (e.g., container
rebuilt before host restart), the on_wake column won't exist and
the query crashes, causing a restart loop.

Detect the column via PRAGMA table_info and conditionally include
the filter clause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 11:08:02 +03:00
github-actions[bot] 0a1b396d12 docs: update token count to 175k tokens · 87% of context window 2026-05-11 07:05:10 +00:00
github-actions[bot] cf7da26c34 chore: bump version to 2.0.55 2026-05-11 07:04:57 +00:00
glifocat 6e3c60ce94 Merge pull request #2408 from glifocat/chore/rename-qwibitai-references 2026-05-11 09:04:44 +02:00
glifocat bda72a4bf4 chore: rename remaining qwibitai/nanoclaw references to nanocoai/nanoclaw
Sweep of outbound strings, doc URLs, comments, and clone instructions
that were missed in the original org rename. One both-match case in
setup/lib/channels-remote.sh (URL detection) accepts either name so
existing forks with a `qwibitai` remote continue to resolve cleanly;
everywhere else is a straight rename.

Historical mentions left intact:
- CHANGELOG.md (v2.0.0 entry, frozen history)
- .claude/skills/add-gmail-tool/SKILL.md (pre-v2 qwibitai skill — historical)
- repo-tokens/badge.svg (auto-regenerated by update-tokens.yml)
2026-05-11 08:40:09 +02:00
glifocat 35d667c3ae Merge pull request #2400 from dvirarad/docs/fix-contributing-repo-urls
docs: update CONTRIBUTING.md repo references after nanocoai migration
2026-05-10 23:58:14 +02:00
glifocat a98ce59374 Merge pull request #2402 from glifocat/fix/workflow-repo-guards
fix(ci): workflows no-op after repo rename — update repository guards
2026-05-10 23:29:04 +02:00
glifocat 069928a445 fix(ci): update update-tokens repo guard 2026-05-10 23:24:56 +02:00
glifocat 45189abaf1 fix(ci): update bump-version repo guard 2026-05-10 23:24:46 +02:00
Dvir Arad 43d69a9966 docs: update CONTRIBUTING.md repo references after nanocoai migration 2026-05-10 22:37:26 +03:00
gavrielc e185bb8bad Merge pull request #2392 from glifocat/fix/cli-scope-hardening
fix(cli-scope): fail-closed scopeField enforcement + sessions-get oracle guard
2026-05-10 22:24:46 +03:00
glifocat c6d5cd7d02 fixup(cli-scope): build error, false-positive on custom ops, tests, drop FORK.md
Addresses review feedback on this branch:

- Fix TS2352 build error in dispatch.ts: `getSession()` returns `Session`,
  which has no index signature, so `(s as Record<string, unknown>)` is rejected
  by tsc. `Session.agent_group_id` exists — read it directly.

- Fix a regression introduced by dropping the `groupField in data` guard:
  the post-handler scope check now runs for *every* command under a whitelisted
  resource, including custom ops, which return ad-hoc shapes. `ncl groups config
  get` (access:open, reachable by a group-scoped agent) returns a config object
  with no `id` field → `data['id'] !== ctx.agentGroupId` → `forbidden`, even on
  the agent's own config. Fix: tag the auto-generated list/get handlers with
  `generic: 'list' | 'get'` on `CommandDef` (set in `registerResource`) and run
  the post-handler check only when `cmd.generic` is set. Generic handlers return
  raw DB rows that carry `scopeField`; custom ops are already pinned to the
  caller's group by the pre-handler `--id` auto-fill or the approval gate.
  Fail-closed-when-`scopeField`-missing is preserved (now scoped to generic
  list/get).

- Tests: `dispatch.test.ts` mocks `getResource` (the real resources aren't
  registered in this unit), tags the two post-handler test commands as `generic`,
  and adds coverage for: custom op returning a non-row object not being rejected;
  `sessions-get` pre-handler returning "session not found" for foreign and
  non-existent UUIDs (no existence oracle) and allowing the caller's own session;
  generic list/get failing closed when a resource declares no `scopeField`.
  Full suite: 323 passing.

- Remove FORK.md from the PR diff — it's the fork's personal README, carried in
  because the branch was cut from the fork's `main` rather than upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:47:51 +02:00
glifocat b323b55efe fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:41 +02:00
glifocat bf34857d11 fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:41 +02:00
glifocat d8aa46c0a7 fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:40 +02:00
glifocat 610a692519 fix(cli-scope): add scopeField to groups, sessions, destinations, members 2026-05-10 20:30:30 +02:00
glifocat 8a8ec84ef1 fix(cli-scope): fail-closed scopeField enforcement and sessions-get oracle guard 2026-05-10 20:30:25 +02:00
glifocat 47c85d0985 fix(cli-scope): add scopeField to ResourceDef for fail-closed group scope 2026-05-10 20:30:15 +02:00
glifocat f338bd47ea Merge branch 'nanocoai:main' into main 2026-05-10 20:27:30 +02:00
Gabi Simons 0de46f8b38 Merge pull request #2384 from johnnyfish/fix/mcp-install-credential-instructions
fix: teach agent to use OneCLI gateway credentials after MCP server install
2026-05-10 21:12:25 +03:00
johnnyfish f49de0fb01 fix: teach agent to use OneCLI gateway credentials after MCP server install 2026-05-10 19:23:22 +03:00
glifocat a33b1ae8bb Merge pull request #2373 from nanocoai/docs/changelog-2.0.54
docs: add changelog entry for 2.0.54
2026-05-10 08:53:14 +02:00
glifocat d8e3f9f959 docs: add changelog entry for 2.0.54 2026-05-10 08:51:53 +02:00
github-actions[bot] 8d57bdfa3d chore: bump version to 2.0.54 2026-05-09 18:16:05 +00:00
gavrielc ead25ee6e2 Merge pull request #2364 from yaniv-golan/pr/claude-code-bump-2.1.128
chore(container): bump claude-code 2.1.116 → 2.1.128
2026-05-09 21:15:53 +03:00
Yaniv Golan 9e1dbdf48c chore(container): bump claude-code 2.1.116 → 2.1.128
12 patch versions ahead. The 2.1.120 binary baseline introduced a
number of plugin and skill behaviors that have since landed in the
public Claude Code docs: ${CLAUDE_EFFORT} substitution, settled
`arguments` field in skill frontmatter, plugin `channels` field.

No breaking changes for nanoclaw's runtime contract. Verified by
running container/skills/{agent-browser,vercel-cli,slack-formatting}
under the bumped image; all three load and execute as expected.
SDK at ^0.2.116 (caret) remains compatible with claude-code 2.1.128.

Bumping CLAUDE_CODE_VERSION invalidates the pnpm install layer in
container/Dockerfile and triggers a full rebuild of the agent image.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:15:43 +03:00
github-actions[bot] 0774667826 chore: bump version to 2.0.53 2026-05-09 18:08:06 +00:00
gavrielc 0ba4ecadb1 Merge pull request #2233 from tamasPetki/pr/container-config-model-effort
feat(container-config): add per-group model + effort overrides
2026-05-09 21:07:52 +03:00
Petki Tamás ad5d4d2664 feat(container-config): add per-group model + effort overrides
Allow individual agent groups to opt into different models or effort levels
without changing host-wide defaults. Useful when one group is high-stakes
(opus, high effort) but most are routine (sonnet/haiku, low effort).

container.json gains two optional fields:
  - model: alias ("sonnet" | "opus" | "haiku") or full model ID
  - effort: "low" | "medium" | "high" | "xhigh" | "max"

Both omitted = SDK default (current behavior). The host plumbs them as
NANOCLAW_MODEL / NANOCLAW_EFFORT env vars at container spawn time; the
agent-runner reads them in providers/index.ts and threads through to the
provider via ProviderOptions. The Claude provider passes them straight to
sdkQuery options.

`effort` is currently typed as `any` because the @anthropic-ai/claude-
agent-sdk type doesn't surface it yet — passing it through still works at
runtime via the SDK's loose option handling. Drop the cast once the SDK
adds an `effort` field to its options type.
2026-05-09 21:04:08 +03:00
github-actions[bot] 9267d52bdb chore: bump version to 2.0.52 2026-05-09 17:45:17 +00:00
gavrielc 4c57e4d69b docs: soften restart description wording 2026-05-09 20:44:59 +03:00
github-actions[bot] eff13717f9 chore: bump version to 2.0.51 2026-05-09 17:44:09 +00:00
gavrielc dc13300fb1 docs: clarify --message flag on restart for config help
Explain that --message sets an on-wake instruction so the fresh
container can continue after restart (verify tools, notify user).
Without it, the container only comes back on the next user message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:43:50 +03:00
github-actions[bot] d324419d7b chore: bump version to 2.0.50 2026-05-09 17:41:21 +00:00
gavrielc 0287d71595 docs: move restart guidance into config help descriptions
One-liner in cli.instructions.md pointing to `ncl groups config help`.
Each config operation's description now says whether restart or rebuild
is needed — agent discovers it via progressive disclosure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:41:02 +03:00
github-actions[bot] 05906e4b6a chore: bump version to 2.0.49 2026-05-09 17:39:43 +00:00
gavrielc 6539c0286a docs: explain that CLI config changes require restart
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:39:24 +03:00
gavrielc 5ba9d23ea8 docs: remove empty Unreleased section from changelog 2026-05-09 20:32:55 +03:00
gavrielc f7a8df0e8e docs: move changelog entries to 2.0.48
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:32:41 +03:00
gavrielc 9312d467bd docs: add changelog entries for container config DB, on-wake, CLI scope
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:31:32 +03:00
gavrielc bd50ef7e38 fix: only re-stage previously staged files in pre-commit hook
Capture staged file list before prettier runs, then re-add only
those files. Prevents pulling in unrelated unstaged changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:30:36 +03:00
gavrielc 25a5b81c59 fix: re-stage prettier-formatted files in pre-commit hook
The hook ran format:fix but didn't re-stage the modified files, so
commits went through with unformatted code and CI caught the diff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:26:48 +03:00
github-actions[bot] f33f2d89ce docs: update token count to 174k tokens · 87% of context window 2026-05-09 17:26:34 +00:00
github-actions[bot] 661da3969e chore: bump version to 2.0.48 2026-05-09 17:26:30 +00:00
gavrielc aeeb54a495 Merge pull request #2351 from qwibitai/feat/container-config-to-db
feat(db): move container config from filesystem to DB
2026-05-09 20:26:17 +03:00
gavrielc f9d30e8b9c style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:25:11 +03:00
gavrielc 1c7623ca41 docs: update for container config DB, on-wake, and CLI scope
- CLAUDE.md: new key files, updated groups verbs, rewritten self-mod
  section, new Container Config and Container Restart sections
- db-central.md: container_configs table (§1.15), migrations 014+015
- db-session.md: messages_in schema with trigger, source_session_id,
  on_wake columns
- schema.ts: comment no longer references disk-based config
- cli.instructions.md: rewritten for scope-aware usage, auto-fill,
  restart/config ops, group-scoped examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:23:44 +03:00
gavrielc faeeba198e fix(security): block cli_scope escalation and cross-group data leaks
Group-scoped agents could previously:
- See all agent groups via `groups list` (generic list skips --id filter)
- Look up any session by UUID via `sessions get`
- Request cli_scope change to global via config update approval

Fixed by:
- Post-handler filtering: list results filtered, get results verified
  against caller's agent_group_id
- Pre-handler --id check scoped to resources where id IS the group ID
  (groups, destinations) so session UUIDs aren't falsely rejected
- cli_scope/cli-scope args blocked outright for group-scoped agents,
  before the approval gate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:17:13 +03:00
gavrielc 04e41fb0ef feat: default owner agent group to global CLI scope
When init-first-agent creates an agent group for an owner, set
cli_scope to 'global' so the owner's personal agent has full ncl
access. All other agent groups remain 'group'-scoped by default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:09:05 +03:00
gavrielc aebcffe180 feat: per-group CLI scope (disabled/group/global)
Add cli_scope column to container_configs with three levels:
- disabled: agent never learns about ncl (instructions excluded from
  CLAUDE.md) and host dispatch rejects any cli_request
- group (default): agent can only access groups, sessions, destinations,
  and members resources, scoped to its own agent group with auto-filled
  --id/--agent_group_id/--group args. Help output reflects the scope.
- global: unrestricted access (current behavior)

Enforcement is host-side only — no image rebuild or env var needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:02:31 +03:00
gavrielc be3a8a97c6 feat: race-free on-wake messages and explicit restart CLI
Decouple container restart from config updates — config CLI ops now only
write to the DB; restart is a separate `ncl groups restart` command with
--rebuild and --message flags. Add on_wake column to messages_in so wake
messages are only picked up by a fresh container's first poll, preventing
dying containers from stealing them during the SIGTERM grace window.
killContainer accepts an onExit callback for race-free respawn. Agent-
called restart auto-scopes to the calling session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 19:02:15 +03:00
github-actions[bot] a84327573e chore: bump version to 2.0.47 2026-05-09 13:28:07 +00:00
gavrielc 39e9583820 Merge pull request #2352 from Shlomog/claude/romantic-dirac-2d077b
fix(container-runner): raise install_packages build timeout to 15min
2026-05-09 16:27:53 +03:00
gavrielc 08698da0d2 fix(cli): decouple package commands from docker build
config add/remove-package should only update the DB and restart.
Image rebuild is handled by the self-mod approval flow or manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:10:46 +03:00
gavrielc 9ce82588d9 refactor(cli): remove deprecated agent_provider from groups columns
Provider is now managed via `ncl groups config update --provider`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:08:18 +03:00
gavrielc 37b54968ce refactor(cli): use spaces in custom operation keys
Operation keys like 'config get' read naturally and crud.ts normalizes
spaces to dashes for the registry name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:07:13 +03:00
gavrielc 1efe28ccdc feat(cli): support space-separated multi-word verbs
`ncl groups config get` now works alongside `ncl groups config-get`.
Parser joins all positionals with dashes; dispatcher falls back by
trimming the last segment as a target ID (`ncl groups get abc123`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:04:45 +03:00
MoBot 78cf2433a3 fix(container-runner): raise install_packages build timeout to 15min
The 5-minute timeout in buildAgentGroupImage was tight for first-time
apt + pnpm global installs on slow networks (the exact scenario
install_packages triggers, since the image hasn't pre-installed the
requested packages). Hit ETIMEDOUT on a real install with apt + npm
packages.

900_000ms gives realistic headroom without masking genuinely hung builds.
2026-05-08 16:10:59 -04:00
gavrielc 4c83a8193b style: move column whitelist consts to module top
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 22:36:58 +03:00
gavrielc 7eebcf74c2 fix: harden container config DB layer
- config-add/remove-package now rebuild image + restart containers
- Deduplicate packages in self-mod install_packages handler
- Add runtime whitelist guards for SQL column interpolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 22:33:42 +03:00
gavrielc 31ccc61b27 feat(db): move container config from filesystem to DB
Source of truth for container runtime config moves from
groups/<folder>/container.json to a new container_configs table.
The file becomes a materialized view written at spawn time.

- New container_configs table with scalar columns (provider, model,
  effort, image_tag, assistant_name, max_messages_per_prompt) and
  JSON columns (mcp_servers, packages_apt, packages_npm, skills,
  additional_mounts)
- Startup backfill seeds DB from existing container.json files
- materializeContainerJson() replaces readContainerConfig + ensureRuntimeFields
- Self-mod handlers (install_packages, add_mcp_server) write to DB
- Provider cascade simplified: session -> container_configs -> 'claude'
- ncl groups config-{get,update,add-mcp-server,remove-mcp-server,
  add-package,remove-package} custom operations
- restartAgentGroupContainers() helper for config change propagation
- Container side unchanged (still reads /workspace/agent/container.json)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 22:27:55 +03:00
gavrielc ef43cbb3d9 docs: remove migration fixes from changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:18:02 +03:00
gavrielc 0060c6b84a docs: add v2.0.45 changelog entry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:14:37 +03:00
gavrielc e6d470d831 docs: add ncl CLI to changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:14:02 +03:00
github-actions[bot] 0e11eaf186 docs: update token count to 166k tokens · 83% of context window 2026-05-08 18:05:57 +00:00
github-actions[bot] 4990994204 chore: bump version to 2.0.46 2026-05-08 18:05:53 +00:00
gavrielc 2d03c94252 Merge pull request #2350 from qwibitai/ncl
feat(cli): add ncl admin CLI
2026-05-08 21:05:29 +03:00
gavrielc 01eac7b225 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:04:07 +03:00
gavrielc 6caad0757a fix(cli): add list filtering/pagination, fix double-close in container ncl
- genericList now accepts column filters (--flag value) and LIMIT (default 200)
- Remove early inDb.close() in container pollResponse to avoid double-close
- Document filtering and --limit in cli.instructions.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:02:23 +03:00
gavrielc ed571d1f66 docs(cli): add write examples, approval flow, and nc→ncl rename
- Add approval flow section explaining the request→notify→result mechanics
- Add write command examples (groups create, roles grant, members add, etc.)
- Rename stale `nc` references to `ncl` in container instructions
- Add CLI reference section to host CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 20:45:18 +03:00
gavrielc 93ec82ce38 Merge pull request #2300 from alipgoldberg/setup/slack-member-id-card
setup: correct Slack member-ID card directions
2026-05-08 20:14:27 +03:00
gavrielc 046b99c745 feat(cli): wire approval flow for agent CLI commands
When a container agent calls an approval-gated ncl command, dispatch
now sends an approval card to an admin instead of returning a stub
error. On approve, the handler re-dispatches the original command
and notifies the agent with the result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 16:31:30 +03:00
gavrielc 0855369b79 refactor(cli): rename nc to ncl
Rename the CLI binary, socket path, container wrapper, error prefixes,
and all references from `nc` to `ncl`. Add ~/.local/bin symlink during
setup and pnpm script alias.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:56:09 +03:00
gavrielc 33cbf59dd8 Merge remote-tracking branch 'origin/main' into nc-cli 2026-05-08 15:35:03 +03:00
gavrielc 9a649fadc5 feat(setup): default to interactive Claude handoff on failure
Failures now launch an interactive Claude session instead of the
non-interactive assist (REASON/COMMAND parser). The user debugs
with full terminal access and types /exit to return to setup.

The original assist mode is available via --assist-mode flag or
NANOCLAW_SETUP_ASSIST_MODE=1 env var.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:34:47 +03:00
github-actions[bot] 405dd34148 chore: bump version to 2.0.45 2026-05-08 12:30:04 +00:00
gavrielc 81cb13ec46 fix(tests): add missing in_reply_to fields, correct session status type
- host-core.test.ts: add in_reply_to: null to routeAgentMessage calls
  (required after #2267 added the field to RoutableAgentMessage)
- agent-route.test.ts: use 'closed' instead of 'archived' (not a valid
  Session status)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:29:36 +03:00
github-actions[bot] 9629d1cc4a docs: update token count to 150k tokens · 75% of context window 2026-05-08 12:25:00 +00:00
gavrielc 85850874ab test: add delivery retry, permission check, and poll-loop error recovery coverage
Delivery:
- Retry exhaustion: adapter fails 3x → markDeliveryFailed
- Retry recovery: transient failure then success clears counter
- Permission check: unauthorized channel destination blocked

Poll-loop (container):
- Provider error: error written to outbound, loop continues
- Stale session: isSessionInvalid → continuation cleared
- /clear command: session wiped, confirmation written

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:24:42 +03:00
github-actions[bot] 6e9f35a646 chore: bump version to 2.0.44 2026-05-07 22:23:26 +00:00
gavrielc 635a49369f test(agent-to-agent): add missing routing coverage
- Stale origin fallback (archived session falls through to newest)
- Cross-agent-group guard (origin from wrong group rejected)
- Non-a2a in_reply_to (channel message ref falls through)
- Self-message bypass (no destination row needed)
- File forwarding (bytes copied from outbox to inbox)
- Unbounded ping-pong documenting #2063 loop gap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 01:22:59 +03:00
github-actions[bot] 028cb017ed chore: bump version to 2.0.43 2026-05-07 22:09:22 +00:00
gavrielc 2f552ce1bb Merge pull request #2321 from johnnyfish/jf/onecli-gateway-skill
feat(skills): add onecli-gateway container skill with auto-composed instructions
2026-05-08 01:09:09 +03:00
gavrielc f3e19872ac refactor: use static gateway skill instead of fetching on spawn
Remove the dynamic `onecli.getGatewaySkill()` fetch from `buildMounts` —
the skill content ships as a static SKILL.md. This avoids adding latency
to every container spawn and dirtying the source tree at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 01:07:09 +03:00
github-actions[bot] 9b670563b8 chore: bump version to 2.0.42 2026-05-07 21:50:35 +00:00
gavrielc 6ea49898dd test: remove stale A2A session coexistence tests
The skipped coexistence test and the findSessionByAgentGroup
bug-documenting test were written before the A2A return-path fix
(#2267). That fix sidesteps findSessionByAgentGroup entirely —
A2A replies now use source_session_id for routing, so the
"newest session wins" behavior is only a fallback for unsolicited
first-contact A2A where any session will do.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:50:20 +03:00
gavrielc 9090c33e7e docs(cli): add agent instructions for nc CLI
Auto-discovered by composeGroupClaudeMd() as module-cli.md fragment,
included in every agent group's composed CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:48:57 +03:00
github-actions[bot] 3b64d6cf76 chore: bump version to 2.0.41 2026-05-07 21:48:38 +00:00
github-actions[bot] 35233dabe8 docs: update token count to 149k tokens · 75% of context window 2026-05-07 21:48:28 +00:00
gavrielc 107945f10c fix(agent-to-agent): route A2A replies back to originating session (#2267)
Squash merge of PR #2267 by ddaniels.

When an agent group has more than one active session, A2A replies landed
in the newest session via findSessionByAgentGroup's ORDER BY created_at
DESC. The session that asked the question never saw the answer.

Adds origin-aware return-path routing with three layers:

1. Direct return-path: if the reply has in_reply_to, look up the
   triggering inbound row's source_session_id and route there.
2. Peer-affinity fallback: find the most recent A2A inbound from this
   peer and use its source_session_id.
3. Legacy fallback: newest active session (pre-migration compat).

Container-side: MCP send_message/send_file now thread the current
batch's in_reply_to through to outbound rows via current-batch.ts.

Also flips our A2A bug-documenting test (#2332) from asserting the
broken behavior to asserting the fixed behavior.

Co-Authored-By: Doug Daniels <ddaniels888@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:48:10 +03:00
johnnyfish 1240a0cf4f feat: fetch gateway skill from OneCLI API with static fallback 2026-05-07 22:16:48 +03:00
johnnyfish 4305c6a87d fix: slim credential docs in group CLAUDE.md and add onecli-gateway container skill 2026-05-07 13:25:27 +03:00
glifocat bdb8cf559c Merge branch 'qwibitai:main' into main 2026-05-06 16:25:59 +02:00
exe.dev user 5213c98506 setup: correct Slack member-ID card directions
Slack's profile button is in the bottom-left of the desktop sidebar (not
the top-right), and the "More" overflow icon next to "Copy member ID" is
the vertical kebab `⋮`, not the horizontal `⋯`. Match what users actually
see in Slack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:13:23 +00:00
gavrielc 3dc29bb674 Merge remote-tracking branch 'origin/main' into nc-cli 2026-05-06 00:46:53 +03:00
gavrielc 8771e259a8 style(cli): apply prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:42:33 +03:00
gavrielc a597b42648 feat(cli): add remaining resources, fix descriptions from code review
New read-only resources:
- destinations (agent-to-agent ACL + routing map)
- user-dms (DM channel cache)
- dropped-messages (audit trail for dropped messages)
- approvals (in-flight approval cards)

Description fixes from reading source:
- messaging-groups: add denied_at column (router checks it)
- sessions: fix container_status (idle is unused, stopped is
  auto-restarted by sweep)
- wirings: add note that threaded adapters force per-thread

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:40:15 +03:00
gavrielc 6865811147 feat(cli): add CRUD helper, resource definitions, and help command
Resource-first CLI: `nc groups list`, `nc wirings get <id>`, etc.
Seven resources defined (groups, messaging-groups, wirings, users,
roles, members, sessions) with full column documentation that serves
as the single source of truth for help output and arg validation.

- CRUD helper auto-registers list/get/create/update/delete from
  declarative resource definitions with generic SQL
- Custom operations for composite-PK resources (roles grant/revoke,
  members add/remove)
- Access model: open (reads) / approval (writes) / hidden
- `nc help` lists resources; `nc <resource> help` shows fields
- Positional target IDs: `nc groups get <id>`
- Removed unused priority column from wirings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:33:10 +03:00
gavrielc 5e2bf1cb54 feat(cli): replace MCP tool with standalone nc client in container
Drop the nc MCP tool in favor of a standalone Bun CLI script at
container/agent-runner/src/cli/nc.ts. Same interface as host-side
bin/nc — all three callers (operator, Claude on host, agent in
container) now use the same nc CLI.

Container transport: writes cli_request to outbound.db (BEGIN
IMMEDIATE for seq safety), polls inbound.db for response, acks via
processing_ack. Dockerfile adds a /usr/local/bin/nc wrapper that
execs the mounted source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:07:37 +03:00
gavrielc bc19b716bf feat(cli): wire nc CLI commands into container agent
Add delivery action handler (cli_request) so the host dispatches CLI
commands arriving from container agents via outbound.db and writes
responses back to inbound.db. Add nc MCP tool in the agent-runner
following the ask_user_question blocking pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 23:48:39 +03:00
glifocat ff90c8f565 Merge branch 'qwibitai:main' into main 2026-05-05 17:29:57 +02:00
gavrielc 13f6fc2093 merge: catch up nc-cli to main
Resolve conflict in src/index.ts shutdown sequence — keep both
stopCliServer() from nc-cli and try/finally + resetCircuitBreaker()
from main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:24:26 +03:00
glifocat 295275df69 Merge branch 'qwibitai:main' into main 2026-05-05 00:19:11 +02:00
gavrielc 594d1b4055 style(cli): apply prettier formatting
Pre-commit hook ran prettier on the prior commit but left the reformats
unstaged. Folding them in here so the branch is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:03:47 +03:00
gavrielc 3a3d2ee644 feat(cli): scaffold nc CLI with list-groups command
Adds a transport-agnostic CLI control plane shared between three eventual
callers (host shell, Claude in project, container agent) — though only the
host-side socket transport is wired in this commit. Container DB transport
and approval flow land alongside the first risky command.

- src/cli/frame.ts:        wire format (RequestFrame, ResponseFrame, CallerContext)
- src/cli/registry.ts:     command registry with RiskClass
- src/cli/dispatch.ts:     transport-agnostic dispatcher
- src/cli/transport.ts:    Transport interface
- src/cli/socket-client.ts: SocketTransport against data/nc.sock
- src/cli/socket-server.ts: host-side listener (chmod 0600, line-delimited JSON)
- src/cli/format.ts:       human table / --json output modes
- src/cli/client.ts:       `nc` argv -> frame -> transport -> stdout
- src/cli/commands/list-groups.ts: first command (riskClass: safe)
- bin/nc:                  bash launcher (resolves project root via symlink)
- src/index.ts:            start/stop server + import command barrel

`data/nc.sock` is intentionally separate from `data/cli.sock` (which the
existing chat-style channel adapter still owns).

Verified end-to-end: `nc list-groups`, `nc list groups`, `--json`,
unknown-command error, host-down ENOENT message with start instructions.
typecheck clean; eslint reports only the same `no-catch-all` warnings the
rest of the codebase has.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:03:16 +03:00
glifocat b92fdb5771 Merge remote-tracking branch 'upstream/main' 2026-04-24 17:12:34 +02:00
glifocat d3581bc65e Merge remote-tracking branch 'upstream/main' 2026-04-24 13:11:51 +02:00
glifocat ae2c09cbde docs: add fork-specific notes in FORK.md 2026-04-23 10:33:54 +02:00
447 changed files with 27821 additions and 6984 deletions
+1
View File
@@ -0,0 +1 @@
../.claude/skills
@@ -0,0 +1,49 @@
# Remove Atomic Chat
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied files (both trees)
```bash
rm -f container/agent-runner/src/atomic-chat-mcp-stdio.ts \
container/agent-runner/src/atomic-chat-registration.test.ts \
src/atomic-chat-env.ts \
src/atomic-chat-wiring.test.ts
```
## 2. Unregister the MCP server
In `container/agent-runner/src/index.ts`, remove the `atomic_chat: { … }` entry from the `mcpServers` object (leave `nanoclaw` and any other entries).
## 3. Revert the host-side edits in `src/container-runner.ts`
- Remove the `import { atomicChatEnvArgs } from './atomic-chat-env.js';` import.
- Remove the `args.push(...atomicChatEnvArgs());` line that follows the `TZ` env line.
- Restore the `container.stderr` logger to its single-line `log.debug(line, …)` form (remove the `[ATOMIC]` info-level branch).
## 4. Remove env vars
Remove the Atomic Chat block from `.env.example`, and the `ATOMIC_CHAT_*` lines from `.env` if you set them.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```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)
```
## Verification
After removal, confirm the tool is gone — in a wired agent, asking it to "list atomic chat models" should report no such tool, and the logs should show no `[ATOMIC]` lines after the last restart:
```bash
grep "\[ATOMIC\]" logs/nanoclaw.log | tail -5
```
+42 -32
View File
@@ -13,7 +13,7 @@ Tools exposed:
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
The skill ships the MCP server source (and its test) in this folder and copies them into the agent-runner tree at install time, then registers the server in `index.ts` and forwards host env vars in `container-runner.ts`. Registering the server is enough to expose its tools — the agent's allow-pattern (`mcp__atomic_chat__*`) is derived from the registered server name.
## Phase 1: Pre-flight
@@ -39,10 +39,19 @@ If the request fails:
## Phase 2: Apply Code Changes
### Copy the MCP server source
### Copy the skill's source and tests into both trees
This skill reaches into both the container (Bun) tree and the host (Node) tree, so its
files go into both, alongside the integration points they cover.
```bash
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
S=.claude/skills/add-atomic-chat-tool
# Container (Bun) tree — the MCP server and the registration wiring test
cp $S/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
cp $S/atomic-chat-registration.test.ts container/agent-runner/src/atomic-chat-registration.test.ts
# Host (Node) tree — the env-forwarding helper and the wiring test
cp $S/atomic-chat-env.ts src/atomic-chat-env.ts
cp $S/atomic-chat-wiring.test.ts src/atomic-chat-wiring.test.ts
```
### Register the MCP server in the agent-runner
@@ -79,40 +88,31 @@ Add an `atomic_chat` entry alongside `nanoclaw`:
};
```
### Add the tool glob to the allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
```ts
'mcp__nanoclaw__*',
'mcp__atomic_chat__*',
];
```
`atomic-chat-registration.test.ts` asserts this entry is present and points at the server module — the tool only appears to the agent if it is registered here.
### Forward host env vars into the container
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
The env-forwarding logic lives in the copied `src/atomic-chat-env.ts` (`atomicChatEnvArgs()`), so the reach-in into `buildContainerArgs` is a single call.
Import it in `src/container-runner.ts` (alongside the other local imports):
```ts
import { atomicChatEnvArgs } from './atomic-chat-env.js';
```
Then, in `buildContainerArgs`, find the `TZ` env line and add the call right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
args.push(...atomicChatEnvArgs());
```
Add ATOMIC_CHAT forwarding right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
```
`atomic-chat-wiring.test.ts` asserts this `args.push(...atomicChatEnvArgs())` call exists inside `buildContainerArgs`.
### Surface `[ATOMIC]` log lines at info level
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-ollama-tool` for `[OLLAMA]`) also edit to surface their own prefix. Touch only the `[ATOMIC]` branch and leave the rest of the block intact, so the edits coexist and removal restores it cleanly.
In the same file, find the stderr logger:
```ts
@@ -157,10 +157,18 @@ Append to `.env.example`:
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
# Host tree: buildContainerArgs wiring
pnpm exec vitest run src/atomic-chat-wiring.test.ts
# Container tree: index.ts registration
(cd container/agent-runner && bun test src/atomic-chat-registration.test.ts)
./container/build.sh
```
All three must be clean before proceeding.
All must be clean before proceeding. The wiring and registration tests confirm the two
integration points — the `buildContainerArgs` call and the `index.ts` registration — are
actually in place; a failure means one drifted. (The MCP server's own request/response
behavior against Atomic Chat is the author's build-time concern, not part of these tests —
verify it manually in Phase 4.)
## Phase 3: Configure
@@ -182,9 +190,12 @@ ATOMIC_CHAT_API_KEY=sk-...
### Restart the service
Run from your NanoClaw project root:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## Phase 4: Verify
@@ -215,9 +226,8 @@ Look for:
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
4. The container wasn't rebuilt — run `./container/build.sh`
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Atomic Chat"
@@ -0,0 +1,18 @@
/**
* Host-side env forwarding for the Atomic Chat MCP tool. Returns the Docker `-e`
* arguments that pass any `ATOMIC_CHAT_*` host overrides into the container.
*
* Lives in its own file so the reach-in in `container-runner.ts` is a single call
* (`args.push(...atomicChatEnvArgs())`) and this logic is behavior-testable in
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
*/
export function atomicChatEnvArgs(): string[] {
const args: string[] = [];
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
return args;
}
@@ -0,0 +1,65 @@
/**
* Wiring test for the MCP-server registration integration point (container/Bun tree).
*
* The handlers are behavior-tested in atomic-chat-mcp-stdio.test.ts, but that does not
* prove the server is registered — delete the index.ts entry and the tool simply never
* appears, yet the handler test stays green. index.ts is the container boot entry and is
* not cheaply invocable, so we assert the registration structurally: the `mcpServers`
* object literal has an `atomic_chat` property whose command runs `atomic-chat-mcp-stdio.ts`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.join(import.meta.dir, 'index.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
/** Find the object literal assigned to `const mcpServers = { ... }`. */
function mcpServersLiteral(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
let found: ts.ObjectLiteralExpression | undefined;
const visit = (node: ts.Node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'mcpServers' &&
node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
) {
found = node.initializer;
}
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
return obj.properties.find(
(p): p is ts.PropertyAssignment =>
ts.isPropertyAssignment(p) &&
((ts.isIdentifier(p.name) && p.name.text === name) ||
(ts.isStringLiteral(p.name) && p.name.text === name)),
);
}
describe('index.ts registers the atomic_chat MCP server', () => {
const obj = mcpServersLiteral(sourceFile());
it('finds the mcpServers object literal', () => {
expect(obj).toBeDefined();
});
it('has an atomic_chat entry', () => {
expect(obj && property(obj, 'atomic_chat')).toBeDefined();
});
it('points atomic_chat at atomic-chat-mcp-stdio.ts', () => {
const entry = obj && property(obj, 'atomic_chat');
const text = entry ? entry.getText() : '';
expect(text).toContain('atomic-chat-mcp-stdio.ts');
});
});
@@ -0,0 +1,69 @@
/**
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
*
* The env helper is behavior-tested in atomic-chat-env.test.ts, but that does not prove
* buildContainerArgs actually uses it — a direct unit test stays green even if the reach-in
* is deleted. buildContainerArgs is entangled with OneCLI and not cheaply invocable, so we
* assert the integration structurally: inside buildContainerArgs there is an
* `args.push(...atomicChatEnvArgs())` call. Delete the reach-in and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.resolve(process.cwd(), 'src/container-runner.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
function findFunction(sf: ts.SourceFile, name: string): ts.FunctionDeclaration | undefined {
let found: ts.FunctionDeclaration | undefined;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/** Is this node `args.push(...atomicChatEnvArgs())`? */
function isSpreadPushOfEnvArgs(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) return false;
const callee = node.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
callee.name.text !== 'push' ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== 'args'
) {
return false;
}
return node.arguments.some(
(arg) =>
ts.isSpreadElement(arg) &&
ts.isCallExpression(arg.expression) &&
ts.isIdentifier(arg.expression.expression) &&
arg.expression.expression.text === 'atomicChatEnvArgs',
);
}
describe('container-runner.ts wires in atomicChatEnvArgs', () => {
const sf = sourceFile();
const fn = findFunction(sf, 'buildContainerArgs');
it('finds buildContainerArgs', () => {
expect(fn).toBeDefined();
});
it('calls args.push(...atomicChatEnvArgs()) inside buildContainerArgs', () => {
let wired = false;
const visit = (node: ts.Node) => {
if (isSpreadPushOfEnvArgs(node)) wired = true;
if (!wired) ts.forEachChild(node, visit);
};
if (fn?.body) visit(fn.body);
expect(wired).toBe(true);
});
});
+22
View File
@@ -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.
+168
View File
@@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>"&'`), '&lt;script&gt;&quot;&amp;&#39;');
});
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('&lt;script&gt;'));
});
// ---- 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 &quot; 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 &quot; (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&quot;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');
});
});
+21
View File
@@ -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 };
}
+78
View File
@@ -0,0 +1,78 @@
# Remove the Codex agent provider
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. Switch codex groups back to the default
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`
## 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/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.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
```
This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so the provider can be re-added later.
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
## 4. Remove the CLI manifest entry
Delete the `@openai/codex` entry from `container/cli-tools.json`:
```bash
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");
'
```
## 5. Vault secret (optional)
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.
## 6. Rebuild and verify
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
pnpm test
cd container/agent-runner && bun test
```
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).
+103 -120
View File
@@ -1,161 +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`
- `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`
- `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
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: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
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");
}
'
```
### 3. Append the self-registration imports
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.
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
### Build
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
## Configuration
### Restart the host
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:
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
codex login
# macOS (launchd)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux (systemd)
systemctl --user restart nanoclaw
```
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.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
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.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
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.
**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.
### Per group / per session
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.
## Verify
### Validate
```bash
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
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/
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
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.
- `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
## Authenticate
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.
> **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.
```bash
pnpm exec tsx setup/index.ts --step provider-auth codex
```
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.
## Use it
Per group:
```bash
ncl groups config update --id <group-id> --provider codex
ncl groups restart --id <group-id>
```
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.
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.
## Troubleshooting
- **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.-]+)?$/);
});
});
+38 -52
View File
@@ -28,61 +28,34 @@ NanoClaw (pusher) Dashboard (npm package)
pnpm install @nanoco/nanoclaw-dashboard
```
### 2. Copy the pusher module
### 2. Copy the pusher module and its tests
Copy the resource file into src:
Copy all three resource files into `src/`. The tests ship with the skill and run against the composed project — they're how you confirm the skill works and is wired in correctly.
```
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.test.ts → src/dashboard-pusher.test.ts
.claude/skills/add-dashboard/resources/dashboard-wiring.test.ts → src/dashboard-wiring.test.ts
```
### 3. Add exports to src/db/index.ts
- `dashboard-pusher.test.ts` — behavior: starts the pusher, posts a real snapshot to a fake dashboard.
- `dashboard-wiring.test.ts` — the code edit in step 3: asserts (via the TS AST) that `index.ts` dynamically imports `./dashboard-pusher.js` and `await`s `startDashboard()` as colocated statements of `main()`, after DB init and before the boot-complete log. Delete or misplace the edit and this goes red.
Add these two export blocks if not already present:
### 3. Wire into src/index.ts
This is the skill's one integration point, and it's deliberately minimal and self-contained: all the startup logic lives in `dashboard-pusher.ts`, and the import is **colocated** with the call so the whole edit is a single block in one place — there's no separate top-of-file import to add (or to remember to remove).
Add this block inside `main()`, just before the `log.info('NanoClaw running')` line:
```typescript
// After the messaging-groups exports, add:
export {
getMessagingGroupsByAgentGroup,
} from './messaging-groups.js';
// Before the credentials exports, add:
export {
createDestination,
getDestinations,
getDestinationByName,
getDestinationByTarget,
hasDestination,
deleteDestination,
} from './agent-destinations.js';
// Dashboard (optional; no-ops without DASHBOARD_SECRET)
const { startDashboard } = await import('./dashboard-pusher.js');
await startDashboard();
```
### 4. Wire into src/index.ts
`startDashboard()` reads `DASHBOARD_SECRET`/`DASHBOARD_PORT` itself and no-ops if the secret is unset, so nothing else in core needs to change.
Add the `readEnvFile` import at the top if not already present:
```typescript
import { readEnvFile } from './env.js';
```
Add after step 7 (OneCLI approval handler), before the `log.info('NanoClaw running')` line:
```typescript
// 8. Dashboard (optional)
const dashboardEnv = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const dashboardSecret = process.env.DASHBOARD_SECRET || dashboardEnv.DASHBOARD_SECRET;
const dashboardPort = parseInt(process.env.DASHBOARD_PORT || dashboardEnv.DASHBOARD_PORT || '3100', 10);
if (dashboardSecret) {
const { startDashboard } = await import('@nanoco/nanoclaw-dashboard');
const { startDashboardPusher } = await import('./dashboard-pusher.js');
startDashboard({ port: dashboardPort, secret: dashboardSecret });
startDashboardPusher({ port: dashboardPort, secret: dashboardSecret, intervalMs: 60000 });
} else {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
}
```
### 5. Add environment variables to .env
### 4. Add environment variables to .env
```
DASHBOARD_SECRET=<generate-a-random-secret>
@@ -91,15 +64,23 @@ DASHBOARD_PORT=3100
Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"`
### 6. Build and restart
### 5. Build, test, and restart
Run from your NanoClaw project root:
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
pnpm exec vitest run src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts # behavior + wiring
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### 7. Verify
Run `build` **before** the tests: it's what guards the `@nanoco/nanoclaw-dashboard` dependency. `dashboard-pusher.ts` reaches the package through `await import('@nanoco/nanoclaw-dashboard')`, so if step 4 was skipped, `pnpm run build` fails with `TS2307: Cannot find module`. The behavior test deliberately *mocks* that package — its `startDashboard` binds a real dashboard port, a side effect we don't want in a test — so the test alone would pass with the dependency missing. Build is therefore the leg that verifies the dependency is installed; keep it ahead of the tests in the validate step.
### 6. Verify (runtime smoke check)
Once the service is restarted, confirm the dashboard is live:
```bash
curl -s http://localhost:3100/api/status
@@ -129,10 +110,15 @@ Open `http://localhost:3100/dashboard` in a browser.
## Removal
Reverse the apply steps. Safe to re-run even if some pieces are already gone.
```bash
rm -f src/dashboard-pusher.ts src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts
pnpm uninstall @nanoco/nanoclaw-dashboard 2>/dev/null || true
```
Then, by hand, remove the single dashboard block the skill added to `main()` in `src/index.ts` (the `// Dashboard (optional…)` comment, the `await import('./dashboard-pusher.js')` line, and the `await startDashboard();` call), and remove `DASHBOARD_SECRET` and `DASHBOARD_PORT` from `.env`.
```bash
pnpm uninstall @nanoco/nanoclaw-dashboard
rm src/dashboard-pusher.ts
# Remove the dashboard block from src/index.ts
# Remove DASHBOARD_SECRET and DASHBOARD_PORT from .env
pnpm run build
```
@@ -0,0 +1,124 @@
/**
* Integration test for the add-dashboard skill's integration point
* `startDashboard()`, the single call wired into src/index.ts.
*
* Archetype: in-process seam. It drives the *real* entry point against a
* *real* (in-memory) central DB and a *fake* dashboard HTTP endpoint. The
* only things stubbed are the external dashboard package (not needed to prove
* the wiring) and env-file reads (so the test doesn't depend on the real
* .env). This proves the skill works once applied: with a secret set it
* collects a DB snapshot and posts it; with no secret it does nothing.
*
* Ships with the add-dashboard skill; apply copies it to src/ alongside the
* pusher so it runs against the composed project.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import http from 'http';
import type { AddressInfo } from 'net';
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-dashboard', ASSISTANT_NAME: 'TestBot' };
});
// The dashboard server package isn't needed to prove the integration point.
vi.mock('@nanoco/nanoclaw-dashboard', () => ({ startDashboard: vi.fn() }));
// Don't read the real .env — the test controls config via process.env only.
vi.mock('./env.js', () => ({ readEnvFile: () => ({}) }));
const TEST_DIR = '/tmp/nanoclaw-test-dashboard';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
import { startDashboard, stopDashboardPusher } from './dashboard-pusher.js';
function now(): string {
return new Date().toISOString();
}
interface CapturedPost {
path: string;
auth: string | undefined;
body: Record<string, unknown>;
}
/** A fake dashboard server that captures the bodies the pusher POSTs. */
function startFakeDashboard(): Promise<{ port: number; posts: CapturedPost[]; close: () => Promise<void> }> {
const posts: CapturedPost[] = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', () => {
let body: Record<string, unknown> = {};
try { body = JSON.parse(raw); } catch { /* leave empty */ }
posts.push({ path: req.url || '', auth: req.headers.authorization, body });
res.writeHead(200);
res.end('ok');
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({ port, posts, close: () => new Promise<void>((r) => server.close(() => r())) });
});
});
}
async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!pred()) {
if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition');
await new Promise((r) => setTimeout(r, 20));
}
}
describe('add-dashboard integration point (startDashboard)', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
});
afterEach(() => {
stopDashboardPusher();
closeDb();
delete process.env.DASHBOARD_SECRET;
delete process.env.DASHBOARD_PORT;
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('posts a snapshot of the seeded state when DASHBOARD_SECRET is set', async () => {
createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now() });
const dash = await startFakeDashboard();
process.env.DASHBOARD_SECRET = 'test-secret';
process.env.DASHBOARD_PORT = String(dash.port);
await startDashboard();
await waitFor(() => dash.posts.some((p) => p.path === '/api/ingest'));
const ingest = dash.posts.find((p) => p.path === '/api/ingest')!;
expect(ingest.auth).toBe('Bearer test-secret');
expect(ingest.body.assistant_name).toBe('TestBot');
const groups = ingest.body.agent_groups as Array<{ id: string }>;
expect(groups.map((g) => g.id)).toContain('ag-1');
for (const key of ['timestamp', 'sessions', 'channels', 'users', 'tokens', 'context_windows', 'activity', 'messages']) {
expect(ingest.body).toHaveProperty(key);
}
await dash.close();
});
it('does nothing when DASHBOARD_SECRET is not set', async () => {
const dash = await startFakeDashboard();
// no DASHBOARD_SECRET in env, and readEnvFile is stubbed to {}
await startDashboard();
await new Promise((r) => setTimeout(r, 100));
expect(dash.posts).toHaveLength(0);
await dash.close();
});
});
@@ -10,15 +10,17 @@ import Database from 'better-sqlite3';
import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js';
import { getSessionsByAgentGroup } from './db/sessions.js';
import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js';
import { getDestinations } from './db/agent-destinations.js';
import { getMembers } from './db/agent-group-members.js';
import { getAllUsers, getUser } from './db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './db/user-roles.js';
import { getUserDmsForUser } from './db/user-dms.js';
import { getDestinations } from './modules/agent-to-agent/db/agent-destinations.js';
import { getMembers } from './modules/permissions/db/agent-group-members.js';
import { getAllUsers, getUser } from './modules/permissions/db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './modules/permissions/db/user-roles.js';
import { getUserDmsForUser } from './modules/permissions/db/user-dms.js';
import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js';
import { DATA_DIR, ASSISTANT_NAME } from './config.js';
import { getDb } from './db/connection.js';
import { getContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { readEnvFile } from './env.js';
interface PusherConfig {
port: number;
@@ -56,6 +58,26 @@ export function stopDashboardPusher(): void {
}
}
/**
* Skill entry point the single call wired into the host boot sequence.
*
* All of the dashboard's startup logic lives here, in the skill's own file,
* so the integration point in src/index.ts is just `await startDashboard()`.
* No-ops (and says so) when DASHBOARD_SECRET is unset.
*/
export async function startDashboard(): Promise<void> {
const env = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const secret = process.env.DASHBOARD_SECRET || env.DASHBOARD_SECRET;
const port = parseInt(process.env.DASHBOARD_PORT || env.DASHBOARD_PORT || '3100', 10);
if (!secret) {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
return;
}
const { startDashboard: startServer } = await import('@nanoco/nanoclaw-dashboard');
startServer({ port, secret });
startDashboardPusher({ port, secret, intervalMs: 60000 });
}
/** Fire-and-forget POST to the dashboard. */
function postJson(config: PusherConfig, urlPath: string, data: unknown): void {
const body = JSON.stringify(data);
@@ -157,7 +179,7 @@ function collectAgentGroups() {
name: g.name,
folder: g.folder,
agent_provider: g.agent_provider,
container_config: g.container_config ? JSON.parse(g.container_config) : null,
container_config: getContainerConfig(g.id) ?? null,
sessionCount: sessions.length,
runningSessions: running.length,
wirings,
@@ -0,0 +1,81 @@
/**
* Wiring test for the add-dashboard skill's code-edit integration point.
*
* The skill inserts one colocated block into src/index.ts (a dynamic
* `import('./dashboard-pusher.js')` + `await startDashboard()` in main()). A
* behavioral test of the pusher can't see whether that edit is actually
* present and correctly placed booting the real host is too heavy so this
* asserts the edit *structurally*, via the TypeScript AST. It verifies not
* just that the call exists, but that:
* - the pusher module is dynamically imported by its correct path,
* - startDashboard() is awaited,
* - both are DIRECT statements of main()'s body (right scope/level, not
* nested or stranded in another function),
* - the import precedes the call, and the whole block sits after DB init
* and before the boot-complete log (right place).
*
* Delete or misplace the edit and this goes red. Combined with the unit test
* (behavior of startDashboard) and the build (the call still type-checks),
* the three together cover deletion, misplacement, drift, and behavior for
* a true code edit, with no registry required.
*
* Ships with the skill; apply copies it to src/.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
const indexPath = path.resolve(process.cwd(), 'src/index.ts');
const source = fs.readFileSync(indexPath, 'utf8');
const sf = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
function mainBody(): ts.NodeArray<ts.Statement> {
let body: ts.NodeArray<ts.Statement> | undefined;
sf.forEachChild((n) => {
if (ts.isFunctionDeclaration(n) && n.name?.text === 'main' && n.body) {
body = n.body.statements;
}
});
if (!body) throw new Error('main() not found in src/index.ts');
return body;
}
function isAwaitedStartDashboard(s: ts.Statement): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isAwaitExpression(s.expression) &&
ts.isCallExpression(s.expression.expression) &&
ts.isIdentifier(s.expression.expression.expression) &&
s.expression.expression.expression.text === 'startDashboard'
);
}
/** `const { ... } = await import('./dashboard-pusher.js')` as a statement. */
function isDynamicImportOfPusher(s: ts.Statement): boolean {
if (!ts.isVariableStatement(s)) return false;
const init = s.declarationList.declarations[0]?.initializer;
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
const call = init.expression;
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
const arg = call.arguments[0];
return !!arg && ts.isStringLiteral(arg) && arg.text === './dashboard-pusher.js';
}
describe('add-dashboard wiring in src/index.ts', () => {
it('dynamically imports the pusher and awaits startDashboard(), colocated in main(), after DB init and before the boot-complete log', () => {
const stmts = mainBody();
const importIdx = stmts.findIndex(isDynamicImportOfPusher);
const callIdx = stmts.findIndex(isAwaitedStartDashboard);
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
expect(importIdx, "dynamic import('./dashboard-pusher.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
expect(callIdx, 'await startDashboard() must be a statement of main()').toBeGreaterThanOrEqual(0);
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
expect(importIdx, 'the dynamic import must come after DB init').toBeGreaterThan(migrateIdx);
expect(callIdx, 'the call must come after its import (colocated)').toBeGreaterThan(importIdx);
expect(callIdx, 'startDashboard() must run before the boot-complete log').toBeLessThan(runningIdx);
});
});
+14 -5
View File
@@ -1,11 +1,17 @@
# Remove DeltaChat
## 1. Disable the adapter
## 1. Remove the adapter
Comment out the import in `src/channels/index.ts`:
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
// import './deltachat.js';
import './deltachat.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/deltachat.ts src/channels/deltachat-registration.test.ts
```
## 2. Remove credentials
@@ -23,14 +29,17 @@ DC_SMTP_PORT
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart nanoclaw
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
## 4. Remove account data (optional)
+17 -6
View File
@@ -14,6 +14,7 @@ The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directl
Skip to **Credentials** if all of these are already in place:
- `src/channels/deltachat.ts` exists
- `src/channels/deltachat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './deltachat.js';`
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
@@ -25,10 +26,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
git show origin/channels:src/channels/deltachat-registration.test.ts > src/channels/deltachat-registration.test.ts
```
### 3. Append the self-registration import
@@ -45,12 +47,17 @@ import './deltachat.js';
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/deltachat-registration.test.ts
```
Both must be clean before proceeding. `deltachat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `deltachat`. It goes red if the `import './deltachat.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@deltachat/stdio-rpc-server` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. Importing is safe: deltachat instantiates the rpc client only in `setup()` (at host startup), never at import.
End-to-end message delivery against a real email account is verified manually once the service is running — see Wiring and Troubleshooting.
## Account Setup
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
@@ -98,12 +105,16 @@ The `/set-avatar` command (send an image with that caption) is the easiest way t
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# Linux
systemctl --user restart nanoclaw
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
@@ -232,7 +243,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
```bash
rm -f dc-account/accounts.lock
systemctl --user restart nanoclaw
systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"
```
### Bot not responding after restart
-54
View File
@@ -1,54 +0,0 @@
# Verify DeltaChat
## 1. Check the adapter started
```bash
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
```
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
## 2. Check IMAP/SMTP connectivity
Replace with your provider's hostnames from `.env`:
```bash
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
```
## 3. End-to-end message test
1. Open DeltaChat on your device
2. Add the bot email address as a contact
3. Send a message
4. The bot should respond within a few seconds
If nothing arrives, check:
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
```
## 4. Check messaging group was created
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
## 5. Verify user access
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.
+37 -4
View File
@@ -1,7 +1,40 @@
# Remove Discord
1. Comment out `import './discord.js'` in `src/channels/index.ts`
2. Remove `DISCORD_BOT_TOKEN` from `.env`
3. Rebuild and restart
Every step is idempotent — safe to re-run.
No package to uninstall — Discord is built in.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './discord.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/discord.ts src/channels/discord-registration.test.ts
```
## 2. Remove credentials
Remove `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/discord
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+9 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter i
Skip to **Credentials** if all of these are already in place:
- `src/channels/discord.ts` exists
- `src/channels/discord-registration.test.ts` exists
- `src/channels/index.ts` contains `import './discord.js';`
- `@chat-adapter/discord` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
```
### 3. Append the self-registration import
@@ -44,15 +46,18 @@ 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
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/discord-registration.test.ts
```
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
### Create Discord Bot
-3
View File
@@ -1,3 +0,0 @@
# Verify Discord
Send a message in a channel where the bot has access, or DM the bot directly. The bot should respond within a few seconds.
+63
View File
@@ -0,0 +1,63 @@
# Remove Emacs
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './emacs.js';
```
Then delete the copied adapter, its tests, and the Lisp client:
```bash
rm -f src/channels/emacs.ts src/channels/emacs.test.ts src/channels/emacs-registration.test.ts emacs/nanoclaw.el
```
## 2. Remove credentials
Remove the `EMACS_*` lines from `.env`:
```bash
EMACS_ENABLED
EMACS_CHANNEL_PORT
EMACS_AUTH_TOKEN
EMACS_PLATFORM_ID
```
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
## 4. Remove the Emacs config (optional)
Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, or `init.el`):
```elisp
;; NanoClaw — personal AI assistant channel
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; ...and the associated keybindings / nanoclaw-auth-token / nanoclaw-port settings
```
Reload your config or restart Emacs.
## 5. Remove the messaging group (optional)
To clean up the wired messaging group:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
+18 -20
View File
@@ -24,6 +24,8 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and
Skip to **Enable** if all of these are already in place:
- `src/channels/emacs.ts` exists
- `src/channels/emacs.test.ts` exists
- `src/channels/emacs-registration.test.ts` exists
- `emacs/nanoclaw.el` exists
- `src/channels/index.ts` contains `import './emacs.js';`
@@ -39,9 +41,10 @@ git fetch origin channels
```bash
mkdir -p emacs
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:src/channels/emacs-registration.test.ts > src/channels/emacs-registration.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
```
### 3. Append the self-registration import
@@ -52,13 +55,16 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './emacs.js';
```
### 4. Build
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/emacs-registration.test.ts
```
No npm package to install — the adapter uses only Node builtins (`http`).
Both must be clean before proceeding. `emacs-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `emacs`. It goes red if the `import './emacs.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter uses only Node builtins (`http`), so there is no npm dependency to guard for this channel.
End-to-end message delivery from a real Emacs buffer is verified manually once the service is running — see Verify and Troubleshooting.
## Enable
@@ -162,10 +168,13 @@ If you changed `EMACS_CHANNEL_PORT` from the default:
## Restart NanoClaw
Run from your NanoClaw project root:
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# systemctl --user restart nanoclaw # Linux
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
```
## Verify
@@ -240,7 +249,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
### No response from agent
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
1. NanoClaw running: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
@@ -282,15 +291,4 @@ If an agent outputs org-mode directly, markers get double-converted and render i
## Removal
```bash
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
# Remove the `import './emacs.js';` line from src/channels/index.ts
# Remove EMACS_* lines from .env
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# systemctl --user restart nanoclaw # Linux
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
See [REMOVE.md](REMOVE.md) to uninstall this channel.
+67
View File
@@ -0,0 +1,67 @@
# Remove Google Calendar Tool
Idempotent — safe to run even if some steps were never applied.
## 1. Unregister the MCP server (per group)
For each group that had Calendar wired (`ncl groups list` to enumerate):
```bash
ncl groups config remove-mcp-server --id <group-id> --name calendar
```
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
## 3. Delete the copied test file
```bash
rm -f src/gcal-dockerfile.test.ts
```
## 4. Revert the Dockerfile edits
Remove the `ARG CALENDAR_MCP_VERSION=...` line and the `@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}` entry from the pnpm global-install block in `container/Dockerfile`. If Calendar shared the gmail install block, leave the gmail entry intact; if it had a standalone `RUN ... pnpm install -g "@cocal/google-calendar-mcp@..."` block, delete that whole `RUN` line.
## 5. Rebuild and restart
```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)
```
Kill any running agent containers so they respawn without the `calendar` MCP server:
```bash
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
```
## 6. Optional: remove stubs and disconnect OneCLI
```bash
rm -rf ~/.calendar-mcp/
onecli apps disconnect --provider google-calendar
```
## Verification
After removal, in a wired agent asking it to "list my calendars" should report no calendar tool, and the dependency-guard test is gone:
```bash
ls src/gcal-dockerfile.test.ts 2>&1 # No such file or directory
```
+58 -35
View File
@@ -92,7 +92,6 @@ onecli agents list
```bash
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
@@ -121,9 +120,18 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
### Add tools to allowlist
`container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `calendar` in Phase 3 automatically allows `mcp__calendar__*`.
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
### Install the dependency-guard test
`@cocal/google-calendar-mcp` is a stdio CLI installed in the image, not an imported module, so `tsc` and the runtime tests never reference it — only the Dockerfile edit above proves it is present. Copy the guard test into the host test tree (vitest) so the Dockerfile `ARG` + install line stay covered:
```bash
cp .claude/skills/add-gcal-tool/gcal-dockerfile.test.ts src/gcal-dockerfile.test.ts
pnpm exec vitest run src/gcal-dockerfile.test.ts
```
`cp` overwrites in place, so re-running this skill is safe.
### Rebuild the container image
@@ -133,40 +141,59 @@ Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'`
## Phase 3: Wire Per-Agent-Group
For each agent group, merge into `groups/<folder>/container.json`:
For each agent group, persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.calendar` entry and an `additionalMounts` entry for `.calendar-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
```jsonc
{
"mcpServers": {
"calendar": {
"command": "google-calendar-mcp",
"args": [],
"env": {
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.calendar-mcp",
"containerPath": ".calendar-mcp",
"readonly": false
}
]
}
### Register the MCP server
For each chosen `<group-id>` (use `ncl groups list` to enumerate):
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name calendar \
--command google-calendar-mcp \
--args '[]' \
--env '{"GOOGLE_OAUTH_CREDENTIALS":"/workspace/extra/.calendar-mcp/gcp-oauth.keys.json","GOOGLE_CALENDAR_MCP_TOKEN_PATH":"/workspace/extra/.calendar-mcp/credentials.json"}'
```
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
### Add the `.calendar-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
## Phase 4: Build and Restart
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Kill any existing agent containers so they respawn with the new mcpServers config:
@@ -193,18 +220,14 @@ Common signals:
- `command not found: google-calendar-mcp` → image not rebuilt.
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
- Agent says "I don't have calendar tools" → the `calendar` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (`./container/build.sh`, `--no-cache` if suspicious).
## Removal
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
See [REMOVE.md](REMOVE.md) — unregisters the MCP server, drops the `.calendar-mcp` mount, deletes the copied test, reverts the Dockerfile edits, and rebuilds.
## Credits & references
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
- **Why not gongrzhe:** `@gongrzhe/server-calendar-autoauth-mcp` only supports the primary calendar with 5 event-level tools. The cocal server supports multi-account and multi-calendar with the full tool surface.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
@@ -0,0 +1,36 @@
/**
* Dependency guard for the Google Calendar MCP server (host/vitest tree).
*
* `@cocal/google-calendar-mcp` is a stdio CLI installed globally in the image,
* not an imported module, so no behavior test can drive it and `tsc` never sees
* it. The only in-tree footprint of this skill is the Dockerfile edit, so the
* guard is structural: assert the pinned `ARG` and the pnpm global-install line
* both exist. Drop either Phase 2 Dockerfile edit and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
const p = path.resolve(process.cwd(), 'container/Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs @cocal/google-calendar-mcp', () => {
const text = dockerfile();
it('pins the version via an ARG', () => {
expect(text).toMatch(/^\s*ARG\s+CALENDAR_MCP_VERSION=/m);
});
it('installs the package pinned to that ARG in a pnpm global-install block', () => {
// Match `pnpm install -g ... "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"`,
// tolerating line continuations between `install -g` and the package.
const installsCalendar =
/pnpm\s+install\s+-g[\s\S]*?@cocal\/google-calendar-mcp@\$\{CALENDAR_MCP_VERSION\}/.test(
text,
);
expect(installsCalendar).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove Google Chat Channel
# Remove Google Chat
1. Comment out `import './gchat.js'` in `src/channels/index.ts`
2. Remove `GCHAT_CREDENTIALS` from `.env`
3. `pnpm uninstall @chat-adapter/gchat`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './gchat.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/gchat.ts src/channels/gchat-registration.test.ts
```
## 2. Remove credentials
Remove `GCHAT_CREDENTIALS` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/gchat
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+11 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapt
Skip to **Credentials** if all of these are already in place:
- `src/channels/gchat.ts` exists
- `src/channels/gchat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './gchat.js';`
- `@chat-adapter/gchat` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
```
### 3. Append the self-registration import
@@ -44,15 +46,20 @@ 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
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
-3
View File
@@ -1,3 +0,0 @@
# Verify Google Chat Channel
Add the bot to a Google Chat space, then send a message or @mention the bot. The bot should respond within a few seconds.
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove GitHub Channel
# Remove GitHub
1. Comment out `import './github.js'` in `src/channels/index.ts`
2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @chat-adapter/github`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './github.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/github.ts src/channels/github-registration.test.ts
```
## 2. Remove credentials
Remove `GITHUB_TOKEN`, `GITHUB_WEBHOOK_SECRET`, and `GITHUB_BOT_USERNAME` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/github
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+22 -7
View File
@@ -20,6 +20,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/github.ts` exists
- `src/channels/github-registration.test.ts` exists
- `src/channels/index.ts` contains `import './github.js';`
- `@chat-adapter/github` is listed in `package.json` dependencies
@@ -31,10 +32,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
```
### 3. Append the self-registration import
@@ -48,15 +50,20 @@ 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
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/github-registration.test.ts
```
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
### 1. Create a Personal Access Token for the bot account
@@ -104,8 +111,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
```sql
-- Create messaging group (one per repo)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
@@ -136,7 +143,15 @@ Use `per-thread` session mode so each PR/issue gets its own agent session.
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
## Channel Info
-3
View File
@@ -1,3 +0,0 @@
# Verify GitHub Channel
@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds.
+57
View File
@@ -0,0 +1,57 @@
# Remove Gmail Tool
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied tests
```bash
rm -f container/agent-runner/src/providers/gmail-dockerfile.test.ts \
container/agent-runner/src/providers/gmail-allow-pattern.test.ts
```
## 2. Unregister the MCP server (per group)
`ncl groups list` shows the groups. For each group that had Gmail wired:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
## 3. Remove the `.gmail-mcp` mount (per group)
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
```bash
GROUP_ID='<group-id>'
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
## 4. Remove the Dockerfile install
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```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)
```
## 6. (Optional) Drop the host stubs and disconnect
```bash
rm -rf ~/.gmail-mcp/ # only if no other host tool needs the stubs
onecli apps disconnect --provider gmail # revoke the OneCLI Gmail connection
```
+72 -42
View File
@@ -98,18 +98,30 @@ onecli agents secrets --id <agent-id>
```bash
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Copy the skill's tests into the container tree
Both integration points this skill relies on live in the container (Bun) tree — the Dockerfile package install and the dynamic allow-pattern derivation in `claude.ts` — so the guards go there. `cp` overwrites, so re-running is safe.
```bash
S=.claude/skills/add-gmail-tool
cp $S/gmail-dockerfile.test.ts container/agent-runner/src/providers/gmail-dockerfile.test.ts
cp $S/gmail-allow-pattern.test.ts container/agent-runner/src/providers/gmail-allow-pattern.test.ts
```
- `gmail-dockerfile.test.ts` asserts the `GMAIL_MCP_VERSION` ARG and the pinned `pnpm install -g` line are present — the `gmail-mcp` binary is a Dockerfile-installed CLI, not importable or typed, so this structural guard is what goes red if the install is dropped.
- `gmail-allow-pattern.test.ts` asserts `claude.ts` still spreads `Object.keys(this.mcpServers).map(mcpAllowPattern)` into `allowedTools` — the derivation that makes registering `gmail` (Phase 3) enough to expose `mcp__gmail__*`.
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block:
```dockerfile
ARG CLAUDE_CODE_VERSION=2.1.116
ARG CLAUDE_CODE_VERSION=2.1.154
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
```
@@ -119,7 +131,7 @@ Add a new line:
ARG GMAIL_MCP_VERSION=1.1.11
```
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block directly after it (before the `# ---- ncl CLI wrapper` section):
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
@@ -132,9 +144,7 @@ Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates tr
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
The Gmail allow-pattern is derived automatically. `container/agent-runner/src/providers/claude.ts` builds `allowedTools` from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 exposes `mcp__gmail__*` to the agent.
### Rebuild the container image
@@ -146,42 +156,67 @@ Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cache
## Phase 3: Wire Per-Agent-Group
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.gmail` entry and an `additionalMounts` entry for `.gmail-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
Merge these into the group's `container.json`:
### List groups, pick which ones get Gmail
```jsonc
{
"mcpServers": {
"gmail": {
"command": "gmail-mcp",
"args": [],
"env": {
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.gmail-mcp",
"containerPath": ".gmail-mcp",
"readonly": false
}
]
}
```bash
ncl groups list
```
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
### Register the MCP server
For each chosen `<group-id>`:
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name gmail \
--command gmail-mcp \
--args '[]' \
--env '{"GMAIL_OAUTH_PATH":"/workspace/extra/.gmail-mcp/gcp-oauth.keys.json","GMAIL_CREDENTIALS_PATH":"/workspace/extra/.gmail-mcp/credentials.json"}'
```
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
### Add the `.gmail-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
## Phase 4: Build and Restart
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
## Phase 4: Build, Validate, Restart
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
(cd container/agent-runner && bun test src/providers/gmail-dockerfile.test.ts src/providers/gmail-allow-pattern.test.ts)
```
All must be clean before proceeding. `gmail-dockerfile.test.ts` confirms the package install is wired into the image; `gmail-allow-pattern.test.ts` confirms the allow-pattern derivation that exposes `mcp__gmail__*`. A failure means one drifted.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
## Phase 5: Verify
@@ -206,16 +241,11 @@ Common signals:
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
- Agent says "I don't have Gmail tools" → the `gmail` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (rebuild with `./container/build.sh`, with `--no-cache` if suspicious).
## Removal
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
See [REMOVE.md](REMOVE.md) for the idempotent removal procedure (delete the copied tests, unregister the MCP server per group, drop the mount, remove the Dockerfile install, rebuild, and optionally drop the stubs and disconnect OneCLI).
## Notes
@@ -228,5 +258,5 @@ Common signals:
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
- **Addresses:** [issue #1500](https://github.com/nanocoai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/nanocoai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
@@ -0,0 +1,55 @@
/**
* Guard for the dynamic MCP allow-pattern derivation this skill depends on.
*
* Registering `gmail` in a group's mcpServers map is the *only* wiring needed to expose
* `mcp__gmail__*` to the agent there is no static TOOL_ALLOWLIST edit. That holds solely
* because `claude.ts` derives the allow-pattern from the registered servers at query time:
*
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* so we assert the derivation structurally. Delete or rename the derivation and this goes
* red surfacing that `gmail` tools would silently be filtered out despite being registered.
*
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function source(): { sf: ts.SourceFile; text: string } {
const p = path.join(import.meta.dir, 'claude.ts');
const text = fs.readFileSync(p, 'utf8');
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
}
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
function expectedPattern(name: string): string {
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
const { sf, text } = source();
it('defines an mcpAllowPattern function', () => {
let found = false;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === 'mcpAllowPattern') found = true;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
expect(found).toBe(true);
});
it('spreads Object.keys(this.mcpServers).map(mcpAllowPattern) into allowedTools', () => {
// Normalize whitespace so formatting changes don't break the assertion.
const flat = text.replace(/\s+/g, ' ');
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
});
it('maps a gmail server name to mcp__gmail__*', () => {
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
});
});
@@ -0,0 +1,36 @@
/**
* Structural guard for the Gmail MCP package-install integration point (container image).
*
* `@gongrzhe/server-gmail-autoauth-mcp` is a CLI binary installed into the image via the
* Dockerfile it is not importable or typed from this tree, so the build leg can't catch
* its removal and there's no runtime seam to behavior-test. This asserts the Dockerfile
* still carries the ARG and the pinned pnpm global-install line. Drop either and this goes
* red, signalling the agent would boot without the `gmail-mcp` binary on PATH.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
function dockerfile(): string {
// container/agent-runner/src/providers/ -> ../../../Dockerfile == container/Dockerfile
const p = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs the Gmail MCP server', () => {
const text = dockerfile();
it('declares the GMAIL_MCP_VERSION ARG', () => {
expect(/ARG\s+GMAIL_MCP_VERSION=/.test(text)).toBe(true);
});
it('pnpm-installs @gongrzhe/server-gmail-autoauth-mcp pinned to the ARG', () => {
expect(text).toContain('pnpm install -g');
expect(/@gongrzhe\/server-gmail-autoauth-mcp@\$\{GMAIL_MCP_VERSION\}/.test(text)).toBe(true);
});
it('pins the zod-to-json-schema workaround version', () => {
expect(/zod-to-json-schema@3\.22\.5/.test(text)).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove iMessage Channel
# Remove iMessage
1. Comment out `import './imessage.js'` in `src/channels/index.ts`
2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env`
3. `pnpm uninstall chat-adapter-imessage`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './imessage.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/imessage.ts src/channels/imessage-registration.test.ts
```
## 2. Remove credentials
Remove `IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, and `IMESSAGE_API_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall chat-adapter-imessage
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+11 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter
Skip to **Credentials** if all of these are already in place:
- `src/channels/imessage.ts` exists
- `src/channels/imessage-registration.test.ts` exists
- `src/channels/index.ts` contains `import './imessage.js';`
- `chat-adapter-imessage` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './imessage.js';
pnpm install chat-adapter-imessage@0.1.1
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
## Credentials
### Local Mode (macOS)
@@ -75,7 +82,7 @@ Stop and wait for the user to confirm before continuing.
### Remote Mode (Photon API)
1. Set up a [Photon](https://photon.im) account
1. Set up a [Photon](https://photon.codes) account
2. Get your server URL and API key
### Configure environment
-3
View File
@@ -1,3 +0,0 @@
# Verify iMessage Channel
Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds.
@@ -0,0 +1,38 @@
# Remove Karpathy LLM Wiki
Every step is idempotent — safe to re-run.
## 1. Remove the shared container skill
The wiki container skill lives in the shared `container/skills/` mount, which is auto-discovered and symlinked into every agent group. Delete it so it stops appearing in all containers:
```bash
rm -rf container/skills/wiki
```
## 2. Remove the wiki section from the group CLAUDE.md
The wiki section is wrapped in marker comments. Delete the block (markers included) from the group's CLAUDE.md — find it under `groups/<folder>/CLAUDE.md`:
```bash
# Replace <folder> with the group folder you set up the wiki for.
perl -0pi -e 's/\n?<!-- BEGIN karpathy-llm-wiki -->.*?<!-- END karpathy-llm-wiki -->\n?//s' groups/<folder>/CLAUDE.md
```
If the markers are absent, nothing is removed (the block was already gone or never added).
## 3. Restart so containers drop the skill
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## User content is preserved
The per-group `groups/<folder>/wiki/` and `groups/<folder>/sources/` directories hold the user's own knowledge base and ingested sources. They are left in place. Delete them by hand only if the user explicitly wants their wiki content gone:
```bash
rm -rf groups/<folder>/wiki groups/<folder>/sources
```
+21 -5
View File
@@ -7,6 +7,8 @@ description: Add a persistent wiki knowledge base to a NanoClaw group. Based on
Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern.
Each step is safe to re-run: directory creation uses `mkdir -p`, initial wiki files are created only if absent, the container skill is preserved unless the user opts to update it, and the group CLAUDE.md section is replaced in place via marker comments rather than duplicated.
## Step 1: Read the pattern
Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build.
@@ -33,15 +35,26 @@ Based on this discussion, create three things:
### 3a. Directory structure
Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain.
Create `wiki/` and `sources/` directories in the group folder (`mkdir -p` — safe if they already exist). Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section, adapted to the user's domain. Skip any of these files that already exist so a populated wiki is never clobbered on re-run.
### 3b. Container skill
Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
Create `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
If `container/skills/wiki/SKILL.md` already exists, ask the user whether to update it before overwriting, so an existing tailored schema is preserved on re-run.
### 3c. Group CLAUDE.md
Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should:
Edit the group's CLAUDE.md to add a wiki section, wrapped in marker comments so it can be located and replaced on re-run:
```markdown
<!-- BEGIN karpathy-llm-wiki -->
## Wiki
...section body...
<!-- END karpathy-llm-wiki -->
```
If a `<!-- BEGIN karpathy-llm-wiki -->` block already exists, replace it in place rather than appending a second copy. This is critical — it's what turns the agent into a wiki maintainer. The section should:
- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint)
- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`)
@@ -75,9 +88,12 @@ If yes, ask the agent to schedule the lint task using the `schedule_task` MCP to
## Step 6: Restart
Run from your NanoClaw project root:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Tell the user to test by sending a source to the wiki group.
+48 -5
View File
@@ -1,6 +1,49 @@
# Remove Linear Channel
# Remove Linear
1. Comment out `import './linear.js'` in `src/channels/index.ts`
2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @chat-adapter/linear`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './linear.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
```
## 2. Remove credentials
Remove the Linear env vars from `.env`, then re-sync to the container:
```bash
LINEAR_CLIENT_ID
LINEAR_CLIENT_SECRET
LINEAR_API_KEY
LINEAR_WEBHOOK_SECRET
LINEAR_BOT_USERNAME
LINEAR_TEAM_KEY
```
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/linear
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+24 -39
View File
@@ -22,16 +22,16 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/linear.ts` exists
- `src/channels/linear-registration.test.ts` exists
- `src/channels/index.ts` contains `import './linear.js';`
- `@chat-adapter/linear` is listed in `package.json` dependencies
- `src/channels/chat-sdk-bridge.ts` contains `catchAll`
Otherwise continue. Every step below is safe to re-run.
@@ -41,10 +41,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
```
### 3. Append the self-registration import
@@ -55,47 +56,23 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './linear.js';
```
### 4. Patch the Chat SDK bridge for catch-all message forwarding
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
```typescript
/**
* Forward ALL messages in unsubscribed threads, not just @-mentions.
* Use for platforms where the bot identity can't be @-mentioned (e.g.
* Linear OAuth apps). The thread is auto-subscribed on first message.
*/
catchAll?: boolean;
```
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
```typescript
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
if (config.catchAll) {
chat.onNewMessage(/.*/, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
await thread.subscribe();
});
}
```
### 5. Install the adapter package (pinned)
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.27.0
pnpm install @chat-adapter/linear@4.29.0
```
### 6. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/linear-registration.test.ts
```
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
## Credentials
### 1. Set up a webhook
@@ -142,8 +119,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
@@ -156,7 +133,15 @@ The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
## Channel Info
-3
View File
@@ -1,3 +0,0 @@
# Verify Linear Channel
@mention the bot in a Linear issue comment. The bot should respond within a few seconds.
@@ -0,0 +1,22 @@
# Remove macOS Menu Bar Status Indicator
Every step is idempotent — safe to re-run.
## 1. Unload the launchd service
```bash
launchctl bootout gui/$(id -u)/com.nanoclaw.statusbar 2>/dev/null \
|| launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist 2>/dev/null \
|| true
```
## 2. Delete the produced files
```bash
rm -f ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist \
dist/statusbar \
logs/statusbar.log \
logs/statusbar.error.log
```
The menu bar icon disappears once the service is unloaded.
+1 -7
View File
@@ -124,10 +124,4 @@ Tell the user:
>
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
## Removal
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm dist/statusbar
```
To uninstall, follow [REMOVE.md](REMOVE.md).
+54 -5
View File
@@ -1,6 +1,55 @@
# Remove Matrix Channel
# Remove Matrix
1. Comment out `import './matrix.js'` in `src/channels/index.ts`
2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env`
3. `pnpm uninstall @beeper/chat-adapter-matrix`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './matrix.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
```
## 2. Remove credentials
Remove the `MATRIX_*` lines from `.env`:
```bash
MATRIX_BASE_URL
MATRIX_USERNAME
MATRIX_PASSWORD
MATRIX_USER_ID
MATRIX_BOT_USERNAME
MATRIX_ACCESS_TOKEN
MATRIX_INVITE_AUTOJOIN
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
MATRIX_RECOVERY_KEY
MATRIX_DEVICE_ID
```
Then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @beeper/chat-adapter-matrix
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/matrix.ts` exists
- `src/channels/matrix-registration.test.ts` exists
- `src/channels/index.ts` contains `import './matrix.js';`
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
```
### 3. Append the self-registration import
@@ -69,12 +71,17 @@ node -e '
Re-run this after every `pnpm install` that touches the adapter.
### 6. Build
### 6. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/matrix-registration.test.ts
```
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
## Credentials
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
-3
View File
@@ -1,3 +0,0 @@
# Verify Matrix Channel
Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds.
+60
View File
@@ -0,0 +1,60 @@
# Remove Mnemon
Every step is idempotent — safe to run even if some steps were never applied.
## 1. Strip the Dockerfile install layer
Open `container/Dockerfile` and delete the mnemon block (the `# ---- mnemon` comment, the `ARG MNEMON_VERSION`, the `RUN` that downloads the binary, and the `ENV MNEMON_DATA_DIR` line):
```dockerfile
# ---- mnemon — persistent agent memory ----------------------------------------
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
If the block is already gone, skip this step.
## 2. Strip the entrypoint setup line
Open `container/entrypoint.sh` and delete the `mnemon setup` line that follows `set -e`:
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
If the line is already gone, skip this step.
## 3. Delete the copied test files
```bash
rm -f src/mnemon-dockerfile.test.ts src/mnemon-entrypoint.test.ts
```
## 4. Rebuild and restart
```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)
```
## 5. Delete stored memory (optional)
Mnemon's graph lives at `/home/node/.claude/mnemon/` in each container, which maps to the per-agent-group `.claude/` directory on the host. To find the host path and clear it:
```bash
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
```
Stop the container, then delete the `mnemon/` subdirectory from that path.
+31 -62
View File
@@ -9,16 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
## Provider Compatibility
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
Check your provider:
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:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
```
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
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.
## Phase 1: Pre-flight
@@ -28,7 +25,7 @@ grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
If already applied, re-run Phase 2 anyway — every step is idempotent and skips work that is already in place — then continue to Phase 3 (Verify).
### Check latest mnemon version
@@ -38,11 +35,11 @@ curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
## Phase 2: Apply Changes (Claude Code path)
## Phase 2: Apply Changes
### 1. Dockerfile — install mnemon binary
Add after the AWS CLI block, before the Bun runtime section:
Insert the mnemon block immediately above the `# ---- Bun runtime` section of `container/Dockerfile` (skip if `grep -q 'MNEMON_VERSION' container/Dockerfile` already matches):
```dockerfile
# ---- mnemon — persistent agent memory ----------------------------------------
@@ -55,11 +52,17 @@ RUN ARCH=$(dpkg --print-architecture) && \
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount, so memory persists across container restarts.
### 2. Entrypoint — run mnemon setup on each container start
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
`mnemon setup` is idempotent. Run it once per `container/entrypoint.sh`. First check whether the line is already present:
```bash
grep -q 'mnemon setup' container/entrypoint.sh && echo "Already wired" || echo "Wire it"
```
If it prints `Wire it`, add the setup call right after `set -e`, before the `cat` that captures stdin, so the result looks like:
```bash
#!/bin/bash
@@ -78,7 +81,19 @@ exec bun run /app/src/index.ts < /tmp/input.json
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
### 3. Rebuild and smoke-test the image
### 3. Copy the integration tests
Both reach-ins are into container build/runtime files that aren't importable or typed (a GitHub-release binary in the Dockerfile, a shell line in the entrypoint), so structural tests guard them. Copy them into the host test tree:
```bash
cp .claude/skills/add-mnemon/mnemon-dockerfile.test.ts src/mnemon-dockerfile.test.ts
cp .claude/skills/add-mnemon/mnemon-entrypoint.test.ts src/mnemon-entrypoint.test.ts
pnpm exec vitest run src/mnemon-dockerfile.test.ts src/mnemon-entrypoint.test.ts
```
`mnemon-dockerfile.test.ts` asserts the `MNEMON_VERSION` ARG and `MNEMON_DATA_DIR` ENV are present (red if the install layer is dropped on an upgrade). `mnemon-entrypoint.test.ts` asserts the entrypoint invokes `mnemon setup --target claude-code` (red if the wiring is removed).
### 4. Rebuild and smoke-test the image
```bash
./container/build.sh
@@ -89,9 +104,12 @@ docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
### Restart the service
Run from your NanoClaw project root:
```bash
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### Confirm mnemon hooks are registered
@@ -113,36 +131,6 @@ docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
## Phase 2 (OpenCode path) — context injection
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
Then rebuild: `./container/build.sh`
### Verify (OpenCode)
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
```bash
# Also confirm the binary is present in the image:
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Memory Storage
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
@@ -154,25 +142,6 @@ docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | hea
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
## Migration Guide Update
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
**Dockerfile — after AWS CLI, before Bun runtime:**
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
**`container/entrypoint.sh` — add after `set -e`:**
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
## Troubleshooting
### `mnemon: command not found` in container
@@ -0,0 +1,36 @@
/**
* Structural guard for the mnemon Dockerfile reach-in (the dependency install).
*
* mnemon ships as a GitHub-release binary, not an npm package, so it can't be
* imported or typechecked. The only red-on-drift guard is asserting the install
* layer is present in container/Dockerfile: drop the layer on an upgrade and the
* container starts with "mnemon: command not found", but nothing else fails.
* This test reads the Dockerfile and asserts the MNEMON_VERSION ARG and the
* MNEMON_DATA_DIR ENV are both present.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
// From src/ up to repo root, then into container/.
const p = path.resolve(__dirname, '..', 'container', 'Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs the mnemon binary', () => {
const text = dockerfile();
it('declares the MNEMON_VERSION build arg', () => {
expect(text).toMatch(/ARG\s+MNEMON_VERSION/);
});
it('downloads the mnemon release binary', () => {
expect(text).toContain('mnemon-dev/mnemon/releases/download');
});
it('sets MNEMON_DATA_DIR into the .claude mount', () => {
expect(text).toMatch(/ENV\s+MNEMON_DATA_DIR=/);
});
});
@@ -0,0 +1,27 @@
/**
* Structural guard for the mnemon entrypoint reach-in.
*
* container/entrypoint.sh runs on every container start; the inserted
* `mnemon setup --target claude-code` line is what registers the Claude Code
* memory hooks. The entrypoint is a shell script, not an invocable function, so
* the guard is structural: assert the setup invocation is present. Drop it on an
* upgrade and the hooks silently never register this test goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function entrypoint(): string {
// From src/ up to repo root, then into container/.
const p = path.resolve(__dirname, '..', 'container', 'entrypoint.sh');
return fs.readFileSync(p, 'utf8');
}
describe('container/entrypoint.sh runs mnemon setup on start', () => {
const text = entrypoint();
it('invokes mnemon setup targeting claude-code', () => {
expect(text).toMatch(/mnemon\s+setup\s+--target\s+claude-code/);
});
});
+6 -3
View File
@@ -130,12 +130,15 @@ file, not from env vars. This file is bind-mounted into the container as `~/.cla
## 5. Build and restart
Run from your NanoClaw project root:
```bash
export PATH="/opt/homebrew/bin:$PATH"
pnpm run build
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux: systemctl --user restart nanoclaw
source setup/lib/install-slug.sh
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux: systemctl --user restart $(systemd_unit)
```
## 6. Verify
+49
View File
@@ -0,0 +1,49 @@
# Remove Ollama
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied files (both trees)
```bash
rm -f container/agent-runner/src/ollama-mcp-stdio.ts \
container/agent-runner/src/ollama-registration.test.ts \
src/ollama-env.ts \
src/ollama-wiring.test.ts
```
## 2. Unregister the MCP server
In `container/agent-runner/src/index.ts`, remove the `ollama: { … }` entry from the `mcpServers` object (leave `nanoclaw` and any other entries).
## 3. Revert the host-side edits in `src/container-runner.ts`
- Remove the `import { ollamaEnvArgs } from './ollama-env.js';` import.
- Remove the `args.push(...ollamaEnvArgs());` line that follows the `TZ` env line.
- Remove the `[OLLAMA]` branch from the `container.stderr` logger. If `[OLLAMA]` was the only prefix branch, restore the logger to its single-line `log.debug(line, …)` form; if other local-model tools still have branches there, just drop the `[OLLAMA]` one and leave the rest intact.
## 4. Remove env vars
Remove the Ollama block from `.env.example`, and the `OLLAMA_HOST` / `OLLAMA_ADMIN_TOOLS` lines from `.env` if you set them.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```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)
```
## Verification
After removal, confirm the tool is gone — in a wired agent, asking it to "list ollama models" should report no such tool, and the logs should show no `[OLLAMA]` lines after the last restart:
```bash
grep "\[OLLAMA\]" logs/nanoclaw.log | tail -5
```
+170 -66
View File
@@ -5,17 +5,19 @@ description: Add Ollama MCP server so the container agent can call local models
# Add Ollama Integration
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
This skill adds a stdio-based MCP server that exposes local [Ollama](https://ollama.com) models as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by the Ollama daemon on the host, and can optionally manage the model library directly. Ollama runs locally and is keyless — there are no credentials to thread; the only configuration is the daemon's base URL.
Core tools (always available):
- `ollama_list_models` — list installed Ollama models with name, size, and family
- `ollama_generate` — send a prompt to a specified model and return the response
- `ollama_list_models` — list installed models with name, size, and family (`GET /api/tags`)
- `ollama_generate` — send a prompt to a specified model and return the response (`POST /api/generate`)
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
- `ollama_pull_model` — pull (download) a model from the Ollama registry
- `ollama_delete_model` — delete a locally installed model to free disk space
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
- `ollama_pull_model` — pull (download) a model from the Ollama registry (`POST /api/pull`)
- `ollama_delete_model` — delete a locally installed model to free disk space (`DELETE /api/delete`)
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info (`POST /api/show`)
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type (`GET /api/ps`)
The skill ships the MCP server source (and its tests) in this folder and copies them into the agent-runner tree at install time, then registers the server in `index.ts` and forwards host env vars in `container-runner.ts`. Registering the server is enough to expose its tools — the agent's allow-pattern (`mcp__ollama__*`) is derived from the registered server name.
## Phase 1: Pre-flight
@@ -25,77 +27,173 @@ Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, sk
### Check prerequisites
Verify Ollama is installed and running on the host:
Verify Ollama is installed and its daemon is reachable. On the host:
```bash
ollama list
curl -s http://127.0.0.1:11434/api/tags | head
```
If Ollama is not installed, direct the user to https://ollama.com/download.
If the request fails:
1. Install Ollama from https://ollama.com/download.
2. Start it (the desktop app runs the daemon, or run `ollama serve`).
3. Confirm the daemon answers: `curl -s http://127.0.0.1:11434/api/tags`.
If no models are installed, suggest pulling one:
> You need at least one model. I recommend:
> You need at least one model. For example:
>
> ```bash
> ollama pull gemma3:1b # Small, fast (1GB)
> ollama pull llama3.2 # Good general purpose (2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
> ollama pull gemma3:1b # Small, fast (~1GB)
> ollama pull llama3.2 # Good general purpose (~2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (~18GB)
> ```
## Phase 2: Apply Code Changes
### Ensure upstream remote
### Copy the skill's source and tests into both trees
This skill reaches into both the container (Bun) tree and the host (Node) tree, so its
files go into both, alongside the integration points they cover.
```bash
git remote -v
S=.claude/skills/add-ollama-tool
# Container (Bun) tree — the MCP server and the registration wiring test
cp $S/ollama-mcp-stdio.ts container/agent-runner/src/ollama-mcp-stdio.ts
cp $S/ollama-registration.test.ts container/agent-runner/src/ollama-registration.test.ts
# Host (Node) tree — the env-forwarding helper and the wiring test
cp $S/ollama-env.ts src/ollama-env.ts
cp $S/ollama-wiring.test.ts src/ollama-wiring.test.ts
```
If `upstream` is missing, add it:
### Register the MCP server in the agent-runner
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
};
```
### Merge the skill branch
Add an `ollama` entry alongside `nanoclaw`:
```bash
git fetch upstream skill/ollama-tool
git merge upstream/skill/ollama-tool
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
ollama: {
command: 'bun',
args: ['run', path.join(__dirname, 'ollama-mcp-stdio.ts')],
env: {
...(process.env.OLLAMA_HOST ? { OLLAMA_HOST: process.env.OLLAMA_HOST } : {}),
...(process.env.OLLAMA_ADMIN_TOOLS ? { OLLAMA_ADMIN_TOOLS: process.env.OLLAMA_ADMIN_TOOLS } : {}),
},
},
};
```
This merges in:
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
- `scripts/ollama-watch.sh` (macOS notification watcher)
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
- `OLLAMA_HOST` in `.env.example`
`ollama-registration.test.ts` asserts this entry is present and points at the server module — the tool only appears to the agent if it is registered here.
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Forward host env vars into the container
### Copy to per-group agent-runner
The container receives `TZ` and OneCLI networking vars by default; any other host env
var the MCP subprocess needs must be forwarded explicitly. The forwarding logic lives in
the copied `src/ollama-env.ts` (`ollamaEnvArgs()`) — `OLLAMA_HOST` (the daemon base URL)
and `OLLAMA_ADMIN_TOOLS` (the library-management opt-in flag). Both are configuration, not
credentials, so they are passed through plainly; Ollama itself is local and keyless.
Existing groups have a cached copy of the agent-runner source. Copy the new files:
Import it in `src/container-runner.ts` (alongside the other local imports):
```ts
import { ollamaEnvArgs } from './ollama-env.js';
```
Then, in `buildContainerArgs`, find the `TZ` env line and add the call right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
args.push(...ollamaEnvArgs());
```
`ollama-wiring.test.ts` asserts this `args.push(...ollamaEnvArgs())` call exists inside `buildContainerArgs`.
### Surface `[OLLAMA]` log lines at info level
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-atomic-chat-tool` for `[ATOMIC]`) also edit to surface their own prefix. Touch only the `[OLLAMA]` branch and leave the rest of the block intact, so the edits coexist and removal restores it cleanly.
In the same file, find the stderr logger:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
```
Replace it with:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (!line) continue;
if (line.includes('[OLLAMA]')) {
log.info(line, { container: agentGroup.folder });
} else {
log.debug(line, { container: agentGroup.folder });
}
}
});
```
If `add-atomic-chat-tool` (or another local-model tool) has already turned this into a
multi-branch block, just add an `else if (line.includes('[OLLAMA]'))` branch instead of
replacing it.
### Add env-var stubs to `.env.example`
Append to `.env.example`:
```bash
for dir in data/sessions/*/agent-runner-src; do
cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
cp container/agent-runner/src/index.ts "$dir/"
done
# Ollama MCP tool (.claude/skills/add-ollama-tool)
# Override the host where the Ollama daemon listens.
# Default: http://host.docker.internal:11434 (with fallback to localhost)
# OLLAMA_HOST=http://host.docker.internal:11434
# Opt in to library-management tools (pull, delete, show, list-running).
# Leave unset to expose only list + generate.
# OLLAMA_ADMIN_TOOLS=true
```
### Validate code changes
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
# Host tree: buildContainerArgs wiring
pnpm exec vitest run src/ollama-wiring.test.ts
# Container tree: index.ts registration
(cd container/agent-runner && bun test src/ollama-registration.test.ts)
./container/build.sh
```
Build must be clean before proceeding.
All must be clean before proceeding. The wiring and registration tests confirm the two
integration points — the `buildContainerArgs` call and the `index.ts` registration — are
actually in place; a failure means one drifted. (The MCP server's own request/response
behavior against the Ollama daemon is the author's build-time concern, not part of these
tests — verify it manually in Phase 4.)
## Phase 3: Configure
### Enable model management tools (optional)
### Enable library-management tools (optional)
Ask the user:
@@ -110,7 +208,7 @@ If the user wants management tools, add to `.env`:
OLLAMA_ADMIN_TOOLS=true
```
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
If they decline (or don't answer), leave the variable unset — only list + generate are exposed.
### Set Ollama host (optional)
@@ -122,9 +220,12 @@ OLLAMA_HOST=http://your-ollama-host:11434
### Restart the service
Run from your NanoClaw project root:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## Phase 4: Verify
@@ -145,14 +246,6 @@ If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
>
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
### Monitor activity (optional)
Run the watcher script for macOS notifications when Ollama is used:
```bash
./scripts/ollama-watch.sh
```
### Check logs if needed
```bash
@@ -160,34 +253,45 @@ tail -f logs/nanoclaw.log | grep -i ollama
```
Look for:
- `[OLLAMA] >>> Generating` — generation started
- `[OLLAMA] <<< Done` — generation completed
- `[OLLAMA] Listing models...` — list request started
- `[OLLAMA] Found N models` — models discovered
- `[OLLAMA] >>> Generating with <model>` — generation started
- `[OLLAMA] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
- `[OLLAMA] Deleted:` — model removed (management tools)
## Troubleshooting
### Agent says "Ollama is not installed"
### Agent says "Ollama is not installed" or tries to run a CLI
The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
2. The per-group source wasn't updated — re-copy files (see Phase 2)
The agent is looking for an `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/ollama-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Ollama"
1. Verify Ollama is running: `ollama list`
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
3. If using a custom host, check `OLLAMA_HOST` in `.env`
1. Verify the daemon is reachable: `curl http://127.0.0.1:11434/api/tags`
2. Confirm Ollama is running (`ollama list` on the host)
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
4. If using a custom host, check `OLLAMA_HOST` in `.env`
### `model not found` / 404 on generate
The model name passed to `ollama_generate` must exactly match one of the names returned by `ollama_list_models` (including any `:tag` suffix, e.g. `gemma3:1b`). Ask the agent to list models first, then pick one from that list.
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until the pull completes — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`.
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it. The management tools are only registered when that flag is present in the container's environment.
### Slow first response
Ollama lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
### Agent doesn't use Ollama tools
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.
@@ -0,0 +1,22 @@
/**
* Host-side env forwarding for the Ollama MCP tool. Returns the Docker `-e`
* arguments that pass any `OLLAMA_*` host overrides into the container.
*
* Ollama is local and keyless these are configuration, not credentials:
* `OLLAMA_HOST` is the base URL of the host's Ollama daemon, and
* `OLLAMA_ADMIN_TOOLS` is the opt-in flag for the library-management tools.
*
* Lives in its own file so the reach-in in `container-runner.ts` is a single
* call (`args.push(...ollamaEnvArgs())`) and this logic is behavior-testable in
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
*/
export function ollamaEnvArgs(): string[] {
const args: string[] = [];
if (process.env.OLLAMA_HOST) {
args.push('-e', `OLLAMA_HOST=${process.env.OLLAMA_HOST}`);
}
if (process.env.OLLAMA_ADMIN_TOOLS) {
args.push('-e', `OLLAMA_ADMIN_TOOLS=${process.env.OLLAMA_ADMIN_TOOLS}`);
}
return args;
}
@@ -0,0 +1,482 @@
/**
* Ollama MCP Server for NanoClaw
* Exposes local Ollama models (native Ollama REST API, /api/*) as tools for the
* container agent. Uses host.docker.internal to reach the host's Ollama daemon
* from inside the container.
*
* Ollama runs locally and is keyless there are no credentials to thread. The
* only configuration is the base URL (OLLAMA_HOST) and an opt-in flag for the
* library-management tools (OLLAMA_ADMIN_TOOLS).
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
const OLLAMA_HOST =
process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
const OLLAMA_ADMIN_TOOLS = process.env.OLLAMA_ADMIN_TOOLS === 'true';
const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
function log(msg: string): void {
console.error(`[OLLAMA] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
} catch {
/* best-effort */
}
}
async function ollamaFetch(
apiPath: string,
options?: RequestInit,
): Promise<Response> {
const url = `${OLLAMA_HOST}${apiPath}`;
try {
return await fetch(url, options);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (OLLAMA_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, options);
}
throw err;
}
}
function formatBytes(bytes?: number): string {
if (bytes === undefined || bytes === null) return '?';
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(1)}GB`;
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(0)}MB`;
}
const server = new McpServer({
name: 'ollama',
version: '1.0.0',
});
server.tool(
'ollama_list_models',
'List all models installed in the local Ollama daemon. Use this to see which models are available before calling ollama_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing installed models');
try {
const res = await ollamaFetch('/api/tags');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Ollama API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
models?: Array<{
name: string;
size?: number;
details?: { family?: string; parameter_size?: string };
}>;
};
const models = data.models || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models installed. Pull one on the host with `ollama pull <model>` (e.g. `ollama pull llama3.2`).',
},
],
};
}
const list = models
.map((m) => {
const family = m.details?.family ? ` ${m.details.family}` : '';
const params = m.details?.parameter_size
? ` ${m.details.parameter_size}`
: '';
return `- ${m.name} (${formatBytes(m.size)}${family}${params})`;
})
.join('\n');
log(`Found ${models.length} models`);
return {
content: [
{ type: 'text' as const, text: `Installed models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_generate',
'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model name as returned by ollama_list_models (e.g. "llama3.2" or "gemma3:1b")',
),
prompt: z.string().describe('The prompt to send to the model'),
system: z
.string()
.optional()
.describe('Optional system prompt to set model behavior'),
temperature: z
.number()
.optional()
.describe('Sampling temperature (0.02.0). Defaults to model default.'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const body: Record<string, unknown> = {
model: args.model,
prompt: args.prompt,
stream: false,
};
if (args.system) body.system = args.system;
if (args.temperature !== undefined) {
body.options = { temperature: args.temperature };
}
const startedAt = Date.now();
const res = await ollamaFetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
response?: string;
eval_count?: number;
};
const response = data.response ?? '';
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
const evalCount = data.eval_count;
const meta = `\n\n[${args.model} | ${elapsedSec}s${
evalCount !== undefined ? ` | ${evalCount} tokens` : ''
}]`;
log(
`<<< Done: ${args.model} | ${elapsedSec}s | ${
evalCount ?? '?'
} tokens | ${response.length} chars`,
);
writeStatus(
'done',
`${args.model} | ${elapsedSec}s | ${evalCount ?? '?'} tokens`,
);
return { content: [{ type: 'text' as const, text: response + meta }] };
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
// Library-management tools — opt-in via OLLAMA_ADMIN_TOOLS=true. These mutate
// the host's model library (pull/delete) or inspect it, so they are gated
// behind an explicit flag rather than exposed by default.
if (OLLAMA_ADMIN_TOOLS) {
server.tool(
'ollama_pull_model',
'Pull (download) a model from the Ollama registry into the local daemon. Blocks until the download completes — large models can take several minutes.',
{
model: z
.string()
.describe('The model name to pull (e.g. "llama3.2" or "qwen3-coder:30b")'),
},
async (args) => {
log(`Pulling model: ${args.model}`);
writeStatus('pulling', `Pulling ${args.model}`);
try {
const res = await ollamaFetch('/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model, stream: false }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama pull error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as { status?: string };
log(`Pulled: ${args.model} (${data.status ?? 'ok'})`);
return {
content: [
{
type: 'text' as const,
text: `Pulled ${args.model}: ${data.status ?? 'success'}`,
},
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to pull ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_delete_model',
'Delete a locally installed model from the Ollama daemon to free disk space.',
{
model: z.string().describe('The model name to delete (e.g. "gemma3:1b")'),
},
async (args) => {
log(`Deleting model: ${args.model}`);
writeStatus('deleting', `Deleting ${args.model}`);
try {
const res = await ollamaFetch('/api/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama delete error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
log(`Deleted: ${args.model}`);
return {
content: [
{ type: 'text' as const, text: `Deleted ${args.model}.` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to delete ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_show_model',
'Show details for a locally installed model: modelfile, parameters, template, and architecture info.',
{
model: z
.string()
.describe('The model name to inspect (e.g. "llama3.2")'),
},
async (args) => {
log(`Showing model: ${args.model}`);
try {
const res = await ollamaFetch('/api/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama show error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
parameters?: string;
template?: string;
details?: {
family?: string;
parameter_size?: string;
quantization_level?: string;
};
};
const parts: string[] = [`Model: ${args.model}`];
if (data.details) {
const d = data.details;
parts.push(
`Family: ${d.family ?? '?'} | Params: ${d.parameter_size ?? '?'} | Quant: ${d.quantization_level ?? '?'}`,
);
}
if (data.parameters) parts.push(`Parameters:\n${data.parameters}`);
if (data.template) parts.push(`Template:\n${data.template}`);
return {
content: [{ type: 'text' as const, text: parts.join('\n\n') }],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to show ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_list_running',
'List models currently loaded in memory, with memory usage and processor type (CPU/GPU). Use this to see what is warm and consuming resources.',
{},
async () => {
log('Listing running models...');
try {
const res = await ollamaFetch('/api/ps');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Ollama API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
models?: Array<{
name: string;
size?: number;
size_vram?: number;
}>;
};
const models = data.models || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models currently loaded in memory.',
},
],
};
}
const list = models
.map((m) => {
const vram = m.size_vram ?? 0;
const total = m.size ?? 0;
const processor =
vram === 0
? 'CPU'
: vram >= total
? 'GPU'
: `${Math.round((vram / total) * 100)}% GPU`;
return `- ${m.name} (${formatBytes(total)}, ${processor})`;
})
.join('\n');
return {
content: [
{ type: 'text' as const, text: `Loaded models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
}
const transport = new StdioServerTransport();
await server.connect(transport);
@@ -0,0 +1,66 @@
/**
* Wiring test for the MCP-server registration integration point (container/Bun tree).
*
* The handlers are exercised against a live Ollama daemon at build time, but that does
* not prove the server is registered delete the index.ts entry and the tool simply
* never appears, yet any handler check stays green. index.ts is the container boot entry
* and is not cheaply invocable, so we assert the registration structurally: the
* `mcpServers` object literal has an `ollama` property whose command runs
* `ollama-mcp-stdio.ts`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.join(import.meta.dir, 'index.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
/** Find the object literal assigned to `const mcpServers = { ... }`. */
function mcpServersLiteral(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
let found: ts.ObjectLiteralExpression | undefined;
const visit = (node: ts.Node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'mcpServers' &&
node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
) {
found = node.initializer;
}
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
return obj.properties.find(
(p): p is ts.PropertyAssignment =>
ts.isPropertyAssignment(p) &&
((ts.isIdentifier(p.name) && p.name.text === name) ||
(ts.isStringLiteral(p.name) && p.name.text === name)),
);
}
describe('index.ts registers the ollama MCP server', () => {
const obj = mcpServersLiteral(sourceFile());
it('finds the mcpServers object literal', () => {
expect(obj).toBeDefined();
});
it('has an ollama entry', () => {
expect(obj && property(obj, 'ollama')).toBeDefined();
});
it('points ollama at ollama-mcp-stdio.ts', () => {
const entry = obj && property(obj, 'ollama');
const text = entry ? entry.getText() : '';
expect(text).toContain('ollama-mcp-stdio.ts');
});
});
@@ -0,0 +1,69 @@
/**
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
*
* The env helper is skill-owned and could be unit-tested directly, but that does not prove
* buildContainerArgs actually uses it a direct unit test stays green even if the reach-in
* is deleted. buildContainerArgs is entangled with OneCLI and not cheaply invocable, so we
* assert the integration structurally: inside buildContainerArgs there is an
* `args.push(...ollamaEnvArgs())` call. Delete the reach-in and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.resolve(process.cwd(), 'src/container-runner.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
function findFunction(sf: ts.SourceFile, name: string): ts.FunctionDeclaration | undefined {
let found: ts.FunctionDeclaration | undefined;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/** Is this node `args.push(...ollamaEnvArgs())`? */
function isSpreadPushOfEnvArgs(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) return false;
const callee = node.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
callee.name.text !== 'push' ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== 'args'
) {
return false;
}
return node.arguments.some(
(arg) =>
ts.isSpreadElement(arg) &&
ts.isCallExpression(arg.expression) &&
ts.isIdentifier(arg.expression.expression) &&
arg.expression.expression.text === 'ollamaEnvArgs',
);
}
describe('container-runner.ts wires in ollamaEnvArgs', () => {
const sf = sourceFile();
const fn = findFunction(sf, 'buildContainerArgs');
it('finds buildContainerArgs', () => {
expect(fn).toBeDefined();
});
it('calls args.push(...ollamaEnvArgs()) inside buildContainerArgs', () => {
let wired = false;
const visit = (node: ts.Node) => {
if (isSpreadPushOfEnvArgs(node)) wired = true;
if (!wired) ts.forEachChild(node, visit);
};
if (fn?.body) visit(fn.body);
expect(wired).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
# Remove OpenCode 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, the agent-runner dependency, and the Dockerfile CLI install.
## 1. Delete the barrel import lines (both trees)
Delete (do not comment out) the `import './opencode.js';` line from each barrel:
- `src/providers/index.ts`
- `container/agent-runner/src/providers/index.ts`
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
## 2. Delete the copied files (both trees)
```bash
rm -f src/providers/opencode.ts \
src/providers/opencode-registration.test.ts \
src/opencode-dockerfile.test.ts \
container/agent-runner/src/providers/opencode.ts \
container/agent-runner/src/providers/mcp-to-opencode.ts \
container/agent-runner/src/providers/mcp-to-opencode.test.ts \
container/agent-runner/src/providers/opencode.factory.test.ts \
container/agent-runner/src/providers/opencode-registration.test.ts
```
## 3. Remove the agent-runner dependency
`@opencode-ai/sdk` is an importable package in the container tree (agent-runner is a Bun package, not a pnpm workspace — use `bun remove`):
```bash
cd container/agent-runner && bun remove @opencode-ai/sdk && cd -
```
## 4. Revert the Dockerfile CLI install
In `container/Dockerfile`, remove both OpenCode edits (skip whichever is already gone):
**(a)** Delete the version ARG from the "Pin CLI versions" block:
```dockerfile
ARG OPENCODE_VERSION=1.4.17
```
**(b)** Delete the standalone OpenCode install layer:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
```
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
## 5. Clean up per-group overlays
Any group that had the OpenCode files copied into its live source overlay still carries them — remove the OpenCode-specific files from each overlay (the barrel `index.ts` is re-synced from the cleaned tree, not deleted):
```bash
for overlay in data/v2-sessions/*/agent-runner-src/providers/; do
[ -d "$overlay" ] || continue
rm -f "$overlay/opencode.ts" "$overlay/mcp-to-opencode.ts"
[ -f container/agent-runner/src/providers/index.ts ] && \
cp container/agent-runner/src/providers/index.ts "$overlay"
echo "Cleaned: $overlay"
done
```
## 6. Unset OpenCode env vars
Remove any OpenCode-specific lines you added to `.env` (`OPENCODE_PROVIDER`, `OPENCODE_MODEL`, `OPENCODE_SMALL_MODEL`, and `ANTHROPIC_BASE_URL` if no other integration uses it) if no other integration needs them, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
Switch any group still on OpenCode back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
## 7. Rebuild and restart
Run from your NanoClaw project root:
```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)
```
> If the rebuild still reports OpenCode after these steps, the buildkit COPY cache may be stale. Prune the builder and rebuild: `docker builder prune -f && ./container/build.sh`.
## Verification
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
```bash
grep -R "opencode.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
grep "@opencode-ai/sdk" container/agent-runner/package.json # no output
grep "opencode-ai" container/Dockerfile # no output
```
In a wired agent, requesting `agent_provider = 'opencode'` should fall back to the default provider since `opencode` is no longer in the registry.
+48 -25
View File
@@ -17,10 +17,13 @@ If all of the following are already present, skip to **Configuration**:
- `src/providers/opencode.ts`
- `container/agent-runner/src/providers/opencode.ts`
- `src/providers/opencode-registration.test.ts`
- `container/agent-runner/src/providers/opencode-registration.test.ts`
- `import './opencode.js';` line in `src/providers/index.ts`
- `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts`
- `@opencode-ai/sdk` in `container/agent-runner/package.json`
- `opencode-ai@${OPENCODE_VERSION}` in the pnpm global-install block in `container/Dockerfile`
- `ARG OPENCODE_VERSION` and `"opencode-ai@${OPENCODE_VERSION}"` in `container/Dockerfile`
- `src/opencode-dockerfile.test.ts` (the Dockerfile install guard)
Missing pieces — continue below. All steps are idempotent; re-running is safe.
@@ -35,13 +38,20 @@ git fetch origin providers
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts
git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts
git show origin/providers:container/agent-runner/src/providers/opencode.factory.test.ts > container/agent-runner/src/providers/opencode.factory.test.ts
```
Also copy the two barrel-registration guards — one per tree. These import the real provider barrels and assert `opencode` is registered, so they go red the moment a barrel import line is deleted or drifts:
```bash
git show origin/providers:src/providers/opencode-registration.test.ts > src/providers/opencode-registration.test.ts
git show origin/providers:container/agent-runner/src/providers/opencode-registration.test.ts > container/agent-runner/src/providers/opencode-registration.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line appended at the end — skip if the line is already present.
@@ -70,7 +80,7 @@ cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd -
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 VERCEL_VERSION=latest`:
**(a)** In the "Pin CLI versions" ARG block (around line 22), add after `ARG VERCEL_VERSION=...`:
```dockerfile
ARG OPENCODE_VERSION=1.4.17
@@ -78,30 +88,47 @@ ARG OPENCODE_VERSION=1.4.17
> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x.
**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list:
**(b)** Add a new standalone `RUN` block for the OpenCode CLI, after the existing per-CLI install blocks (around line 111, 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
pnpm install -g \
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
"agent-browser@${AGENT_BROWSER_VERSION}" \
"vercel@${VERCEL_VERSION}" \
"opencode-ai@${OPENCODE_VERSION}"
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
```
### 6. Build
### 6. Copy the Dockerfile install guard
The `opencode-ai` CLI is a globally-installed binary — not importable or typed — so a structural test guards the Dockerfile install. Copy it into the host test tree:
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
cp .claude/skills/add-opencode/opencode-dockerfile.test.ts src/opencode-dockerfile.test.ts
```
### 7. Build and validate
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
pnpm exec vitest run src/providers/opencode-registration.test.ts # host registration guard
pnpm exec vitest run src/opencode-dockerfile.test.ts # Dockerfile install guard
cd container/agent-runner && bun test src/providers/opencode-registration.test.ts && cd - # container registration guard
./container/build.sh # agent image
```
All four must be clean before proceeding. Each guards a distinct integration point:
- **`src/providers/opencode-registration.test.ts`** (host, vitest) imports the real host barrel (`./index.js``listProviderContainerConfigNames`) and asserts `opencode` is present. It goes red if the `import './opencode.js';` line in `src/providers/index.ts` is deleted or drifts, or if that barrel fails to evaluate.
- **`container/agent-runner/src/providers/opencode-registration.test.ts`** (container, bun:test) imports the real container barrel (`./index.js``listProviderNames`) and asserts `opencode` is present. It goes red if the `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts` is deleted or drifts. Because the barrel is imported unmocked, it also pulls in `opencode.ts`, which imports **`@opencode-ai/sdk`** — so this test implicitly guards the step-4 dependency too: if the package isn't installed, the import throws and the test goes red.
- **`src/opencode-dockerfile.test.ts`** parses `container/Dockerfile` and asserts both the `ARG OPENCODE_VERSION=...` (rejecting `latest`) and the `pnpm install -g "opencode-ai@${OPENCODE_VERSION}"` line are present. The `opencode-ai` CLI binary is not importable, so it is guarded by this structural test plus the container build — not the registration test.
- **`pnpm run build`** type-checks the host provider's consumption of the host-side container-config registry; the container typecheck does the same for the container provider against the agent-runner core APIs.
The pre-existing `opencode.factory.test.ts` imports `opencode.ts` directly and self-registers, so it stays green even if a barrel import is removed — it is a unit test of `createProvider('opencode')`, not the registration guard. Keep it; it adds factory coverage but does not stand in for the registration tests above.
> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild:
> ```bash
> docker builder prune -f && ./container/build.sh
> ```
### 7. Propagate to existing per-group overlays
### 8. Propagate to existing per-group overlays
Each agent group has a live source overlay at `data/v2-sessions/<group-id>/agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually.
@@ -221,12 +248,8 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder.
- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI).
## Verify
## Next Steps
```bash
grep -q "./opencode.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./opencode.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@opencode-ai/sdk" container/agent-runner/package.json && echo "agent-runner dep: OK"
grep -q "opencode-ai@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/ && cd -
```
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.
To remove this provider, see [REMOVE.md](REMOVE.md).
@@ -0,0 +1,47 @@
/**
* Dependency guard for the OpenCode CLI integration point (host tree, vitest).
*
* add-opencode installs the `opencode-ai` CLI globally in the agent container
* image via `container/Dockerfile`. A globally-installed CLI binary is not
* importable or typed, so neither `tsc` nor a runtime import can catch its
* removal only the container image build would, and the skill's validate step
* does not rebuild the image in CI. This structural test stands in for that
* build leg: it parses the Dockerfile and asserts both halves of the install are
* present the pinned `ARG OPENCODE_VERSION=...` and the
* `pnpm install -g "opencode-ai@${OPENCODE_VERSION}"` line. Drop or drift either
* and this goes red.
*
* Pinning matters here beyond reproducibility: the `opencode-ai` CLI version
* must match the `@opencode-ai/sdk` version the container provider imports. An
* unpinned `latest` would silently upgrade the CLI past the SDK's compatible
* range and break sessions. The test therefore also rejects `@latest`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
// Walk up from this test file to the repo root (the dir holding container/Dockerfile),
// so the test works wherever it is copied (src/ on the host, or the skill folder).
let dir = __dirname;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, 'container', 'Dockerfile');
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8');
dir = path.dirname(dir);
}
throw new Error('container/Dockerfile not found walking up from ' + __dirname);
}
describe('container/Dockerfile installs the OpenCode CLI', () => {
const text = dockerfile();
it('declares a pinned OPENCODE_VERSION build arg (not latest)', () => {
expect(text).toMatch(/^ARG\s+OPENCODE_VERSION=\S+/m);
expect(text).not.toMatch(/^ARG\s+OPENCODE_VERSION=latest\s*$/m);
});
it('globally installs the pinned opencode-ai package via pnpm', () => {
expect(text).toMatch(/pnpm install -g\s+"?opencode-ai@\$\{OPENCODE_VERSION\}"?/);
});
});
-290
View File
@@ -1,290 +0,0 @@
# Add Parallel AI Integration
Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.
## What This Adds
- **Quick Search** - Fast web lookups using Parallel Search API (free to use)
- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission)
- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking)
## Prerequisites
User must have:
1. Parallel AI API key from https://platform.parallel.ai
2. NanoClaw already set up and running
3. Docker installed and running
## Implementation Steps
Run all steps automatically. Only pause for user input when explicitly needed.
### 1. Get Parallel AI API Key
Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?`
**If they have one:**
Collect it now.
**If they need one:**
Tell them:
> 1. Go to https://platform.parallel.ai
> 2. Sign up or log in
> 3. Navigate to API Keys section
> 4. Create a new API key
> 5. Copy the key and paste it here
Wait for the API key.
### 2. Add API Key to Environment
Add `PARALLEL_API_KEY` to `.env`:
```bash
# Check if .env exists, create if not
if [ ! -f .env ]; then
touch .env
fi
# Add PARALLEL_API_KEY if not already present
if ! grep -q "PARALLEL_API_KEY=" .env; then
echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env
echo "✓ Added PARALLEL_API_KEY to .env"
else
# Update existing key
sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env
echo "✓ Updated PARALLEL_API_KEY in .env"
fi
```
Verify:
```bash
grep "PARALLEL_API_KEY" .env | head -c 50
```
### 3. Update Container Runner
Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`:
Find the line:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
```
Replace with:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
```
### 4. Configure MCP Servers in Agent Runner
Update `container/agent-runner/src/index.ts`:
Find the section where `mcpServers` is configured (around line 237-252):
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
```
Add Parallel AI MCP servers after the nanoclaw server:
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
// Add Parallel AI MCP servers if API key is available
const parallelApiKey = process.env.PARALLEL_API_KEY;
if (parallelApiKey) {
mcpServers['parallel-search'] = {
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
url: 'https://search-mcp.parallel.ai/mcp',
headers: {
'Authorization': `Bearer ${parallelApiKey}`
}
};
mcpServers['parallel-task'] = {
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
url: 'https://task-mcp.parallel.ai/mcp',
headers: {
'Authorization': `Bearer ${parallelApiKey}`
}
};
log('Parallel AI MCP servers configured');
} else {
log('PARALLEL_API_KEY not set, skipping Parallel AI integration');
}
```
Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248):
```typescript
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'mcp__nanoclaw__*',
'mcp__parallel-search__*',
'mcp__parallel-task__*'
],
```
### 5. Add Usage Instructions to CLAUDE.md
Add Parallel AI usage instructions to `groups/main/CLAUDE.md`:
Find the "## What You Can Do" section and add after the existing bullet points:
```markdown
- Use Parallel AI for web research and deep learning tasks
```
Then add a new section after "## What You Can Do":
```markdown
## Web Research Tools
You have access to two Parallel AI research tools:
### Quick Web Search (`mcp__parallel-search__search`)
**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts.
**Examples:**
- "Who invented the transistor?"
- "What's the latest news about quantum computing?"
- "When was the UN founded?"
- "What are the top programming languages in 2026?"
**Speed:** Fast (2-5 seconds)
**Cost:** Low
**Permission:** Not needed - use whenever it helps answer the question
### Deep Research (`mcp__parallel-task__create_task_run`)
**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research.
**Examples:**
- "Explain the development of quantum mechanics from 1900-1930"
- "Compare the literary styles of Hemingway and Faulkner"
- "Research the evolution of jazz from bebop to fusion"
- "Analyze the causes of the French Revolution"
**Speed:** Slower (1-20 minutes depending on depth)
**Cost:** Higher (varies by processor tier)
**Permission:** ALWAYS use `AskUserQuestion` before using this tool
**How to ask permission:**
```
AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed?
```
**After permission - DO NOT BLOCK! Use scheduler instead:**
1. Create the task using `mcp__parallel-task__create_task_run`
2. Get the `run_id` from the response
3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`:
```
Prompt: "Check Parallel AI task run [run_id] and send results when ready.
1. Use the Parallel Task MCP to check the task status
2. If status is 'completed', extract the results
3. Send results to user with mcp__nanoclaw__send_message
4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done
If status is still 'running' or 'pending', do nothing (task will run again in 30s).
If status is 'failed', send error message and complete the task."
Schedule: interval every 30 seconds
Context mode: isolated
```
4. Send acknowledgment with tracking link
5. Exit immediately - scheduler handles the rest
### Choosing Between Them
**Use Search when:**
- Question needs a quick fact or recent information
- Simple definition or clarification
- Verifying specific details
- Current events or news
**Use Deep Research (with permission) when:**
- User wants to learn about a complex topic
- Question requires analysis or comparison
- Historical context or evolution of concepts
- Structured, comprehensive understanding needed
- User explicitly asks to "research" or "explain in depth"
**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis.
```
### 6. Rebuild Container
Build the container with updated agent runner:
```bash
./container/build.sh
```
Verify the build:
```bash
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```
### 7. Restart Service
Rebuild the main app and restart:
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Wait 3 seconds for service to start, then verify:
```bash
sleep 3
launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
```
### 8. Test Integration
Tell the user to test:
> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?`
>
> The assistant should use Parallel Search API to find current information.
>
> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?`
>
> The assistant should ask for permission before using the Task API.
Check logs to verify MCP servers loaded:
```bash
tail -20 logs/nanoclaw.log
```
Look for: `Parallel AI MCP servers configured`
## Troubleshooting
**Container hangs or times out:**
- Check that `type: 'http'` is specified in MCP server config
- Verify API key is correct in .env
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
**MCP servers not loading:**
- Ensure PARALLEL_API_KEY is in .env
- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars
- Check agent-runner logs for "Parallel AI MCP servers configured" message
**Task polling not working:**
- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"`
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
- Ensure task prompt includes proper Parallel MCP tool names
## Uninstalling
To remove Parallel AI integration:
1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env`
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && pnpm run build`
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
+38 -4
View File
@@ -1,6 +1,40 @@
# Remove Resend Email Channel
1. Comment out `import './resend.js'` in `src/channels/index.ts`
2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @resend/chat-sdk-adapter`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './resend.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/resend.ts src/channels/resend-registration.test.ts
```
## 2. Remove credentials
Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, and `RESEND_WEBHOOK_SECRET` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @resend/chat-sdk-adapter
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+8 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/resend.ts` exists
- `src/channels/resend-registration.test.ts` exists
- `src/channels/index.ts` contains `import './resend.js';`
- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,15 @@ import './resend.js';
pnpm install @resend/chat-sdk-adapter@0.1.1
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/resend-registration.test.ts
```
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
1. Go to [resend.com](https://resend.com) and create an account.

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