Compare commits

..

124 Commits

Author SHA1 Message Date
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
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
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
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 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
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
glifocat ff90c8f565 Merge branch 'qwibitai:main' into main 2026-05-05 17:29:57 +02:00
glifocat 295275df69 Merge branch 'qwibitai:main' into main 2026-05-05 00:19:11 +02: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
110 changed files with 2562 additions and 693 deletions
+5 -2
View File
@@ -182,9 +182,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
+5 -2
View File
@@ -93,10 +93,13 @@ Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes
### 6. Build 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
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### 7. Verify
+5 -2
View File
@@ -23,14 +23,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)
+7 -3
View File
@@ -98,12 +98,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 +236,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
+11 -5
View File
@@ -162,10 +162,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 +243,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,13 +285,16 @@ If an agent outputs org-mode directly, markers get double-converted and render i
## Removal
Run from your NanoClaw project root:
```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
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
+61 -33
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,7 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
**No `TOOL_ALLOWLIST` edit needed.** `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__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
### Rebuild the container image
@@ -133,40 +130,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,16 +209,28 @@ 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`.
1. For each group that had Calendar wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name calendar
```
2. Remove the `.calendar-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```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. 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`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
## 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.
+9 -1
View File
@@ -136,7 +136,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
+65 -35
View File
@@ -98,7 +98,6 @@ 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"
```
@@ -132,9 +131,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.
**No `TOOL_ALLOWLIST` edit needed.** `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 `gmail` in Phase 3 automatically allows `mcp__gmail__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
### Rebuild the container image
@@ -146,42 +143,63 @@ 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.
**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 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
```
## Phase 5: Verify
@@ -206,17 +224,29 @@ 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`.
1. For each group that had Gmail wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
2. Remove the `.gmail-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```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') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
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`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
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`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
## Notes
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
@@ -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.
@@ -75,9 +75,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.
+9 -1
View File
@@ -156,7 +156,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
+5 -2
View File
@@ -89,9 +89,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
+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
+6 -3
View File
@@ -54,7 +54,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
git remote add upstream https://github.com/nanocoai/nanoclaw.git
```
### Merge the skill branch
@@ -122,9 +122,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
systemctl --user restart $(systemd_unit) # Linux
```
## Phase 4: Verify
+9 -6
View File
@@ -229,19 +229,22 @@ echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Containe
### 7. Restart Service
Rebuild the main app and restart:
Rebuild the main app and restart.
Run from your NanoClaw project root:
```bash
pnpm run build
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)
```
Wait 3 seconds for service to start, then verify:
```bash
sleep 3
launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)" # macOS
# Linux: systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"
```
### 8. Test Integration
@@ -287,4 +290,4 @@ To remove Parallel AI integration:
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)
5. Restart: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)` (macOS) or `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (Linux)
+15 -7
View File
@@ -90,17 +90,21 @@ No output = success.
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
# optionally: --avatar /path/to/avatar.jpg
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux
systemctl --user stop nanoclaw
systemctl --user stop $(systemd_unit)
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start nanoclaw
systemctl --user start $(systemd_unit)
```
### Path B: Link as secondary device
@@ -185,12 +189,16 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart nanoclaw
systemctl --user restart $(systemd_unit)
```
## Wiring
@@ -283,7 +291,7 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
3. Service running: `launchctl print gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
### Messages delivered but never arrive (null platformMsgId)
+1 -1
View File
@@ -60,7 +60,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
+5 -2
View File
@@ -41,9 +41,12 @@ DELETE FROM messaging_groups WHERE channel_type = 'wechat';
### 6. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
+6 -3
View File
@@ -82,12 +82,15 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
Restart NanoClaw:
Restart NanoClaw.
Run from your NanoClaw project root:
```bash
systemctl --user restart nanoclaw # Linux
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
+7 -4
View File
@@ -244,12 +244,15 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
### "waiting for this message" on reactions
Signal sessions corrupted from rapid restarts. Clear sessions:
Signal sessions corrupted from rapid restarts. Clear sessions.
Run from your NanoClaw project root:
```bash
systemctl --user stop nanoclaw
source setup/lib/install-slug.sh
systemctl --user stop $(systemd_unit)
rm store/auth/session-*.json
systemctl --user start nanoclaw
systemctl --user start $(systemd_unit)
```
### Bot not responding
@@ -257,7 +260,7 @@ systemctl --user start nanoclaw
1. Auth exists: `test -f store/auth/creds.json`
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
4. Service running: `systemctl --user status nanoclaw`
4. Service running: `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"`
### "conflict" disconnection
@@ -58,7 +58,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
git remote add upstream https://github.com/nanocoai/nanoclaw.git
```
### Merge the skill branch
@@ -171,9 +171,12 @@ Expected: Both operations succeed.
### Full integration test
Run from your NanoClaw project root:
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
Send a message via WhatsApp and verify the agent responds.
+8 -4
View File
@@ -88,15 +88,19 @@ Implementation:
## After Changes
Always tell the user:
Always tell the user.
Run from your NanoClaw project root:
```bash
# Rebuild and restart
pnpm run build
source setup/lib/install-slug.sh
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux:
# systemctl --user restart nanoclaw
# systemctl --user restart $(systemd_unit)
```
## Example Interaction
+1 -1
View File
@@ -9,7 +9,7 @@ Stand up the first NanoClaw agent for a channel and verify end-to-end delivery b
## Prerequisites
- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
- **Service running.** Check: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) or `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux). If stopped, tell the user to run `/setup` first.
- **Target channel installed.** At least one `/add-<channel>` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`.
- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel.
+6 -3
View File
@@ -236,9 +236,12 @@ pnpm run build
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first.
Restart the service:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux (systemd): `systemctl --user restart nanoclaw`
Restart the service.
Run from your NanoClaw project root:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
- Linux (systemd): `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
## Phase 5: Verify
+8 -3
View File
@@ -41,7 +41,12 @@ npx tsx setup/index.ts --step mounts --force -- --empty
## After Changes
Restart the service so containers pick up the new config:
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
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
```
+1 -1
View File
@@ -34,7 +34,7 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/nanocoai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
+5 -5
View File
@@ -11,7 +11,7 @@ Run `/update-nanoclaw` in Claude Code.
## How it works
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/nanocoai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.
@@ -69,7 +69,7 @@ If output is non-empty:
Confirm remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Add it: `git remote add upstream <user-provided-url>`
- Then: `git fetch upstream --prune`
@@ -270,9 +270,9 @@ Show:
Tell the user:
- To rollback: `git reset --hard <backup-tag-from-step-1>`
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
- Restart the service to apply changes. Detect platform with `uname -s`:
- **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- **Linux**: detect the service name with `systemctl --user list-units --type=service | grep nanoclaw | awk '{print $1}'`, then `systemctl --user restart <detected-name>`
- Restart the service to apply changes. The unit/label names are per-install — derive them with `setup/lib/install-slug.sh`. Run from your NanoClaw project root:
- **macOS (Darwin)**: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)`
- **Linux**: `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (or, if you want to confirm the unit name first: `systemctl --user list-units --type=service | grep "$(. setup/lib/install-slug.sh && systemd_unit)"`)
- **Manual** (no service found): restart `pnpm run dev`
+1 -1
View File
@@ -42,7 +42,7 @@ Check remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- `git remote add upstream <url>`
Fetch:
@@ -40,7 +40,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
git remote add upstream https://github.com/nanocoai/nanoclaw.git
```
### Merge the skill branch
@@ -128,9 +128,12 @@ echo 'ANTHROPIC_API_KEY=<key>' >> .env
pnpm run build
```
Then restart the service:
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
Then restart the service.
Run from your NanoClaw project root:
- macOS: `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
- Linux: `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
2. Check logs for successful proxy startup:
+23 -10
View File
@@ -38,6 +38,8 @@ Before using this skill, ensure:
## Quick Start
Run from your NanoClaw project root:
```bash
# 1. Setup authentication (interactive)
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
@@ -49,9 +51,10 @@ pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/s
# 3. Rebuild host and restart service
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
# Verify: launchctl list | grep "$(launchd_label)" (macOS) or systemctl --user status $(systemd_unit) (Linux)
```
## Configuration
@@ -270,16 +273,23 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
### 4. Restart Service
Run from your NanoClaw project root:
```bash
pnpm run build
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)
```
**Verify success:**
**Verify success.**
Run from your NanoClaw project root:
```bash
launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status nanoclaw
source setup/lib/install-slug.sh
launchctl list | grep "$(launchd_label)" # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status $(systemd_unit)
```
## Usage via WhatsApp
@@ -343,10 +353,13 @@ echo '{"content":"Test"}' | pnpm exec tsx .claude/skills/x-integration/scripts/p
### Authentication Expired
Run from your NanoClaw project root:
```bash
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
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)
```
### Browser Lock Files
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
bump-version:
if: github.repository == 'qwibitai/nanoclaw'
if: github.repository == 'nanocoai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
update-tokens:
if: github.repository == 'qwibitai/nanoclaw'
if: github.repository == 'nanocoai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+4
View File
@@ -1 +1,5 @@
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
pnpm run format:fix
if [ -n "$staged" ]; then
echo "$staged" | xargs git add
fi
+32 -3
View File
@@ -2,12 +2,41 @@
All notable changes to NanoClaw will be documented in this file.
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
## [2.0.64] - 2026-05-18
## [Unreleased]
- **`ncl destinations add` and `remove` through the approval flow now reach the receiver immediately.** Approved destinations weren't being projected into the receiving agent's local session state, so a freshly-added destination silently failed at `send_message` with `unknown destination`, and a removed destination stayed resolvable until the next container restart. Both now take effect the moment the approval executes. Direct (non-approval) calls were unaffected.
## [2.0.63] - 2026-05-15
Rollup release covering v2.0.55 through v2.0.63 — everything merged since the v2.0.54 tag. Starting with this release, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`; see [RELEASING.md](RELEASING.md).
- [BREAKING] **Service names are now per-install.** On v2 installs the launchd label and systemd unit are slugged to your project root: `com.nanoclaw.<sha1(projectRoot)[:8]>` and `nanoclaw-<slug>.service`. The old `com.nanoclaw` / `nanoclaw.service` names no longer match a real service — update any copy-pasted restart or status commands. Find your install's names with `source setup/lib/install-slug.sh && launchd_label` (macOS) or `systemd_unit` (Linux). The `ncl` transport-error help text and 26 skill files now use the canonical helper-driven pattern; see [setup/lib/install-slug.sh](setup/lib/install-slug.sh).
- **Compaction destination reminder placement fixed.** The reminder injected after SDK auto-compaction now appears at the end of the compaction summary so it isn't stripped during truncation. Replaces the placement shipped in v2.0.54.
- **Stronger message-wrapping enforcement.** The poll loop nudges the agent when its output lacks `<message>` wrapping, and `CLAUDE.md` core instructions now require wrapping even for single-destination agents. The welcome flow no longer double-greets.
- **OneCLI credentials after MCP install.** MCP servers added through `add_mcp_server` now inherit OneCLI gateway routing — fixes the case where the agent kept asking for API keys after installing a new server.
- **CLI scope hardening.** `scopeField` now fails closed when scope is missing, and `sessions get` is guarded against cross-group oracle access from group-scoped agents.
- **gmail/gcal skills aligned with v2.** `/add-gmail-tool` and `/add-gcal-tool` now reflect the v2 container-config model — DB-backed mounts, no dead `TOOL_ALLOWLIST` edits, no `container.json` writes that get clobbered on next spawn. Manual sqlite3/JSON1 invocations corrected.
- **Repo-rename cleanup.** Remaining `qwibitai/nanoclaw` references swept to `nanocoai/nanoclaw` across code and docs; CI workflow guards updated so they no longer no-op after the rename.
- Slack scope checklist now includes `files:read` and `files:write` for skills that read or post attachments.
- The internal-tag description in destination instructions no longer mentions scratchpads (which confused agents into routing them incorrectly).
- Container startup is now graceful when the `on_wake` column is missing on older sessions DBs.
## [2.0.54] - 2026-05-10
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
## [2.0.48] - 2026-05-09
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
## [2.0.45] - 2026-05-08
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md``CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:<id>` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default.
## [2.0.0] - 2026-04-22
+28 -3
View File
@@ -72,7 +72,10 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
@@ -93,7 +96,7 @@ ncl help
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) |
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
@@ -120,10 +123,32 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
One tier of agent self-modification today:
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`.
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`.
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
## Container Config
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
## Container Restart
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
## Secrets / Credentials / OneCLI
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
+3 -3
View File
@@ -4,8 +4,8 @@
1. **Check for existing work.** Search open PRs and issues before starting:
```bash
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
gh pr list --repo nanocoai/nanoclaw --search "<your feature>"
gh issue list --repo nanocoai/nanoclaw --search "<your feature>"
```
If a related PR or issue exists, build on it rather than duplicating effort.
@@ -43,7 +43,7 @@ Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setu
3. Claude walks through interactive setup (env vars, bot creation, etc.)
**Contributing a feature skill:**
1. Fork `qwibitai/nanoclaw` and branch from `main`
1. Fork `nanocoai/nanoclaw` and branch from `main`
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
4. Open a PR. We'll create the `skill/<name>` branch from your work
+2 -2
View File
@@ -26,7 +26,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
## Quick Start
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
@@ -39,7 +39,7 @@ bash nanoclaw.sh
Run from a fresh v2 checkout next to your v1 install:
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClawは同じコア機能を提供しますが、理解できる規模のコ
## クイックスタート
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能
## 快速开始
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
+50
View File
@@ -0,0 +1,50 @@
# Releasing NanoClaw
Starting with v2.0.63, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`. Releases are cut manually by a maintainer, so there can be lag between a bump merging and its release being published. The intent is *timeliness*, not strict 1:1 correlation with every bump.
Each release ships:
- A tagged commit on `main` (`vX.Y.Z`).
- A `CHANGELOG.md` entry under `## [<version>] - <YYYY-MM-DD>`.
- A GitHub Release whose body mirrors the CHANGELOG entry plus a contributors section.
## When to cut a release
A release is cut by a maintainer publishing it. The trigger is a `package.json` bump on `main`, but the publish step is manual — there is no fixed schedule, and bumps that land back-to-back may be rolled into a single release (as v2.0.55 through v2.0.63 were). Cutting more frequently is preferable to batching: smaller releases are easier to read, pin, and revert.
## What goes in a release
`CHANGELOG.md` is the canonical record of user-visible change. The release body on GitHub mirrors it. Aim for:
- **Bold lead-ins** per major feature or fix, then a sentence-case prose explanation.
- **`[BREAKING]` prefix** for any change that requires user action. Always include the workaround inline — never link to a separate doc for the fix.
- **Doc links** for major features (relative paths into the repo, e.g. `[setup/lib/install-slug.sh](setup/lib/install-slug.sh)`).
- **Inline commands** for actionable steps, in backticks.
- **Minor items** as single plain bullets at the bottom of the entry, no bold lead-in.
- **No PR numbers** in the user-facing prose. PR references can live in the GitHub Release's `## Contributors` section.
## Publishing the release
1. Bump `package.json` and add a `CHANGELOG.md` entry in the same commit (commit message: `chore: bump version to vX.Y.Z`).
2. Once the bump commit lands on `main`, open a draft GitHub Release:
- **Tag:** `vX.Y.Z`, target `main`.
- **Title:** `vX.Y.Z` (bare version — descriptive content lives in the body, matching the CHANGELOG header pattern).
- **Body:** copy the CHANGELOG entry verbatim. Append a `## Contributors` section listing every PR author who landed work in the release window. Append a `**Full Changelog**: https://github.com/nanocoai/nanoclaw/compare/<prev-tag>...vX.Y.Z` line at the bottom.
3. If anyone in the window opened their first NanoClaw PR, add a `## New Contributors` section above `## Contributors`, with each first-timer's first PR link and an invite to Discord.
4. Publish (not just save draft).
## Rollup releases
If multiple `package.json` bumps land between two GitHub Releases (as happened between v2.0.54 and v2.0.63), the next release is a rollup: its CHANGELOG entry covers everything merged since the last released tag, and the body opens with a one-line "Rollup release covering vX.Y.Z through vX.Y.W." note. After the catchup, return to one release per bump.
## Channels and stability
NanoClaw currently ships a single channel: every published release is a stable release.
- **Latest** — the most recent release on `main`, shown as "Latest release" on the GitHub Releases page. Consumers that want auto-bump follow GitHub's `/releases/latest` pointer.
- **Stable** — currently identical to latest. NanoClaw has no separate stable branch and no pre-release/RC channel.
- **Pinned** — any tagged release. Reproducible and the recommended choice for packagers and forks; published tags are not moved or retracted.
If a pre-release channel is introduced later (e.g. `vX.Y.Z-rc.N`), those releases will be marked "Pre-release" on GitHub so they do not become the `latest` pointer, and this section will be updated to describe the promotion path.
The tag is the source of truth — a GitHub Release's `target_commitish` always points to a tagged commit.
+1 -1
View File
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.116
ARG CLAUDE_CODE_VERSION=2.1.128
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
+10 -10
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
@@ -18,23 +18,23 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.138", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
+3 -6
View File
@@ -165,12 +165,9 @@ function parseArgv(argv: string[]): {
process.exit(2);
}
const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0];
// Third positional is the target ID
if (positional.length >= 3) {
args.id = positional[2];
}
// Join all positionals with dashes. The dispatcher trims the last
// segment as a target ID if the full name isn't a registered command.
const command = positional.join('-');
return { command, args, json };
}
@@ -26,9 +26,9 @@ const instructions = [
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. The `from` attribute identifies which destination sent the message.',
' The agent MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
'3. At the END of the compaction summary, include this verbatim reminder:',
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
];
console.log(instructions.join('\n'));
+4
View File
@@ -16,6 +16,8 @@ export interface RunnerConfig {
agentGroupId: string;
maxMessagesPerPrompt: number;
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
}
const DEFAULT_MAX_MESSAGES = 10;
@@ -43,6 +45,8 @@ export function loadConfig(): RunnerConfig {
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
};
return _config;
+2 -1
View File
@@ -196,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL
content TEXT NOT NULL,
on_wake INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE delivered (
message_out_id TEXT PRIMARY KEY,
+18 -3
View File
@@ -10,6 +10,19 @@
import { getConfig } from '../config.js';
import { openInboundDb, getOutboundDb } from './connection.js';
// Cache whether inbound.db has the on_wake column (added in v2.0.48).
// The container opens inbound.db read-only, so it can't ALTER —
// gracefully degrade when running against an older session DB.
let _hasOnWake: boolean | null = null;
function hasOnWakeColumn(db: ReturnType<typeof openInboundDb>): boolean {
if (_hasOnWake !== null) return _hasOnWake;
const cols = new Set(
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
);
_hasOnWake = cols.has('on_wake');
return _hasOnWake;
}
export interface MessageInRow {
id: string;
seq: number | null;
@@ -49,20 +62,22 @@ function getMaxMessagesPerPrompt(): number {
* sees the prior context it missed. Host's countDueMessages gates waking on
* trigger=1 separately (see src/db/session-db.ts).
*/
export function getPendingMessages(): MessageInRow[] {
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
const inbound = openInboundDb();
const outbound = getOutboundDb();
try {
const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : '';
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
${onWakeFilter}
ORDER BY seq DESC
LIMIT ?`,
LIMIT ?2`,
)
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
if (pending.length === 0) return [];
@@ -27,18 +27,18 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('from="name"');
expect(prompt).toContain('`casa`');
expect(prompt).toContain('`whatsapp-mg-17780`');
});
it('requires explicit wrapping even for a single destination', () => {
it('describes message wrapping for a single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('`casa`');
});
@@ -47,7 +47,7 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('no configured destinations');
expect(prompt).not.toContain('Default routing');
expect(prompt).not.toContain('default to addressing');
});
it('includes default-routing and wrapping instructions for single destination', () => {
@@ -55,9 +55,9 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('`casa`');
});
});
+6 -7
View File
@@ -115,17 +115,16 @@ function buildDestinationsSection(): string {
}
}
lines.push('');
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
lines.push('');
lines.push(
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
);
lines.push('');
lines.push(
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `<message>` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").',
);
lines.push('');
lines.push(
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
);
return lines.join('\n');
}
+2
View File
@@ -91,6 +91,8 @@ async function main(): Promise<void> {
mcpServers,
env: { ...process.env },
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
});
await runPollLoop({
@@ -295,115 +295,8 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('should inject destination reminder after a compacted event', async () => {
// Two destinations — required for the reminder to fire (single-destination
// groups have a fallback path that works without <message to="…"> wrapping).
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
)
.run();
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
expect(reminder).toBeDefined();
expect(reminder).toContain('2 destinations');
expect(reminder).toContain('discord-test');
expect(reminder).toContain('discord-second');
expect(reminder).toContain('<message to="name">');
await loopPromise.catch(() => {});
});
it('should NOT inject destination reminder with a single destination', async () => {
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
// Only the original prompt push (if any) — no reminder, since beforeEach
// seeds exactly one destination.
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
expect(reminders).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that emits a single compacted event mid-stream, then returns a
* result. Captures every push() call so tests can assert on the injected
* reminder content.
*/
class CompactingProvider {
readonly supportsNativeSlashCommands = false;
readonly pushes: string[] = [];
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const pushes = this.pushes;
let ended = false;
let aborted = false;
let resolveWaiter: (() => void) | null = null;
async function* events() {
yield { type: 'activity' as const };
yield { type: 'init' as const, continuation: 'compaction-test-session' };
yield { type: 'activity' as const };
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
// Wait for poll-loop to push the reminder (or end / abort)
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
// Belt-and-braces: don't hang forever if the reminder never arrives
setTimeout(resolve, 200);
});
yield { type: 'activity' as const };
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
while (!ended && !aborted) {
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
setTimeout(resolve, 50);
});
}
}
return {
push(message: string) {
pushes.push(message);
resolveWaiter?.();
},
end() {
ended = true;
resolveWaiter?.();
},
abort() {
aborted = true;
resolveWaiter?.();
},
events: events(),
};
}
}
// Helper: run poll loop until aborted or timeout
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
return Promise.race([
@@ -1,49 +1,51 @@
## Admin CLI (`ncl`)
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more.
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
### Usage
```
ncl <resource> <verb> [<id>] [--flags]
ncl <resource> <verb> [--flags]
ncl <resource> help
ncl help
```
### Scope
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
### Resources
Run `ncl help` for the full list. Common resources:
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) |
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
### When to use
- **Looking up your own config**`ncl groups get <your-group-id>` to see your agent group settings.
- **Finding who you're wired to**`ncl wirings list` to see which messaging groups route to which agent groups.
- **Checking user roles**`ncl roles list` to see who is an owner/admin.
- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `ncl` rather than guessing.
- **Looking up your own config**`ncl groups get` or `ncl groups config get` to see your container config.
- **Restarting your container**`ncl groups restart` (with optional `--rebuild` and `--message`).
- **Checking who's in your group**`ncl members list`.
- **Seeing your destinations**`ncl destinations list`.
- **Answering questions about the system** — query `ncl` rather than guessing.
### Access rules
Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it.
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
### Approval flow
Write commands (create, update, delete, grant, revoke, add, remove) require admin approval. Here's what happens:
Write commands require admin approval. Here's what happens:
1. You run the command (e.g. `ncl groups create --name "Research" --folder research`).
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
3. An admin or owner gets a notification (on the same channel when possible) showing exactly what you requested, with approve/reject options.
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
4. Once the admin responds:
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
- **Rejected:** you get a system message saying the request was rejected.
@@ -54,25 +56,28 @@ You don't need to poll or retry — the result arrives automatically.
```bash
# Read commands (no approval needed)
ncl groups list
ncl groups get abc123
ncl wirings list --messaging-group-id mg_xyz
ncl roles list
ncl wirings help
ncl groups get
ncl groups config get
ncl sessions list
ncl destinations list
ncl members list
# Write commands (approval required)
ncl groups create --name "Research" --folder research
ncl groups update abc123 --name "Research v2"
ncl roles grant --user telegram:jane --role admin
ncl roles grant --user discord:bob --role admin --group abc123
ncl members add --user-id telegram:jane --agent-group-id abc123
ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz
ncl groups restart
ncl groups restart --rebuild --message "Config updated."
ncl groups config update --model claude-sonnet-4-5-20250514
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
ncl groups config add-package --npm some-package
ncl members add --user telegram:jane
```
### Important
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
### Tips
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are required or updatable.
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
- `list` supports filtering by any non-auto column (e.g. `ncl wirings list --messaging-group-id mg_xyz`). Default limit is 200 rows; override with `--limit N`.
- For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete.
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.
@@ -1,6 +1,6 @@
## Sending messages
Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `<message to="name">...</message>` blocks). See that section for the current destination list.
**Every response** must be wrapped in `<message to="name">...</message>` blocks — even if you only have one destination. Bare text outside of `<message>` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names.
### Mid-turn updates (`send_message`)
@@ -22,4 +22,4 @@ Use **`add_mcp_server`** to add an MCP server to your configuration. Browse avai
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
```
Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential.
Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery).
+61 -4
View File
@@ -14,13 +14,18 @@ afterEach(() => {
closeSessionDb();
});
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
function insertMessage(
id: string,
kind: string,
content: object,
opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 },
) {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`,
)
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content));
}
describe('formatter', () => {
@@ -131,6 +136,58 @@ describe('accumulate gate (trigger column)', () => {
});
});
describe('on_wake filtering', () => {
it('first poll returns on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('subsequent polls skip on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(0);
});
it('normal messages returned regardless of isFirstPoll', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
expect(getPendingMessages(true)).toHaveLength(1);
// Reset: mark completed so we can re-test with a fresh message
markCompleted(['m1']);
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
expect(getPendingMessages(false)).toHaveLength(1);
});
it('mixed batch: first poll returns both normal and on_wake messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(2);
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
});
it('mixed batch: subsequent poll returns only normal messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('on_wake defaults to 0 for inserts without explicit value', () => {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
)
.run();
// Should be returned even on non-first poll (on_wake=0)
expect(getPendingMessages(false)).toHaveLength(1);
});
});
describe('routing', () => {
it('should extract routing from messages', () => {
getInboundDb()
+21 -24
View File
@@ -67,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
clearStaleProcessingAcks();
let pollCount = 0;
let isFirstPoll = true;
while (true) {
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
const messages = getPendingMessages().filter((m) => m.kind !== 'system');
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
isFirstPoll = false;
pollCount++;
// Periodic heartbeat so we know the loop is alive
@@ -263,6 +265,7 @@ async function processQuery(
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
let unwrappedNudged = false;
// Concurrent polling: push follow-ups into the active query as they arrive.
// We do NOT force-end the stream on silence — keeping the query open avoids
@@ -336,6 +339,7 @@ async function processQuery(
const keptIds = keep.map((m) => m.id);
const prompt = formatMessages(keep);
log(`Pushing ${keep.length} follow-up message(s) into active query`);
unwrappedNudged = false;
query.push(prompt);
markCompleted(keptIds);
} catch (err) {
@@ -374,24 +378,18 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
dispatchResultText(event.text, routing);
}
} else if (event.type === 'compacted') {
// The SDK auto-compacted the conversation. After compaction the
// model often drops the learned `<message to="…">` wrapping
// discipline (the destinations are still in the system prompt,
// but the behavioral pattern is summarized away). Inject a
// reminder back into the live query so the next turn re-anchors
// on the destination model. Only do this when there's >1
// destination — single-destination groups have a fallback that
// works without wrapping. See qwibitai/nanoclaw#2325.
const destinations = getAllDestinations();
if (destinations.length > 1) {
const names = destinations.map((d) => d.name).join(', ');
query.push(
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
);
const { hasUnwrapped } = dispatchResultText(event.text, routing);
if (hasUnwrapped && !unwrappedNudged) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
}
}
}
@@ -419,9 +417,6 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
case 'progress':
log(`Progress: ${event.message}`);
break;
case 'compacted':
log(`Compacted: ${event.text}`);
break;
}
}
@@ -433,7 +428,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
* The agent must always wrap output in <message to="name">...</message>
* blocks, even with a single destination. Bare text is scratchpad only.
*/
function dispatchResultText(text: string, routing: RoutingContext): void {
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
let match: RegExpExecArray | null;
@@ -468,9 +463,11 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
}
if (sent === 0 && text.trim()) {
const hasUnwrapped = sent === 0 && !!scratchpad;
if (hasUnwrapped) {
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
}
return { sent, hasUnwrapped };
}
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
@@ -257,11 +257,15 @@ export class ClaudeProvider implements AgentProvider {
private mcpServers: Record<string, McpServerConfig>;
private env: Record<string, string | undefined>;
private additionalDirectories?: string[];
private model?: string;
private effort?: string;
constructor(options: ProviderOptions = {}) {
this.assistantName = options.assistantName;
this.mcpServers = options.mcpServers ?? {};
this.additionalDirectories = options.additionalDirectories;
this.model = options.model;
this.effort = options.effort;
this.env = {
...(options.env ?? {}),
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
@@ -293,6 +297,9 @@ export class ClaudeProvider implements AgentProvider {
],
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
effort: this.effort as any,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
@@ -329,7 +336,7 @@ export class ClaudeProvider implements AgentProvider {
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
yield { type: 'compacted', text: `Context compacted${detail}.` };
yield { type: 'result', text: `Context compacted${detail}.` };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { summary?: string };
yield { type: 'progress', message: tn.summary || 'Task notification' };
+11 -9
View File
@@ -25,6 +25,16 @@ export interface ProviderOptions {
mcpServers?: Record<string, McpServerConfig>;
env?: Record<string, string | undefined>;
additionalDirectories?: string[];
/**
* Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
* to the underlying SDK. If omitted, the SDK default is used.
*/
model?: string;
/**
* Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
* through to the underlying SDK. If omitted, the SDK default is used.
*/
effort?: string;
}
export interface QueryInput {
@@ -79,12 +89,4 @@ export type ProviderEvent =
* event (tool call, thinking, partial message, anything) so the
* poll-loop's idle timer stays honest during long tool runs.
*/
| { type: 'activity' }
/**
* The provider's underlying SDK auto-compacted the conversation context.
* The poll-loop reacts by injecting a destination reminder back into
* the live query so the agent doesn't drop `<message to="…">` wrapping
* after compaction. Distinct from `result` so it doesn't mark the turn
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
*/
| { type: 'compacted'; text: string };
| { type: 'activity' };
@@ -4,4 +4,4 @@ Your HTTP requests go through the OneCLI proxy, which injects real credentials a
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI.
If you get a `401`/`403`/`app_not_connected`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.
+1 -1
View File
@@ -9,7 +9,7 @@ You've just been connected to a new user. This your time to shine and make a str
## What to do
1. Send a short, warm greeting using `send_message`
1. Send a short, warm greeting
2. State your name (from your system prompt / CLAUDE.md)
3. Signal that you're capable of a lot — but don't list everything upfront. Be intriguing, not encyclopedic
4. Ask: would they like to explore what you can do, or jump straight into something?
+1 -1
View File
@@ -2,7 +2,7 @@
## Structure
**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**`nanocoai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**Channel forks** (`nanoclaw-whatsapp`, `nanoclaw-telegram`, `nanoclaw-slack`, etc.) — each fork = upstream + one channel's code applied. Users clone upstream, then merge a fork into their clone to add a channel.
+29 -1
View File
@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
### 1.1 `agent_groups`
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB.
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
```sql
CREATE TABLE agent_groups (
@@ -294,6 +294,32 @@ CREATE TABLE schema_version (
);
```
### 1.15 `container_configs`
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.
```sql
CREATE TABLE container_configs (
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
provider TEXT,
model TEXT,
effort TEXT,
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
updated_at TEXT NOT NULL
);
```
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
---
## 2. Migration system
@@ -313,6 +339,8 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
+16 -13
View File
@@ -33,19 +33,22 @@ Every message landing in the session: user chat, scheduled task, recurring task,
```sql
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL -- JSON; shape depends on kind
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL, -- JSON; shape depends on kind
source_session_id TEXT, -- agent-to-agent return path
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll
);
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
```
+2 -2
View File
@@ -77,7 +77,7 @@ NanoClaw must live inside the workspace directory — Docker-in-Docker can only
```bash
# Clone to home first (virtiofs can corrupt git pack files during clone)
cd ~
git clone https://github.com/qwibitai/nanoclaw.git
git clone https://github.com/nanocoai/nanoclaw.git
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
WORKSPACE=/Users/you/nanoclaw-workspace
@@ -347,7 +347,7 @@ docker sandbox network proxy <sandbox-name> \
### Git clone fails with "inflate: data stream error"
Clone to a non-workspace path first, then move:
```bash
cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
```
### WhatsApp QR code doesn't display
+22 -22
View File
@@ -23,7 +23,7 @@ This replaces the previous `skills-engine/` system (three-way file merging, `.na
### Repository structure
The upstream repo (`qwibitai/nanoclaw`) maintains:
The upstream repo (`nanocoai/nanoclaw`) maintains:
- `main` — core NanoClaw (no skill code)
- `skill/discord` — main + Discord integration
@@ -46,7 +46,7 @@ Skills are split into two categories:
**Feature skills** (in marketplace, installed on demand):
- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc.
- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code
- Live in the marketplace repo (`qwibitai/nanoclaw-skills`)
- Live in the marketplace repo (`nanocoai/nanoclaw-skills`)
Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently:
@@ -78,7 +78,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
"repo": "nanocoai/nanoclaw-skills"
}
}
}
@@ -88,7 +88,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
The marketplace repo uses Claude Code's plugin structure:
```
qwibitai/nanoclaw-skills/
nanocoai/nanoclaw-skills/
.claude-plugin/
marketplace.json # Plugin catalog
plugins/
@@ -213,7 +213,7 @@ A GitHub Action runs on every push to `main`:
### New users (recommended)
1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button)
1. Fork `nanocoai/nanoclaw` on GitHub (click the Fork button)
2. Clone your fork:
```bash
git clone https://github.com/<you>/nanoclaw.git
@@ -229,9 +229,9 @@ Forking is recommended because it gives users a remote to push their customizati
### Existing users migrating from clone
Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations:
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
1. Fork `qwibitai/nanoclaw` on GitHub
1. Fork `nanocoai/nanoclaw` on GitHub
2. Reroute remotes:
```bash
git remote rename origin upstream
@@ -239,7 +239,7 @@ Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` an
git push --force origin main
```
The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose.
3. From this point, `origin` = their fork, `upstream` = qwibitai/nanoclaw
3. From this point, `origin` = their fork, `upstream` = nanocoai/nanoclaw
### Existing users migrating from the old skills engine
@@ -316,7 +316,7 @@ git fetch upstream main
git checkout -b my-fix upstream/main
# Make changes
git push origin my-fix
# Create PR from my-fix to qwibitai/nanoclaw:main
# Create PR from my-fix to nanocoai/nanoclaw:main
```
Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR.
@@ -327,7 +327,7 @@ The flow below is for **feature skills** (branch-based). For utility skills (sel
### Contributor flow (feature skills)
1. Fork `qwibitai/nanoclaw`
1. Fork `nanocoai/nanoclaw`
2. Branch from `main`
3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.)
4. Open a PR to `main`
@@ -345,7 +345,7 @@ When a skill PR is reviewed and approved:
```
2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes)
3. Merge the slimmed PR into `main` (just the contributor addition)
4. Add the skill's SKILL.md to the marketplace repo (`qwibitai/nanoclaw-skills`)
4. Add the skill's SKILL.md to the marketplace repo (`nanocoai/nanoclaw-skills`)
This way:
- The contributor gets merge credit (their PR is merged)
@@ -388,7 +388,7 @@ If the community contributor is trusted, they can open a PR to add their marketp
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
"repo": "nanocoai/nanoclaw-skills"
}
},
"alice-nanoclaw-skills": {
@@ -434,7 +434,7 @@ A flavor is a curated fork of NanoClaw — a combination of skills, custom chang
### Creating a flavor
1. Fork `qwibitai/nanoclaw`
1. Fork `nanocoai/nanoclaw`
2. Merge in the skills you want
3. Make custom changes (trigger word, prompts, integrations, etc.)
4. Your fork's `main` IS the flavor
@@ -462,7 +462,7 @@ Then setup continues normally (dependencies, auth, container, service).
After installation, the user's fork has three remotes:
- `origin` — their fork (push customizations here)
- `upstream``qwibitai/nanoclaw` (core updates)
- `upstream``nanocoai/nanoclaw` (core updates)
- `<flavor-name>` — the flavor fork (flavor updates)
### Updating a flavor
@@ -538,14 +538,14 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
Before:
```bash
git clone https://github.com/qwibitai/NanoClaw.git
git clone https://github.com/nanocoai/NanoClaw.git
cd NanoClaw
claude
```
After:
```
1. Fork qwibitai/nanoclaw on GitHub
1. Fork nanocoai/nanoclaw on GitHub
2. git clone https://github.com/<you>/nanoclaw.git
3. cd nanoclaw
4. claude
@@ -556,8 +556,8 @@ After:
Updates to the setup flow:
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git`
- Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration.
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/nanocoai/nanoclaw.git`
- Check if `origin` points to the user's fork (not nanocoai). If it points to nanocoai, guide them through the fork migration.
- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart)
- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels
- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp)
@@ -573,7 +573,7 @@ Marketplace configuration so the official marketplace is auto-registered:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
"repo": "nanocoai/nanoclaw-skills"
}
}
}
@@ -601,7 +601,7 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
### New infrastructure
- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **Marketplace repo** (`nanocoai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution
- **`/update-skills` skill** — checks for and applies skill branch updates using git history
- **`CONTRIBUTORS.md`** — tracks skill contributors
@@ -650,7 +650,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to.
>
> **If you currently have a clone with local changes**, migrate to a fork:
> 1. Fork `qwibitai/nanoclaw` on GitHub
> 1. Fork `nanocoai/nanoclaw` on GitHub
> 2. Run:
> ```
> git remote rename origin upstream
@@ -668,7 +668,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **Contributing skills**
>
> To contribute a skill:
> 1. Fork `qwibitai/nanoclaw`
> 1. Fork `nanocoai/nanoclaw`
> 2. Branch from `main` and make your code changes
> 3. Open a regular PR
>
+1 -1
View File
@@ -240,7 +240,7 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
printf ' %s\n' "$(dim '4. Log out: exit')"
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/nanocoai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
exit 1
;;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.45",
"version": "2.0.64",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+3 -3
View File
@@ -12,7 +12,7 @@ A GitHub Action that calculates the size of your codebase in terms of tokens and
## Usage
```yaml
- uses: qwibitai/nanoclaw/repo-tokens@v1
- uses: nanocoai/nanoclaw/repo-tokens@v1
with:
include: 'src/**/*.ts'
exclude: 'src/**/*.test.ts'
@@ -34,7 +34,7 @@ Repos using repo-tokens:
| Repo | Badge |
|------|-------|
| [NanoClaw](https://github.com/qwibitai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/qwibitai/NanoClaw/main/repo-tokens/badge.svg) |
| [NanoClaw](https://github.com/nanocoai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/nanocoai/NanoClaw/main/repo-tokens/badge.svg) |
### Full workflow example
@@ -59,7 +59,7 @@ jobs:
with:
python-version: '3.12'
- uses: qwibitai/nanoclaw/repo-tokens@v1
- uses: nanocoai/nanoclaw/repo-tokens@v1
id: tokens
with:
include: 'src/**/*.ts'
+2 -2
View File
@@ -114,7 +114,7 @@ runs:
with open(readme_path, "r", encoding="utf-8") as f:
content = f.read()
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
linked_badge = f'<a href="{repo_tokens_url}">{badge}</a>'
new_content = marker_re.sub(rf"\1{linked_badge}\2", content)
@@ -148,7 +148,7 @@ runs:
lx = label_w // 2
vx = label_w + value_w // 2
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total_w}" height="20" role="img" aria-label="{full_desc}">
<title>{full_desc}</title>
+5 -5
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="150k tokens, 75% of context window">
<title>150k tokens, 75% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="174k tokens, 87% of context window">
<title>174k tokens, 87% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -7,7 +7,7 @@
<clipPath id="r">
<rect width="90" height="20" rx="3" fill="#fff"/>
</clipPath>
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
<a xlink:href="https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens">
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#e05d44"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">150k</text>
<text x="71" y="14">150k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">174k</text>
<text x="71" y="14">174k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -47,6 +47,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
import { upsertUser } from '../src/modules/permissions/db/users.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
@@ -231,6 +232,8 @@ async function main(): Promise<void> {
granted_at: now,
});
}
// Owner's agent group gets global CLI access
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
} else if (args.role === 'admin') {
const alreadyAdmin = existingRoles.some(
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
+3 -2
View File
@@ -146,6 +146,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
' • chat:write',
' • users:read',
' • reactions:write',
' • files:read, files:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
@@ -317,9 +318,9 @@ async function collectSlackUserId(): Promise<string> {
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (top right)',
' 1. In Slack, click your profile picture (bottom left)',
' 2. Click "Profile"',
' 3. Click the three dots () → "Copy member ID"',
' 3. Click the three dots () → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
Regular → Executable
+4 -4
View File
@@ -6,10 +6,10 @@
# `upstream`, with `origin` pointing at the user's fork. The channels branch
# only lives upstream, so a hardcoded `git fetch origin channels` fails for
# forks. This helper walks `git remote -v`, picks the remote whose URL points
# at qwibitai/nanoclaw, and prints its name.
# at nanocoai/nanoclaw, and prints its name.
#
# Fallback: if no existing remote matches, add `upstream` pointing at
# github.com/qwibitai/nanoclaw and return that — keeps forks without an
# github.com/nanocoai/nanoclaw and return that — keeps forks without an
# explicit upstream configured working on the first try.
#
# Explicit override: set NANOCLAW_CHANNELS_REMOTE=<name> to skip detection.
@@ -23,7 +23,7 @@ resolve_channels_remote() {
local remote url
while IFS=$'\t' read -r remote url; do
case "$url" in
*qwibitai/nanoclaw*)
*qwibitai/nanoclaw*|*nanocoai/nanoclaw*)
printf '%s' "$remote"
return 0
;;
@@ -33,6 +33,6 @@ resolve_channels_remote() {
# No matching remote — add `upstream` and use it. Silent on failure so
# callers see the eventual `git fetch` error rather than a cryptic
# remote-add failure.
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
git remote add upstream https://github.com/nanocoai/nanoclaw.git 2>/dev/null || true
printf '%s' "upstream"
}
+1 -1
View File
@@ -66,7 +66,7 @@ async function getJson<T>(url: string, token: string, fetchImpl: FetchFn): Promi
const res = await fetchImpl(url, {
headers: {
Authorization: `Bot ${token}`,
'User-Agent': 'NanoClaw-Migration (https://github.com/qwibitai/nanoclaw, 2.x)',
'User-Agent': 'NanoClaw-Migration (https://github.com/nanocoai/nanoclaw, 2.x)',
},
});
if (!res.ok) {
+2 -1
View File
@@ -105,6 +105,7 @@ function writeEnvOnecliUrl(url: string): void {
// Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships.
const ONECLI_GATEWAY_VERSION = '1.23.0';
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
@@ -153,7 +154,7 @@ function installOnecli(): { stdout: string; ok: boolean } {
if (cleanup) stdout += cleanup + '\n';
// Gateway install (docker-compose based, no rate-limit concerns).
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
const gw = runInstall(`export ONECLI_VERSION=${ONECLI_GATEWAY_VERSION} && curl -fsSL onecli.sh/install | sh`);
stdout += gw.stdout;
if (!gw.ok) {
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
+78
View File
@@ -0,0 +1,78 @@
/**
* One-time backfill: seed `container_configs` rows from existing
* `groups/<folder>/container.json` files and `agent_groups.agent_provider`.
*
* Runs after migrations, before channel adapters start. Idempotent skips
* groups that already have a config row.
*/
import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import type { McpServerConfig, AdditionalMountConfig } from './container-config.js';
import { getAllAgentGroups } from './db/agent-groups.js';
import { getContainerConfig, createContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import type { ContainerConfigRow } from './types.js';
interface LegacyContainerJson {
mcpServers?: Record<string, McpServerConfig>;
packages?: { apt?: string[]; npm?: string[] };
imageTag?: string;
additionalMounts?: AdditionalMountConfig[];
skills?: string[] | 'all';
provider?: string;
assistantName?: string;
maxMessagesPerPrompt?: number;
}
export function backfillContainerConfigs(): void {
const groups = getAllAgentGroups();
let backfilled = 0;
for (const group of groups) {
// Skip if already has a config row
if (getContainerConfig(group.id)) continue;
// Read legacy container.json from disk
const filePath = path.join(GROUPS_DIR, group.folder, 'container.json');
let legacy: LegacyContainerJson = {};
if (fs.existsSync(filePath)) {
try {
legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson;
} catch (err) {
log.warn('Backfill: failed to parse container.json, using defaults', {
folder: group.folder,
err: String(err),
});
}
}
// DB agent_provider wins over file provider (matches old cascade)
const provider = group.agent_provider || legacy.provider || null;
const row: ContainerConfigRow = {
agent_group_id: group.id,
provider,
model: null,
effort: null,
image_tag: legacy.imageTag ?? null,
assistant_name: legacy.assistantName ?? null,
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
skills: JSON.stringify(legacy.skills ?? 'all'),
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
cli_scope: 'group',
updated_at: new Date().toISOString(),
};
createContainerConfig(row);
backfilled++;
}
if (backfilled > 0) {
log.info('Backfilled container_configs from disk', { count: backfilled });
}
}
+10 -4
View File
@@ -18,7 +18,8 @@ import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import { readContainerConfig } from './container-config.js';
import type { McpServerConfig } from './container-config.js';
import { getContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import type { AgentGroup } from './types.js';
@@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
}
// Desired fragment set.
const config = readContainerConfig(group.folder);
const configRow = getContainerConfig(group.id);
const mcpServers: Record<string, McpServerConfig> = configRow
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
: {};
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
// Skill fragments — every skill that ships an `instructions.md`.
@@ -75,13 +79,15 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
// Built-in module fragments — every MCP tool source file that ships a
// sibling `<name>.instructions.md`. These describe how the agent should
// use that module's MCP tools (schedule_task, install_packages, etc.).
// Always included — these are built-in, not toggleable.
// Skip cli.instructions.md when cli_scope is disabled.
const cliDisabled = configRow?.cli_scope === 'disabled';
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
if (fs.existsSync(mcpToolsHostDir)) {
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
const match = entry.match(/^(.+)\.instructions\.md$/);
if (!match) continue;
const moduleName = match[1];
if (moduleName === 'cli' && cliDisabled) continue;
desired.set(`module-${moduleName}.md`, {
type: 'symlink',
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
@@ -91,7 +97,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
// MCP server fragments — inline instructions from container.json for
// user-added external MCP servers.
for (const [name, mcp] of Object.entries(config.mcpServers)) {
for (const [name, mcp] of Object.entries(mcpServers)) {
if (mcp.instructions) {
desired.set(`mcp-${name}.md`, {
type: 'inline',
+6 -29
View File
@@ -21,6 +21,7 @@ import { formatResponse } from './format.js';
import type { RequestFrame } from './frame.js';
import { SocketTransport } from './socket-client.js';
import type { Transport } from './transport.js';
import { formatTransportError } from './transport-errors.js';
async function main(): Promise<void> {
const argv = process.argv.slice(2);
@@ -85,20 +86,11 @@ function parseArgv(argv: string[]): {
process.exit(2);
}
// Single word: `ncl help`
// Two words: `ncl groups list`, `ncl groups help`
// Three words: `ncl groups get abc123`
let command: string;
if (positional.length === 1) {
command = positional[0];
} else {
command = `${positional[0]}-${positional[1]}`;
}
// Third positional is the target ID
if (positional.length >= 3) {
args.id = positional[2];
}
// Join all positionals with dashes to form the command name.
// If the full name isn't a command, the dispatcher will try trimming
// the last segment and using it as the target ID (e.g. `groups get abc`
// → command "groups-get", id "abc").
const command = positional.join('-');
return { command, args, json };
}
@@ -114,21 +106,6 @@ function printUsage(): void {
);
}
function formatTransportError(e: unknown): string {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
return [
`ncl: cannot reach NanoClaw host (${msg}).`,
`Is the host running? Start it with: pnpm run dev`,
`Or, if installed as a service:`,
` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
` Linux: systemctl --user restart nanoclaw`,
``,
].join('\n');
}
return `ncl: transport error: ${msg}\n`;
}
main().catch((err) => {
process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(2);
+37 -6
View File
@@ -4,19 +4,38 @@
* ncl help list all resources and commands
* ncl groups help show group resource details (verbs, columns, enums)
*/
import { getContainerConfig } from '../../db/container-configs.js';
import { getResource, getResources } from '../crud.js';
import type { CallerContext } from '../frame.js';
import { listCommands, register } from '../registry.js';
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
function getCliScope(ctx: CallerContext): string | undefined {
if (ctx.caller !== 'agent') return undefined;
return getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group';
}
register({
name: 'help',
description: 'List available resources and commands.',
access: 'open',
parseArgs: () => ({}),
handler: async () => {
const resources = getResources();
handler: async (_args, ctx) => {
const cliScope = getCliScope(ctx);
let resources = getResources();
if (cliScope === 'group') {
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
}
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
const lines: string[] = [];
if (cliScope === 'group') {
lines.push('CLI scope: group (--id and group args are auto-filled to your agent group)');
lines.push('');
}
if (resources.length > 0) {
lines.push('Resources:');
for (const r of resources) {
@@ -61,18 +80,27 @@ export function registerResourceHelpCommands(): void {
access: 'open',
resource: res.plural,
parseArgs: () => ({}),
handler: async () => {
handler: async (_args, ctx) => {
const cliScope = getCliScope(ctx);
const lines: string[] = [];
lines.push(`${res.plural}: ${res.description}`);
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
lines.push('');
lines.push('Note: --id and group args are auto-filled to your agent group. You do not need to pass them.');
}
lines.push('');
// Verbs
const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
const idHint = idAutoFilled ? '' : ' <id>';
const verbs: string[] = [];
if (res.operations.list) verbs.push(`list [open]`);
if (res.operations.get) verbs.push(`get <id> [open]`);
if (res.operations.get) verbs.push(`get${idHint} [open]`);
if (res.operations.create) verbs.push(`create [approval]`);
if (res.operations.update) verbs.push(`update <id> [approval]`);
if (res.operations.delete) verbs.push(`delete <id> [approval]`);
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
if (res.customOperations) {
for (const [verb, op] of Object.entries(res.customOperations)) {
verbs.push(`${verb} [${op.access}] — ${op.description}`);
@@ -83,9 +111,12 @@ export function registerResourceHelpCommands(): void {
lines.push('');
// Columns
const autoFilledFields =
cliScope === 'group' ? new Set(['id', 'agent_group_id', 'group']) : new Set<string>();
lines.push('Fields:');
for (const col of res.columns) {
const tags: string[] = [];
if (autoFilledFields.has(col.name)) tags.push('auto-filled');
if (col.generated) tags.push('auto');
if (col.required) tags.push('required');
if (col.updatable) tags.push('updatable');
+9 -1
View File
@@ -52,6 +52,12 @@ export interface ResourceDef {
description: string;
/** Primary key column name. */
idColumn: string;
/**
* Column that carries the agent group ID for group-scope enforcement.
* Required on every resource in the CLI whitelist (groups, sessions,
* destinations, members). When absent, post-handler filtering fails closed.
*/
scopeField?: string;
columns: ColumnDef[];
/** Which standard CRUD operations are enabled. */
operations: {
@@ -226,6 +232,7 @@ export function registerResource(def: ResourceDef): void {
description: `List all ${def.plural}.`,
access: def.operations.list,
resource: def.plural,
generic: 'list',
parseArgs: (raw) => normalizeArgs(raw),
handler: genericList(def),
});
@@ -237,6 +244,7 @@ export function registerResource(def: ResourceDef): void {
description: `Get a ${def.name} by ID.`,
access: def.operations.get,
resource: def.plural,
generic: 'get',
parseArgs: (raw) => normalizeArgs(raw),
handler: genericGet(def),
});
@@ -279,7 +287,7 @@ export function registerResource(def: ResourceDef): void {
if (def.customOperations) {
for (const [verb, op] of Object.entries(def.customOperations)) {
register({
name: `${def.plural}-${verb}`,
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
description: op.description,
access: op.access,
resource: def.plural,
+514
View File
@@ -0,0 +1,514 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mocks ---
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const mockGetContainerConfig = vi.fn();
vi.mock('../db/container-configs.js', () => ({
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
}));
const mockGetAgentGroup = vi.fn();
vi.mock('../db/agent-groups.js', () => ({
getAgentGroup: (...args: unknown[]) => mockGetAgentGroup(...args),
}));
const mockGetSession = vi.fn();
vi.mock('../db/sessions.js', () => ({
getSession: (...args: unknown[]) => mockGetSession(...args),
}));
// dispatch's post-handler looks up the resource's `scopeField` via getResource.
// The real resources aren't registered in this unit test, so mock it.
const mockGetResource = vi.fn();
vi.mock('./crud.js', () => ({
getResource: (...args: unknown[]) => mockGetResource(...args),
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
}));
// Register a test command so dispatch has something to find
import { register } from './registry.js';
register({
name: 'test-cmd',
description: 'test command (non-group resource)',
resource: 'test',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'groups-test',
description: 'test command (groups resource)',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'general-cmd',
description: 'test command (no resource, like help)',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'sessions-list',
description: 'test command (sessions resource)',
resource: 'sessions',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'destinations-list',
description: 'test command (destinations resource)',
resource: 'destinations',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'members-add',
description: 'test command (members resource)',
resource: 'members',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'wirings-list',
description: 'test command (wirings resource — not allowed)',
resource: 'wirings',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
description: 'returns mock group rows',
resource: 'groups',
access: 'open',
generic: 'list',
parseArgs: (raw) => raw,
handler: async () => [
{ id: 'g1', name: 'my-group' },
{ id: 'g2', name: 'other-group' },
],
});
register({
name: 'sessions-get-data',
description: 'returns a mock session row',
resource: 'sessions',
access: 'open',
generic: 'get',
parseArgs: (raw) => raw,
handler: async (args) => ({
id: args.id,
agent_group_id: (args as Record<string, unknown>).belongs_to ?? 'g1',
}),
});
// A custom op under the `groups` resource that returns a config-shaped object
// (no `id` key). The post-handler must not touch this — only `generic` handlers.
register({
name: 'groups-config-get',
description: 'custom op returning a config object (no id)',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => ({ agent_group_id: 'g1', model: 'opus' }),
});
// The real `sessions-get` name — triggers the pre-handler ownership check.
register({
name: 'sessions-get',
description: 'generic sessions get',
resource: 'sessions',
access: 'open',
generic: 'get',
parseArgs: (raw) => raw,
handler: async (args) => ({ id: (args as Record<string, unknown>).id, agent_group_id: 'g1' }),
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
// Default: the four CLI-whitelisted resources with their real scopeFields.
const scopeFields: Record<string, string> = {
groups: 'id',
sessions: 'agent_group_id',
destinations: 'agent_group_id',
members: 'agent_group_id',
};
mockGetResource.mockImplementation((plural: string) =>
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
);
});
// --- Helpers ---
function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }>>): CallerContext {
return {
caller: 'agent',
sessionId: 's1',
agentGroupId: 'g1',
messagingGroupId: 'mg1',
...overrides,
};
}
// --- Tests ---
describe('CLI scope enforcement', () => {
it('disabled: rejects all CLI requests from agent', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('disabled');
}
});
it('group: auto-fills --id with caller agent group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBe('g1');
}
});
it('group: rejects cross-group access', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'other-group' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('scoped');
}
});
it('group: allows same-group id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'g1' } }, agentCtx());
expect(resp.ok).toBe(true);
});
it('group: blocks cli_scope escalation', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('cli_scope');
}
});
it('group: blocks cli-scope escalation (hyphenated)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('group: blocks non-group resources', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('test');
}
});
it('group: allows general commands with no resource (e.g. help)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'general-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
});
it('group: allows sessions, auto-fills --agent_group_id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'sessions-list', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.agent_group_id).toBe('g1');
// --id should NOT be auto-filled for sessions (it's session UUID, not group)
expect(data.echo.id).toBeUndefined();
}
});
it('group: allows destinations, auto-fills --id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'destinations-list', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBe('g1');
}
});
it('group: allows members, auto-fills --group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'members-add', args: { user: 'u1' } }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.group).toBe('g1');
}
});
it('group: blocks non-whitelisted resources (wirings)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('wirings');
}
});
it('group: rejects cross-group --agent_group_id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'sessions-list', args: { agent_group_id: 'other-group' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('group: rejects cross-group --group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'members-add', args: { user: 'u1', group: 'other-group' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('global: allows cross-group access', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'other-group' } }, agentCtx());
expect(resp.ok).toBe(true);
});
it('global: allows non-group resources', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
});
it('global: does not auto-fill --id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { foo: 'bar' } }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBeUndefined();
}
});
it('defaults to group when cli_scope is missing', async () => {
mockGetContainerConfig.mockReturnValue({});
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('host caller bypasses CLI scope enforcement', async () => {
// No config check should happen for host callers
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'any-group' } }, { caller: 'host' });
expect(resp.ok).toBe(true);
expect(mockGetContainerConfig).not.toHaveBeenCalled();
});
// --- Post-handler filtering ---
it('group: groups list filters out other groups', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as Array<{ id: string }>;
expect(data).toHaveLength(1);
expect(data[0].id).toBe('g1');
}
});
it('group: sessions get rejects cross-group session', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'other-group' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('different agent group');
}
});
it('group: sessions get allows own-group session', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'g1' } },
agentCtx(),
);
expect(resp.ok).toBe(true);
});
it('global: no post-handler filtering', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as Array<{ id: string }>;
expect(data).toHaveLength(2); // both groups returned
}
});
// --- Custom ops bypass post-handler row filtering (regression: #2392 review) ---
it('group: a custom op returning a non-row object is not falsely rejected', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
// groups-config-get is access:open and reachable by a group-scoped agent;
// it returns { agent_group_id, model } with no `id` field. Before this fix
// the post-handler compared data['id'] (undefined) and returned forbidden.
const resp = await dispatch({ id: '1', command: 'groups-config-get', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
expect((resp.data as { model: string }).model).toBe('opus');
}
});
// --- sessions-get pre-handler ownership check (no existence oracle) ---
it('group: sessions-get returns "session not found" for a foreign session UUID', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's-x', agent_group_id: 'other-group' });
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-x' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('handler-error');
expect(resp.error.message).toContain('session not found');
}
});
it('group: sessions-get returns "session not found" for a non-existent UUID', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue(undefined);
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-nope' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('handler-error');
expect(resp.error.message).toContain('session not found');
}
});
it('group: sessions-get allows the callers own session', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's-mine', agent_group_id: 'g1' });
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-mine' } }, agentCtx());
expect(resp.ok).toBe(true);
});
// --- Fail-closed regression guard for a missing scopeField ---
it('group: generic list/get fails closed when the resource declares no scopeField', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetResource.mockReturnValue(undefined); // a whitelisted resource that forgot scopeField
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('not available in group scope');
}
});
});
+123 -2
View File
@@ -6,18 +6,101 @@
* Approval gating for risky calls from the container is the only branch
* that differs by caller. Host callers and `open` commands run inline.
*/
import { getContainerConfig } from '../db/container-configs.js';
import { getAgentGroup } from '../db/agent-groups.js';
import { getSession } from '../db/sessions.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { getResource } from './crud.js';
import { lookup } from './registry.js';
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
const cmd = lookup(req.command);
let cmd = lookup(req.command);
// Fallback: if the full command isn't registered, trim the last
// dash-segment and treat it as the target ID. This lets clients join
// all positional args with dashes (e.g. `ncl groups get abc123`
// → command "groups-get-abc123" → trim → "groups-get" + id "abc123").
if (!cmd) {
const idx = req.command.lastIndexOf('-');
if (idx > 0) {
const shortened = req.command.slice(0, idx);
const tail = req.command.slice(idx + 1);
const fallback = lookup(shortened);
if (fallback) {
cmd = fallback;
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
}
}
}
if (!cmd) {
return err(req.id, 'unknown-command', `no command "${req.command}"`);
}
// CLI scope enforcement for agent callers
if (ctx.caller === 'agent') {
const configRow = getContainerConfig(ctx.agentGroupId);
const cliScope = configRow?.cli_scope ?? 'group';
if (cliScope === 'disabled') {
return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.');
}
if (cliScope === 'group') {
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
// Only allow whitelisted resources and general commands (no resource, like help)
if (cmd.resource && !allowed.has(cmd.resource)) {
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
}
// Enforce group scope on all agent-group-related args.
// Different resources use different arg names for the agent group ID.
// Only check --id for resources where it IS the agent group ID.
const groupArgs = ['agent_group_id', 'group'] as const;
for (const key of groupArgs) {
if (req.args[key] && req.args[key] !== ctx.agentGroupId) {
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
}
}
if (
(cmd.resource === 'groups' || cmd.resource === 'destinations') &&
req.args.id &&
req.args.id !== ctx.agentGroupId
) {
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
}
// Block cli_scope changes from group-scoped agents (privilege escalation)
if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) {
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
}
// Auto-fill agent-group-related args so the agent doesn't need
// to pass its own group ID explicitly.
const fill: Record<string, unknown> = {
agent_group_id: req.args.agent_group_id ?? ctx.agentGroupId,
group: req.args.group ?? ctx.agentGroupId,
};
// Only auto-fill --id for resources where it IS the agent group ID
// (groups, destinations). For sessions/members --id is a different key.
if (cmd.resource === 'groups' || cmd.resource === 'destinations') {
fill.id = req.args.id ?? ctx.agentGroupId;
}
req = { ...req, args: { ...req.args, ...fill } };
// Fail-closed pre-handler check for sessions-get: returns "not found"
// regardless of whether the UUID exists in another group, preventing an
// existence oracle across group boundaries.
if (cmd.resource === 'sessions' && req.command === 'sessions-get' && req.args.id) {
const s = getSession(req.args.id as string);
if (!s || s.agent_group_id !== ctx.agentGroupId) {
return err(req.id, 'handler-error', `session not found: ${req.args.id}`);
}
}
}
}
if (ctx.caller !== 'host' && cmd.access === 'approval') {
const session = getSession(ctx.sessionId);
if (!session) {
@@ -50,7 +133,45 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
}
try {
const data = await cmd.handler(parsed, ctx);
let data = await cmd.handler(parsed, ctx);
// Post-handler group-scope enforcement. Applies only to the auto-generated
// `list` / `get` handlers (`cmd.generic`), which return raw DB rows carrying
// the resource's `scopeField`:
// - `list` → drop rows that don't belong to the caller's agent group
// (covers `groups list`, where the generic list handler ignores
// the auto-filled `--id`)
// - `get` → reject if the single row belongs to another group
// Custom operations return ad-hoc shapes (e.g. `groups config get` → a config
// object with no `id`) and are NOT checked here — they would be falsely
// rejected, and they're already pinned to the caller's group by the
// pre-handler `--id` auto-fill (groups/destinations) or gated behind approval,
// so they can't reach another group's data anyway.
if (ctx.caller === 'agent' && cmd.resource && cmd.generic) {
const configRow = getContainerConfig(ctx.agentGroupId);
if ((configRow?.cli_scope ?? 'group') === 'group') {
const def = getResource(cmd.resource);
const groupField = def?.scopeField;
if (!groupField) {
// Fail closed: a whitelisted resource exposing list/get must declare
// `scopeField` so its rows can be filtered.
return err(req.id, 'forbidden', `"${cmd.resource}" is not available in group scope.`);
}
if (Array.isArray(data)) {
data = data.filter(
(row) =>
typeof row === 'object' &&
row !== null &&
(row as Record<string, unknown>)[groupField] === ctx.agentGroupId,
);
} else if (data && typeof data === 'object') {
if ((data as Record<string, unknown>)[groupField] !== ctx.agentGroupId) {
return err(req.id, 'forbidden', 'Resource belongs to a different agent group.');
}
}
}
}
return { id: req.id, ok: true, data };
} catch (e) {
return err(req.id, 'handler-error', errMsg(e));
+1
View File
@@ -25,6 +25,7 @@ export type ErrorCode =
| 'unknown-command'
| 'invalid-args'
| 'permission-denied'
| 'forbidden'
| 'approval-pending'
| 'not-found'
| 'handler-error'
+7
View File
@@ -15,6 +15,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
access: Access;
/** Resource this command belongs to (for help grouping). */
resource?: string;
/**
* Set on the auto-generated `list` / `get` handlers (see `registerResource`).
* These return raw DB rows that carry the resource's `scopeField`, so the
* dispatcher applies post-handler group-scope filtering to their output.
* Custom operations return ad-hoc shapes and leave this undefined.
*/
generic?: 'list' | 'get';
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
parseArgs: (raw: Record<string, unknown>) => TArgs;
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
+147
View File
@@ -0,0 +1,147 @@
/**
* Regression test for #2465 approval-path `ncl destinations add/remove`
* must hydrate every active session's `inbound.db` `destinations` table,
* not just the central `agent_destinations` row.
*
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
* `caller: 'host'` after admin approval, so this test invokes dispatch
* with the host caller same code path as a real approval payload.
*/
import Database from 'better-sqlite3';
import fs from 'fs';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-destinations' };
});
const TEST_DIR = '/tmp/nanoclaw-test-cli-destinations';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { createSession } from '../../db/sessions.js';
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `destinations-add` / `destinations-remove` commands.
import './destinations.js';
function now(): string {
return new Date().toISOString();
}
function readSessionDestinations(agentGroupId: string, sessionId: string) {
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
const rows = db.prepare('SELECT name, type, agent_group_id FROM destinations ORDER BY name').all() as Array<{
name: string;
type: string;
agent_group_id: string | null;
}>;
db.close();
return rows;
}
describe('destinations CLI custom ops project to inbound.db (#2465)', () => {
const SOURCE = 'ag-source';
const TARGET = 'ag-target';
const SESSION_A = 'sess-source-1';
const SESSION_B = 'sess-source-2';
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: SOURCE, name: 'source', folder: 'source', agent_provider: null, created_at: now() });
createAgentGroup({ id: TARGET, name: 'target', folder: 'target', agent_provider: null, created_at: now() });
// Two active sessions for the source agent — both must receive the
// projected destination row. Fixing only the "newest" session is a
// common regression shape, so the second session catches that.
for (const sid of [SESSION_A, SESSION_B]) {
createSession({
id: sid,
agent_group_id: SOURCE,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: now(),
});
initSessionFolder(SOURCE, sid);
}
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('add: projects the new destination into every active session inbound.db', async () => {
// Sanity: inbound.db starts with no destinations.
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
// caller: 'host' is what the cli_command approval handler in dispatch.ts
// uses when it re-enters dispatch after admin approval.
const resp = await dispatch(
{
id: 'req-1',
command: 'destinations-add',
args: {
agent_group_id: SOURCE,
local_name: 'helper',
target_type: 'agent',
target_id: TARGET,
},
},
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
for (const sid of [SESSION_A, SESSION_B]) {
const rows = readSessionDestinations(SOURCE, sid);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ name: 'helper', type: 'agent', agent_group_id: TARGET });
}
});
it('remove: clears the destination from every active session inbound.db', async () => {
await dispatch(
{
id: 'req-add',
command: 'destinations-add',
args: { agent_group_id: SOURCE, local_name: 'helper', target_type: 'agent', target_id: TARGET },
},
{ caller: 'host' },
);
// Precondition: add succeeded and projected to both sessions.
expect(readSessionDestinations(SOURCE, SESSION_A)).toHaveLength(1);
expect(readSessionDestinations(SOURCE, SESSION_B)).toHaveLength(1);
const resp = await dispatch(
{
id: 'req-remove',
command: 'destinations-remove',
args: { agent_group_id: SOURCE, local_name: 'helper' },
},
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
});
});
+30 -1
View File
@@ -1,6 +1,32 @@
import { getDb } from '../../db/connection.js';
import { getDb, hasTable } from '../../db/connection.js';
import { getSessionsByAgentGroup } from '../../db/sessions.js';
import { log } from '../../log.js';
import { registerResource } from '../crud.js';
/**
* Project the agent's central `agent_destinations` rows into every active
* session's `inbound.db`. The agent-to-agent module is optional, so we guard
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily
* same pattern as container-runner.ts on container wake.
*
* Called from both `add` and `remove` so the live container picks up the
* change without waiting for the next spawn. Without this, send_message to
* the new local_name silently drops with "unknown destination" until restart.
* See the destination-projection invariant in
* src/modules/agent-to-agent/db/agent-destinations.ts.
*/
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
if (!hasTable(getDb(), 'agent_destinations')) return;
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
for (const session of getSessionsByAgentGroup(agentGroupId)) {
try {
writeDestinations(agentGroupId, session.id);
} catch (err) {
log.warn('Failed to project destinations to session inbound.db', { agentGroupId, sessionId: session.id, err });
}
}
}
registerResource({
name: 'destination',
plural: 'destinations',
@@ -8,6 +34,7 @@ registerResource({
description:
'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.',
idColumn: 'agent_group_id',
scopeField: 'agent_group_id',
columns: [
{
name: 'agent_group_id',
@@ -55,6 +82,7 @@ registerResource({
VALUES (?, ?, ?, ?, datetime('now'))`,
)
.run(agentGroupId, localName, targetType, targetId);
await projectDestinationsToSessions(agentGroupId);
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
},
},
@@ -70,6 +98,7 @@ registerResource({
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
.run(agentGroupId, localName);
if (result.changes === 0) throw new Error('destination not found');
await projectDestinationsToSessions(agentGroupId);
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
},
},
+254 -8
View File
@@ -1,5 +1,36 @@
import type { McpServerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
import { restartAgentGroupContainers } from '../../container-restart.js';
import { getSession } from '../../db/sessions.js';
import { writeSessionMessage } from '../../session-manager.js';
import {
getContainerConfig,
updateContainerConfigScalars,
updateContainerConfigJson,
} from '../../db/container-configs.js';
import type { ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
/** Deserialize JSON columns for display. */
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
return {
agent_group_id: row.agent_group_id,
provider: row.provider,
model: row.model,
effort: row.effort,
image_tag: row.image_tag,
assistant_name: row.assistant_name,
max_messages_per_prompt: row.max_messages_per_prompt,
skills: JSON.parse(row.skills),
mcp_servers: JSON.parse(row.mcp_servers),
packages_apt: JSON.parse(row.packages_apt),
packages_npm: JSON.parse(row.packages_npm),
additional_mounts: JSON.parse(row.additional_mounts),
cli_scope: row.cli_scope,
updated_at: row.updated_at,
};
}
registerResource({
name: 'group',
plural: 'groups',
@@ -7,6 +38,7 @@ registerResource({
description:
'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.',
idColumn: 'id',
scopeField: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{
@@ -23,15 +55,229 @@ registerResource({
'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.',
required: true,
},
{
name: 'agent_provider',
type: 'string',
description:
'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-<provider>.',
updatable: true,
default: null,
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
customOperations: {
restart: {
access: 'approval',
description:
'Restart containers for a group. Use --id <group-id> [--rebuild] [--message <text>]. ' +
'From inside a container, --id is auto-filled and only the calling session is restarted. ' +
'--rebuild rebuilds the container image first (required for package changes). ' +
'--message sets an on-wake instruction for the fresh container to act on when it starts — ' +
'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' +
'Without --message, the container stops and only starts again on the next user message.',
handler: async (args, ctx) => {
const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined);
if (!id) throw new Error('--id is required');
if (args.rebuild) {
await buildAgentGroupImage(id);
}
const message = args.message as string | undefined;
// From an agent: scope to the calling session only
if (ctx.caller === 'agent') {
if (message) {
writeSessionMessage(id, ctx.sessionId, {
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }),
onWake: 1,
});
}
killContainer(
ctx.sessionId,
'restarted via ncl',
message
? () => {
const s = getSession(ctx.sessionId);
if (s) wakeContainer(s);
}
: undefined,
);
return { restarted: 1, rebuilt: !!args.rebuild };
}
// From the host: restart all running containers in the group
const count = restartAgentGroupContainers(id, 'restarted via ncl', message);
return { restarted: count, rebuilt: !!args.rebuild };
},
},
'config get': {
access: 'open',
description: 'Show the container config for a group. Use --id <group-id>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
return presentConfig(row);
},
},
'config update': {
access: 'approval',
description:
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const updates: Partial<
Pick<
ContainerConfigRow,
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
> = {};
if (args.provider !== undefined) updates.provider = args.provider as string;
if (args.model !== undefined) updates.model = args.model as string;
if (args.effort !== undefined) updates.effort = args.effort as string;
if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string;
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
if (args.max_messages_per_prompt !== undefined)
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
if (!['disabled', 'group', 'global'].includes(scope)) {
throw new Error('--cli-scope must be one of: disabled, group, global');
}
updates.cli_scope = scope;
}
if (Object.keys(updates).length === 0) {
throw new Error(
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
);
}
updateContainerConfigScalars(id, updates);
const updated = getContainerConfig(id)!;
return presentConfig(updated);
},
},
'config add-mcp-server': {
access: 'approval',
description:
'Add an MCP server to a group. Requires `ncl groups restart` to take effect. ' +
'Use --id <group-id> --name <server-name> --command <cmd> [--args <json-array>] [--env <json-object>].',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const name = args.name as string;
if (!name) throw new Error('--name is required');
const command = args.command as string;
if (!command) throw new Error('--command is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
servers[name] = {
command,
args: args.args ? (JSON.parse(args.args as string) as string[]) : [],
env: args.env ? (JSON.parse(args.env as string) as Record<string, string>) : {},
};
updateContainerConfigJson(id, 'mcp_servers', servers);
return { added: name, servers };
},
},
'config remove-mcp-server': {
access: 'approval',
description:
'Remove an MCP server from a group. Requires `ncl groups restart` to take effect. Use --id <group-id> --name <server-name>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const name = args.name as string;
if (!name) throw new Error('--name is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
if (!servers[name]) throw new Error(`MCP server "${name}" not found`);
delete servers[name];
updateContainerConfigJson(id, 'mcp_servers', servers);
return { removed: name };
},
},
'config add-package': {
access: 'approval',
description:
'Add a package to a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const apt = args.apt as string | undefined;
const npm = args.npm as string | undefined;
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
if (apt) {
const existing = JSON.parse(row.packages_apt) as string[];
if (!existing.includes(apt)) {
existing.push(apt);
updateContainerConfigJson(id, 'packages_apt', existing);
}
}
if (npm) {
const existing = JSON.parse(row.packages_npm) as string[];
if (!existing.includes(npm)) {
existing.push(npm);
updateContainerConfigJson(id, 'packages_npm', existing);
}
}
return {
added: { apt: apt || null, npm: npm || null },
note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.',
};
},
},
'config remove-package': {
access: 'approval',
description:
'Remove a package from a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const apt = args.apt as string | undefined;
const npm = args.npm as string | undefined;
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
if (apt) {
const existing = JSON.parse(row.packages_apt) as string[];
const filtered = existing.filter((p) => p !== apt);
updateContainerConfigJson(id, 'packages_apt', filtered);
}
if (npm) {
const existing = JSON.parse(row.packages_npm) as string[];
const filtered = existing.filter((p) => p !== npm);
updateContainerConfigJson(id, 'packages_npm', filtered);
}
return {
removed: { apt: apt || null, npm: npm || null },
note: 'Image rebuild required for package changes to take effect.',
};
},
},
},
});
+1
View File
@@ -8,6 +8,7 @@ registerResource({
description:
'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".',
idColumn: 'user_id',
scopeField: 'agent_group_id',
columns: [
{
name: 'user_id',
+1
View File
@@ -7,6 +7,7 @@ registerResource({
description:
'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.',
idColumn: 'id',
scopeField: 'agent_group_id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{ name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' },
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { getLaunchdLabel, getSystemdUnit } from '../install-slug.js';
import { formatTransportError } from './transport-errors.js';
describe('formatTransportError', () => {
it('renders per-install service names on ENOENT, not the bare v1 names', () => {
const out = formatTransportError(new Error('connect ENOENT /tmp/nanoclaw.sock'));
// Regression for #2484: pre-fix, this string was a hardcoded
// `com.nanoclaw` / `nanoclaw`, which doesn't match the actual
// v2 per-install slug-suffixed unit and label.
expect(out).toContain(`gui/$(id -u)/${getLaunchdLabel()}`);
expect(out).toContain(`systemctl --user restart ${getSystemdUnit()}`);
expect(out).not.toMatch(/gui\/\$\(id -u\)\/com\.nanoclaw\b(?!-v2)/);
expect(out).not.toMatch(/systemctl --user restart nanoclaw\b(?!-v2)/);
});
it('renders the same on ECONNREFUSED', () => {
const out = formatTransportError(new Error('connect ECONNREFUSED'));
expect(out).toContain(getLaunchdLabel());
expect(out).toContain(getSystemdUnit());
});
it('falls back to a generic transport error for other failures', () => {
const out = formatTransportError(new Error('some unrelated failure'));
expect(out).toBe('ncl: transport error: some unrelated failure\n');
expect(out).not.toContain('launchctl');
expect(out).not.toContain('systemctl');
});
});
+19
View File
@@ -0,0 +1,19 @@
import { getLaunchdLabel, getSystemdUnit } from '../install-slug.js';
export function formatTransportError(e: unknown): string {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
// `bin/ncl` cd's to the project root before exec'ing client.ts, so
// process.cwd() is the install dir — install-slug helpers pick up
// the right per-checkout suffix.
return [
`ncl: cannot reach NanoClaw host (${msg}).`,
`Is the host running? Start it with: pnpm run dev`,
`Or, if installed as a service:`,
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
` Linux: systemctl --user restart ${getSystemdUnit()}`,
``,
].join('\n');
}
return `ncl: transport error: ${msg}\n`;
}
+42 -83
View File
@@ -1,26 +1,25 @@
/**
* Per-group container config, stored as a plain JSON file at
* `groups/<folder>/container.json`. Mounted read-only inside the container
* at `/workspace/agent/container.json` the runner reads it at startup but
* cannot modify it. Config changes go through the self-mod approval flow.
* Container config types and materialization.
*
* All fields are optional a missing file or a partial file both resolve
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
* worth the ceremony here since there's only one writer in practice: the
* host, from the delivery thread that processes approved system actions).
* Source of truth is the `container_configs` table in the central DB.
* This module provides:
* - Type definitions for the file shape (read by the container runner)
* - `materializeContainerJson()` writes `groups/<folder>/container.json`
* from the DB at spawn time
* - `configFromDb()` builds a `ContainerConfig` from a DB row + agent group
*/
import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import { getContainerConfig } from './db/container-configs.js';
import { getAgentGroup } from './db/agent-groups.js';
import type { AgentGroup, ContainerConfigRow } from './types.js';
export interface McpServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
// Optional always-in-context guidance. When set, the host writes the
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
// into the composed CLAUDE.md.
instructions?: string;
}
@@ -30,101 +29,61 @@ export interface AdditionalMountConfig {
readonly?: boolean;
}
/** Shape of the materialized `container.json` file read by the container runner. */
export interface ContainerConfig {
mcpServers: Record<string, McpServerConfig>;
packages: { apt: string[]; npm: string[] };
imageTag?: string;
additionalMounts: AdditionalMountConfig[];
/** Which skills to enable — array of skill names or "all" (default). */
skills: string[] | 'all';
/** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */
provider?: string;
/** Agent group display name (used in transcript archiving). */
groupName?: string;
/** Assistant display name (used in system prompt / responses). */
assistantName?: string;
/** Agent group ID — set by the host, read by the runner. */
agentGroupId?: string;
/** Max messages per prompt. Falls back to code default if unset. */
maxMessagesPerPrompt?: number;
model?: string;
effort?: string;
}
function emptyConfig(): ContainerConfig {
/** Build a `ContainerConfig` from a DB row + agent group identity. */
export function configFromDb(row: ContainerConfigRow, group: AgentGroup): ContainerConfig {
return {
mcpServers: {},
packages: { apt: [], npm: [] },
additionalMounts: [],
skills: 'all',
mcpServers: JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>,
packages: {
apt: JSON.parse(row.packages_apt) as string[],
npm: JSON.parse(row.packages_npm) as string[],
},
imageTag: row.image_tag ?? undefined,
additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[],
skills: JSON.parse(row.skills) as string[] | 'all',
provider: row.provider ?? undefined,
groupName: group.name,
assistantName: row.assistant_name ?? group.name,
agentGroupId: group.id,
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
model: row.model ?? undefined,
effort: row.effort ?? undefined,
};
}
function configPath(folder: string): string {
return path.join(GROUPS_DIR, folder, 'container.json');
}
/**
* Read the container config for a group, returning sensible defaults for
* any missing fields (or an entirely empty config if the file is absent).
* Never throws for missing / malformed files corruption logs a warning
* via console.error and falls back to empty.
* Materialize `container.json` from the DB. Called at spawn time so the
* container always sees fresh config. Returns the `ContainerConfig` for
* use by the caller (buildMounts, buildContainerArgs, etc.).
*/
export function readContainerConfig(folder: string): ContainerConfig {
const p = configPath(folder);
if (!fs.existsSync(p)) return emptyConfig();
try {
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial<ContainerConfig>;
return {
mcpServers: raw.mcpServers ?? {},
packages: {
apt: raw.packages?.apt ?? [],
npm: raw.packages?.npm ?? [],
},
imageTag: raw.imageTag,
additionalMounts: raw.additionalMounts ?? [],
skills: raw.skills ?? 'all',
provider: raw.provider,
groupName: raw.groupName,
assistantName: raw.assistantName,
agentGroupId: raw.agentGroupId,
maxMessagesPerPrompt: raw.maxMessagesPerPrompt,
};
} catch (err) {
console.error(`[container-config] failed to parse ${p}: ${String(err)}`);
return emptyConfig();
}
}
export function materializeContainerJson(agentGroupId: string): ContainerConfig {
const group = getAgentGroup(agentGroupId);
if (!group) throw new Error(`Agent group not found: ${agentGroupId}`);
/**
* Write the container config for a group, creating the groups/<folder>/
* directory if necessary. Pretty-printed JSON so diffs in the activation
* flow are reviewable.
*/
export function writeContainerConfig(folder: string, config: ContainerConfig): void {
const p = configPath(folder);
const row = getContainerConfig(agentGroupId);
if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`);
const config = configFromDb(row, group);
const p = path.join(GROUPS_DIR, group.folder, 'container.json');
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
}
/**
* Apply a mutator function to a group's container config and persist the
* result. Convenient for append-style changes like `install_packages` and
* `add_mcp_server` handlers.
*/
export function updateContainerConfig(folder: string, mutate: (config: ContainerConfig) => void): ContainerConfig {
const config = readContainerConfig(folder);
mutate(config);
writeContainerConfig(folder, config);
return config;
}
/**
* Initialize an empty container.json for a group if one doesn't already
* exist. Idempotent used from `group-init.ts`.
*/
export function initContainerConfig(folder: string): boolean {
const p = configPath(folder);
if (fs.existsSync(p)) return false;
writeContainerConfig(folder, emptyConfig());
return true;
}
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mocks ---
vi.mock('./log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const mockIsContainerRunning = vi.fn<(id: string) => boolean>();
const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void) => void>();
const mockWakeContainer = vi.fn();
vi.mock('./container-runner.js', () => ({
isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string),
killContainer: (...args: unknown[]) =>
mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined),
wakeContainer: (...args: unknown[]) => mockWakeContainer(...args),
}));
const mockGetSessionsByAgentGroup = vi.fn();
const mockGetSession = vi.fn();
vi.mock('./db/sessions.js', () => ({
getSessionsByAgentGroup: (...args: unknown[]) => mockGetSessionsByAgentGroup(...args),
getSession: (...args: unknown[]) => mockGetSession(...args),
}));
const mockWriteSessionMessage = vi.fn();
vi.mock('./session-manager.js', () => ({
writeSessionMessage: (...args: unknown[]) => mockWriteSessionMessage(...args),
}));
import { restartAgentGroupContainers } from './container-restart.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Helpers ---
function makeSession(id: string, agentGroupId: string, status = 'active') {
return { id, agent_group_id: agentGroupId, status };
}
// --- Tests ---
describe('restartAgentGroupContainers', () => {
it('skips sessions without a running container', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
mockIsContainerRunning.mockReturnValue(false);
const count = restartAgentGroupContainers('g1', 'test');
expect(count).toBe(0);
expect(mockKillContainer).not.toHaveBeenCalled();
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
});
it('skips non-active sessions', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1', 'closed')]);
mockIsContainerRunning.mockReturnValue(true);
const count = restartAgentGroupContainers('g1', 'test');
expect(count).toBe(0);
expect(mockKillContainer).not.toHaveBeenCalled();
});
it('kills running containers and returns count', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
mockIsContainerRunning.mockImplementation((id) => id === 's1');
const count = restartAgentGroupContainers('g1', 'test');
expect(count).toBe(1);
expect(mockKillContainer).toHaveBeenCalledTimes(1);
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
});
it('does not write wake message when wakeMessage is omitted', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
restartAgentGroupContainers('g1', 'test');
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
});
it('writes on_wake message and passes onExit callback when wakeMessage is provided', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
restartAgentGroupContainers('g1', 'test', 'Resuming.');
// Should write an on-wake message
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(1);
const [agentGroupId, sessionId, msg] = mockWriteSessionMessage.mock.calls[0];
expect(agentGroupId).toBe('g1');
expect(sessionId).toBe('s1');
expect(msg.onWake).toBe(1);
expect(JSON.parse(msg.content).text).toBe('Resuming.');
// Should pass an onExit callback to killContainer
expect(mockKillContainer).toHaveBeenCalledTimes(1);
const onExit = mockKillContainer.mock.calls[0][2];
expect(typeof onExit).toBe('function');
});
it('onExit callback calls wakeContainer with refreshed session', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
const freshSession = makeSession('s1', 'g1');
mockGetSession.mockReturnValue(freshSession);
restartAgentGroupContainers('g1', 'test', 'Resuming.');
// Simulate container exit by calling the onExit callback
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
onExit();
expect(mockGetSession).toHaveBeenCalledWith('s1');
expect(mockWakeContainer).toHaveBeenCalledWith(freshSession);
});
it('onExit callback does not wake if session no longer exists', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
mockGetSession.mockReturnValue(undefined);
restartAgentGroupContainers('g1', 'test', 'Resuming.');
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
onExit();
expect(mockWakeContainer).not.toHaveBeenCalled();
});
it('handles multiple running sessions with wake message', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
const count = restartAgentGroupContainers('g1', 'test', 'Config updated.');
expect(count).toBe(2);
expect(mockKillContainer).toHaveBeenCalledTimes(2);
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(2);
// Each session gets its own on-wake message
expect(mockWriteSessionMessage.mock.calls[0][1]).toBe('s1');
expect(mockWriteSessionMessage.mock.calls[1][1]).toBe('s2');
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Helper to restart all running containers for an agent group.
*
* Writes an on_wake message to each session, kills the container, then
* wakes a fresh container via the onExit callback race-free.
*/
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
import { getSession, getSessionsByAgentGroup } from './db/sessions.js';
import { log } from './log.js';
import { writeSessionMessage } from './session-manager.js';
/**
* Kill all running containers for an agent group and respawn them.
*
* Only targets sessions that actually have a running container.
* If `wakeMessage` is provided, each session gets an on_wake message
* (picked up only by the fresh container's first poll) and a
* wakeContainer call on exit. Without it, containers are killed and
* only come back on the next real user message.
*/
export function restartAgentGroupContainers(agentGroupId: string, reason: string, wakeMessage?: string): number {
const sessions = getSessionsByAgentGroup(agentGroupId).filter(
(s) => s.status === 'active' && isContainerRunning(s.id),
);
for (const session of sessions) {
if (wakeMessage) {
writeSessionMessage(agentGroupId, session.id, {
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: agentGroupId,
channelType: 'agent',
threadId: null,
content: JSON.stringify({
text: wakeMessage,
sender: 'system',
senderId: 'system',
}),
onWake: 1,
});
}
killContainer(
session.id,
reason,
wakeMessage
? () => {
const s = getSession(session.id);
if (s) wakeContainer(s);
}
: undefined,
);
}
if (sessions.length > 0) {
log.info('Restarting agent group containers', { agentGroupId, reason, count: sessions.length });
}
return sessions.length;
}
+9 -14
View File
@@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest';
import { resolveProviderName } from './container-runner.js';
describe('resolveProviderName', () => {
it('prefers session over group and container.json', () => {
expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex');
it('prefers session over container config', () => {
expect(resolveProviderName('codex', 'claude')).toBe('codex');
});
it('falls back to group when session is null', () => {
expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex');
});
it('falls back to container.json when session and group are null', () => {
expect(resolveProviderName(null, null, 'opencode')).toBe('opencode');
it('falls back to container config when session is null', () => {
expect(resolveProviderName(null, 'opencode')).toBe('opencode');
});
it('defaults to claude when nothing is set', () => {
expect(resolveProviderName(null, null, undefined)).toBe('claude');
expect(resolveProviderName(null, undefined)).toBe('claude');
});
it('lowercases the resolved name', () => {
expect(resolveProviderName('CODEX', null, null)).toBe('codex');
expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode');
expect(resolveProviderName(null, null, 'Claude')).toBe('claude');
expect(resolveProviderName('CODEX', null)).toBe('codex');
expect(resolveProviderName(null, 'Claude')).toBe('claude');
});
it('treats empty string as unset (falls through)', () => {
expect(resolveProviderName('', 'codex', null)).toBe('codex');
expect(resolveProviderName(null, '', 'opencode')).toBe('opencode');
expect(resolveProviderName('', 'opencode')).toBe('opencode');
expect(resolveProviderName(null, '')).toBe('claude');
});
});
+23 -52
View File
@@ -19,7 +19,9 @@ import {
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { readContainerConfig, writeContainerConfig } from './container-config.js';
import { materializeContainerJson } from './container-config.js';
import { getContainerConfig } from './db/container-configs.js';
import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { composeGroupClaudeMd } from './claude-md-compose.js';
import { getAgentGroup } from './db/agent-groups.js';
@@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise<void> {
}
writeSessionRouting(agentGroup.id, session.id);
// Read container config once — threaded through provider resolution,
// buildMounts, and buildContainerArgs so we don't re-read the file.
const containerConfig = readContainerConfig(agentGroup.folder);
// Ensure container.json has the agent group identity fields the runner needs.
// Written at spawn time so the runner can read them from the RO mount.
ensureRuntimeFields(containerConfig, agentGroup);
// Materialize container.json from DB — writes fresh file and returns
// the config object, threaded through provider resolution, buildMounts,
// and buildContainerArgs so we don't re-read.
const containerConfig = materializeContainerJson(agentGroup.id);
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
@@ -191,10 +190,14 @@ async function spawnContainer(session: Session): Promise<void> {
}
/** Kill a container for a session. */
export function killContainer(sessionId: string, reason: string): void {
export function killContainer(sessionId: string, reason: string, onExit?: () => void): void {
const entry = activeContainers.get(sessionId);
if (!entry) return;
if (onExit) {
entry.process.once('close', onExit);
}
log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
try {
stopContainer(entry.containerName);
@@ -204,22 +207,19 @@ export function killContainer(sessionId: string, reason: string): void {
}
/**
* Resolve the provider name for a session using the precedence documented in
* the provider-install skills:
* Resolve the provider name for a session:
*
* sessions.agent_provider
* agent_groups.agent_provider
* container.json `provider`
* container_configs.provider
* 'claude'
*
* Pure so the precedence can be unit-tested without a DB or filesystem.
*/
export function resolveProviderName(
sessionProvider: string | null | undefined,
agentGroupProvider: string | null | undefined,
containerConfigProvider: string | null | undefined,
): string {
return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase();
return (sessionProvider || containerConfigProvider || 'claude').toLowerCase();
}
function resolveProviderContribution(
@@ -227,7 +227,7 @@ function resolveProviderContribution(
agentGroup: AgentGroup,
containerConfig: import('./container-config.js').ContainerConfig,
): { provider: string; contribution: ProviderContainerContribution } {
const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider);
const provider = resolveProviderName(session.agent_provider, containerConfig.provider);
const fn = getProviderContainerConfig(provider);
const contribution = fn
? fn({
@@ -396,34 +396,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
}
}
/**
* Ensure container.json has the runtime identity fields the runner needs.
* Written at spawn time so they're always current even if the DB values
* change (e.g. group rename). Only writes if values differ to avoid
* unnecessary file churn.
*/
function ensureRuntimeFields(
containerConfig: import('./container-config.js').ContainerConfig,
agentGroup: AgentGroup,
): void {
let dirty = false;
if (containerConfig.agentGroupId !== agentGroup.id) {
containerConfig.agentGroupId = agentGroup.id;
dirty = true;
}
if (containerConfig.groupName !== agentGroup.name) {
containerConfig.groupName = agentGroup.name;
dirty = true;
}
if (containerConfig.assistantName !== agentGroup.name) {
containerConfig.assistantName = agentGroup.name;
dirty = true;
}
if (dirty) {
writeContainerConfig(agentGroup.folder, containerConfig);
}
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
@@ -497,10 +469,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
const agentGroup = getAgentGroup(agentGroupId);
if (!agentGroup) throw new Error('Agent group not found');
const containerConfig = readContainerConfig(agentGroup.folder);
const aptPackages = containerConfig.packages.apt;
const npmPackages = containerConfig.packages.npm;
const configRow = getContainerConfig(agentGroup.id);
if (!configRow) throw new Error('Container config not found');
const aptPackages = JSON.parse(configRow.packages_apt) as string[];
const npmPackages = JSON.parse(configRow.packages_npm) as string[];
if (aptPackages.length === 0 && npmPackages.length === 0) {
throw new Error('No packages to install. Use install_packages first.');
}
@@ -530,15 +502,14 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
cwd: DATA_DIR,
stdio: 'pipe',
timeout: 300_000,
timeout: 900_000,
});
} finally {
fs.unlinkSync(tmpDockerfile);
}
// Store the image tag in groups/<folder>/container.json
containerConfig.imageTag = imageTag;
writeContainerConfig(agentGroup.folder, containerConfig);
// Store the image tag in the DB
updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag });
log.info('Per-agent-group image built', { agentGroupId, imageTag });
}
+97
View File
@@ -0,0 +1,97 @@
import type { ContainerConfigRow } from '../types.js';
import { getDb } from './connection.js';
const SCALAR_COLUMNS = new Set([
'provider',
'model',
'effort',
'image_tag',
'assistant_name',
'max_messages_per_prompt',
'cli_scope',
]);
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
| ContainerConfigRow
| undefined;
}
export function getAllContainerConfigs(): ContainerConfigRow[] {
return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[];
}
/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */
export function createContainerConfig(config: ContainerConfigRow): void {
getDb()
.prepare(
`INSERT INTO container_configs (
agent_group_id, provider, model, effort, image_tag, assistant_name,
max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
additional_mounts, updated_at
) VALUES (
@agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
@max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
@additional_mounts, @updated_at
)`,
)
.run(config);
}
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
export function ensureContainerConfig(agentGroupId: string): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
VALUES (?, ?)`,
)
.run(agentGroupId, new Date().toISOString());
}
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
export function updateContainerConfigScalars(
agentGroupId: string,
updates: Partial<
Pick<
ContainerConfigRow,
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
>,
): void {
const fields: string[] = [];
const values: Record<string, unknown> = { agent_group_id: agentGroupId };
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
if (!SCALAR_COLUMNS.has(key)) throw new Error(`Invalid scalar column: ${key}`);
fields.push(`${key} = @${key}`);
values[key] = value;
}
}
if (fields.length === 0) return;
fields.push('updated_at = @updated_at');
values.updated_at = new Date().toISOString();
getDb()
.prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`)
.run(values);
}
/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
export function updateContainerConfigJson(
agentGroupId: string,
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
value: unknown,
): void {
if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
const now = new Date().toISOString();
getDb()
.prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`)
.run(JSON.stringify(value), now, agentGroupId);
}
export function deleteContainerConfig(agentGroupId: string): void {
getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId);
}
+9
View File
@@ -42,3 +42,12 @@ export {
deletePendingApproval,
getPendingApprovalsByAction,
} from './sessions.js';
export {
getContainerConfig,
getAllContainerConfigs,
createContainerConfig,
ensureContainerConfig,
updateContainerConfigScalars,
updateContainerConfigJson,
deleteContainerConfig,
} from './container-configs.js';
@@ -0,0 +1,26 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration014: Migration = {
version: 14,
name: 'container-configs',
up(db: Database.Database) {
db.exec(`
CREATE TABLE container_configs (
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
provider TEXT,
model TEXT,
effort TEXT,
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
`);
},
};
+10
View File
@@ -0,0 +1,10 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration015: Migration = {
version: 15,
name: 'cli-scope',
up(db: Database.Database) {
db.prepare("ALTER TABLE container_configs ADD COLUMN cli_scope TEXT NOT NULL DEFAULT 'group'").run();
},
};

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