Compare commits

...

145 Commits

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:48 +03:00
gavrielc bb1db4e35b fix(db): renumber idle-timeout migration to 017 — 016 is taken by #2733's instance migration
Both PRs branched from the same base and picked the next free number.
The runner dedupes by name so runtime behavior is unaffected either way;
the renumber avoids a barrel symbol conflict for whichever merges second.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:08:35 +03:00
gavrielc 5684cfd69b feat(container): per-group idle timeout — clean exit for ephemeral sessions
Adds container_configs.idle_timeout_ms (migration 016) and threads it
host-side (ContainerConfigRow → configFromDb → materializeContainerJson →
container.json) and container-side (loadConfig → idle tracker → poll loop).
When set, an idle container exits 0 once the window elapses instead of
riding until host-sweep's 30-min absolute ceiling kill. NULL/0 (the
default) keeps today's behavior byte-identical. Operator path:
ncl groups config update --idle-timeout-ms.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #2185.

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

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

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

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

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

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

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

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

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

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

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

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

Two unintended consequences:

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

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

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

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

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

Fix:

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

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

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

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

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

Two files:

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

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

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

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

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

No source changes (`setup/`, `src/`) — preserves the 'browser method dropped' decision in `setup/whatsapp-auth.ts`. No new npm deps — uses `qrcode` (already pinned by this skill) and Node's built-in `http`.
2026-05-19 13:13:35 +02:00
glifocat 4635c406e7 review(cli): explicit container_configs delete in cascade
migration-014 has ON DELETE CASCADE on container_configs.agent_group_id,
so the row was already being removed by the final DELETE FROM agent_groups.
Doing the delete explicitly here mirrors the shape of every other table
in the cascade and lets the handler surface a container_configs count in
the `removed` response, matching the rest of the breakdown.
2026-05-18 11:43:45 +02:00
glifocat d1a53a0deb review(cli): count deletes inside the transaction
Move the row-count queries out of a separate pre-flight pass and source
the `removed` counts from each DELETE's `.changes` instead, so the
response describes exactly what the transaction did rather than a
snapshot from before it ran.

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

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

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

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

Out of scope (filed separately): killing any running container for the
group, and on-disk cleanup of `groups/<folder>/` and
`data/v2-sessions/<group-id>/`. The DB cascade is the load-bearing
fix; the filesystem leak is cosmetic.
2026-05-18 01:54:25 +02:00
hinotoi-agent 728c6a641b fix(approvals): require admin for approval responses 2026-05-15 10:34:46 +08:00
hinotoi-agent 8385236c30 fix(agent-route): reject unsafe forwarded attachments 2026-05-14 21:04:04 +08:00
239 changed files with 12978 additions and 5298 deletions
@@ -0,0 +1,49 @@
# Remove Atomic Chat
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied files (both trees)
```bash
rm -f container/agent-runner/src/atomic-chat-mcp-stdio.ts \
container/agent-runner/src/atomic-chat-registration.test.ts \
src/atomic-chat-env.ts \
src/atomic-chat-wiring.test.ts
```
## 2. Unregister the MCP server
In `container/agent-runner/src/index.ts`, remove the `atomic_chat: { … }` entry from the `mcpServers` object (leave `nanoclaw` and any other entries).
## 3. Revert the host-side edits in `src/container-runner.ts`
- Remove the `import { atomicChatEnvArgs } from './atomic-chat-env.js';` import.
- Remove the `args.push(...atomicChatEnvArgs());` line that follows the `TZ` env line.
- Restore the `container.stderr` logger to its single-line `log.debug(line, …)` form (remove the `[ATOMIC]` info-level branch).
## 4. Remove env vars
Remove the Atomic Chat block from `.env.example`, and the `ATOMIC_CHAT_*` lines from `.env` if you set them.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Verification
After removal, confirm the tool is gone — in a wired agent, asking it to "list atomic chat models" should report no such tool, and the logs should show no `[ATOMIC]` lines after the last restart:
```bash
grep "\[ATOMIC\]" logs/nanoclaw.log | tail -5
```
+37 -30
View File
@@ -13,7 +13,7 @@ Tools exposed:
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
The skill ships the MCP server source (and its test) in this folder and copies them into the agent-runner tree at install time, then registers the server in `index.ts` and forwards host env vars in `container-runner.ts`. Registering the server is enough to expose its tools — the agent's allow-pattern (`mcp__atomic_chat__*`) is derived from the registered server name.
## Phase 1: Pre-flight
@@ -39,10 +39,19 @@ If the request fails:
## Phase 2: Apply Code Changes
### Copy the MCP server source
### Copy the skill's source and tests into both trees
This skill reaches into both the container (Bun) tree and the host (Node) tree, so its
files go into both, alongside the integration points they cover.
```bash
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
S=.claude/skills/add-atomic-chat-tool
# Container (Bun) tree — the MCP server and the registration wiring test
cp $S/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
cp $S/atomic-chat-registration.test.ts container/agent-runner/src/atomic-chat-registration.test.ts
# Host (Node) tree — the env-forwarding helper and the wiring test
cp $S/atomic-chat-env.ts src/atomic-chat-env.ts
cp $S/atomic-chat-wiring.test.ts src/atomic-chat-wiring.test.ts
```
### Register the MCP server in the agent-runner
@@ -79,40 +88,31 @@ Add an `atomic_chat` entry alongside `nanoclaw`:
};
```
### Add the tool glob to the allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
```ts
'mcp__nanoclaw__*',
'mcp__atomic_chat__*',
];
```
`atomic-chat-registration.test.ts` asserts this entry is present and points at the server module — the tool only appears to the agent if it is registered here.
### Forward host env vars into the container
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
The env-forwarding logic lives in the copied `src/atomic-chat-env.ts` (`atomicChatEnvArgs()`), so the reach-in into `buildContainerArgs` is a single call.
Import it in `src/container-runner.ts` (alongside the other local imports):
```ts
import { atomicChatEnvArgs } from './atomic-chat-env.js';
```
Then, in `buildContainerArgs`, find the `TZ` env line and add the call right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
args.push(...atomicChatEnvArgs());
```
Add ATOMIC_CHAT forwarding right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
```
`atomic-chat-wiring.test.ts` asserts this `args.push(...atomicChatEnvArgs())` call exists inside `buildContainerArgs`.
### Surface `[ATOMIC]` log lines at info level
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-ollama-tool` for `[OLLAMA]`) also edit to surface their own prefix. Touch only the `[ATOMIC]` branch and leave the rest of the block intact, so the edits coexist and removal restores it cleanly.
In the same file, find the stderr logger:
```ts
@@ -157,10 +157,18 @@ Append to `.env.example`:
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
# Host tree: buildContainerArgs wiring
pnpm exec vitest run src/atomic-chat-wiring.test.ts
# Container tree: index.ts registration
(cd container/agent-runner && bun test src/atomic-chat-registration.test.ts)
./container/build.sh
```
All three must be clean before proceeding.
All must be clean before proceeding. The wiring and registration tests confirm the two
integration points — the `buildContainerArgs` call and the `index.ts` registration — are
actually in place; a failure means one drifted. (The MCP server's own request/response
behavior against Atomic Chat is the author's build-time concern, not part of these tests —
verify it manually in Phase 4.)
## Phase 3: Configure
@@ -218,9 +226,8 @@ Look for:
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
4. The container wasn't rebuilt — run `./container/build.sh`
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Atomic Chat"
@@ -0,0 +1,18 @@
/**
* Host-side env forwarding for the Atomic Chat MCP tool. Returns the Docker `-e`
* arguments that pass any `ATOMIC_CHAT_*` host overrides into the container.
*
* Lives in its own file so the reach-in in `container-runner.ts` is a single call
* (`args.push(...atomicChatEnvArgs())`) and this logic is behavior-testable in
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
*/
export function atomicChatEnvArgs(): string[] {
const args: string[] = [];
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
return args;
}
@@ -0,0 +1,65 @@
/**
* Wiring test for the MCP-server registration integration point (container/Bun tree).
*
* The handlers are behavior-tested in atomic-chat-mcp-stdio.test.ts, but that does not
* prove the server is registered — delete the index.ts entry and the tool simply never
* appears, yet the handler test stays green. index.ts is the container boot entry and is
* not cheaply invocable, so we assert the registration structurally: the `mcpServers`
* object literal has an `atomic_chat` property whose command runs `atomic-chat-mcp-stdio.ts`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.join(import.meta.dir, 'index.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
/** Find the object literal assigned to `const mcpServers = { ... }`. */
function mcpServersLiteral(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
let found: ts.ObjectLiteralExpression | undefined;
const visit = (node: ts.Node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'mcpServers' &&
node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
) {
found = node.initializer;
}
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
return obj.properties.find(
(p): p is ts.PropertyAssignment =>
ts.isPropertyAssignment(p) &&
((ts.isIdentifier(p.name) && p.name.text === name) ||
(ts.isStringLiteral(p.name) && p.name.text === name)),
);
}
describe('index.ts registers the atomic_chat MCP server', () => {
const obj = mcpServersLiteral(sourceFile());
it('finds the mcpServers object literal', () => {
expect(obj).toBeDefined();
});
it('has an atomic_chat entry', () => {
expect(obj && property(obj, 'atomic_chat')).toBeDefined();
});
it('points atomic_chat at atomic-chat-mcp-stdio.ts', () => {
const entry = obj && property(obj, 'atomic_chat');
const text = entry ? entry.getText() : '';
expect(text).toContain('atomic-chat-mcp-stdio.ts');
});
});
@@ -0,0 +1,69 @@
/**
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
*
* The env helper is behavior-tested in atomic-chat-env.test.ts, but that does not prove
* buildContainerArgs actually uses it — a direct unit test stays green even if the reach-in
* is deleted. buildContainerArgs is entangled with OneCLI and not cheaply invocable, so we
* assert the integration structurally: inside buildContainerArgs there is an
* `args.push(...atomicChatEnvArgs())` call. Delete the reach-in and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.resolve(process.cwd(), 'src/container-runner.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
function findFunction(sf: ts.SourceFile, name: string): ts.FunctionDeclaration | undefined {
let found: ts.FunctionDeclaration | undefined;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/** Is this node `args.push(...atomicChatEnvArgs())`? */
function isSpreadPushOfEnvArgs(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) return false;
const callee = node.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
callee.name.text !== 'push' ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== 'args'
) {
return false;
}
return node.arguments.some(
(arg) =>
ts.isSpreadElement(arg) &&
ts.isCallExpression(arg.expression) &&
ts.isIdentifier(arg.expression.expression) &&
arg.expression.expression.text === 'atomicChatEnvArgs',
);
}
describe('container-runner.ts wires in atomicChatEnvArgs', () => {
const sf = sourceFile();
const fn = findFunction(sf, 'buildContainerArgs');
it('finds buildContainerArgs', () => {
expect(fn).toBeDefined();
});
it('calls args.push(...atomicChatEnvArgs()) inside buildContainerArgs', () => {
let wired = false;
const visit = (node: ts.Node) => {
if (isSpreadPushOfEnvArgs(node)) wired = true;
if (!wired) ts.forEachChild(node, visit);
};
if (fn?.body) visit(fn.body);
expect(wired).toBe(true);
});
});
+83
View File
@@ -0,0 +1,83 @@
# Remove Codex provider
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, plus the Dockerfile CLI install.
## 1. Delete the barrel import lines (both trees)
Delete (do not comment out) the `import './codex.js';` line from each barrel:
- `src/providers/index.ts`
- `container/agent-runner/src/providers/index.ts`
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
## 2. Delete the copied files (both trees)
```bash
rm -f src/providers/codex.ts \
src/providers/codex-registration.test.ts \
container/agent-runner/src/providers/codex.ts \
container/agent-runner/src/providers/codex-app-server.ts \
container/agent-runner/src/providers/codex.factory.test.ts \
container/agent-runner/src/providers/codex-registration.test.ts \
container/agent-runner/src/providers/codex-dockerfile.test.ts
```
## 3. Revert the Dockerfile CLI install
In `container/Dockerfile`, remove both Codex edits (skip whichever is already gone):
**(a)** Delete the version ARG from the "Pin CLI versions" block:
```dockerfile
ARG CODEX_VERSION=0.124.0
```
**(b)** Delete the standalone Codex install layer:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
## 4. Dependency
Codex is a CLI binary installed via the Dockerfile — there is no agent-runner package dependency to uninstall. Step 3 removes the only install surface; no `bun remove` / `pnpm uninstall` is needed.
## 5. Unset Codex env vars
Remove any Codex-specific lines you added to `.env` (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `CODEX_MODEL`) if no other integration uses them, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
Switch any group still on Codex back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
## 6. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Verification
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
```bash
grep -R "codex.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
grep "@openai/codex" container/Dockerfile # no output
```
In a wired agent, requesting `agent_provider = 'codex'` should fall back to the default provider since `codex` is no longer in the registry.
+44 -19
View File
@@ -18,9 +18,12 @@ The Codex provider runs `codex app-server` as a child process and speaks JSON-RP
If all of the following are already present, skip to **Configuration**:
- `src/providers/codex.ts`
- `src/providers/codex-registration.test.ts`
- `container/agent-runner/src/providers/codex.ts`
- `container/agent-runner/src/providers/codex-app-server.ts`
- `container/agent-runner/src/providers/codex.factory.test.ts`
- `container/agent-runner/src/providers/codex-registration.test.ts`
- `container/agent-runner/src/providers/codex-dockerfile.test.ts`
- `import './codex.js';` line in `src/providers/index.ts`
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
@@ -33,17 +36,31 @@ Missing pieces — continue below. All steps are idempotent; re-running is safe.
git fetch origin providers
```
### 2. Copy the Codex source files
### 2. Copy the Codex source files and tests
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
git show origin/providers:src/providers/codex-registration.test.ts > src/providers/codex-registration.test.ts
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
git show origin/providers:container/agent-runner/src/providers/codex-registration.test.ts > container/agent-runner/src/providers/codex-registration.test.ts
```
The two `codex-registration.test.ts` files are the **registration guards**. Each imports only the real barrel — the host one calls `listProviderContainerConfigNames()` from `src/providers/index.ts`, the container one calls `listProviderNames()` from `container/agent-runner/src/providers/index.ts` — and asserts `codex` is present. They go red the instant a barrel import line is deleted or drifts. (`codex.factory.test.ts` imports `./codex.js` directly and self-registers, so it stays green even if the barrel line is gone — keep it as a unit test of provider behavior, but it is **not** the registration guard.)
If `git show origin/providers:.../codex-registration.test.ts` errors with `path ... does not exist`, the registration tests have not landed on `origin/providers` yet. Run `git fetch origin providers` again; once the branch carries them, the copies above succeed. The rest of the install proceeds regardless — the Dockerfile and factory tests still run.
Copy the Dockerfile structural test that ships with this skill into the container provider tree:
```bash
cp .claude/skills/add-codex/codex-dockerfile.test.ts container/agent-runner/src/providers/codex-dockerfile.test.ts
```
`codex-dockerfile.test.ts` reads the real `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` line and the `pnpm install -g "@openai/codex@${CODEX_VERSION}"` line are both present. The Codex CLI is a binary, not an importable package, so the registration tests cannot see it — this structural test is what guards the Dockerfile edits in step 4.
### 3. Append the self-registration imports
Each barrel gets one line — alphabetical placement keeps diffs small.
@@ -79,14 +96,27 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
### 5. Build
### 5. Build and validate
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
pnpm run build # host
pnpm exec vitest run src/providers/codex-registration.test.ts # host registration guard
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
cd container/agent-runner && bun test src/providers/codex-registration.test.ts && cd - # container registration guard
cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts && cd - # Dockerfile structural guard
./container/build.sh # agent image
```
All must be clean before proceeding.
- The **host** `codex-registration.test.ts` imports the real host barrel (`src/providers/index.ts`) and asserts `listProviderContainerConfigNames()` contains `codex`. It goes red if the `import './codex.js';` line is deleted or drifts, or if the barrel fails to evaluate.
- The **container** `codex-registration.test.ts` imports the real container barrel (`container/agent-runner/src/providers/index.ts`) and asserts `listProviderNames()` contains `codex`. Same failure surface for the container-side import line.
- The **Dockerfile** `codex-dockerfile.test.ts` reads `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` and `@openai/codex@${CODEX_VERSION}` install lines are present — red if either edit is dropped.
The `@openai/codex` CLI binary is guarded by the Dockerfile structural test plus the container build (`./container/build.sh` fails if the install line is bad), **not** by the registration test — Codex is a CLI binary, not an importable package, so nothing imports it for the registration guard to trip on. To confirm the binary is actually present after the image rebuild, probe it inside a running container with `docker exec <container> codex --version`.
The host-side provider also consumes core APIs (per-session `~/.codex` mount, env passthrough); that typed core-API consumption is guarded by `pnpm run build`.
## Configuration
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
@@ -143,19 +173,14 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
## Verify
## Next Steps
```bash
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
The registration and Dockerfile guards in **Build and validate** confirm the wiring. For a live end-to-end check, set `agent_provider = 'codex'` on a test group and send a message after the image rebuild. A successful round-trip looks like:
- `init` event with a stable thread ID as continuation
- One or more `activity` / `progress` events during the turn
- `result` event with the model's reply
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. To confirm the CLI binary itself landed in the image, `docker exec <container> codex --version`.
To back this provider out, follow [REMOVE.md](REMOVE.md).
@@ -0,0 +1,30 @@
// Structural guard for the Codex CLI install in container/Dockerfile.
//
// @openai/codex is a CLI *binary* installed via the Dockerfile, not an
// importable package, so the barrel-driven registration tests cannot see it.
// This test reads the real Dockerfile and asserts the version ARG and the
// `pnpm install -g` line for @openai/codex are both present. It goes red if
// either Dockerfile edit is dropped or drifts.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts
import { readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/Dockerfile
const DOCKERFILE = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
describe('container/Dockerfile codex CLI install', () => {
const dockerfile = readFileSync(DOCKERFILE, 'utf8');
it('declares the CODEX_VERSION ARG', () => {
expect(dockerfile).toMatch(/ARG\s+CODEX_VERSION=/);
});
it('installs the @openai/codex CLI pinned to that ARG', () => {
expect(dockerfile).toMatch(/pnpm install -g\s+"@openai\/codex@\$\{CODEX_VERSION\}"/);
});
});
+33 -50
View File
@@ -28,61 +28,34 @@ NanoClaw (pusher) Dashboard (npm package)
pnpm install @nanoco/nanoclaw-dashboard
```
### 2. Copy the pusher module
### 2. Copy the pusher module and its tests
Copy the resource file into src:
Copy all three resource files into `src/`. The tests ship with the skill and run against the composed project — they're how you confirm the skill works and is wired in correctly.
```
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.test.ts → src/dashboard-pusher.test.ts
.claude/skills/add-dashboard/resources/dashboard-wiring.test.ts → src/dashboard-wiring.test.ts
```
### 3. Add exports to src/db/index.ts
- `dashboard-pusher.test.ts` — behavior: starts the pusher, posts a real snapshot to a fake dashboard.
- `dashboard-wiring.test.ts` — the code edit in step 3: asserts (via the TS AST) that `index.ts` dynamically imports `./dashboard-pusher.js` and `await`s `startDashboard()` as colocated statements of `main()`, after DB init and before the boot-complete log. Delete or misplace the edit and this goes red.
Add these two export blocks if not already present:
### 3. Wire into src/index.ts
This is the skill's one integration point, and it's deliberately minimal and self-contained: all the startup logic lives in `dashboard-pusher.ts`, and the import is **colocated** with the call so the whole edit is a single block in one place — there's no separate top-of-file import to add (or to remember to remove).
Add this block inside `main()`, just before the `log.info('NanoClaw running')` line:
```typescript
// After the messaging-groups exports, add:
export {
getMessagingGroupsByAgentGroup,
} from './messaging-groups.js';
// Before the credentials exports, add:
export {
createDestination,
getDestinations,
getDestinationByName,
getDestinationByTarget,
hasDestination,
deleteDestination,
} from './agent-destinations.js';
// Dashboard (optional; no-ops without DASHBOARD_SECRET)
const { startDashboard } = await import('./dashboard-pusher.js');
await startDashboard();
```
### 4. Wire into src/index.ts
`startDashboard()` reads `DASHBOARD_SECRET`/`DASHBOARD_PORT` itself and no-ops if the secret is unset, so nothing else in core needs to change.
Add the `readEnvFile` import at the top if not already present:
```typescript
import { readEnvFile } from './env.js';
```
Add after step 7 (OneCLI approval handler), before the `log.info('NanoClaw running')` line:
```typescript
// 8. Dashboard (optional)
const dashboardEnv = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const dashboardSecret = process.env.DASHBOARD_SECRET || dashboardEnv.DASHBOARD_SECRET;
const dashboardPort = parseInt(process.env.DASHBOARD_PORT || dashboardEnv.DASHBOARD_PORT || '3100', 10);
if (dashboardSecret) {
const { startDashboard } = await import('@nanoco/nanoclaw-dashboard');
const { startDashboardPusher } = await import('./dashboard-pusher.js');
startDashboard({ port: dashboardPort, secret: dashboardSecret });
startDashboardPusher({ port: dashboardPort, secret: dashboardSecret, intervalMs: 60000 });
} else {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
}
```
### 5. Add environment variables to .env
### 4. Add environment variables to .env
```
DASHBOARD_SECRET=<generate-a-random-secret>
@@ -91,18 +64,23 @@ DASHBOARD_PORT=3100
Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"`
### 6. Build and restart
### 5. Build, test, and restart
Run from your NanoClaw project root:
```bash
pnpm run build
pnpm exec vitest run src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts # behavior + wiring
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### 7. Verify
Run `build` **before** the tests: it's what guards the `@nanoco/nanoclaw-dashboard` dependency. `dashboard-pusher.ts` reaches the package through `await import('@nanoco/nanoclaw-dashboard')`, so if step 4 was skipped, `pnpm run build` fails with `TS2307: Cannot find module`. The behavior test deliberately *mocks* that package — its `startDashboard` binds a real dashboard port, a side effect we don't want in a test — so the test alone would pass with the dependency missing. Build is therefore the leg that verifies the dependency is installed; keep it ahead of the tests in the validate step.
### 6. Verify (runtime smoke check)
Once the service is restarted, confirm the dashboard is live:
```bash
curl -s http://localhost:3100/api/status
@@ -132,10 +110,15 @@ Open `http://localhost:3100/dashboard` in a browser.
## Removal
Reverse the apply steps. Safe to re-run even if some pieces are already gone.
```bash
rm -f src/dashboard-pusher.ts src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts
pnpm uninstall @nanoco/nanoclaw-dashboard 2>/dev/null || true
```
Then, by hand, remove the single dashboard block the skill added to `main()` in `src/index.ts` (the `// Dashboard (optional…)` comment, the `await import('./dashboard-pusher.js')` line, and the `await startDashboard();` call), and remove `DASHBOARD_SECRET` and `DASHBOARD_PORT` from `.env`.
```bash
pnpm uninstall @nanoco/nanoclaw-dashboard
rm src/dashboard-pusher.ts
# Remove the dashboard block from src/index.ts
# Remove DASHBOARD_SECRET and DASHBOARD_PORT from .env
pnpm run build
```
@@ -0,0 +1,124 @@
/**
* Integration test for the add-dashboard skill's integration point —
* `startDashboard()`, the single call wired into src/index.ts.
*
* Archetype: in-process seam. It drives the *real* entry point against a
* *real* (in-memory) central DB and a *fake* dashboard HTTP endpoint. The
* only things stubbed are the external dashboard package (not needed to prove
* the wiring) and env-file reads (so the test doesn't depend on the real
* .env). This proves the skill works once applied: with a secret set it
* collects a DB snapshot and posts it; with no secret it does nothing.
*
* Ships with the add-dashboard skill; apply copies it to src/ alongside the
* pusher so it runs against the composed project.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import http from 'http';
import type { AddressInfo } from 'net';
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-dashboard', ASSISTANT_NAME: 'TestBot' };
});
// The dashboard server package isn't needed to prove the integration point.
vi.mock('@nanoco/nanoclaw-dashboard', () => ({ startDashboard: vi.fn() }));
// Don't read the real .env — the test controls config via process.env only.
vi.mock('./env.js', () => ({ readEnvFile: () => ({}) }));
const TEST_DIR = '/tmp/nanoclaw-test-dashboard';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
import { startDashboard, stopDashboardPusher } from './dashboard-pusher.js';
function now(): string {
return new Date().toISOString();
}
interface CapturedPost {
path: string;
auth: string | undefined;
body: Record<string, unknown>;
}
/** A fake dashboard server that captures the bodies the pusher POSTs. */
function startFakeDashboard(): Promise<{ port: number; posts: CapturedPost[]; close: () => Promise<void> }> {
const posts: CapturedPost[] = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', () => {
let body: Record<string, unknown> = {};
try { body = JSON.parse(raw); } catch { /* leave empty */ }
posts.push({ path: req.url || '', auth: req.headers.authorization, body });
res.writeHead(200);
res.end('ok');
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({ port, posts, close: () => new Promise<void>((r) => server.close(() => r())) });
});
});
}
async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!pred()) {
if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition');
await new Promise((r) => setTimeout(r, 20));
}
}
describe('add-dashboard integration point (startDashboard)', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
});
afterEach(() => {
stopDashboardPusher();
closeDb();
delete process.env.DASHBOARD_SECRET;
delete process.env.DASHBOARD_PORT;
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('posts a snapshot of the seeded state when DASHBOARD_SECRET is set', async () => {
createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now() });
const dash = await startFakeDashboard();
process.env.DASHBOARD_SECRET = 'test-secret';
process.env.DASHBOARD_PORT = String(dash.port);
await startDashboard();
await waitFor(() => dash.posts.some((p) => p.path === '/api/ingest'));
const ingest = dash.posts.find((p) => p.path === '/api/ingest')!;
expect(ingest.auth).toBe('Bearer test-secret');
expect(ingest.body.assistant_name).toBe('TestBot');
const groups = ingest.body.agent_groups as Array<{ id: string }>;
expect(groups.map((g) => g.id)).toContain('ag-1');
for (const key of ['timestamp', 'sessions', 'channels', 'users', 'tokens', 'context_windows', 'activity', 'messages']) {
expect(ingest.body).toHaveProperty(key);
}
await dash.close();
});
it('does nothing when DASHBOARD_SECRET is not set', async () => {
const dash = await startFakeDashboard();
// no DASHBOARD_SECRET in env, and readEnvFile is stubbed to {}
await startDashboard();
await new Promise((r) => setTimeout(r, 100));
expect(dash.posts).toHaveLength(0);
await dash.close();
});
});
@@ -10,15 +10,17 @@ import Database from 'better-sqlite3';
import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js';
import { getSessionsByAgentGroup } from './db/sessions.js';
import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js';
import { getDestinations } from './db/agent-destinations.js';
import { getMembers } from './db/agent-group-members.js';
import { getAllUsers, getUser } from './db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './db/user-roles.js';
import { getUserDmsForUser } from './db/user-dms.js';
import { getDestinations } from './modules/agent-to-agent/db/agent-destinations.js';
import { getMembers } from './modules/permissions/db/agent-group-members.js';
import { getAllUsers, getUser } from './modules/permissions/db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './modules/permissions/db/user-roles.js';
import { getUserDmsForUser } from './modules/permissions/db/user-dms.js';
import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js';
import { DATA_DIR, ASSISTANT_NAME } from './config.js';
import { getDb } from './db/connection.js';
import { getContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { readEnvFile } from './env.js';
interface PusherConfig {
port: number;
@@ -56,6 +58,26 @@ export function stopDashboardPusher(): void {
}
}
/**
* Skill entry point — the single call wired into the host boot sequence.
*
* All of the dashboard's startup logic lives here, in the skill's own file,
* so the integration point in src/index.ts is just `await startDashboard()`.
* No-ops (and says so) when DASHBOARD_SECRET is unset.
*/
export async function startDashboard(): Promise<void> {
const env = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const secret = process.env.DASHBOARD_SECRET || env.DASHBOARD_SECRET;
const port = parseInt(process.env.DASHBOARD_PORT || env.DASHBOARD_PORT || '3100', 10);
if (!secret) {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
return;
}
const { startDashboard: startServer } = await import('@nanoco/nanoclaw-dashboard');
startServer({ port, secret });
startDashboardPusher({ port, secret, intervalMs: 60000 });
}
/** Fire-and-forget POST to the dashboard. */
function postJson(config: PusherConfig, urlPath: string, data: unknown): void {
const body = JSON.stringify(data);
@@ -157,7 +179,7 @@ function collectAgentGroups() {
name: g.name,
folder: g.folder,
agent_provider: g.agent_provider,
container_config: g.container_config ? JSON.parse(g.container_config) : null,
container_config: getContainerConfig(g.id) ?? null,
sessionCount: sessions.length,
runningSessions: running.length,
wirings,
@@ -0,0 +1,81 @@
/**
* Wiring test for the add-dashboard skill's code-edit integration point.
*
* The skill inserts one colocated block into src/index.ts (a dynamic
* `import('./dashboard-pusher.js')` + `await startDashboard()` in main()). A
* behavioral test of the pusher can't see whether that edit is actually
* present and correctly placed — booting the real host is too heavy — so this
* asserts the edit *structurally*, via the TypeScript AST. It verifies not
* just that the call exists, but that:
* - the pusher module is dynamically imported by its correct path,
* - startDashboard() is awaited,
* - both are DIRECT statements of main()'s body (right scope/level, not
* nested or stranded in another function),
* - the import precedes the call, and the whole block sits after DB init
* and before the boot-complete log (right place).
*
* Delete or misplace the edit and this goes red. Combined with the unit test
* (behavior of startDashboard) and the build (the call still type-checks),
* the three together cover deletion, misplacement, drift, and behavior — for
* a true code edit, with no registry required.
*
* Ships with the skill; apply copies it to src/.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
const indexPath = path.resolve(process.cwd(), 'src/index.ts');
const source = fs.readFileSync(indexPath, 'utf8');
const sf = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
function mainBody(): ts.NodeArray<ts.Statement> {
let body: ts.NodeArray<ts.Statement> | undefined;
sf.forEachChild((n) => {
if (ts.isFunctionDeclaration(n) && n.name?.text === 'main' && n.body) {
body = n.body.statements;
}
});
if (!body) throw new Error('main() not found in src/index.ts');
return body;
}
function isAwaitedStartDashboard(s: ts.Statement): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isAwaitExpression(s.expression) &&
ts.isCallExpression(s.expression.expression) &&
ts.isIdentifier(s.expression.expression.expression) &&
s.expression.expression.expression.text === 'startDashboard'
);
}
/** `const { ... } = await import('./dashboard-pusher.js')` as a statement. */
function isDynamicImportOfPusher(s: ts.Statement): boolean {
if (!ts.isVariableStatement(s)) return false;
const init = s.declarationList.declarations[0]?.initializer;
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
const call = init.expression;
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
const arg = call.arguments[0];
return !!arg && ts.isStringLiteral(arg) && arg.text === './dashboard-pusher.js';
}
describe('add-dashboard wiring in src/index.ts', () => {
it('dynamically imports the pusher and awaits startDashboard(), colocated in main(), after DB init and before the boot-complete log', () => {
const stmts = mainBody();
const importIdx = stmts.findIndex(isDynamicImportOfPusher);
const callIdx = stmts.findIndex(isAwaitedStartDashboard);
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
expect(importIdx, "dynamic import('./dashboard-pusher.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
expect(callIdx, 'await startDashboard() must be a statement of main()').toBeGreaterThanOrEqual(0);
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
expect(importIdx, 'the dynamic import must come after DB init').toBeGreaterThan(migrateIdx);
expect(callIdx, 'the call must come after its import (colocated)').toBeGreaterThan(importIdx);
expect(callIdx, 'startDashboard() must run before the boot-complete log').toBeLessThan(runningIdx);
});
});
+9 -3
View File
@@ -1,11 +1,17 @@
# Remove DeltaChat
## 1. Disable the adapter
## 1. Remove the adapter
Comment out the import in `src/channels/index.ts`:
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
// import './deltachat.js';
import './deltachat.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/deltachat.ts src/channels/deltachat-registration.test.ts
```
## 2. Remove credentials
+10 -3
View File
@@ -14,6 +14,7 @@ The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directl
Skip to **Credentials** if all of these are already in place:
- `src/channels/deltachat.ts` exists
- `src/channels/deltachat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './deltachat.js';`
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
@@ -25,10 +26,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
git show origin/channels:src/channels/deltachat-registration.test.ts > src/channels/deltachat-registration.test.ts
```
### 3. Append the self-registration import
@@ -45,12 +47,17 @@ import './deltachat.js';
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/deltachat-registration.test.ts
```
Both must be clean before proceeding. `deltachat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `deltachat`. It goes red if the `import './deltachat.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@deltachat/stdio-rpc-server` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. Importing is safe: deltachat instantiates the rpc client only in `setup()` (at host startup), never at import.
End-to-end message delivery against a real email account is verified manually once the service is running — see Wiring and Troubleshooting.
## Account Setup
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
-54
View File
@@ -1,54 +0,0 @@
# Verify DeltaChat
## 1. Check the adapter started
```bash
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
```
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
## 2. Check IMAP/SMTP connectivity
Replace with your provider's hostnames from `.env`:
```bash
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
```
## 3. End-to-end message test
1. Open DeltaChat on your device
2. Add the bot email address as a contact
3. Send a message
4. The bot should respond within a few seconds
If nothing arrives, check:
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
```
## 4. Check messaging group was created
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
## 5. Verify user access
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.
+37 -4
View File
@@ -1,7 +1,40 @@
# Remove Discord
1. Comment out `import './discord.js'` in `src/channels/index.ts`
2. Remove `DISCORD_BOT_TOKEN` from `.env`
3. Rebuild and restart
Every step is idempotent — safe to re-run.
No package to uninstall — Discord is built in.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './discord.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/discord.ts src/channels/discord-registration.test.ts
```
## 2. Remove credentials
Remove `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/discord
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+8 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter i
Skip to **Credentials** if all of these are already in place:
- `src/channels/discord.ts` exists
- `src/channels/discord-registration.test.ts` exists
- `src/channels/index.ts` contains `import './discord.js';`
- `@chat-adapter/discord` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,15 @@ import './discord.js';
pnpm install @chat-adapter/discord@4.27.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/discord-registration.test.ts
```
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
### Create Discord Bot
-3
View File
@@ -1,3 +0,0 @@
# Verify Discord
Send a message in a channel where the bot has access, or DM the bot directly. The bot should respond within a few seconds.
+63
View File
@@ -0,0 +1,63 @@
# Remove Emacs
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './emacs.js';
```
Then delete the copied adapter, its tests, and the Lisp client:
```bash
rm -f src/channels/emacs.ts src/channels/emacs.test.ts src/channels/emacs-registration.test.ts emacs/nanoclaw.el
```
## 2. Remove credentials
Remove the `EMACS_*` lines from `.env`:
```bash
EMACS_ENABLED
EMACS_CHANNEL_PORT
EMACS_AUTH_TOKEN
EMACS_PLATFORM_ID
```
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
## 4. Remove the Emacs config (optional)
Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, or `init.el`):
```elisp
;; NanoClaw — personal AI assistant channel
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; ...and the associated keybindings / nanoclaw-auth-token / nanoclaw-port settings
```
Reload your config or restart Emacs.
## 5. Remove the messaging group (optional)
To clean up the wired messaging group:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
+12 -20
View File
@@ -24,6 +24,8 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and
Skip to **Enable** if all of these are already in place:
- `src/channels/emacs.ts` exists
- `src/channels/emacs.test.ts` exists
- `src/channels/emacs-registration.test.ts` exists
- `emacs/nanoclaw.el` exists
- `src/channels/index.ts` contains `import './emacs.js';`
@@ -39,9 +41,10 @@ git fetch origin channels
```bash
mkdir -p emacs
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:src/channels/emacs-registration.test.ts > src/channels/emacs-registration.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
```
### 3. Append the self-registration import
@@ -52,13 +55,16 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './emacs.js';
```
### 4. Build
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/emacs-registration.test.ts
```
No npm package to install — the adapter uses only Node builtins (`http`).
Both must be clean before proceeding. `emacs-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `emacs`. It goes red if the `import './emacs.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter uses only Node builtins (`http`), so there is no npm dependency to guard for this channel.
End-to-end message delivery from a real Emacs buffer is verified manually once the service is running — see Verify and Troubleshooting.
## Enable
@@ -285,18 +291,4 @@ 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
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:
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
See [REMOVE.md](REMOVE.md) to uninstall this channel.
+67
View File
@@ -0,0 +1,67 @@
# Remove Google Calendar Tool
Idempotent — safe to run even if some steps were never applied.
## 1. Unregister the MCP server (per group)
For each group that had Calendar wired (`ncl groups list` to enumerate):
```bash
ncl groups config remove-mcp-server --id <group-id> --name calendar
```
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
## 3. Delete the copied test file
```bash
rm -f src/gcal-dockerfile.test.ts
```
## 4. Revert the Dockerfile edits
Remove the `ARG CALENDAR_MCP_VERSION=...` line and the `@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}` entry from the pnpm global-install block in `container/Dockerfile`. If Calendar shared the gmail install block, leave the gmail entry intact; if it had a standalone `RUN ... pnpm install -g "@cocal/google-calendar-mcp@..."` block, delete that whole `RUN` line.
## 5. Rebuild and restart
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
Kill any running agent containers so they respawn without the `calendar` MCP server:
```bash
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
```
## 6. Optional: remove stubs and disconnect OneCLI
```bash
rm -rf ~/.calendar-mcp/
onecli apps disconnect --provider google-calendar
```
## Verification
After removal, in a wired agent asking it to "list my calendars" should report no calendar tool, and the dependency-guard test is gone:
```bash
ls src/gcal-dockerfile.test.ts 2>&1 # No such file or directory
```
+14 -19
View File
@@ -120,7 +120,18 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
**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.
`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__*`.
### Install the dependency-guard test
`@cocal/google-calendar-mcp` is a stdio CLI installed in the image, not an imported module, so `tsc` and the runtime tests never reference it — only the Dockerfile edit above proves it is present. Copy the guard test into the host test tree (vitest) so the Dockerfile `ARG` + install line stay covered:
```bash
cp .claude/skills/add-gcal-tool/gcal-dockerfile.test.ts src/gcal-dockerfile.test.ts
pnpm exec vitest run src/gcal-dockerfile.test.ts
```
`cp` overwrites in place, so re-running this skill is safe.
### Rebuild the container image
@@ -213,26 +224,10 @@ Common signals:
## Removal
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 "$(. 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.
See [REMOVE.md](REMOVE.md) — unregisters the MCP server, drops the `.calendar-mcp` mount, deletes the copied test, reverts the Dockerfile edits, and rebuilds.
## Credits & references
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
- **Why not gongrzhe:** `@gongrzhe/server-calendar-autoauth-mcp` only supports the primary calendar with 5 event-level tools. The cocal server supports multi-account and multi-calendar with the full tool surface.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
@@ -0,0 +1,36 @@
/**
* Dependency guard for the Google Calendar MCP server (host/vitest tree).
*
* `@cocal/google-calendar-mcp` is a stdio CLI installed globally in the image,
* not an imported module, so no behavior test can drive it and `tsc` never sees
* it. The only in-tree footprint of this skill is the Dockerfile edit, so the
* guard is structural: assert the pinned `ARG` and the pnpm global-install line
* both exist. Drop either Phase 2 Dockerfile edit and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
const p = path.resolve(process.cwd(), 'container/Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs @cocal/google-calendar-mcp', () => {
const text = dockerfile();
it('pins the version via an ARG', () => {
expect(text).toMatch(/^\s*ARG\s+CALENDAR_MCP_VERSION=/m);
});
it('installs the package pinned to that ARG in a pnpm global-install block', () => {
// Match `pnpm install -g ... "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"`,
// tolerating line continuations between `install -g` and the package.
const installsCalendar =
/pnpm\s+install\s+-g[\s\S]*?@cocal\/google-calendar-mcp@\$\{CALENDAR_MCP_VERSION\}/.test(
text,
);
expect(installsCalendar).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove Google Chat Channel
# Remove Google Chat
1. Comment out `import './gchat.js'` in `src/channels/index.ts`
2. Remove `GCHAT_CREDENTIALS` from `.env`
3. `pnpm uninstall @chat-adapter/gchat`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './gchat.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/gchat.ts src/channels/gchat-registration.test.ts
```
## 2. Remove credentials
Remove `GCHAT_CREDENTIALS` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/gchat
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapt
Skip to **Credentials** if all of these are already in place:
- `src/channels/gchat.ts` exists
- `src/channels/gchat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './gchat.js';`
- `@chat-adapter/gchat` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './gchat.js';
pnpm install @chat-adapter/gchat@4.27.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
-3
View File
@@ -1,3 +0,0 @@
# Verify Google Chat Channel
Add the bot to a Google Chat space, then send a message or @mention the bot. The bot should respond within a few seconds.
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove GitHub Channel
# Remove GitHub
1. Comment out `import './github.js'` in `src/channels/index.ts`
2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @chat-adapter/github`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './github.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/github.ts src/channels/github-registration.test.ts
```
## 2. Remove credentials
Remove `GITHUB_TOKEN`, `GITHUB_WEBHOOK_SECRET`, and `GITHUB_BOT_USERNAME` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/github
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+12 -5
View File
@@ -20,6 +20,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/github.ts` exists
- `src/channels/github-registration.test.ts` exists
- `src/channels/index.ts` contains `import './github.js';`
- `@chat-adapter/github` is listed in `package.json` dependencies
@@ -31,10 +32,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
```
### 3. Append the self-registration import
@@ -51,12 +53,17 @@ import './github.js';
pnpm install @chat-adapter/github@4.27.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/github-registration.test.ts
```
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
### 1. Create a Personal Access Token for the bot account
@@ -104,8 +111,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
```sql
-- Create messaging group (one per repo)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
-3
View File
@@ -1,3 +0,0 @@
# Verify GitHub Channel
@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds.
+57
View File
@@ -0,0 +1,57 @@
# Remove Gmail Tool
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied tests
```bash
rm -f container/agent-runner/src/providers/gmail-dockerfile.test.ts \
container/agent-runner/src/providers/gmail-allow-pattern.test.ts
```
## 2. Unregister the MCP server (per group)
`ncl groups list` shows the groups. For each group that had Gmail wired:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
## 3. Remove the `.gmail-mcp` mount (per group)
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
```bash
GROUP_ID='<group-id>'
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
## 4. Remove the Dockerfile install
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## 6. (Optional) Drop the host stubs and disconnect
```bash
rm -rf ~/.gmail-mcp/ # only if no other host tool needs the stubs
onecli apps disconnect --provider gmail # revoke the OneCLI Gmail connection
```
+23 -23
View File
@@ -101,14 +101,27 @@ grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Copy the skill's tests into the container tree
Both integration points this skill relies on live in the container (Bun) tree — the Dockerfile package install and the dynamic allow-pattern derivation in `claude.ts` — so the guards go there. `cp` overwrites, so re-running is safe.
```bash
S=.claude/skills/add-gmail-tool
cp $S/gmail-dockerfile.test.ts container/agent-runner/src/providers/gmail-dockerfile.test.ts
cp $S/gmail-allow-pattern.test.ts container/agent-runner/src/providers/gmail-allow-pattern.test.ts
```
- `gmail-dockerfile.test.ts` asserts the `GMAIL_MCP_VERSION` ARG and the pinned `pnpm install -g` line are present — the `gmail-mcp` binary is a Dockerfile-installed CLI, not importable or typed, so this structural guard is what goes red if the install is dropped.
- `gmail-allow-pattern.test.ts` asserts `claude.ts` still spreads `Object.keys(this.mcpServers).map(mcpAllowPattern)` into `allowedTools` — the derivation that makes registering `gmail` (Phase 3) enough to expose `mcp__gmail__*`.
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block:
```dockerfile
ARG CLAUDE_CODE_VERSION=2.1.116
ARG CLAUDE_CODE_VERSION=2.1.154
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
```
@@ -118,7 +131,7 @@ Add a new line:
ARG GMAIL_MCP_VERSION=1.1.11
```
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block directly after it (before the `# ---- ncl CLI wrapper` section):
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
@@ -131,7 +144,7 @@ Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates tr
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
**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.
The Gmail allow-pattern is derived automatically. `container/agent-runner/src/providers/claude.ts` builds `allowedTools` from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 exposes `mcp__gmail__*` to the agent.
### Rebuild the container image
@@ -188,12 +201,16 @@ Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` place
**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
## Phase 4: Build, Validate, Restart
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
(cd container/agent-runner && bun test src/providers/gmail-dockerfile.test.ts src/providers/gmail-allow-pattern.test.ts)
```
All must be clean before proceeding. `gmail-dockerfile.test.ts` confirms the package install is wired into the image; `gmail-allow-pattern.test.ts` confirms the allow-pattern derivation that exposes `mcp__gmail__*`. A failure means one drifted.
Run from your NanoClaw project root:
```bash
@@ -228,24 +245,7 @@ Common signals:
## Removal
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 "$(. 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.
See [REMOVE.md](REMOVE.md) for the idempotent removal procedure (delete the copied tests, unregister the MCP server per group, drop the mount, remove the Dockerfile install, rebuild, and optionally drop the stubs and disconnect OneCLI).
## Notes
@@ -0,0 +1,55 @@
/**
* Guard for the dynamic MCP allow-pattern derivation this skill depends on.
*
* Registering `gmail` in a group's mcpServers map is the *only* wiring needed to expose
* `mcp__gmail__*` to the agent — there is no static TOOL_ALLOWLIST edit. That holds solely
* because `claude.ts` derives the allow-pattern from the registered servers at query time:
*
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* so we assert the derivation structurally. Delete or rename the derivation and this goes
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
*
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function source(): { sf: ts.SourceFile; text: string } {
const p = path.join(import.meta.dir, 'claude.ts');
const text = fs.readFileSync(p, 'utf8');
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
}
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
function expectedPattern(name: string): string {
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
const { sf, text } = source();
it('defines an mcpAllowPattern function', () => {
let found = false;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === 'mcpAllowPattern') found = true;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
expect(found).toBe(true);
});
it('spreads Object.keys(this.mcpServers).map(mcpAllowPattern) into allowedTools', () => {
// Normalize whitespace so formatting changes don't break the assertion.
const flat = text.replace(/\s+/g, ' ');
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
});
it('maps a gmail server name to mcp__gmail__*', () => {
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
});
});
@@ -0,0 +1,36 @@
/**
* Structural guard for the Gmail MCP package-install integration point (container image).
*
* `@gongrzhe/server-gmail-autoauth-mcp` is a CLI binary installed into the image via the
* Dockerfile — it is not importable or typed from this tree, so the build leg can't catch
* its removal and there's no runtime seam to behavior-test. This asserts the Dockerfile
* still carries the ARG and the pinned pnpm global-install line. Drop either and this goes
* red, signalling the agent would boot without the `gmail-mcp` binary on PATH.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
function dockerfile(): string {
// container/agent-runner/src/providers/ -> ../../../Dockerfile == container/Dockerfile
const p = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs the Gmail MCP server', () => {
const text = dockerfile();
it('declares the GMAIL_MCP_VERSION ARG', () => {
expect(/ARG\s+GMAIL_MCP_VERSION=/.test(text)).toBe(true);
});
it('pnpm-installs @gongrzhe/server-gmail-autoauth-mcp pinned to the ARG', () => {
expect(text).toContain('pnpm install -g');
expect(/@gongrzhe\/server-gmail-autoauth-mcp@\$\{GMAIL_MCP_VERSION\}/.test(text)).toBe(true);
});
it('pins the zod-to-json-schema workaround version', () => {
expect(/zod-to-json-schema@3\.22\.5/.test(text)).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove iMessage Channel
# Remove iMessage
1. Comment out `import './imessage.js'` in `src/channels/index.ts`
2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env`
3. `pnpm uninstall chat-adapter-imessage`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './imessage.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/imessage.ts src/channels/imessage-registration.test.ts
```
## 2. Remove credentials
Remove `IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, and `IMESSAGE_API_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall chat-adapter-imessage
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+11 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter
Skip to **Credentials** if all of these are already in place:
- `src/channels/imessage.ts` exists
- `src/channels/imessage-registration.test.ts` exists
- `src/channels/index.ts` contains `import './imessage.js';`
- `chat-adapter-imessage` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './imessage.js';
pnpm install chat-adapter-imessage@0.1.1
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
## Credentials
### Local Mode (macOS)
@@ -75,7 +82,7 @@ Stop and wait for the user to confirm before continuing.
### Remote Mode (Photon API)
1. Set up a [Photon](https://photon.im) account
1. Set up a [Photon](https://photon.codes) account
2. Get your server URL and API key
### Configure environment
-3
View File
@@ -1,3 +0,0 @@
# Verify iMessage Channel
Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds.
@@ -0,0 +1,38 @@
# Remove Karpathy LLM Wiki
Every step is idempotent — safe to re-run.
## 1. Remove the shared container skill
The wiki container skill lives in the shared `container/skills/` mount, which is auto-discovered and symlinked into every agent group. Delete it so it stops appearing in all containers:
```bash
rm -rf container/skills/wiki
```
## 2. Remove the wiki section from the group CLAUDE.md
The wiki section is wrapped in marker comments. Delete the block (markers included) from the group's CLAUDE.md — find it under `groups/<folder>/CLAUDE.md`:
```bash
# Replace <folder> with the group folder you set up the wiki for.
perl -0pi -e 's/\n?<!-- BEGIN karpathy-llm-wiki -->.*?<!-- END karpathy-llm-wiki -->\n?//s' groups/<folder>/CLAUDE.md
```
If the markers are absent, nothing is removed (the block was already gone or never added).
## 3. Restart so containers drop the skill
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## User content is preserved
The per-group `groups/<folder>/wiki/` and `groups/<folder>/sources/` directories hold the user's own knowledge base and ingested sources. They are left in place. Delete them by hand only if the user explicitly wants their wiki content gone:
```bash
rm -rf groups/<folder>/wiki groups/<folder>/sources
```
+16 -3
View File
@@ -7,6 +7,8 @@ description: Add a persistent wiki knowledge base to a NanoClaw group. Based on
Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern.
Each step is safe to re-run: directory creation uses `mkdir -p`, initial wiki files are created only if absent, the container skill is preserved unless the user opts to update it, and the group CLAUDE.md section is replaced in place via marker comments rather than duplicated.
## Step 1: Read the pattern
Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build.
@@ -33,15 +35,26 @@ Based on this discussion, create three things:
### 3a. Directory structure
Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain.
Create `wiki/` and `sources/` directories in the group folder (`mkdir -p` — safe if they already exist). Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section, adapted to the user's domain. Skip any of these files that already exist so a populated wiki is never clobbered on re-run.
### 3b. Container skill
Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
Create `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
If `container/skills/wiki/SKILL.md` already exists, ask the user whether to update it before overwriting, so an existing tailored schema is preserved on re-run.
### 3c. Group CLAUDE.md
Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should:
Edit the group's CLAUDE.md to add a wiki section, wrapped in marker comments so it can be located and replaced on re-run:
```markdown
<!-- BEGIN karpathy-llm-wiki -->
## Wiki
...section body...
<!-- END karpathy-llm-wiki -->
```
If a `<!-- BEGIN karpathy-llm-wiki -->` block already exists, replace it in place rather than appending a second copy. This is critical — it's what turns the agent into a wiki maintainer. The section should:
- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint)
- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`)
+48 -5
View File
@@ -1,6 +1,49 @@
# Remove Linear Channel
# Remove Linear
1. Comment out `import './linear.js'` in `src/channels/index.ts`
2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @chat-adapter/linear`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './linear.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
```
## 2. Remove credentials
Remove the Linear env vars from `.env`, then re-sync to the container:
```bash
LINEAR_CLIENT_ID
LINEAR_CLIENT_SECRET
LINEAR_API_KEY
LINEAR_WEBHOOK_SECRET
LINEAR_BOT_USERNAME
LINEAR_TEAM_KEY
```
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/linear
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+14 -37
View File
@@ -22,16 +22,16 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/linear.ts` exists
- `src/channels/linear-registration.test.ts` exists
- `src/channels/index.ts` contains `import './linear.js';`
- `@chat-adapter/linear` is listed in `package.json` dependencies
- `src/channels/chat-sdk-bridge.ts` contains `catchAll`
Otherwise continue. Every step below is safe to re-run.
@@ -41,10 +41,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
```
### 3. Append the self-registration import
@@ -55,47 +56,23 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './linear.js';
```
### 4. Patch the Chat SDK bridge for catch-all message forwarding
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
```typescript
/**
* Forward ALL messages in unsubscribed threads, not just @-mentions.
* Use for platforms where the bot identity can't be @-mentioned (e.g.
* Linear OAuth apps). The thread is auto-subscribed on first message.
*/
catchAll?: boolean;
```
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
```typescript
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
if (config.catchAll) {
chat.onNewMessage(/.*/, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
await thread.subscribe();
});
}
```
### 5. Install the adapter package (pinned)
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.27.0
```
### 6. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/linear-registration.test.ts
```
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
## Credentials
### 1. Set up a webhook
@@ -142,8 +119,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
-3
View File
@@ -1,3 +0,0 @@
# Verify Linear Channel
@mention the bot in a Linear issue comment. The bot should respond within a few seconds.
@@ -0,0 +1,22 @@
# Remove macOS Menu Bar Status Indicator
Every step is idempotent — safe to re-run.
## 1. Unload the launchd service
```bash
launchctl bootout gui/$(id -u)/com.nanoclaw.statusbar 2>/dev/null \
|| launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist 2>/dev/null \
|| true
```
## 2. Delete the produced files
```bash
rm -f ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist \
dist/statusbar \
logs/statusbar.log \
logs/statusbar.error.log
```
The menu bar icon disappears once the service is unloaded.
+1 -7
View File
@@ -124,10 +124,4 @@ Tell the user:
>
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
## Removal
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm dist/statusbar
```
To uninstall, follow [REMOVE.md](REMOVE.md).
+54 -5
View File
@@ -1,6 +1,55 @@
# Remove Matrix Channel
# Remove Matrix
1. Comment out `import './matrix.js'` in `src/channels/index.ts`
2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env`
3. `pnpm uninstall @beeper/chat-adapter-matrix`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './matrix.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
```
## 2. Remove credentials
Remove the `MATRIX_*` lines from `.env`:
```bash
MATRIX_BASE_URL
MATRIX_USERNAME
MATRIX_PASSWORD
MATRIX_USER_ID
MATRIX_BOT_USERNAME
MATRIX_ACCESS_TOKEN
MATRIX_INVITE_AUTOJOIN
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
MATRIX_RECOVERY_KEY
MATRIX_DEVICE_ID
```
Then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @beeper/chat-adapter-matrix
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/matrix.ts` exists
- `src/channels/matrix-registration.test.ts` exists
- `src/channels/index.ts` contains `import './matrix.js';`
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
```
### 3. Append the self-registration import
@@ -69,12 +71,17 @@ node -e '
Re-run this after every `pnpm install` that touches the adapter.
### 6. Build
### 6. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/matrix-registration.test.ts
```
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
## Credentials
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
-3
View File
@@ -1,3 +0,0 @@
# Verify Matrix Channel
Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds.
+60
View File
@@ -0,0 +1,60 @@
# Remove Mnemon
Every step is idempotent — safe to run even if some steps were never applied.
## 1. Strip the Dockerfile install layer
Open `container/Dockerfile` and delete the mnemon block (the `# ---- mnemon` comment, the `ARG MNEMON_VERSION`, the `RUN` that downloads the binary, and the `ENV MNEMON_DATA_DIR` line):
```dockerfile
# ---- mnemon — persistent agent memory ----------------------------------------
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
If the block is already gone, skip this step.
## 2. Strip the entrypoint setup line
Open `container/entrypoint.sh` and delete the `mnemon setup` line that follows `set -e`:
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
If the line is already gone, skip this step.
## 3. Delete the copied test files
```bash
rm -f src/mnemon-dockerfile.test.ts src/mnemon-entrypoint.test.ts
```
## 4. Rebuild and restart
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## 5. Delete stored memory (optional)
Mnemon's graph lives at `/home/node/.claude/mnemon/` in each container, which maps to the per-agent-group `.claude/` directory on the host. To find the host path and clear it:
```bash
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
```
Stop the container, then delete the `mnemon/` subdirectory from that path.
+26 -60
View File
@@ -9,16 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
## Provider Compatibility
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
Check your provider:
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
```
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
## Phase 1: Pre-flight
@@ -28,7 +25,7 @@ grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
If already applied, re-run Phase 2 anyway — every step is idempotent and skips work that is already in place — then continue to Phase 3 (Verify).
### Check latest mnemon version
@@ -38,11 +35,11 @@ curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
## Phase 2: Apply Changes (Claude Code path)
## Phase 2: Apply Changes
### 1. Dockerfile — install mnemon binary
Add after the AWS CLI block, before the Bun runtime section:
Insert the mnemon block immediately above the `# ---- Bun runtime` section of `container/Dockerfile` (skip if `grep -q 'MNEMON_VERSION' container/Dockerfile` already matches):
```dockerfile
# ---- mnemon — persistent agent memory ----------------------------------------
@@ -55,11 +52,17 @@ RUN ARCH=$(dpkg --print-architecture) && \
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount, so memory persists across container restarts.
### 2. Entrypoint — run mnemon setup on each container start
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
`mnemon setup` is idempotent. Run it once per `container/entrypoint.sh`. First check whether the line is already present:
```bash
grep -q 'mnemon setup' container/entrypoint.sh && echo "Already wired" || echo "Wire it"
```
If it prints `Wire it`, add the setup call right after `set -e`, before the `cat` that captures stdin, so the result looks like:
```bash
#!/bin/bash
@@ -78,7 +81,19 @@ exec bun run /app/src/index.ts < /tmp/input.json
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
### 3. Rebuild and smoke-test the image
### 3. Copy the integration tests
Both reach-ins are into container build/runtime files that aren't importable or typed (a GitHub-release binary in the Dockerfile, a shell line in the entrypoint), so structural tests guard them. Copy them into the host test tree:
```bash
cp .claude/skills/add-mnemon/mnemon-dockerfile.test.ts src/mnemon-dockerfile.test.ts
cp .claude/skills/add-mnemon/mnemon-entrypoint.test.ts src/mnemon-entrypoint.test.ts
pnpm exec vitest run src/mnemon-dockerfile.test.ts src/mnemon-entrypoint.test.ts
```
`mnemon-dockerfile.test.ts` asserts the `MNEMON_VERSION` ARG and `MNEMON_DATA_DIR` ENV are present (red if the install layer is dropped on an upgrade). `mnemon-entrypoint.test.ts` asserts the entrypoint invokes `mnemon setup --target claude-code` (red if the wiring is removed).
### 4. Rebuild and smoke-test the image
```bash
./container/build.sh
@@ -116,36 +131,6 @@ docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
## Phase 2 (OpenCode path) — context injection
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
Then rebuild: `./container/build.sh`
### Verify (OpenCode)
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
```bash
# Also confirm the binary is present in the image:
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Memory Storage
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
@@ -157,25 +142,6 @@ docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | hea
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
## Migration Guide Update
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
**Dockerfile — after AWS CLI, before Bun runtime:**
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
**`container/entrypoint.sh` — add after `set -e`:**
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
## Troubleshooting
### `mnemon: command not found` in container
@@ -0,0 +1,36 @@
/**
* Structural guard for the mnemon Dockerfile reach-in (the dependency install).
*
* mnemon ships as a GitHub-release binary, not an npm package, so it can't be
* imported or typechecked. The only red-on-drift guard is asserting the install
* layer is present in container/Dockerfile: drop the layer on an upgrade and the
* container starts with "mnemon: command not found", but nothing else fails.
* This test reads the Dockerfile and asserts the MNEMON_VERSION ARG and the
* MNEMON_DATA_DIR ENV are both present.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
// From src/ up to repo root, then into container/.
const p = path.resolve(__dirname, '..', 'container', 'Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs the mnemon binary', () => {
const text = dockerfile();
it('declares the MNEMON_VERSION build arg', () => {
expect(text).toMatch(/ARG\s+MNEMON_VERSION/);
});
it('downloads the mnemon release binary', () => {
expect(text).toContain('mnemon-dev/mnemon/releases/download');
});
it('sets MNEMON_DATA_DIR into the .claude mount', () => {
expect(text).toMatch(/ENV\s+MNEMON_DATA_DIR=/);
});
});
@@ -0,0 +1,27 @@
/**
* Structural guard for the mnemon entrypoint reach-in.
*
* container/entrypoint.sh runs on every container start; the inserted
* `mnemon setup --target claude-code` line is what registers the Claude Code
* memory hooks. The entrypoint is a shell script, not an invocable function, so
* the guard is structural: assert the setup invocation is present. Drop it on an
* upgrade and the hooks silently never register — this test goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function entrypoint(): string {
// From src/ up to repo root, then into container/.
const p = path.resolve(__dirname, '..', 'container', 'entrypoint.sh');
return fs.readFileSync(p, 'utf8');
}
describe('container/entrypoint.sh runs mnemon setup on start', () => {
const text = entrypoint();
it('invokes mnemon setup targeting claude-code', () => {
expect(text).toMatch(/mnemon\s+setup\s+--target\s+claude-code/);
});
});
+49
View File
@@ -0,0 +1,49 @@
# Remove Ollama
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied files (both trees)
```bash
rm -f container/agent-runner/src/ollama-mcp-stdio.ts \
container/agent-runner/src/ollama-registration.test.ts \
src/ollama-env.ts \
src/ollama-wiring.test.ts
```
## 2. Unregister the MCP server
In `container/agent-runner/src/index.ts`, remove the `ollama: { … }` entry from the `mcpServers` object (leave `nanoclaw` and any other entries).
## 3. Revert the host-side edits in `src/container-runner.ts`
- Remove the `import { ollamaEnvArgs } from './ollama-env.js';` import.
- Remove the `args.push(...ollamaEnvArgs());` line that follows the `TZ` env line.
- Remove the `[OLLAMA]` branch from the `container.stderr` logger. If `[OLLAMA]` was the only prefix branch, restore the logger to its single-line `log.debug(line, …)` form; if other local-model tools still have branches there, just drop the `[OLLAMA]` one and leave the rest intact.
## 4. Remove env vars
Remove the Ollama block from `.env.example`, and the `OLLAMA_HOST` / `OLLAMA_ADMIN_TOOLS` lines from `.env` if you set them.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Verification
After removal, confirm the tool is gone — in a wired agent, asking it to "list ollama models" should report no such tool, and the logs should show no `[OLLAMA]` lines after the last restart:
```bash
grep "\[OLLAMA\]" logs/nanoclaw.log | tail -5
```
+166 -65
View File
@@ -5,17 +5,19 @@ description: Add Ollama MCP server so the container agent can call local models
# Add Ollama Integration
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
This skill adds a stdio-based MCP server that exposes local [Ollama](https://ollama.com) models as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by the Ollama daemon on the host, and can optionally manage the model library directly. Ollama runs locally and is keyless — there are no credentials to thread; the only configuration is the daemon's base URL.
Core tools (always available):
- `ollama_list_models` — list installed Ollama models with name, size, and family
- `ollama_generate` — send a prompt to a specified model and return the response
- `ollama_list_models` — list installed models with name, size, and family (`GET /api/tags`)
- `ollama_generate` — send a prompt to a specified model and return the response (`POST /api/generate`)
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
- `ollama_pull_model` — pull (download) a model from the Ollama registry
- `ollama_delete_model` — delete a locally installed model to free disk space
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
- `ollama_pull_model` — pull (download) a model from the Ollama registry (`POST /api/pull`)
- `ollama_delete_model` — delete a locally installed model to free disk space (`DELETE /api/delete`)
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info (`POST /api/show`)
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type (`GET /api/ps`)
The skill ships the MCP server source (and its tests) in this folder and copies them into the agent-runner tree at install time, then registers the server in `index.ts` and forwards host env vars in `container-runner.ts`. Registering the server is enough to expose its tools — the agent's allow-pattern (`mcp__ollama__*`) is derived from the registered server name.
## Phase 1: Pre-flight
@@ -25,77 +27,173 @@ Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, sk
### Check prerequisites
Verify Ollama is installed and running on the host:
Verify Ollama is installed and its daemon is reachable. On the host:
```bash
ollama list
curl -s http://127.0.0.1:11434/api/tags | head
```
If Ollama is not installed, direct the user to https://ollama.com/download.
If the request fails:
1. Install Ollama from https://ollama.com/download.
2. Start it (the desktop app runs the daemon, or run `ollama serve`).
3. Confirm the daemon answers: `curl -s http://127.0.0.1:11434/api/tags`.
If no models are installed, suggest pulling one:
> You need at least one model. I recommend:
> You need at least one model. For example:
>
> ```bash
> ollama pull gemma3:1b # Small, fast (1GB)
> ollama pull llama3.2 # Good general purpose (2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
> ollama pull gemma3:1b # Small, fast (~1GB)
> ollama pull llama3.2 # Good general purpose (~2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (~18GB)
> ```
## Phase 2: Apply Code Changes
### Ensure upstream remote
### Copy the skill's source and tests into both trees
This skill reaches into both the container (Bun) tree and the host (Node) tree, so its
files go into both, alongside the integration points they cover.
```bash
git remote -v
S=.claude/skills/add-ollama-tool
# Container (Bun) tree — the MCP server and the registration wiring test
cp $S/ollama-mcp-stdio.ts container/agent-runner/src/ollama-mcp-stdio.ts
cp $S/ollama-registration.test.ts container/agent-runner/src/ollama-registration.test.ts
# Host (Node) tree — the env-forwarding helper and the wiring test
cp $S/ollama-env.ts src/ollama-env.ts
cp $S/ollama-wiring.test.ts src/ollama-wiring.test.ts
```
If `upstream` is missing, add it:
### Register the MCP server in the agent-runner
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
};
```
### Merge the skill branch
Add an `ollama` entry alongside `nanoclaw`:
```bash
git fetch upstream skill/ollama-tool
git merge upstream/skill/ollama-tool
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
ollama: {
command: 'bun',
args: ['run', path.join(__dirname, 'ollama-mcp-stdio.ts')],
env: {
...(process.env.OLLAMA_HOST ? { OLLAMA_HOST: process.env.OLLAMA_HOST } : {}),
...(process.env.OLLAMA_ADMIN_TOOLS ? { OLLAMA_ADMIN_TOOLS: process.env.OLLAMA_ADMIN_TOOLS } : {}),
},
},
};
```
This merges in:
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
- `scripts/ollama-watch.sh` (macOS notification watcher)
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
- `OLLAMA_HOST` in `.env.example`
`ollama-registration.test.ts` asserts this entry is present and points at the server module — the tool only appears to the agent if it is registered here.
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Forward host env vars into the container
### Copy to per-group agent-runner
The container receives `TZ` and OneCLI networking vars by default; any other host env
var the MCP subprocess needs must be forwarded explicitly. The forwarding logic lives in
the copied `src/ollama-env.ts` (`ollamaEnvArgs()`) — `OLLAMA_HOST` (the daemon base URL)
and `OLLAMA_ADMIN_TOOLS` (the library-management opt-in flag). Both are configuration, not
credentials, so they are passed through plainly; Ollama itself is local and keyless.
Existing groups have a cached copy of the agent-runner source. Copy the new files:
Import it in `src/container-runner.ts` (alongside the other local imports):
```ts
import { ollamaEnvArgs } from './ollama-env.js';
```
Then, in `buildContainerArgs`, find the `TZ` env line and add the call right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
args.push(...ollamaEnvArgs());
```
`ollama-wiring.test.ts` asserts this `args.push(...ollamaEnvArgs())` call exists inside `buildContainerArgs`.
### Surface `[OLLAMA]` log lines at info level
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-atomic-chat-tool` for `[ATOMIC]`) also edit to surface their own prefix. Touch only the `[OLLAMA]` branch and leave the rest of the block intact, so the edits coexist and removal restores it cleanly.
In the same file, find the stderr logger:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
```
Replace it with:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (!line) continue;
if (line.includes('[OLLAMA]')) {
log.info(line, { container: agentGroup.folder });
} else {
log.debug(line, { container: agentGroup.folder });
}
}
});
```
If `add-atomic-chat-tool` (or another local-model tool) has already turned this into a
multi-branch block, just add an `else if (line.includes('[OLLAMA]'))` branch instead of
replacing it.
### Add env-var stubs to `.env.example`
Append to `.env.example`:
```bash
for dir in data/sessions/*/agent-runner-src; do
cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
cp container/agent-runner/src/index.ts "$dir/"
done
# Ollama MCP tool (.claude/skills/add-ollama-tool)
# Override the host where the Ollama daemon listens.
# Default: http://host.docker.internal:11434 (with fallback to localhost)
# OLLAMA_HOST=http://host.docker.internal:11434
# Opt in to library-management tools (pull, delete, show, list-running).
# Leave unset to expose only list + generate.
# OLLAMA_ADMIN_TOOLS=true
```
### Validate code changes
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
# Host tree: buildContainerArgs wiring
pnpm exec vitest run src/ollama-wiring.test.ts
# Container tree: index.ts registration
(cd container/agent-runner && bun test src/ollama-registration.test.ts)
./container/build.sh
```
Build must be clean before proceeding.
All must be clean before proceeding. The wiring and registration tests confirm the two
integration points — the `buildContainerArgs` call and the `index.ts` registration — are
actually in place; a failure means one drifted. (The MCP server's own request/response
behavior against the Ollama daemon is the author's build-time concern, not part of these
tests — verify it manually in Phase 4.)
## Phase 3: Configure
### Enable model management tools (optional)
### Enable library-management tools (optional)
Ask the user:
@@ -110,7 +208,7 @@ If the user wants management tools, add to `.env`:
OLLAMA_ADMIN_TOOLS=true
```
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
If they decline (or don't answer), leave the variable unset — only list + generate are exposed.
### Set Ollama host (optional)
@@ -127,7 +225,7 @@ 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
# Linux: systemctl --user restart $(systemd_unit)
```
## Phase 4: Verify
@@ -148,14 +246,6 @@ If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
>
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
### Monitor activity (optional)
Run the watcher script for macOS notifications when Ollama is used:
```bash
./scripts/ollama-watch.sh
```
### Check logs if needed
```bash
@@ -163,34 +253,45 @@ tail -f logs/nanoclaw.log | grep -i ollama
```
Look for:
- `[OLLAMA] >>> Generating` — generation started
- `[OLLAMA] <<< Done` — generation completed
- `[OLLAMA] Listing models...` — list request started
- `[OLLAMA] Found N models` — models discovered
- `[OLLAMA] >>> Generating with <model>` — generation started
- `[OLLAMA] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
- `[OLLAMA] Deleted:` — model removed (management tools)
## Troubleshooting
### Agent says "Ollama is not installed"
### Agent says "Ollama is not installed" or tries to run a CLI
The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
2. The per-group source wasn't updated — re-copy files (see Phase 2)
The agent is looking for an `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/ollama-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Ollama"
1. Verify Ollama is running: `ollama list`
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
3. If using a custom host, check `OLLAMA_HOST` in `.env`
1. Verify the daemon is reachable: `curl http://127.0.0.1:11434/api/tags`
2. Confirm Ollama is running (`ollama list` on the host)
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
4. If using a custom host, check `OLLAMA_HOST` in `.env`
### `model not found` / 404 on generate
The model name passed to `ollama_generate` must exactly match one of the names returned by `ollama_list_models` (including any `:tag` suffix, e.g. `gemma3:1b`). Ask the agent to list models first, then pick one from that list.
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until the pull completes — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`.
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it. The management tools are only registered when that flag is present in the container's environment.
### Slow first response
Ollama lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
### Agent doesn't use Ollama tools
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.
@@ -0,0 +1,22 @@
/**
* Host-side env forwarding for the Ollama MCP tool. Returns the Docker `-e`
* arguments that pass any `OLLAMA_*` host overrides into the container.
*
* Ollama is local and keyless — these are configuration, not credentials:
* `OLLAMA_HOST` is the base URL of the host's Ollama daemon, and
* `OLLAMA_ADMIN_TOOLS` is the opt-in flag for the library-management tools.
*
* Lives in its own file so the reach-in in `container-runner.ts` is a single
* call (`args.push(...ollamaEnvArgs())`) and this logic is behavior-testable in
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
*/
export function ollamaEnvArgs(): string[] {
const args: string[] = [];
if (process.env.OLLAMA_HOST) {
args.push('-e', `OLLAMA_HOST=${process.env.OLLAMA_HOST}`);
}
if (process.env.OLLAMA_ADMIN_TOOLS) {
args.push('-e', `OLLAMA_ADMIN_TOOLS=${process.env.OLLAMA_ADMIN_TOOLS}`);
}
return args;
}
@@ -0,0 +1,482 @@
/**
* Ollama MCP Server for NanoClaw
* Exposes local Ollama models (native Ollama REST API, /api/*) as tools for the
* container agent. Uses host.docker.internal to reach the host's Ollama daemon
* from inside the container.
*
* Ollama runs locally and is keyless — there are no credentials to thread. The
* only configuration is the base URL (OLLAMA_HOST) and an opt-in flag for the
* library-management tools (OLLAMA_ADMIN_TOOLS).
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
const OLLAMA_HOST =
process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
const OLLAMA_ADMIN_TOOLS = process.env.OLLAMA_ADMIN_TOOLS === 'true';
const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
function log(msg: string): void {
console.error(`[OLLAMA] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
} catch {
/* best-effort */
}
}
async function ollamaFetch(
apiPath: string,
options?: RequestInit,
): Promise<Response> {
const url = `${OLLAMA_HOST}${apiPath}`;
try {
return await fetch(url, options);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (OLLAMA_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, options);
}
throw err;
}
}
function formatBytes(bytes?: number): string {
if (bytes === undefined || bytes === null) return '?';
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(1)}GB`;
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(0)}MB`;
}
const server = new McpServer({
name: 'ollama',
version: '1.0.0',
});
server.tool(
'ollama_list_models',
'List all models installed in the local Ollama daemon. Use this to see which models are available before calling ollama_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing installed models');
try {
const res = await ollamaFetch('/api/tags');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Ollama API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
models?: Array<{
name: string;
size?: number;
details?: { family?: string; parameter_size?: string };
}>;
};
const models = data.models || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models installed. Pull one on the host with `ollama pull <model>` (e.g. `ollama pull llama3.2`).',
},
],
};
}
const list = models
.map((m) => {
const family = m.details?.family ? ` ${m.details.family}` : '';
const params = m.details?.parameter_size
? ` ${m.details.parameter_size}`
: '';
return `- ${m.name} (${formatBytes(m.size)}${family}${params})`;
})
.join('\n');
log(`Found ${models.length} models`);
return {
content: [
{ type: 'text' as const, text: `Installed models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_generate',
'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model name as returned by ollama_list_models (e.g. "llama3.2" or "gemma3:1b")',
),
prompt: z.string().describe('The prompt to send to the model'),
system: z
.string()
.optional()
.describe('Optional system prompt to set model behavior'),
temperature: z
.number()
.optional()
.describe('Sampling temperature (0.02.0). Defaults to model default.'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const body: Record<string, unknown> = {
model: args.model,
prompt: args.prompt,
stream: false,
};
if (args.system) body.system = args.system;
if (args.temperature !== undefined) {
body.options = { temperature: args.temperature };
}
const startedAt = Date.now();
const res = await ollamaFetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
response?: string;
eval_count?: number;
};
const response = data.response ?? '';
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
const evalCount = data.eval_count;
const meta = `\n\n[${args.model} | ${elapsedSec}s${
evalCount !== undefined ? ` | ${evalCount} tokens` : ''
}]`;
log(
`<<< Done: ${args.model} | ${elapsedSec}s | ${
evalCount ?? '?'
} tokens | ${response.length} chars`,
);
writeStatus(
'done',
`${args.model} | ${elapsedSec}s | ${evalCount ?? '?'} tokens`,
);
return { content: [{ type: 'text' as const, text: response + meta }] };
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
// Library-management tools — opt-in via OLLAMA_ADMIN_TOOLS=true. These mutate
// the host's model library (pull/delete) or inspect it, so they are gated
// behind an explicit flag rather than exposed by default.
if (OLLAMA_ADMIN_TOOLS) {
server.tool(
'ollama_pull_model',
'Pull (download) a model from the Ollama registry into the local daemon. Blocks until the download completes — large models can take several minutes.',
{
model: z
.string()
.describe('The model name to pull (e.g. "llama3.2" or "qwen3-coder:30b")'),
},
async (args) => {
log(`Pulling model: ${args.model}`);
writeStatus('pulling', `Pulling ${args.model}`);
try {
const res = await ollamaFetch('/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model, stream: false }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama pull error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as { status?: string };
log(`Pulled: ${args.model} (${data.status ?? 'ok'})`);
return {
content: [
{
type: 'text' as const,
text: `Pulled ${args.model}: ${data.status ?? 'success'}`,
},
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to pull ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_delete_model',
'Delete a locally installed model from the Ollama daemon to free disk space.',
{
model: z.string().describe('The model name to delete (e.g. "gemma3:1b")'),
},
async (args) => {
log(`Deleting model: ${args.model}`);
writeStatus('deleting', `Deleting ${args.model}`);
try {
const res = await ollamaFetch('/api/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama delete error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
log(`Deleted: ${args.model}`);
return {
content: [
{ type: 'text' as const, text: `Deleted ${args.model}.` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to delete ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_show_model',
'Show details for a locally installed model: modelfile, parameters, template, and architecture info.',
{
model: z
.string()
.describe('The model name to inspect (e.g. "llama3.2")'),
},
async (args) => {
log(`Showing model: ${args.model}`);
try {
const res = await ollamaFetch('/api/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama show error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
parameters?: string;
template?: string;
details?: {
family?: string;
parameter_size?: string;
quantization_level?: string;
};
};
const parts: string[] = [`Model: ${args.model}`];
if (data.details) {
const d = data.details;
parts.push(
`Family: ${d.family ?? '?'} | Params: ${d.parameter_size ?? '?'} | Quant: ${d.quantization_level ?? '?'}`,
);
}
if (data.parameters) parts.push(`Parameters:\n${data.parameters}`);
if (data.template) parts.push(`Template:\n${data.template}`);
return {
content: [{ type: 'text' as const, text: parts.join('\n\n') }],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to show ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_list_running',
'List models currently loaded in memory, with memory usage and processor type (CPU/GPU). Use this to see what is warm and consuming resources.',
{},
async () => {
log('Listing running models...');
try {
const res = await ollamaFetch('/api/ps');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Ollama API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
models?: Array<{
name: string;
size?: number;
size_vram?: number;
}>;
};
const models = data.models || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models currently loaded in memory.',
},
],
};
}
const list = models
.map((m) => {
const vram = m.size_vram ?? 0;
const total = m.size ?? 0;
const processor =
vram === 0
? 'CPU'
: vram >= total
? 'GPU'
: `${Math.round((vram / total) * 100)}% GPU`;
return `- ${m.name} (${formatBytes(total)}, ${processor})`;
})
.join('\n');
return {
content: [
{ type: 'text' as const, text: `Loaded models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
}
const transport = new StdioServerTransport();
await server.connect(transport);
@@ -0,0 +1,66 @@
/**
* Wiring test for the MCP-server registration integration point (container/Bun tree).
*
* The handlers are exercised against a live Ollama daemon at build time, but that does
* not prove the server is registered — delete the index.ts entry and the tool simply
* never appears, yet any handler check stays green. index.ts is the container boot entry
* and is not cheaply invocable, so we assert the registration structurally: the
* `mcpServers` object literal has an `ollama` property whose command runs
* `ollama-mcp-stdio.ts`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.join(import.meta.dir, 'index.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
/** Find the object literal assigned to `const mcpServers = { ... }`. */
function mcpServersLiteral(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
let found: ts.ObjectLiteralExpression | undefined;
const visit = (node: ts.Node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'mcpServers' &&
node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
) {
found = node.initializer;
}
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
return obj.properties.find(
(p): p is ts.PropertyAssignment =>
ts.isPropertyAssignment(p) &&
((ts.isIdentifier(p.name) && p.name.text === name) ||
(ts.isStringLiteral(p.name) && p.name.text === name)),
);
}
describe('index.ts registers the ollama MCP server', () => {
const obj = mcpServersLiteral(sourceFile());
it('finds the mcpServers object literal', () => {
expect(obj).toBeDefined();
});
it('has an ollama entry', () => {
expect(obj && property(obj, 'ollama')).toBeDefined();
});
it('points ollama at ollama-mcp-stdio.ts', () => {
const entry = obj && property(obj, 'ollama');
const text = entry ? entry.getText() : '';
expect(text).toContain('ollama-mcp-stdio.ts');
});
});
@@ -0,0 +1,69 @@
/**
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
*
* The env helper is skill-owned and could be unit-tested directly, but that does not prove
* buildContainerArgs actually uses it — a direct unit test stays green even if the reach-in
* is deleted. buildContainerArgs is entangled with OneCLI and not cheaply invocable, so we
* assert the integration structurally: inside buildContainerArgs there is an
* `args.push(...ollamaEnvArgs())` call. Delete the reach-in and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.resolve(process.cwd(), 'src/container-runner.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
function findFunction(sf: ts.SourceFile, name: string): ts.FunctionDeclaration | undefined {
let found: ts.FunctionDeclaration | undefined;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/** Is this node `args.push(...ollamaEnvArgs())`? */
function isSpreadPushOfEnvArgs(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) return false;
const callee = node.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
callee.name.text !== 'push' ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== 'args'
) {
return false;
}
return node.arguments.some(
(arg) =>
ts.isSpreadElement(arg) &&
ts.isCallExpression(arg.expression) &&
ts.isIdentifier(arg.expression.expression) &&
arg.expression.expression.text === 'ollamaEnvArgs',
);
}
describe('container-runner.ts wires in ollamaEnvArgs', () => {
const sf = sourceFile();
const fn = findFunction(sf, 'buildContainerArgs');
it('finds buildContainerArgs', () => {
expect(fn).toBeDefined();
});
it('calls args.push(...ollamaEnvArgs()) inside buildContainerArgs', () => {
let wired = false;
const visit = (node: ts.Node) => {
if (isSpreadPushOfEnvArgs(node)) wired = true;
if (!wired) ts.forEachChild(node, visit);
};
if (fn?.body) visit(fn.body);
expect(wired).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
# Remove OpenCode provider
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, the agent-runner dependency, and the Dockerfile CLI install.
## 1. Delete the barrel import lines (both trees)
Delete (do not comment out) the `import './opencode.js';` line from each barrel:
- `src/providers/index.ts`
- `container/agent-runner/src/providers/index.ts`
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
## 2. Delete the copied files (both trees)
```bash
rm -f src/providers/opencode.ts \
src/providers/opencode-registration.test.ts \
src/opencode-dockerfile.test.ts \
container/agent-runner/src/providers/opencode.ts \
container/agent-runner/src/providers/mcp-to-opencode.ts \
container/agent-runner/src/providers/mcp-to-opencode.test.ts \
container/agent-runner/src/providers/opencode.factory.test.ts \
container/agent-runner/src/providers/opencode-registration.test.ts
```
## 3. Remove the agent-runner dependency
`@opencode-ai/sdk` is an importable package in the container tree (agent-runner is a Bun package, not a pnpm workspace — use `bun remove`):
```bash
cd container/agent-runner && bun remove @opencode-ai/sdk && cd -
```
## 4. Revert the Dockerfile CLI install
In `container/Dockerfile`, remove both OpenCode edits (skip whichever is already gone):
**(a)** Delete the version ARG from the "Pin CLI versions" block:
```dockerfile
ARG OPENCODE_VERSION=1.4.17
```
**(b)** Delete the standalone OpenCode install layer:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
```
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
## 5. Clean up per-group overlays
Any group that had the OpenCode files copied into its live source overlay still carries them — remove the OpenCode-specific files from each overlay (the barrel `index.ts` is re-synced from the cleaned tree, not deleted):
```bash
for overlay in data/v2-sessions/*/agent-runner-src/providers/; do
[ -d "$overlay" ] || continue
rm -f "$overlay/opencode.ts" "$overlay/mcp-to-opencode.ts"
[ -f container/agent-runner/src/providers/index.ts ] && \
cp container/agent-runner/src/providers/index.ts "$overlay"
echo "Cleaned: $overlay"
done
```
## 6. Unset OpenCode env vars
Remove any OpenCode-specific lines you added to `.env` (`OPENCODE_PROVIDER`, `OPENCODE_MODEL`, `OPENCODE_SMALL_MODEL`, and `ANTHROPIC_BASE_URL` if no other integration uses it) if no other integration needs them, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
Switch any group still on OpenCode back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
## 7. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
> If the rebuild still reports OpenCode after these steps, the buildkit COPY cache may be stale. Prune the builder and rebuild: `docker builder prune -f && ./container/build.sh`.
## Verification
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
```bash
grep -R "opencode.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
grep "@opencode-ai/sdk" container/agent-runner/package.json # no output
grep "opencode-ai" container/Dockerfile # no output
```
In a wired agent, requesting `agent_provider = 'opencode'` should fall back to the default provider since `opencode` is no longer in the registry.
+48 -25
View File
@@ -17,10 +17,13 @@ If all of the following are already present, skip to **Configuration**:
- `src/providers/opencode.ts`
- `container/agent-runner/src/providers/opencode.ts`
- `src/providers/opencode-registration.test.ts`
- `container/agent-runner/src/providers/opencode-registration.test.ts`
- `import './opencode.js';` line in `src/providers/index.ts`
- `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts`
- `@opencode-ai/sdk` in `container/agent-runner/package.json`
- `opencode-ai@${OPENCODE_VERSION}` in the pnpm global-install block in `container/Dockerfile`
- `ARG OPENCODE_VERSION` and `"opencode-ai@${OPENCODE_VERSION}"` in `container/Dockerfile`
- `src/opencode-dockerfile.test.ts` (the Dockerfile install guard)
Missing pieces — continue below. All steps are idempotent; re-running is safe.
@@ -35,13 +38,20 @@ git fetch origin providers
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts
git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts
git show origin/providers:container/agent-runner/src/providers/opencode.factory.test.ts > container/agent-runner/src/providers/opencode.factory.test.ts
```
Also copy the two barrel-registration guards — one per tree. These import the real provider barrels and assert `opencode` is registered, so they go red the moment a barrel import line is deleted or drifts:
```bash
git show origin/providers:src/providers/opencode-registration.test.ts > src/providers/opencode-registration.test.ts
git show origin/providers:container/agent-runner/src/providers/opencode-registration.test.ts > container/agent-runner/src/providers/opencode-registration.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line appended at the end — skip if the line is already present.
@@ -70,7 +80,7 @@ cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd -
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`:
**(a)** In the "Pin CLI versions" ARG block (around line 22), add after `ARG VERCEL_VERSION=...`:
```dockerfile
ARG OPENCODE_VERSION=1.4.17
@@ -78,30 +88,47 @@ ARG OPENCODE_VERSION=1.4.17
> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x.
**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list:
**(b)** Add a new standalone `RUN` block for the OpenCode CLI, after the existing per-CLI install blocks (around line 111, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
```dockerfile
pnpm install -g \
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
"agent-browser@${AGENT_BROWSER_VERSION}" \
"vercel@${VERCEL_VERSION}" \
"opencode-ai@${OPENCODE_VERSION}"
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
```
### 6. Build
### 6. Copy the Dockerfile install guard
The `opencode-ai` CLI is a globally-installed binary — not importable or typed — so a structural test guards the Dockerfile install. Copy it into the host test tree:
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
cp .claude/skills/add-opencode/opencode-dockerfile.test.ts src/opencode-dockerfile.test.ts
```
### 7. Build and validate
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
pnpm exec vitest run src/providers/opencode-registration.test.ts # host registration guard
pnpm exec vitest run src/opencode-dockerfile.test.ts # Dockerfile install guard
cd container/agent-runner && bun test src/providers/opencode-registration.test.ts && cd - # container registration guard
./container/build.sh # agent image
```
All four must be clean before proceeding. Each guards a distinct integration point:
- **`src/providers/opencode-registration.test.ts`** (host, vitest) imports the real host barrel (`./index.js``listProviderContainerConfigNames`) and asserts `opencode` is present. It goes red if the `import './opencode.js';` line in `src/providers/index.ts` is deleted or drifts, or if that barrel fails to evaluate.
- **`container/agent-runner/src/providers/opencode-registration.test.ts`** (container, bun:test) imports the real container barrel (`./index.js``listProviderNames`) and asserts `opencode` is present. It goes red if the `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts` is deleted or drifts. Because the barrel is imported unmocked, it also pulls in `opencode.ts`, which imports **`@opencode-ai/sdk`** — so this test implicitly guards the step-4 dependency too: if the package isn't installed, the import throws and the test goes red.
- **`src/opencode-dockerfile.test.ts`** parses `container/Dockerfile` and asserts both the `ARG OPENCODE_VERSION=...` (rejecting `latest`) and the `pnpm install -g "opencode-ai@${OPENCODE_VERSION}"` line are present. The `opencode-ai` CLI binary is not importable, so it is guarded by this structural test plus the container build — not the registration test.
- **`pnpm run build`** type-checks the host provider's consumption of the host-side container-config registry; the container typecheck does the same for the container provider against the agent-runner core APIs.
The pre-existing `opencode.factory.test.ts` imports `opencode.ts` directly and self-registers, so it stays green even if a barrel import is removed — it is a unit test of `createProvider('opencode')`, not the registration guard. Keep it; it adds factory coverage but does not stand in for the registration tests above.
> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild:
> ```bash
> docker builder prune -f && ./container/build.sh
> ```
### 7. Propagate to existing per-group overlays
### 8. Propagate to existing per-group overlays
Each agent group has a live source overlay at `data/v2-sessions/<group-id>/agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually.
@@ -221,12 +248,8 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder.
- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI).
## Verify
## Next Steps
```bash
grep -q "./opencode.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./opencode.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@opencode-ai/sdk" container/agent-runner/package.json && echo "agent-runner dep: OK"
grep -q "opencode-ai@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/ && cd -
```
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
To remove this provider, see [REMOVE.md](REMOVE.md).
@@ -0,0 +1,47 @@
/**
* Dependency guard for the OpenCode CLI integration point (host tree, vitest).
*
* add-opencode installs the `opencode-ai` CLI globally in the agent container
* image via `container/Dockerfile`. A globally-installed CLI binary is not
* importable or typed, so neither `tsc` nor a runtime import can catch its
* removal only the container image build would, and the skill's validate step
* does not rebuild the image in CI. This structural test stands in for that
* build leg: it parses the Dockerfile and asserts both halves of the install are
* present the pinned `ARG OPENCODE_VERSION=...` and the
* `pnpm install -g "opencode-ai@${OPENCODE_VERSION}"` line. Drop or drift either
* and this goes red.
*
* Pinning matters here beyond reproducibility: the `opencode-ai` CLI version
* must match the `@opencode-ai/sdk` version the container provider imports. An
* unpinned `latest` would silently upgrade the CLI past the SDK's compatible
* range and break sessions. The test therefore also rejects `@latest`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
// Walk up from this test file to the repo root (the dir holding container/Dockerfile),
// so the test works wherever it is copied (src/ on the host, or the skill folder).
let dir = __dirname;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, 'container', 'Dockerfile');
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8');
dir = path.dirname(dir);
}
throw new Error('container/Dockerfile not found walking up from ' + __dirname);
}
describe('container/Dockerfile installs the OpenCode CLI', () => {
const text = dockerfile();
it('declares a pinned OPENCODE_VERSION build arg (not latest)', () => {
expect(text).toMatch(/^ARG\s+OPENCODE_VERSION=\S+/m);
expect(text).not.toMatch(/^ARG\s+OPENCODE_VERSION=latest\s*$/m);
});
it('globally installs the pinned opencode-ai package via pnpm', () => {
expect(text).toMatch(/pnpm install -g\s+"?opencode-ai@\$\{OPENCODE_VERSION\}"?/);
});
});
-293
View File
@@ -1,293 +0,0 @@
# Add Parallel AI Integration
Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.
## What This Adds
- **Quick Search** - Fast web lookups using Parallel Search API (free to use)
- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission)
- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking)
## Prerequisites
User must have:
1. Parallel AI API key from https://platform.parallel.ai
2. NanoClaw already set up and running
3. Docker installed and running
## Implementation Steps
Run all steps automatically. Only pause for user input when explicitly needed.
### 1. Get Parallel AI API Key
Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?`
**If they have one:**
Collect it now.
**If they need one:**
Tell them:
> 1. Go to https://platform.parallel.ai
> 2. Sign up or log in
> 3. Navigate to API Keys section
> 4. Create a new API key
> 5. Copy the key and paste it here
Wait for the API key.
### 2. Add API Key to Environment
Add `PARALLEL_API_KEY` to `.env`:
```bash
# Check if .env exists, create if not
if [ ! -f .env ]; then
touch .env
fi
# Add PARALLEL_API_KEY if not already present
if ! grep -q "PARALLEL_API_KEY=" .env; then
echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env
echo "✓ Added PARALLEL_API_KEY to .env"
else
# Update existing key
sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env
echo "✓ Updated PARALLEL_API_KEY in .env"
fi
```
Verify:
```bash
grep "PARALLEL_API_KEY" .env | head -c 50
```
### 3. Update Container Runner
Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`:
Find the line:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
```
Replace with:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
```
### 4. Configure MCP Servers in Agent Runner
Update `container/agent-runner/src/index.ts`:
Find the section where `mcpServers` is configured (around line 237-252):
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
```
Add Parallel AI MCP servers after the nanoclaw server:
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
// Add Parallel AI MCP servers if API key is available
const parallelApiKey = process.env.PARALLEL_API_KEY;
if (parallelApiKey) {
mcpServers['parallel-search'] = {
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
url: 'https://search-mcp.parallel.ai/mcp',
headers: {
'Authorization': `Bearer ${parallelApiKey}`
}
};
mcpServers['parallel-task'] = {
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
url: 'https://task-mcp.parallel.ai/mcp',
headers: {
'Authorization': `Bearer ${parallelApiKey}`
}
};
log('Parallel AI MCP servers configured');
} else {
log('PARALLEL_API_KEY not set, skipping Parallel AI integration');
}
```
Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248):
```typescript
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'mcp__nanoclaw__*',
'mcp__parallel-search__*',
'mcp__parallel-task__*'
],
```
### 5. Add Usage Instructions to CLAUDE.md
Add Parallel AI usage instructions to `groups/main/CLAUDE.md`:
Find the "## What You Can Do" section and add after the existing bullet points:
```markdown
- Use Parallel AI for web research and deep learning tasks
```
Then add a new section after "## What You Can Do":
```markdown
## Web Research Tools
You have access to two Parallel AI research tools:
### Quick Web Search (`mcp__parallel-search__search`)
**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts.
**Examples:**
- "Who invented the transistor?"
- "What's the latest news about quantum computing?"
- "When was the UN founded?"
- "What are the top programming languages in 2026?"
**Speed:** Fast (2-5 seconds)
**Cost:** Low
**Permission:** Not needed - use whenever it helps answer the question
### Deep Research (`mcp__parallel-task__create_task_run`)
**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research.
**Examples:**
- "Explain the development of quantum mechanics from 1900-1930"
- "Compare the literary styles of Hemingway and Faulkner"
- "Research the evolution of jazz from bebop to fusion"
- "Analyze the causes of the French Revolution"
**Speed:** Slower (1-20 minutes depending on depth)
**Cost:** Higher (varies by processor tier)
**Permission:** ALWAYS use `AskUserQuestion` before using this tool
**How to ask permission:**
```
AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed?
```
**After permission - DO NOT BLOCK! Use scheduler instead:**
1. Create the task using `mcp__parallel-task__create_task_run`
2. Get the `run_id` from the response
3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`:
```
Prompt: "Check Parallel AI task run [run_id] and send results when ready.
1. Use the Parallel Task MCP to check the task status
2. If status is 'completed', extract the results
3. Send results to user with mcp__nanoclaw__send_message
4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done
If status is still 'running' or 'pending', do nothing (task will run again in 30s).
If status is 'failed', send error message and complete the task."
Schedule: interval every 30 seconds
Context mode: isolated
```
4. Send acknowledgment with tracking link
5. Exit immediately - scheduler handles the rest
### Choosing Between Them
**Use Search when:**
- Question needs a quick fact or recent information
- Simple definition or clarification
- Verifying specific details
- Current events or news
**Use Deep Research (with permission) when:**
- User wants to learn about a complex topic
- Question requires analysis or comparison
- Historical context or evolution of concepts
- Structured, comprehensive understanding needed
- User explicitly asks to "research" or "explain in depth"
**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis.
```
### 6. Rebuild Container
Build the container with updated agent runner:
```bash
./container/build.sh
```
Verify the build:
```bash
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```
### 7. Restart Service
Rebuild the main app and restart.
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
Wait 3 seconds for service to start, then verify:
```bash
sleep 3
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
Tell the user to test:
> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?`
>
> The assistant should use Parallel Search API to find current information.
>
> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?`
>
> The assistant should ask for permission before using the Task API.
Check logs to verify MCP servers loaded:
```bash
tail -20 logs/nanoclaw.log
```
Look for: `Parallel AI MCP servers configured`
## Troubleshooting
**Container hangs or times out:**
- Check that `type: 'http'` is specified in MCP server config
- Verify API key is correct in .env
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
**MCP servers not loading:**
- Ensure PARALLEL_API_KEY is in .env
- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars
- Check agent-runner logs for "Parallel AI MCP servers configured" message
**Task polling not working:**
- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"`
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
- Ensure task prompt includes proper Parallel MCP tool names
## Uninstalling
To remove Parallel AI integration:
1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env`
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && pnpm run build`
5. Restart: `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)
+38 -4
View File
@@ -1,6 +1,40 @@
# Remove Resend Email Channel
1. Comment out `import './resend.js'` in `src/channels/index.ts`
2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @resend/chat-sdk-adapter`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './resend.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/resend.ts src/channels/resend-registration.test.ts
```
## 2. Remove credentials
Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, and `RESEND_WEBHOOK_SECRET` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @resend/chat-sdk-adapter
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+8 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/resend.ts` exists
- `src/channels/resend-registration.test.ts` exists
- `src/channels/index.ts` contains `import './resend.js';`
- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,15 @@ import './resend.js';
pnpm install @resend/chat-sdk-adapter@0.1.1
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/resend-registration.test.ts
```
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
1. Go to [resend.com](https://resend.com) and create an account.
-3
View File
@@ -1,3 +0,0 @@
# Verify Resend Email Channel
Send an email to the configured from address. The bot should respond via email within a few seconds.
+47
View File
@@ -0,0 +1,47 @@
# Remove rtk
Idempotent — safe to run even if some steps were never applied. Run Steps 13 once per agent group that had rtk wired (`ncl groups list`).
## 1. Remove the mount from the container config
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
```
Write the filtered array (omit any entry with `"containerPath":"/usr/local/bin/rtk"`):
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"UPDATE container_configs SET additional_mounts = '<filtered-json>' WHERE agent_group_id = '<group-id>'"
```
If no rtk entry is present, leave the array as-is.
## 2. Remove the PreToolUse hook from settings.json
Delete the rtk Bash hook entry (not comment it out). This leaves any other `PreToolUse` entries intact and is safe to re-run:
```bash
SETTINGS="data/v2-sessions/<group-id>/.claude-shared/settings.json"
jq '.hooks.PreToolUse = ((.hooks.PreToolUse // [])
| map(select((.hooks // []) | any(.command == "rtk hook claude") | not)))' \
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
```
## 3. Restart the container
```bash
ncl groups restart --id <group-id>
```
## 4. Remove the host binary (optional)
Once no group mounts rtk anymore, remove the binary:
```bash
rm -f ~/.local/bin/rtk
```
+143
View File
@@ -0,0 +1,143 @@
---
name: add-rtk
description: Install rtk token-compression proxy into agent containers. Routes Bash tool calls through rtk for 6090% token savings on dev commands (git, cargo, pytest, docker, kubectl, etc.).
---
# Add rtk
Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 6090% token savings on common dev commands (git, cargo, pytest, docker, kubectl, etc.) — and wire it transparently into agent containers via the Claude Code `PreToolUse` hook.
## What this sets up
- `rtk` binary at `~/.local/bin/rtk` on the host
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
## Step 1 — Install rtk on the host
```bash
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
```
If the script put the binary elsewhere, move it:
```bash
find ~/.local ~/.cargo/bin ~/bin -name rtk 2>/dev/null
mv "$(which rtk 2>/dev/null)" ~/.local/bin/rtk
```
Verify:
```bash
~/.local/bin/rtk --version
chmod +x ~/.local/bin/rtk # if needed
```
## Step 2 — Identify the target agent group
```bash
ncl groups list
```
Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 35 for each group.
## Step 3 — Mount rtk into the container config
`additional_mounts` is a JSON array column on `container_configs`. Read the current value, merge in the rtk entry, and write the merged array back.
Read current mounts first:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
```
Build the merged array: keep every existing entry, drop any entry whose `containerPath` is `/usr/local/bin/rtk` (so re-running replaces rather than duplicates), then add the rtk entry:
```json
{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}
```
Write the merged array back:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
```
Verify:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
```
## Step 4 — Add the PreToolUse hook to settings.json
Each agent group has a `settings.json` at:
```
data/v2-sessions/<group-id>/.claude-shared/settings.json
```
This file is mounted at `/home/node/.claude/settings.json` inside the container and is read by Claude Code for hooks, env, and model config.
Add the `PreToolUse` entry with `jq`. This drops any existing rtk Bash hook first, then appends a fresh one, so it is safe to re-run without creating duplicates:
```bash
SETTINGS="data/v2-sessions/<group-id>/.claude-shared/settings.json"
jq '.hooks.PreToolUse = ((.hooks.PreToolUse // [])
| map(select((.hooks // []) | any(.command == "rtk hook claude") | not)))
+ [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
```
## Step 5 — Restart the container
```bash
ncl groups restart --id <group-id>
```
## Verify
Confirm the binary is executable inside the container so a missing or non-executable mount surfaces immediately rather than as a silent hook failure:
```bash
docker exec "$(docker ps --filter "name=<group-id>" --format '{{.Names}}' | head -1)" rtk --version
```
Then ask the agent to run `git status` or any other supported command. rtk intercepts it silently. Check savings with:
```bash
~/.local/bin/rtk gain
```
## Troubleshooting
### `rtk: command not found` inside the container
Mount wasn't applied or container wasn't restarted:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
# Look for entry with /usr/local/bin/rtk
ncl groups restart --id <group-id>
```
### Hook not firing
Verify the hook is in `settings.json`:
```bash
jq '.hooks.PreToolUse' data/v2-sessions/<group-id>/.claude-shared/settings.json
```
If missing, re-run Step 4.
### Binary won't execute — permission denied
```bash
chmod +x ~/.local/bin/rtk
```
+53 -5
View File
@@ -1,13 +1,61 @@
# Remove Signal
1. Comment out `import './signal.js'` in `src/channels/index.ts`
2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env`
3. Rebuild and restart
Every step is idempotent — safe to re-run.
If you also want to unlink the Signal account from `signal-cli`:
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './signal.js';
```
Then delete the copied adapter and its tests:
```bash
rm -f src/channels/signal.ts src/channels/signal-registration.test.ts src/channels/signal.test.ts
```
## 2. Remove credentials
Remove the `SIGNAL_*` lines from `.env`:
```bash
SIGNAL_ACCOUNT
SIGNAL_TCP_HOST
SIGNAL_TCP_PORT
SIGNAL_CLI_PATH
SIGNAL_MANAGE_DAEMON
SIGNAL_DATA_DIR
```
Then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
## 4. Unlink the Signal account (optional)
To unlink NanoClaw's device from the Signal account:
```bash
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
```
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.
+9 -5
View File
@@ -123,7 +123,9 @@ This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings
Skip to **Credentials** if all of these are already in place:
- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist
- `src/channels/signal.ts` exists
- `src/channels/signal.test.ts` exists
- `src/channels/signal-registration.test.ts` exists
- `src/channels/index.ts` contains `import './signal.js';`
Otherwise continue. Every step below is safe to re-run.
@@ -137,8 +139,9 @@ git fetch origin channels
### 2. Copy the adapter and tests
```bash
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
git show origin/channels:src/channels/signal-registration.test.ts > src/channels/signal-registration.test.ts
```
### 3. Append the self-registration import
@@ -149,13 +152,14 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './signal.js';
```
### 4. Build
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/signal-registration.test.ts
```
No npm packages to install — the adapter uses only Node.js builtins.
Both must be clean before proceeding. `signal-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `signal`. It goes red if the `import './signal.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter consumes only Node.js builtins, so there is no npm dependency to guard for this channel. The adapter's typed core-API consumption is guarded by `pnpm run build`.
## Credentials
-5
View File
@@ -1,5 +0,0 @@
# Verify Signal
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
+38 -4
View File
@@ -1,6 +1,40 @@
# Remove Slack
1. Comment out `import './slack.js'` in `src/channels/index.ts`
2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env`
3. `pnpm uninstall @chat-adapter/slack`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './slack.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/slack.ts src/channels/slack-registration.test.ts
```
## 2. Remove credentials
Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/slack
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/slack.ts` exists
- `src/channels/slack-registration.test.ts` exists
- `src/channels/index.ts` contains `import './slack.js';`
- `@chat-adapter/slack` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './slack.js';
pnpm install @chat-adapter/slack@4.27.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/slack-registration.test.ts
```
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
### Create Slack App
-3
View File
@@ -1,3 +0,0 @@
# Verify Slack
Add the bot to a Slack channel, then send a message or @mention the bot. The bot should respond within a few seconds.
+46 -5
View File
@@ -1,6 +1,47 @@
# Remove Microsoft Teams Channel
# Remove Microsoft Teams
1. Comment out `import './teams.js'` in `src/channels/index.ts`
2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env`
3. `pnpm uninstall @chat-adapter/teams`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './teams.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
```
## 2. Remove credentials
Remove the `TEAMS_*` lines from `.env`, then re-sync to the container:
```bash
TEAMS_APP_ID
TEAMS_APP_PASSWORD
TEAMS_APP_TENANT_ID
TEAMS_APP_TYPE
```
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/teams
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+51 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/teams.ts` exists
- `src/channels/teams-registration.test.ts` exists
- `src/channels/index.ts` contains `import './teams.js';`
- `@chat-adapter/teams` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
git show origin/channels:src/channels/teams-registration.test.ts > src/channels/teams-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,14 +49,60 @@ import './teams.js';
pnpm install @chat-adapter/teams@4.27.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/teams-registration.test.ts
```
Both must be clean before proceeding. `teams-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `teams`. It goes red if the `import './teams.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Teams workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
Two paths — manual (Azure Portal) or auto (Teams CLI).
### Auto: Teams CLI
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
1. Install the CLI:
```bash
npm install -g @microsoft/teams.cli@preview
```
2. Sign in and verify:
```bash
teams login
teams status
```
3. Create the Entra app, client secret, and bot registration:
```bash
teams app create \
--name "NanoClaw" \
--endpoint "https://your-domain/api/webhooks/teams"
```
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
- `CLIENT_ID``TEAMS_APP_ID`
- `CLIENT_SECRET``TEAMS_APP_PASSWORD`
- `TENANT_ID``TEAMS_APP_TENANT_ID`
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
Continue to [Configure environment](#configure-environment).
---
The steps below describe the **manual Azure Portal path**.
### Step 1: Create an Azure AD App Registration
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
-3
View File
@@ -1,3 +0,0 @@
# Verify Microsoft Teams Channel
Add the bot to a Teams channel or send it a direct message. The bot should respond within a few seconds.
+49 -4
View File
@@ -1,6 +1,51 @@
# Remove Telegram
1. Comment out `import './telegram.js'` in `src/channels/index.ts`
2. Remove `TELEGRAM_BOT_TOKEN` from `.env`
3. `pnpm uninstall @chat-adapter/telegram`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './telegram.js';
```
Then delete the copied adapter, helpers, tests, registration test, and setup step:
```bash
rm -f src/channels/telegram.ts src/channels/telegram-registration.test.ts \
src/channels/telegram-pairing.ts src/channels/telegram-markdown-sanitize.ts \
src/channels/telegram-pairing.test.ts src/channels/telegram-markdown-sanitize.test.ts \
setup/pair-telegram.ts
```
## 2. Remove the setup step
Delete this entry from the `STEPS` map in `setup/index.ts` (skip if already gone):
```typescript
'pair-telegram': () => import('./pair-telegram.js'),
```
## 3. Remove credentials
Remove `TELEGRAM_BOT_TOKEN` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 4. Remove the package
```bash
pnpm uninstall @chat-adapter/telegram
```
## 5. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+9 -2
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter,
Skip to **Credentials** if all of these are already in place:
- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
- `src/channels/telegram-registration.test.ts` exists
- `src/channels/index.ts` contains `import './telegram.js';`
- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':`
- `@chat-adapter/telegram` is listed in `package.json` dependencies
@@ -28,10 +29,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter, helpers, tests, and setup step
### 2. Copy the adapter, helpers, tests, registration test, and setup step
```bash
git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts
git show origin/channels:src/channels/telegram-registration.test.ts > src/channels/telegram-registration.test.ts
git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts
git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts
git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts
@@ -61,12 +63,17 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
pnpm install @chat-adapter/telegram@4.27.0
```
### 6. Build
### 6. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/telegram-registration.test.ts
```
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Telegram bot is verified manually once the service is running — see Next Steps and the pairing flow in Channel Info.
## Credentials
### Create Telegram Bot
-3
View File
@@ -1,3 +0,0 @@
# Verify Telegram
Send a message to your bot in Telegram (search for its username), or add the bot to a group and send a message there. The bot should respond within a few seconds.
+47
View File
@@ -0,0 +1,47 @@
# Remove Vercel
Every step is idempotent — safe to re-run. Steps delete the files and config the apply created.
## 1. Remove the container skill
Delete the copied container skill and its per-group session copies:
```bash
rm -rf container/skills/vercel-cli
for session_dir in data/v2-sessions/ag-*; do
rm -rf "$session_dir/.claude-shared/skills/vercel-cli"
done
```
## 2. Remove the dependency guard test
```bash
rm -f src/vercel-dockerfile.test.ts
```
## 3. Remove the OneCLI credential
Delete the Vercel secret and strip its id from every agent's assigned list. `set-secrets` replaces the whole list, so read, filter, and write back per agent:
```bash
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
if [ -n "$VERCEL_SECRET_ID" ]; then
for agent in $(onecli agents list | jq -r '.data[].id'); do
REMAINING=$(onecli agents secrets --id "$agent" | jq -r --arg id "$VERCEL_SECRET_ID" '[.data[] | select(. != $id)] | join(",")')
onecli agents set-secrets --id "$agent" --secret-ids "$REMAINING"
done
onecli secrets delete --id "$VERCEL_SECRET_ID"
fi
```
## 4. The Vercel CLI in the container image
The Vercel CLI ships with the agent image on the NanoClaw trunk (`ARG VERCEL_VERSION` and `pnpm install -g "vercel@${VERCEL_VERSION}"` in `container/Dockerfile`). Leave those lines — they are part of the base image, not added by this skill. No rebuild is needed.
## 5. Restart running containers
So sessions stop loading the removed `vercel-cli` skill on next wake:
```bash
docker ps --format "{{.ID}} {{.Names}}" | grep nanoclaw-v2 | awk '{print $1}' | xargs -r docker stop
```
+17 -4
View File
@@ -101,19 +101,32 @@ done
## Phase 4: Ensure Vercel CLI in Container Image
Check if `vercel` is already in the Dockerfile:
The Vercel CLI is installed globally in the agent image via `container/Dockerfile`. Check for both halves of the install — the pinned version arg and the install line:
```bash
grep -q 'vercel' container/Dockerfile && echo "PRESENT" || echo "MISSING"
grep -Eq '^ARG VERCEL_VERSION=' container/Dockerfile && \
grep -Eq 'pnpm install -g "?vercel@\$\{VERCEL_VERSION\}"?' container/Dockerfile && \
echo "PRESENT" || echo "MISSING"
```
If `MISSING`, add `vercel` to the global npm install line in `container/Dockerfile`, then rebuild:
If `MISSING`, add a pinned `ARG VERCEL_VERSION=52.2.1` near the other version args and a `pnpm install -g "vercel@${VERCEL_VERSION}"` step in the global-install block of `container/Dockerfile`, then rebuild the image:
```bash
./container/build.sh
```
If `PRESENT`, skip — no rebuild needed.
If `PRESENT`, the CLI is already in the image — skip the rebuild.
## Phase 4b: Copy and Run the Dependency Guard
The Vercel CLI is a globally-installed binary — not importable or typed — so a structural test guards the Dockerfile install. Copy it into the host test tree and run it:
```bash
cp .claude/skills/add-vercel/vercel-dockerfile.test.ts src/vercel-dockerfile.test.ts
pnpm exec vitest run src/vercel-dockerfile.test.ts
```
The test parses `container/Dockerfile` and asserts both the `ARG VERCEL_VERSION=...` and the `pnpm install -g "vercel@${VERCEL_VERSION}"` line are present. It goes red if either is dropped or drifts.
## Phase 5: Sync Skills to Running Agent Groups
@@ -0,0 +1,40 @@
/**
* Dependency guard for the Vercel CLI integration point (host tree, vitest).
*
* add-vercel installs the `vercel` CLI globally in the agent container image via
* `container/Dockerfile`. A globally-installed CLI binary is not importable or
* typed, so neither `tsc` nor a runtime import can catch its removal only the
* container image build would, and the skill's validate step does not rebuild the
* image in CI. This structural test stands in for that build leg: it parses the
* Dockerfile and asserts both halves of the install are present the pinned
* `ARG VERCEL_VERSION=...` and the `pnpm install -g "vercel@${VERCEL_VERSION}"`
* line. Drop or drift either and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
// Walk up from this test file to the repo root (the dir holding container/Dockerfile),
// so the test works wherever it is copied (src/ on the host, or the skill folder).
let dir = __dirname;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, 'container', 'Dockerfile');
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8');
dir = path.dirname(dir);
}
throw new Error('container/Dockerfile not found walking up from ' + __dirname);
}
describe('container/Dockerfile installs the Vercel CLI', () => {
const text = dockerfile();
it('declares a pinned VERCEL_VERSION build arg', () => {
expect(text).toMatch(/^ARG\s+VERCEL_VERSION=\S+/m);
});
it('globally installs the pinned vercel package via pnpm', () => {
expect(text).toMatch(/pnpm install -g\s+"?vercel@\$\{VERCEL_VERSION\}"?/);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove Webex Channel
# Remove Webex
1. Comment out `import './webex.js'` in `src/channels/index.ts`
2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @bitbasti/chat-adapter-webex`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './webex.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/webex.ts src/channels/webex-registration.test.ts
```
## 2. Remove credentials
Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @bitbasti/chat-adapter-webex
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/webex.ts` exists
- `src/channels/webex-registration.test.ts` exists
- `src/channels/index.ts` contains `import './webex.js';`
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './webex.js';
pnpm install @bitbasti/chat-adapter-webex@0.1.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/webex-registration.test.ts
```
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
-3
View File
@@ -1,3 +0,0 @@
# Verify Webex Channel
Add the bot to a Webex space or send it a direct message. The bot should respond within a few seconds.
+21 -16
View File
@@ -1,36 +1,42 @@
# Remove WeChat Channel
Undo `/add-wechat`.
Every step is idempotent — safe to re-run.
### 1. Remove credentials
## 1. Remove the adapter
Delete WeChat lines from `.env`:
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```bash
sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak
cp .env data/env/env
```typescript
import './wechat.js';
```
### 2. Remove adapter and import
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/wechat.ts
sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak
rm -f src/channels/wechat.ts src/channels/wechat-registration.test.ts
```
### 3. Uninstall the package
## 2. Remove credentials
Remove `WECHAT_ENABLED` from `.env`, then re-sync to the container:
```bash
pnpm remove wechat-ilink-client
mkdir -p data/env && cp .env data/env/env
```
### 4. Remove saved auth + sync state
## 3. Remove the package
```bash
pnpm uninstall wechat-ilink-client
```
## 4. Remove saved auth + sync state
```bash
rm -rf data/wechat
```
### 5. Remove DB wiring
## 5. Remove DB wiring
```sql
-- Remove any sessions first (foreign key)
@@ -39,14 +45,13 @@ DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM m
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
```
### 6. Rebuild and restart
## 6. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -29,6 +29,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/wechat.ts` exists
- `src/channels/wechat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './wechat.js';`
- `wechat-ilink-client` is listed in `package.json` dependencies
@@ -40,10 +41,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts
git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts
git show origin/channels:src/channels/wechat-registration.test.ts > src/channels/wechat-registration.test.ts
```
### 3. Append the self-registration import
@@ -60,12 +62,17 @@ import './wechat.js';
pnpm install wechat-ilink-client@0.1.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/wechat-registration.test.ts
```
Both must be clean before proceeding. `wechat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `wechat`. It goes red if the `import './wechat.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `wechat-ilink-client` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. Importing is safe: the adapter opens its long-poll connection only in `setup()` (at host startup), never at import.
End-to-end message delivery against a real WeChat account is verified manually once the service is running — see Credentials and Wire your first DM above.
## Credentials
Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone.
+38 -4
View File
@@ -1,6 +1,40 @@
# Remove WhatsApp Cloud API Channel
1. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts`
2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env`
3. `pnpm uninstall @chat-adapter/whatsapp`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './whatsapp-cloud.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/whatsapp-cloud.ts src/channels/whatsapp-cloud-registration.test.ts
```
## 2. Remove credentials
Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, and `WHATSAPP_VERIFY_TOKEN` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/whatsapp
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud ad
Skip to **Credentials** if all of these are already in place:
- `src/channels/whatsapp-cloud.ts` exists
- `src/channels/whatsapp-cloud-registration.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp-cloud.js';`
- `@chat-adapter/whatsapp` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
git show origin/channels:src/channels/whatsapp-cloud-registration.test.ts > src/channels/whatsapp-cloud-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './whatsapp-cloud.js';
pnpm install @chat-adapter/whatsapp@4.27.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
```
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
@@ -1,3 +0,0 @@
# Verify WhatsApp Cloud API Channel
Send a message to your WhatsApp Business number. The bot should respond within a few seconds. Note: WhatsApp Cloud API only supports 1:1 DMs, not group chats.
+71
View File
@@ -0,0 +1,71 @@
# Remove WhatsApp
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './whatsapp.js';
```
Then delete the copied adapter, its registration test, and its unit test:
```bash
rm -f src/channels/whatsapp.ts src/channels/whatsapp-registration.test.ts src/channels/whatsapp.test.ts
```
## 2. Remove the setup steps
Delete these entries from the `STEPS` map in `setup/index.ts` (skip lines already gone):
```typescript
groups: () => import('./groups.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
```
> Keep `groups: ...` if another installed channel relies on the `groups` setup step. Only the `'whatsapp-auth':` entry is WhatsApp-specific.
Then delete the copied setup step files:
```bash
rm -f setup/whatsapp-auth.ts
```
> Keep `setup/groups.ts` if another installed channel relies on it.
## 3. Remove credentials
Remove `ASSISTANT_HAS_OWN_NUMBER` from `.env` (only present if a dedicated number was configured), then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 4. Remove the packages
```bash
pnpm uninstall @whiskeysockets/baileys qrcode @types/qrcode pino
```
## 5. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## 6. Remove auth state (optional)
To fully remove the linked-device authentication and session state:
```bash
rm -rf store/auth/
```
> **Warning:** This unlinks the device. Re-installing WhatsApp requires re-pairing from your phone via QR or pairing code (see SKILL.md Credentials).
To keep the linked device for a later reinstall, leave `store/auth/` intact.
+25 -11
View File
@@ -16,10 +16,13 @@ NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (
Skip to **Credentials** if all of these are already in place:
- `src/channels/whatsapp.ts` exists
- `src/channels/whatsapp-registration.test.ts` exists
- `src/channels/whatsapp.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp.js';`
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
Otherwise continue. Every step below is safe to re-run.
@@ -32,9 +35,11 @@ git fetch origin channels
### 2. Copy the adapter and setup steps
```bash
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
git show origin/channels:setup/groups.ts > setup/groups.ts
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
git show origin/channels:src/channels/whatsapp-registration.test.ts > src/channels/whatsapp-registration.test.ts
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
git show origin/channels:setup/groups.ts > setup/groups.ts
```
### 3. Append the self-registration import
@@ -60,12 +65,17 @@ groups: () => import('./groups.js'),
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
### 6. Build
### 6. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/whatsapp-registration.test.ts
```
Both must be clean before proceeding. `whatsapp-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@whiskeysockets/baileys` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5.
End-to-end message delivery against a real WhatsApp number is verified manually once the service is running — see Credentials, Wiring, and Troubleshooting.
## Credentials
WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone.
@@ -95,7 +105,7 @@ If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenti
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
- **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code
- **QR code in browser** (Recommended) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser
- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
@@ -114,11 +124,13 @@ rm -rf store/auth/
For QR code in browser (recommended):
```bash
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
```
(Bash timeout: 150000ms)
The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`.
Tell the user:
> A browser window will open with a QR code.
@@ -130,11 +142,13 @@ Tell the user:
For QR code in terminal:
```bash
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
```
(Bash timeout: 150000ms)
The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above.
Tell the user:
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
@@ -220,10 +234,10 @@ Not supported (WhatsApp linked device limitation): edit messages, delete message
### QR code expired
QR codes expire after ~60 seconds. Re-run the auth command:
QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`:
```bash
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
```
### Pairing code not working
@@ -236,10 +250,10 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
Ensure: digits only (no `+`), phone has internet, WhatsApp is updated.
If pairing code keeps failing, switch to QR-browser auth instead:
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate:
```bash
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
```
### "waiting for this message" on reactions
@@ -0,0 +1,246 @@
/**
* scripts/wa-qr-browser.ts serve WhatsApp pairing QR in the browser.
*
* Wraps `setup/index.ts --step whatsapp-auth -- --method qr` and renders the
* rotating QR string as a PNG in a small local HTTP page. Avoids the unreadable
* ASCII terminal QR. macOS / desktop-Linux only no headless support needed.
*
* Usage:
* pnpm exec tsx scripts/wa-qr-browser.ts [--clean] [--port 8765]
*
* --clean rm -rf store/auth/ before spawning the auth step.
* --port N bind to port N (default 8765, falls back to a free port).
*/
import { spawn, exec } from 'node:child_process';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import QRCode from 'qrcode';
type Status = 'waiting' | 'ready' | 'success' | 'failed';
type State = {
qr: string | null;
status: Status;
error?: string;
version: number;
};
const state: State = { qr: null, status: 'waiting', version: 0 };
const args = process.argv.slice(2);
const clean = args.includes('--clean');
const portIdx = args.indexOf('--port');
const requestedPort = portIdx >= 0 ? Number(args[portIdx + 1]) : 8765;
if (clean) {
fs.rmSync(path.join(process.cwd(), 'store', 'auth'), {
recursive: true,
force: true,
});
console.log('[wa-qr-browser] cleaned store/auth/');
}
function htmlPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>WhatsApp pairing</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0b141a; color: #e9edef; }
.card { background: #202c33; padding: 32px 40px; border-radius: 16px;
box-shadow: 0 12px 36px rgba(0,0,0,0.4); text-align: center;
min-width: 420px; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 20px; color: #aebac1; }
.qr-wrap { background: white; padding: 16px; border-radius: 12px;
display: inline-block; }
#qr { width: 360px; height: 360px; display: block; image-rendering: pixelated; }
#status { margin-top: 20px; font-size: 14px; color: #8696a0; min-height: 20px; }
#status.ok { color: #00d26a; font-size: 18px; font-weight: 500; }
#status.err { color: #ff6b6b; }
ol { text-align: left; color: #aebac1; font-size: 13px; line-height: 1.8;
margin: 20px 0 0; padding-left: 20px; }
</style>
</head>
<body>
<div class="card">
<h1>Scan with WhatsApp</h1>
<div class="qr-wrap"><img id="qr" alt="QR code" /></div>
<div id="status">Waiting for QR</div>
<ol>
<li>Open WhatsApp on your phone</li>
<li>Settings &rarr; Linked Devices &rarr; Link a Device</li>
<li>Point the camera at this QR code</li>
</ol>
</div>
<script>
let lastVersion = -1;
const qr = document.getElementById('qr');
const status = document.getElementById('status');
async function tick() {
try {
const r = await fetch('/qr.json', { cache: 'no-store' });
const s = await r.json();
if (s.status === 'success') {
qr.style.display = 'none';
status.className = 'ok';
status.textContent = '✓ Authenticated!';
return;
}
if (s.status === 'failed') {
qr.style.display = 'none';
status.className = 'err';
status.textContent = '✗ ' + (s.error || 'failed');
return;
}
if (s.qr && s.version !== lastVersion) {
lastVersion = s.version;
qr.src = '/qr.png?v=' + s.version;
status.textContent = 'QR ready — scan within ~20s';
}
} catch (e) { /* server closing, ignore */ }
setTimeout(tick, 1500);
}
tick();
</script>
</body>
</html>`;
}
const server = http.createServer(async (req, res) => {
const url = req.url ?? '/';
if (url === '/' || url.startsWith('/?')) {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(htmlPage());
return;
}
if (url === '/qr.json') {
res.setHeader('content-type', 'application/json');
res.setHeader('cache-control', 'no-store');
res.end(JSON.stringify(state));
return;
}
if (url.startsWith('/qr.png')) {
if (!state.qr) {
res.statusCode = 404;
res.end();
return;
}
try {
const buf = await QRCode.toBuffer(state.qr, { width: 360, margin: 1 });
res.setHeader('content-type', 'image/png');
res.setHeader('cache-control', 'no-store');
res.end(buf);
} catch (e) {
res.statusCode = 500;
res.end(String(e));
}
return;
}
res.statusCode = 404;
res.end();
});
function listen(port: number): Promise<number> {
return new Promise((resolve, reject) => {
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE' && port === requestedPort) {
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === 'object') resolve(addr.port);
else reject(new Error('unexpected address'));
});
} else {
reject(err);
}
});
server.listen(port, () => {
const addr = server.address();
if (addr && typeof addr === 'object') resolve(addr.port);
else reject(new Error('unexpected address'));
});
});
}
const port = await listen(requestedPort);
const url = `http://localhost:${port}`;
console.log(`[wa-qr-browser] QR server on ${url}`);
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
exec(`${opener} ${url}`, (err) => {
if (err) console.log(`[wa-qr-browser] could not auto-open browser: ${err.message}`);
else console.log('[wa-qr-browser] opening browser…');
});
const child = spawn(
'pnpm',
['exec', 'tsx', 'setup/index.ts', '--step', 'whatsapp-auth', '--', '--method', 'qr'],
{ stdio: ['inherit', 'pipe', 'inherit'] },
);
let stdoutBuf = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString();
process.stdout.write(text);
stdoutBuf += text;
const blockRe = /=== NANOCLAW SETUP: (\w+) ===\n([\s\S]*?)\n=== END ===/g;
let m: RegExpExecArray | null;
let lastEnd = 0;
while ((m = blockRe.exec(stdoutBuf)) !== null) {
const [, name, body] = m;
const fields: Record<string, string> = {};
for (const line of body.split('\n')) {
const kv = line.match(/^(\w+):\s*(.*)$/);
if (kv) fields[kv[1]] = kv[2];
}
handleBlock(name, fields);
lastEnd = m.index + m[0].length;
}
if (lastEnd > 0) stdoutBuf = stdoutBuf.slice(lastEnd);
});
function handleBlock(name: string, fields: Record<string, string>): void {
if (name === 'WHATSAPP_AUTH_QR' && fields.QR) {
state.qr = fields.QR;
state.status = 'ready';
state.version++;
return;
}
if (name === 'WHATSAPP_AUTH') {
if (fields.STATUS === 'success') {
state.status = 'success';
console.log('[wa-qr-browser] authenticated');
setTimeout(() => server.close(() => process.exit(0)), 3000);
} else if (fields.STATUS === 'skipped') {
state.status = 'success';
state.error = `already authenticated (${fields.REASON ?? 'unknown'})`;
console.log(`[wa-qr-browser] ${state.error}`);
setTimeout(() => server.close(() => process.exit(0)), 3000);
} else if (fields.STATUS === 'failed') {
state.status = 'failed';
state.error = fields.ERROR ?? 'unknown error';
console.error(`[wa-qr-browser] failed: ${state.error}`);
}
}
}
child.on('exit', (code) => {
if (state.status === 'success') return;
if (state.status !== 'failed') {
state.status = 'failed';
state.error = `auth process exited (code=${code ?? 'null'})`;
}
setTimeout(() => {
server.close(() => process.exit(1));
}, 3000);
});
process.on('SIGINT', () => {
console.log('\n[wa-qr-browser] aborting…');
child.kill('SIGTERM');
server.close(() => process.exit(130));
});
-131
View File
@@ -1,131 +0,0 @@
---
name: claw
description: Install the claw CLI tool — run NanoClaw agent containers from the command line without opening a chat app.
---
# claw — NanoClaw CLI
`claw` is a Python CLI that sends prompts directly to a NanoClaw agent container from the terminal. It reads registered groups from the NanoClaw database, picks up secrets from `.env`, and pipes a JSON payload into a container run — no chat app required.
## What it does
- Send a prompt to any registered group by name, folder, or JID
- Default target is the main group (no `-g` needed for most use)
- Resume a previous session with `-s <session-id>`
- Read prompts from stdin (`--pipe`) for scripting and piping
- List all registered groups with `--list-groups`
- Auto-detects `container` or `docker` runtime (or override with `--runtime`)
- Prints the agent's response to stdout; session ID to stderr
- Verbose mode (`-v`) shows the command, redacted payload, and exit code
## Prerequisites
- Python 3.8 or later
- NanoClaw installed with a built and tagged container image (`nanoclaw-agent:latest`)
- Either `container` (Apple Container, macOS 15+) or `docker` available in `PATH`
## Install
Run this skill from within the NanoClaw directory. The script auto-detects its location, so the symlink always points to the right place.
### 1. Copy the script
```bash
mkdir -p scripts
cp "${CLAUDE_SKILL_DIR}/scripts/claw" scripts/claw
chmod +x scripts/claw
```
### 2. Symlink into PATH
```bash
mkdir -p ~/bin
ln -sf "$(pwd)/scripts/claw" ~/bin/claw
```
Make sure `~/bin` is in `PATH`. Add this to `~/.zshrc` or `~/.bashrc` if needed:
```bash
export PATH="$HOME/bin:$PATH"
```
Then reload the shell:
```bash
source ~/.zshrc # or ~/.bashrc
```
### 3. Verify
```bash
claw --list-groups
```
You should see registered groups. If NanoClaw isn't running or the database doesn't exist yet, the list will be empty — that's fine.
## Usage Examples
```bash
# Send a prompt to the main group
claw "What's on my calendar today?"
# Send to a specific group by name (fuzzy match)
claw -g "family" "Remind everyone about dinner at 7"
# Send to a group by exact JID
claw -j "120363336345536173@g.us" "Hello"
# Resume a previous session
claw -s abc123 "Continue where we left off"
# Read prompt from stdin
echo "Summarize this" | claw --pipe -g dev
# Pipe a file
cat report.txt | claw --pipe "Summarize this report"
# List all registered groups
claw --list-groups
# Force a specific runtime
claw --runtime docker "Hello"
# Use a custom image tag (e.g. after rebuilding with a new tag)
claw --image nanoclaw-agent:dev "Hello"
# Verbose mode (debug info, secrets redacted)
claw -v "Hello"
# Custom timeout for long-running tasks
claw --timeout 600 "Run the full analysis"
```
## Troubleshooting
### "neither 'container' nor 'docker' found"
Install Docker Desktop or Apple Container (macOS 15+), or pass `--runtime` explicitly.
### "no secrets found in .env"
The script auto-detects your NanoClaw directory and reads `.env` from it. Check that the file exists and contains at least one of: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`.
### Container times out
The default timeout is 300 seconds. For longer tasks, pass `--timeout 600` (or higher). If the container consistently hangs, check that your `nanoclaw-agent:latest` image is up to date by running `./container/build.sh`.
### "group not found"
Run `claw --list-groups` to see what's registered. Group lookup does a fuzzy partial match on name and folder — if your query matches multiple groups, you'll get an error listing the ambiguous matches.
### Container crashes mid-stream
Containers run with `--rm` so they are automatically removed. If the agent crashes before emitting the output sentinel, `claw` falls back to printing raw stdout. Use `-v` to see what the container produced. Rebuild the image with `./container/build.sh` if crashes are consistent.
### Override the NanoClaw directory
If `claw` can't find your database or `.env`, set the `NANOCLAW_DIR` environment variable:
```bash
export NANOCLAW_DIR=/path/to/your/nanoclaw
```
-374
View File
@@ -1,374 +0,0 @@
#!/usr/bin/env python3
"""
claw — NanoClaw CLI
Run a NanoClaw agent container from the command line.
Usage:
claw "What is 2+2?"
claw -g <channel_name> "Review this code"
claw -g "<channel name with spaces>" "What's the latest issue?"
claw -j "<chatJid>" "Hello"
claw -g <channel_name> -s <session-id> "Continue"
claw --list-groups
echo "prompt text" | claw --pipe -g <channel_name>
cat prompt.txt | claw --pipe
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
import threading
from pathlib import Path
# ── Globals ─────────────────────────────────────────────────────────────────
VERBOSE = False
def dbg(*args):
if VERBOSE:
print("»", *args, file=sys.stderr)
# ── Config ──────────────────────────────────────────────────────────────────
def _find_nanoclaw_dir() -> Path:
"""Locate the NanoClaw installation directory.
Resolution order:
1. NANOCLAW_DIR env var
2. The directory containing this script (if it looks like a NanoClaw install)
3. ~/src/nanoclaw (legacy default)
"""
if env := os.environ.get("NANOCLAW_DIR"):
return Path(env).expanduser()
# If this script lives inside the NanoClaw tree (e.g. scripts/claw), walk up
here = Path(__file__).resolve()
for parent in [here.parent, here.parent.parent]:
if (parent / "store" / "messages.db").exists() or (parent / ".env").exists():
return parent
return Path.home() / "src" / "nanoclaw"
NANOCLAW_DIR = _find_nanoclaw_dir()
DB_PATH = NANOCLAW_DIR / "store" / "messages.db"
ENV_FILE = NANOCLAW_DIR / ".env"
IMAGE = "nanoclaw-agent:latest"
SECRET_KEYS = [
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"OLLAMA_HOST",
]
# ── Helpers ──────────────────────────────────────────────────────────────────
def detect_runtime(preference: str | None) -> str:
if preference:
dbg(f"runtime: forced to {preference}")
return preference
for rt in ("container", "docker"):
result = subprocess.run(["which", rt], capture_output=True)
if result.returncode == 0:
dbg(f"runtime: auto-detected {rt} at {result.stdout.decode().strip()}")
return rt
sys.exit("error: neither 'container' nor 'docker' found. Install one or pass --runtime.")
def read_secrets(env_file: Path) -> dict:
secrets = {}
if not env_file.exists():
return secrets
for line in env_file.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
key = key.strip()
if key in SECRET_KEYS:
secrets[key] = val.strip()
return secrets
def get_groups(db: Path) -> list[dict]:
conn = sqlite3.connect(db)
rows = conn.execute(
"SELECT jid, name, folder, is_main FROM registered_groups ORDER BY name"
).fetchall()
conn.close()
return [{"jid": r[0], "name": r[1], "folder": r[2], "is_main": bool(r[3])} for r in rows]
def find_group(groups: list[dict], query: str) -> dict | None:
q = query.lower()
# Exact name match
for g in groups:
if g["name"].lower() == q or g["folder"].lower() == q:
return g
# Partial match
matches = [g for g in groups if q in g["name"].lower() or q in g["folder"].lower()]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
names = ", ".join(f'"{g["name"]}"' for g in matches)
sys.exit(f"error: ambiguous group '{query}'. Matches: {names}")
return None
def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]:
"""Return list of (host_path, container_path, readonly) tuples."""
groups_dir = NANOCLAW_DIR / "groups"
data_dir = NANOCLAW_DIR / "data"
sessions_dir = data_dir / "sessions" / folder
ipc_dir = data_dir / "ipc" / folder
# Ensure required dirs exist
group_dir = groups_dir / folder
group_dir.mkdir(parents=True, exist_ok=True)
(sessions_dir / ".claude").mkdir(parents=True, exist_ok=True)
for sub in ("messages", "tasks", "input"):
(ipc_dir / sub).mkdir(parents=True, exist_ok=True)
agent_runner_src = sessions_dir / "agent-runner-src"
project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src"
if not agent_runner_src.exists() and project_agent_runner.exists():
import shutil
shutil.copytree(project_agent_runner, agent_runner_src)
mounts: list[tuple[str, str, bool]] = []
if is_main:
mounts.append((str(NANOCLAW_DIR), "/workspace/project", True))
mounts.append((str(group_dir), "/workspace/group", False))
mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False))
mounts.append((str(ipc_dir), "/workspace/ipc", False))
if agent_runner_src.exists():
mounts.append((str(agent_runner_src), "/app/src", False))
return mounts
def run_container(runtime: str, image: str, payload: dict,
folder: str | None = None, is_main: bool = False,
timeout: int = 300) -> None:
cmd = [runtime, "run", "-i", "--rm"]
if folder:
for host, container, readonly in build_mounts(folder, is_main):
if readonly:
cmd += ["--mount", f"type=bind,source={host},target={container},readonly"]
else:
cmd += ["-v", f"{host}:{container}"]
cmd.append(image)
dbg(f"cmd: {' '.join(cmd)}")
# Show payload sans secrets
if VERBOSE:
safe = {k: v for k, v in payload.items() if k != "secrets"}
safe["secrets"] = {k: "***" for k in payload.get("secrets", {})}
dbg(f"payload: {json.dumps(safe, indent=2)}")
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
dbg(f"container pid: {proc.pid}")
# Write JSON payload and close stdin
proc.stdin.write(json.dumps(payload).encode())
proc.stdin.close()
dbg("stdin closed, waiting for response...")
stdout_lines: list[str] = []
stderr_lines: list[str] = []
done = threading.Event()
def stream_stderr():
for raw in proc.stderr:
line = raw.decode(errors="replace").rstrip()
if line.startswith("npm notice"):
continue
stderr_lines.append(line)
print(line, file=sys.stderr)
def stream_stdout():
for raw in proc.stdout:
line = raw.decode(errors="replace").rstrip()
stdout_lines.append(line)
dbg(f"stdout: {line}")
# Kill the container as soon as we see the closing sentinel —
# the Node.js event loop often keeps the process alive indefinitely.
if line.strip() == "---NANOCLAW_OUTPUT_END---":
dbg("output sentinel found, terminating container")
done.set()
try:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
dbg("graceful stop timed out, force killing container")
proc.kill()
except ProcessLookupError:
pass
return
t_err = threading.Thread(target=stream_stderr, daemon=True)
t_out = threading.Thread(target=stream_stdout, daemon=True)
t_err.start()
t_out.start()
# Wait for sentinel or timeout
if not done.wait(timeout=timeout):
# Also check if process exited naturally
t_out.join(timeout=2)
if not done.is_set():
proc.kill()
sys.exit(f"error: container timed out after {timeout}s (no output sentinel received)")
t_err.join(timeout=5)
t_out.join(timeout=5)
proc.wait()
dbg(f"container done (rc={proc.returncode}), {len(stdout_lines)} stdout lines")
stdout = "\n".join(stdout_lines)
# Parse output block
match = re.search(
r"---NANOCLAW_OUTPUT_START---\n(.*?)\n---NANOCLAW_OUTPUT_END---",
stdout,
re.DOTALL,
)
success = False
if match:
try:
data = json.loads(match.group(1))
status = data.get("status", "unknown")
if status == "success":
print(data.get("result", ""))
session_id = data.get("newSessionId") or data.get("sessionId")
if session_id:
print(f"\n[session: {session_id}]", file=sys.stderr)
success = True
else:
print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print(match.group(1))
else:
# No structured output — print raw stdout
print(stdout)
if success:
return
if proc.returncode not in (0, None):
sys.exit(proc.returncode)
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
prog="claw",
description="Run a NanoClaw agent from the command line.",
)
parser.add_argument("prompt", nargs="?", help="Prompt to send")
parser.add_argument("-g", "--group", help="Group name or folder (fuzzy match)")
parser.add_argument("-j", "--jid", help="Chat JID (exact)")
parser.add_argument("-s", "--session", help="Session ID to resume")
parser.add_argument("-p", "--pipe", action="store_true",
help="Read prompt from stdin (can be combined with a prompt arg as prefix)")
parser.add_argument("--runtime", choices=["docker", "container"],
help="Container runtime (default: auto-detect)")
parser.add_argument("--image", default=IMAGE, help=f"Container image (default: {IMAGE})")
parser.add_argument("--list-groups", action="store_true", help="List registered groups and exit")
parser.add_argument("--raw", action="store_true", help="Print raw JSON output")
parser.add_argument("--timeout", type=int, default=300, metavar="SECS",
help="Max seconds to wait for a response (default: 300)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug info: cmd, payload (secrets redacted), stdout lines, exit code")
args = parser.parse_args()
global VERBOSE
VERBOSE = args.verbose
groups = get_groups(DB_PATH) if DB_PATH.exists() else []
if args.list_groups:
print(f"{'NAME':<35} {'FOLDER':<30} {'JID'}")
print("-" * 100)
for g in groups:
main_tag = " [main]" if g["is_main"] else ""
print(f"{g['name']:<35} {g['folder']:<30} {g['jid']}{main_tag}")
return
# Resolve prompt: --pipe reads stdin, optionally prepended with positional arg
if args.pipe or (not sys.stdin.isatty() and not args.prompt):
stdin_text = sys.stdin.read().strip()
if args.prompt:
prompt = f"{args.prompt}\n\n{stdin_text}"
else:
prompt = stdin_text
else:
prompt = args.prompt
if not prompt:
parser.print_help()
sys.exit(1)
# Resolve group → jid
jid = args.jid
group_name = None
group_folder = None
is_main = False
if args.group:
g = find_group(groups, args.group)
if g is None:
sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
jid = g["jid"]
group_name = g["name"]
group_folder = g["folder"]
is_main = g["is_main"]
elif not jid:
# Default: main group
mains = [g for g in groups if g["is_main"]]
if mains:
jid = mains[0]["jid"]
group_name = mains[0]["name"]
group_folder = mains[0]["folder"]
is_main = True
else:
sys.exit("error: no group specified and no main group found. Use -g or -j.")
runtime = detect_runtime(args.runtime)
secrets = read_secrets(ENV_FILE)
if not secrets:
print("warning: no secrets found in .env — agent may not be authenticated", file=sys.stderr)
payload: dict = {
"prompt": prompt,
"chatJid": jid,
"isMain": is_main,
"secrets": secrets,
}
if group_name:
payload["groupFolder"] = group_name
if args.session:
payload["sessionId"] = args.session
payload["resumeAt"] = "latest"
print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr)
run_container(runtime, args.image, payload,
folder=group_folder, is_main=is_main,
timeout=args.timeout)
if __name__ == "__main__":
main()
@@ -1,215 +0,0 @@
---
name: convert-to-apple-container
description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container".
---
# Convert to Apple Container
This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification.
**What this changes:**
- Container runtime binary: `docker``container`
- Mount syntax: `-v path:path:ro``--mount type=bind,source=...,target=...,readonly`
- Startup check: `docker info``container system status` (with auto-start)
- Orphan detection: `docker ps --filter``container ls --format json`
- Build script default: `docker``container`
- Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay)
- Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv`
**What stays the same:**
- Mount security/allowlist validation
- All exported interfaces and IPC protocol
- Non-main container behavior (still uses `--user` flag)
- All other functionality
## Prerequisites
Verify Apple Container is installed:
```bash
container --version && echo "Apple Container ready" || echo "Install Apple Container first"
```
If not installed:
- Download from https://github.com/apple/container/releases
- Install the `.pkg` file
- Verify: `container --version`
Apple Container requires macOS. It does not work on Linux.
## Phase 1: Pre-flight
### Check if already applied
```bash
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
```
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 4.
## Phase 2: Apply Code Changes
### Ensure upstream remote
```bash
git remote -v
```
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/apple-container
git merge upstream/skill/apple-container
```
This merges in:
- `src/container-runtime.ts` — Apple Container implementation (replaces Docker)
- `src/container-runtime.test.ts` — Apple Container-specific tests
- `src/container-runner.ts` — .env shadow mount fix and privilege dropping
- `container/Dockerfile` — entrypoint that shadows .env via `mount --bind`
- `container/build.sh` — default runtime set to `container`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
pnpm test
pnpm run build
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Credential proxy network binding
Apple Container uses a bridge network (bridge100) that only exists while containers are running. The credential proxy must start before any container, so it cannot bind to the bridge IP. It must bind to `0.0.0.0`, which exposes port 3001 on all network interfaces — anyone on your local network could route API requests through the proxy using your credentials.
Use AskUserQuestion to ask the user:
**"The credential proxy needs to bind to all interfaces (0.0.0.0). Is this Mac on a trusted private network?"**
Options:
1. **Yes, private/home network** — description: "No firewall rule needed."
2. **No, shared/public network** — description: "Add a macOS firewall rule to block external access to port 3001."
For both options, add `CREDENTIAL_PROXY_HOST=0.0.0.0` to `.env`:
```bash
grep -q 'CREDENTIAL_PROXY_HOST' .env 2>/dev/null || echo 'CREDENTIAL_PROXY_HOST=0.0.0.0' >> .env
```
If they chose the public network option, set up and persist the firewall rule:
```bash
echo "block in on en0 proto tcp to any port 3001" | sudo pfctl -ef -
```
```bash
grep -q 'nanoclaw proxy' /etc/pf.conf 2>/dev/null || echo '# nanoclaw proxy — block LAN access to credential proxy
block in on en0 proto tcp to any port 3001' | sudo tee -a /etc/pf.conf > /dev/null
```
Verify the rule is working:
```bash
curl -sf http://$(ipconfig getifaddr en0):3001 && echo "EXPOSED — rule not working" || echo "BLOCKED — rule active"
```
If the verification shows "EXPOSED", warn the user and retry. If "BLOCKED", confirm success and continue.
## Phase 4: Verify
### Ensure Apple Container runtime is running
```bash
container system status || container system start
```
### Build the container image
```bash
./container/build.sh
```
### Test basic execution
```bash
echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```
### Test readonly mounts
```bash
mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt
container run --rm --entrypoint /bin/bash \
--mount type=bind,source=/tmp/test-ro,target=/test,readonly \
nanoclaw-agent:latest \
-c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'"
rm -rf /tmp/test-ro
```
Expected: Read succeeds, write fails with "Read-only file system".
### Test read-write mounts
```bash
mkdir -p /tmp/test-rw
container run --rm --entrypoint /bin/bash \
-v /tmp/test-rw:/test \
nanoclaw-agent:latest \
-c "echo 'test write' > /test/new.txt && cat /test/new.txt"
cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw
```
Expected: Both operations succeed.
### Full integration test
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
Send a message via WhatsApp and verify the agent responds.
## Troubleshooting
**Apple Container not found:**
- Download from https://github.com/apple/container/releases
- Install the `.pkg` file
- Verify: `container --version`
**Runtime won't start:**
```bash
container system start
container system status
```
**Image build fails:**
```bash
# Clean rebuild — Apple Container caches aggressively
container builder stop && container builder rm && container builder start
./container/build.sh
```
**Container can't write to mounted directories:**
Check directory permissions on the host. The container runs as uid 1000.
## Summary of Changed Files
| File | Type of Change |
|------|----------------|
| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API |
| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior |
| `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop |
| `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop |
| `container/build.sh` | Default runtime: `docker``container` |
+55 -43
View File
@@ -9,82 +9,94 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion
## Workflow
1. **Understand the request** - Ask clarifying questions
3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually.
4. **Implement** - Make changes directly to the code
5. **Test guidance** - Tell user how to verify
1. **Understand the request** Ask clarifying questions.
2. **Prefer a dedicated skill** — If a skill covers the request, invoke it instead of editing core by hand:
- Channels: `/add-telegram`, `/add-slack`, `/add-discord`, `/add-whatsapp`, `/add-signal`, `/add-imessage`, and the rest of the `/add-<channel>` family.
- Wiring channels to agents and isolation levels: `/manage-channels`.
- Container directory access: `/manage-mounts`.
- Agent providers (non-default): `/add-opencode`, `/add-codex`, `/add-ollama-provider`.
- Integrations as MCP tools: `/add-gmail-tool`, `/add-gcal-tool`, `/add-ollama-tool`, etc.
3. **Plan the changes** — Identify the v2 surface the change belongs to (entity model in the central DB, per-agent-group container config, per-group `CLAUDE.md`, or core code).
4. **Implement** — Make the change on the right surface.
5. **Test guidance** — Tell the user how to verify.
## Entity Model
Customizations route through the v2 entity model: users → messaging groups → agent groups → sessions. A messaging group is one chat/channel on one platform; an agent group holds the workspace, personality, and container config; a wiring links a messaging group to an agent group with a session mode and trigger rules. Inspect and edit all of this with the `ncl` admin CLI. See `docs/isolation-model.md` for the three isolation levels.
## Key Files
| File | Purpose |
|------|---------|
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
| `src/ipc.ts` | IPC watcher and task processing |
| `src/router.ts` | Message formatting and outbound routing |
| `src/types.ts` | TypeScript interfaces (includes Channel) |
| `src/config.ts` | Assistant name, trigger pattern, directories |
| `src/db.ts` | Database initialization and queries |
| `src/whatsapp-auth.ts` | Standalone WhatsApp authentication script |
| `groups/CLAUDE.md` | Global memory/persona |
| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake |
| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions |
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; heartbeat path |
| `src/container-runner.ts` | Spawns per-agent-group containers with session DB + outbox mounts, OneCLI `ensureAgent` |
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific adapters install from the `channels` branch |
| `src/config.ts` | Process-level config (assistant name, paths, timeouts) read from `.env` |
| `data/v2.db` | Central DB: users, roles, agent_groups, messaging_groups, wirings, container_configs |
| `data/v2-sessions/<session>/` | Per-session `inbound.db` (host→container) + `outbound.db` (container→host) |
| `groups/<folder>/CLAUDE.md` | Per-agent-group memory/persona and instructions |
For ad-hoc DB queries, use `pnpm exec tsx scripts/q.ts <db> "<sql>"`.
## Common Customization Patterns
### Adding a New Input Channel (e.g., Telegram, Slack, Email)
Questions to ask:
- Which channel? (Telegram, Slack, Discord, email, SMS, etc.)
- Same trigger word or different?
- Same memory hierarchy or separate?
- Should messages from this channel go to existing groups or new ones?
- Which channel? (Telegram, Slack, Discord, WhatsApp, Signal, email, etc.)
- Should this channel reach an existing agent group or a new one?
- What isolation level — share an agent group with other channels, or keep it separate?
- Same trigger rules as other channels on that agent group, or different?
Implementation pattern:
1. Create `src/channels/{name}.ts` implementing the `Channel` interface from `src/types.ts` (see `src/channels/whatsapp.ts` for reference)
2. Add the channel instance to `main()` in `src/index.ts` and wire callbacks (`onMessage`, `onChatMetadata`)
3. Messages are stored via the `onMessage` callback; routing is automatic via `ownsJid()`
Implementation:
1. Run the matching install skill (`/add-telegram`, `/add-slack`, …). It fetches the adapter from the `channels` branch, wires the registration import, installs the pinned package, and builds.
2. Run `/manage-channels` (or use `ncl messaging-groups` + `ncl wirings`) to create the messaging group, choose the isolation level, and wire it to an agent group with a session mode and trigger rules.
### Adding a New MCP Integration
Questions to ask:
- What service? (Calendar, Notion, database, etc.)
- What operations needed? (read, write, both)
- Which groups should have access?
- What operations are needed? (read, write, both)
- Which agent group should have access?
Implementation:
1. Add MCP server config to the container settings (see `src/container-runner.ts` for how MCP servers are mounted)
2. Document available tools in `groups/CLAUDE.md`
- If an `/add-<service>-tool` skill exists (e.g. `/add-gmail-tool`, `/add-gcal-tool`), run it — it wires the MCP server and routes credentials through OneCLI so no raw keys reach the container.
- Otherwise wire the MCP server into the agent group's container config: `ncl groups config add-mcp-server --id <group-id> --name <name> --command <cmd> [--args <json-array>] [--env <json-object>]`, then `ncl groups restart --id <group-id>` to take effect. From inside a container the agent uses the `add_mcp_server` self-mod tool, which requires one admin approval.
### Changing Assistant Behavior
Questions to ask:
- What aspect? (name, trigger, persona, response style)
- Apply to all groups or specific ones?
- What aspect? (persona, response style, instructions)
- Apply to one agent group or several?
Simple changes → edit `src/config.ts`
Persona changes → edit `groups/CLAUDE.md`
Per-group behavior → edit specific group's `CLAUDE.md`
Implementation:
- Persona, instructions, and personality live per agent group in `groups/<folder>/CLAUDE.md` — edit that file for the target group.
- Container runtime behavior (provider, model, packages, MCP servers) lives in the `container_configs` table: `ncl groups config get/update --id <group-id>`.
### Adding New Commands
Questions to ask:
- What should the command do?
- Available in all groups or main only?
- Which agent group(s)?
- Does it need new MCP tools?
Implementation:
1. Commands are handled by the agent naturally — add instructions to `groups/CLAUDE.md` or the group's `CLAUDE.md`
2. For trigger-level routing changes, modify `processGroupMessages()` in `src/index.ts`
- The agent interprets requests naturally — add instructions to the agent group's `groups/<folder>/CLAUDE.md`.
- For routing or trigger changes (which messages wake which agent group), update the wiring's trigger rules: `ncl wirings update --id <wiring-id> ...`.
### Changing Deployment
Questions to ask:
- Target platform? (Linux server, Docker, different Mac)
- Service manager? (systemd, Docker, supervisord)
- Target platform? (Linux server, different Mac)
- Service manager? (launchd, systemd)
Implementation:
1. Create appropriate service files
2. Update paths in config
3. Provide setup instructions
1. Create the appropriate service files.
2. Update paths in `.env` / config.
3. Provide setup instructions.
## After Changes
@@ -107,8 +119,8 @@ launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
User: "Add Telegram as an input channel"
1. Ask: "Should Telegram use the same @Andy trigger, or a different one?"
2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?"
3. Create `src/channels/telegram.ts` implementing the `Channel` interface (see `src/channels/whatsapp.ts`)
4. Add the channel to `main()` in `src/index.ts`
5. Tell user how to authenticate and test
1. Run `/add-telegram` to install the adapter, wire its registration, and build.
2. Ask: "Should Telegram reach an existing agent group, or a new one?"
3. Ask: "Share an agent group with your other channels, or keep Telegram separate?"
4. Run `/manage-channels` (or `ncl messaging-groups create` + `ncl wirings create`) to create the messaging group and wire it to the chosen agent group with a session mode and trigger rules.
5. Tell the user how to authenticate and test.
+149 -244
View File
@@ -1,6 +1,6 @@
---
name: debug
description: Debug container agent issues. Use when things aren't working, container fails, authentication problems, or to understand how the container system works. Covers logs, environment variables, mounts, and common issues.
description: Debug container agent issues. Use when things aren't working, container fails, authentication problems, or to understand how the container system works. Covers logs, session DBs, mounts, and common issues.
---
# NanoClaw Container Debugging
@@ -9,35 +9,45 @@ This guide covers debugging the containerized agent execution system.
## Architecture Overview
The host is a single Node process that orchestrates per-session agent containers. The two session DBs are the **sole** IO surface between host and container — there is no IPC, no file watcher, and no stdin piping.
```
Host (macOS) Container (Linux VM)
─────────────────────────────────────────────────────────────
src/container-runner.ts container/agent-runner/
│ │
│ spawns container runs Claude Agent SDK
│ with volume mounts │ with MCP servers
├── data/env/env ──────────────> /workspace/env-dir/env
├── groups/{folder} ───────────> /workspace/group
├── data/ipc/{folder} ────────> /workspace/ipc
├── data/sessions/{folder}/.claude/ ──> /home/node/.claude/ (isolated per-group)
└── (main only) project root ──> /workspace/project
Host (Node) Container (Bun, Linux VM)
──────────────────────────────────────────────────────────────────────
src/container-runner.ts container/agent-runner/src/
│ spawns one container per sessionpolls inbound.db for work,
│ with the session folder mounted │ calls the agent provider,
at /workspace │ writes replies to outbound.db
│ │
├── data/v2-sessions/<group>/<session>/ ──> /workspace
├── inbound.db (host writes, container reads RO)
├── outbound.db (container writes, host reads)
└── .heartbeat (container touches → /workspace/.heartbeat)
├── groups/<folder> ─────────────────────> /workspace/agent (cwd)
├── <group>/.claude-shared ──────────────> /home/node/.claude
└── agent-runner src + skills ───────────> /app/src, /app/skills
```
**Important:** The container runs as user `node` with `HOME=/home/node`. Session files must be mounted to `/home/node/.claude/` (not `/root/.claude/`) for session resumption to work.
**Message flow:** host writes a row to `inbound.db` (`messages_in`) and wakes the container; the container's poll loop picks it up, runs the agent, and writes the reply to `outbound.db` (`messages_out`); the host's delivery poll reads `messages_out` and sends it through the channel adapter. See [docs/db.md](../../../docs/db.md) and [docs/db-session.md](../../../docs/db-session.md) for the full two-DB model.
**Container identity:** the container runs as user `node` with `HOME=/home/node`. Per-group Claude state (settings, session history) lives in `<group>/.claude-shared` on the host, mounted to `/home/node/.claude`.
## Log Locations
| Log | Location | Content |
|-----|----------|---------|
| **Main app logs** | `logs/nanoclaw.log` | Host-side WhatsApp, routing, container spawning |
| **Main app errors** | `logs/nanoclaw.error.log` | Host-side errors |
| **Container run logs** | `groups/{folder}/logs/container-*.log` | Per-run: input, mounts, stderr, stdout |
| **Claude sessions** | `~/.claude/projects/` | Claude Code session history |
| **Host errors** | `logs/nanoclaw.error.log` | Delivery failures, crash-loop backoff, warnings — check this first |
| **Host app log** | `logs/nanoclaw.log` | Full routing chain: inbound routing, container spawn/exit, delivery |
| **Setup logs** | `logs/setup.log`, `logs/setup-steps/*.log` | Per-step install output (bootstrap, container, onecli, mounts, service) |
| **Session inbound** | `data/v2-sessions/<group>/<session>/inbound.db` (`messages_in`) | Did the message reach the container? |
| **Session outbound** | `data/v2-sessions/<group>/<session>/outbound.db` (`messages_out`) | Did the agent produce a reply? |
Containers run with `--rm`, so the container's own filesystem is gone after it exits. The host streams container **stderr** into `logs/nanoclaw.log` at debug level, tagged with `container=<group folder>`; raise the log level (below) to see it. If the agent silently failed inside an exited container, there is no persistent in-container log — reconstruct from the session DBs and the host log.
## Enabling Debug Logging
Set `LOG_LEVEL=debug` for verbose output:
Set `LOG_LEVEL=debug` for verbose output, including streamed container stderr:
```bash
# For development
@@ -50,32 +60,58 @@ LOG_LEVEL=debug pnpm run dev
# Environment=LOG_LEVEL=debug
```
Debug level shows:
- Full mount configurations
- Container command arguments
- Real-time container stderr
Debug level shows full mount configurations, the container spawn command, and streamed container stderr lines.
## Inspecting Session DBs
The two session DBs are where the message flow lives. Use the in-tree query wrapper (it goes through the `better-sqlite3` dep that setup already installs, avoiding a dependency on the `sqlite3` CLI):
```bash
# List sessions and their agent group / messaging group from the central DB
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, agent_group_id, messaging_group_id, status, container_status, last_active FROM sessions"
# Or via the admin CLI
ncl sessions list
# Did the message reach the container? (inbound.db, host writes / container reads)
pnpm exec tsx scripts/q.ts data/v2-sessions/<group>/<session>/inbound.db \
"SELECT seq, kind, status, timestamp FROM messages_in ORDER BY seq DESC LIMIT 10"
# Did the agent produce a reply? (outbound.db, container writes / host reads)
pnpm exec tsx scripts/q.ts data/v2-sessions/<group>/<session>/outbound.db \
"SELECT seq, kind, timestamp FROM messages_out ORDER BY seq DESC LIMIT 10"
# Container-side processing status for each inbound message
pnpm exec tsx scripts/q.ts data/v2-sessions/<group>/<session>/outbound.db \
"SELECT message_id, status, status_changed FROM processing_ack ORDER BY status_changed DESC LIMIT 10"
```
Reading the flow:
- `messages_in` has the message but no matching `messages_out` → the container never produced a reply (check `processing_ack`, then `logs/nanoclaw.log` for spawn/exit and container stderr).
- `messages_out` has a reply but the user never received it → a delivery problem (see issue 1 below).
- `messages_in` is empty → routing never reached this session (check the router log lines and the central wiring with `ncl wirings list`).
## Common Issues
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
### 1. "No adapter for channel type" / Messages silently lost (null platform_message_id)
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
```
WARN No adapter for channel type channelType="telegram"
WARN No adapter for channel type channelType="signal"
```
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and marked the message delivered without sending it.
**Root cause: two NanoClaw service instances running simultaneously.**
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
When a second service instance is active with a stale binary, it has no channel adapters registered. Its delivery poll races the working instance and wins — marking outbound messages delivered without ever sending them.
**Diagnosis:**
```bash
# Check for duplicate running instances
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
# Check which services are active
# Check which services are active (Linux)
systemctl --user list-units 'nanoclaw*' --all
# Confirm channel adapters registered by the current process
@@ -83,7 +119,7 @@ grep "Channel adapter started" logs/nanoclaw.log | tail -10
```
**Fix:**
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
1. Identify which service has the correct binary and EnvironmentFile (the one whose log shows the expected channels — e.g. `signal`, `telegram`, `cli` all started).
2. Stop and disable the stale duplicate service:
```bash
systemctl --user stop nanoclaw.service # or whichever is the old one
@@ -98,295 +134,164 @@ grep "Channel adapter started" logs/nanoclaw.log | tail -10
```
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
Messages marked delivered with a null `platform_message_id` are not automatically retried. Ask the user to resend.
### 2. "Claude Code process exited with code 1"
### 2. Container exits immediately / agent produces no reply
**Check the container log file** in `groups/{folder}/logs/container-*.log`
A spawned container that exits without writing to `outbound.db` shows up in `logs/nanoclaw.log` as a `Container exited` line with a non-zero `code`, often preceded by streamed `container=<folder>` stderr (at debug level).
Common causes:
#### Missing Authentication
```
Invalid API key · Please run /login
```
**Fix:** Ensure `.env` file exists with either OAuth token or API key:
**Authentication errors:** secrets are injected per request by the OneCLI gateway — none are passed in env vars or chat context. A `401` from an API whose credential is in the vault usually means the agent is in `selective` secret mode and that secret was never assigned:
```bash
cat .env # Should show one of:
# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... (subscription)
# ANTHROPIC_API_KEY=sk-ant-api03-... (pay-per-use)
onecli agents list # check secretMode
onecli agents set-secret-mode --id <agent-id> --mode all # inject all matching secrets
```
If the gateway itself is unreachable, the container runner refuses to spawn (`OneCLI gateway not applied — refusing to spawn container without credentials` in the host log). Confirm the gateway is up at `http://127.0.0.1:10254`.
#### Root User Restriction
```
--dangerously-skip-permissions cannot be used with root/sudo privileges
```
**Fix:** Container must run as non-root user. Check Dockerfile has `USER node`.
### 2. Environment Variables Not Passing
**Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin).
**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed.
To verify env vars are reaching the container:
```bash
echo '{}' | docker run -i \
-v $(pwd)/data/env:/workspace/env-dir:ro \
--entrypoint /bin/bash nanoclaw-agent:latest \
-c 'export $(cat /workspace/env-dir/env | xargs); echo "OAuth: ${#CLAUDE_CODE_OAUTH_TOKEN} chars, API: ${#ANTHROPIC_API_KEY} chars"'
```
**MCP server failures:** a misconfigured MCP server can abort the agent run. Look for MCP initialization errors in the streamed container stderr (`LOG_LEVEL=debug`).
### 3. Mount Issues
**Container mount notes:**
- Docker supports both `-v` and `--mount` syntax
- Use `:ro` suffix for readonly mounts:
```bash
# Readonly
-v /path:/container/path:ro
Session and group folders are bind-mounted into the container. To see the resolved mounts for a spawn, run with `LOG_LEVEL=debug` and read the spawn command in `logs/nanoclaw.log`, or grep the mount targets directly:
# Read-write
-v /path:/container/path
```
To check what's mounted inside a container:
```bash
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/'
grep -n "containerPath" src/container-runner.ts
```
Expected structure:
Expected mount targets inside the container:
```
/workspace/
├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY)
├── group/ # Current group folder (cwd)
├── project/ # Project root (main channel only)
├── global/ # Global CLAUDE.md (non-main only)
├── ipc/ # Inter-process communication
│ ├── messages/ # Outgoing WhatsApp messages
│ ├── tasks/ # Scheduled task commands
│ ├── current_tasks.json # Read-only: scheduled tasks visible to this group
│ └── available_groups.json # Read-only: WhatsApp groups for activation (main only)
└── extra/ # Additional custom mounts
/workspace ← session folder (inbound.db, outbound.db, .heartbeat, inbox/, outbox/)
/workspace/agent ← agent group folder (cwd; CLAUDE.md, skills, working files)
/home/node/.claude ← per-group .claude-shared (Claude state, settings, history)
/app/src ← agent-runner source (read-only)
/app/skills ← container skills (read-only)
```
### 4. Permission Issues
The container runs as user `node` (uid 1000). Check ownership:
To inspect what a fresh container sees:
```bash
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
whoami
ls -la /workspace/
ls -la /app/
'
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'whoami; ls -la /workspace/ /app/'
```
All of `/workspace/` and `/app/` should be owned by `node`. Use `:ro` on a `-v` mount for read-only.
All of `/workspace/` and `/app/` should be owned by `node`.
### 4. Heartbeat / stale-session detection
### 5. Session Not Resuming / "Claude Code process exited with code 1"
Liveness is a file `touch` on `/workspace/.heartbeat` (host path: `data/v2-sessions/<group>/<session>/.heartbeat`), not a DB write. The host sweep reads its mtime plus the `processing_ack` claim age to decide whether a container is alive or stale. A session stuck "processing" with a stale `.heartbeat` mtime means the container died mid-run:
If sessions aren't being resumed (new session ID every time), or Claude Code exits with code 1 when resuming:
**Root cause:** The SDK looks for sessions at `$HOME/.claude/projects/`. Inside the container, `HOME=/home/node`, so it looks at `/home/node/.claude/projects/`.
**Check the mount path:**
```bash
# In container-runner.ts, verify mount is to /home/node/.claude/, NOT /root/.claude/
grep -A3 "Claude sessions" src/container-runner.ts
stat -f '%Sm' data/v2-sessions/<group>/<session>/.heartbeat # macOS
stat -c '%y' data/v2-sessions/<group>/<session>/.heartbeat # Linux
```
**Verify sessions are accessible:**
## Container CLI (`ncl`) inside a session
The agent reaches the central DB from inside the container via `ncl`, which uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`). On the host, `ncl` connects over a Unix socket (`src/cli/socket-server.ts`). If `ncl` calls fail from inside a container, check the agent group's `cli_scope` in its container config:
```bash
docker run --rm --entrypoint /bin/bash \
-v ~/.claude:/home/node/.claude \
nanoclaw-agent:latest -c '
echo "HOME=$HOME"
ls -la $HOME/.claude/projects/ 2>&1 | head -5
'
ncl groups config get --id <group-id> # look at cli_scope: disabled | group | global
```
**Fix:** Ensure `container-runner.ts` mounts to `/home/node/.claude/`:
```typescript
mounts.push({
hostPath: claudeDir,
containerPath: '/home/node/.claude', // NOT /root/.claude
readonly: false
});
```
`disabled` rejects every `cli_request`; `group` scopes the agent to its own group's `groups`/`sessions`/`destinations`/`members`; `global` is unrestricted.
### 6. MCP Server Failures
## Restarting a session's container
If an MCP server fails to start, the agent may exit. Check the container logs for MCP initialization errors.
## Manual Container Testing
### Test the full agent flow:
```bash
# Set up env file
mkdir -p data/env groups/test
cp .env data/env/env
# Restart all containers for an agent group
ncl groups restart --id <group-id>
# Run test query
echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMain":false}' | \
docker run -i \
-v $(pwd)/data/env:/workspace/env-dir:ro \
-v $(pwd)/groups/test:/workspace/group \
-v $(pwd)/data/ipc:/workspace/ipc \
nanoclaw-agent:latest
# Restart and rebuild the image first (after package/Dockerfile changes)
ncl groups restart --id <group-id> --rebuild
# Restart and wake immediately with a message
ncl groups restart --id <group-id> --message "on_wake test"
```
### Test Claude Code directly:
```bash
docker run --rm --entrypoint /bin/bash \
-v $(pwd)/data/env:/workspace/env-dir:ro \
nanoclaw-agent:latest -c '
export $(cat /workspace/env-dir/env | xargs)
claude -p "Say hello" --dangerously-skip-permissions --allowedTools ""
'
```
Without `--message`, the container comes back on the next user message. From inside a container, `--id` is auto-filled and only the calling session restarts.
## Manual Container Probes
The container's entry point is `exec bun run /app/src/index.ts`; it talks only to the mounted session DBs, so there is no JSON to pipe in. To probe the image directly:
### Interactive shell in container:
```bash
# Interactive shell in the image
docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest
# Check the image contents
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
node --version
bun --version
ls /app/src/
'
```
## SDK Options Reference
## Provider SDK Options
The agent-runner uses these Claude Agent SDK options:
The default provider wraps the Claude Agent SDK in `container/agent-runner/src/providers/claude.ts`. The query is configured roughly as:
```typescript
query({
prompt: input.prompt,
options: {
cwd: '/workspace/group',
allowedTools: ['Bash', 'Read', 'Write', ...],
cwd: input.cwd, // /workspace/agent
allowedTools: [...TOOL_ALLOWLIST, ...mcpAllowPatterns],
disallowedTools: SDK_DISALLOWED_TOOLS,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true, // Required with bypassPermissions
settingSources: ['project'],
mcpServers: { ... }
}
settingSources: ['project', 'user', 'local'],
mcpServers: { ... },
},
})
```
**Important:** `allowDangerouslySkipPermissions: true` is required when using `permissionMode: 'bypassPermissions'`. Without it, Claude Code exits with code 1.
Each registered MCP server's allow pattern is derived from the `mcpServers` map, so registering a server already exposes its tools.
## Rebuilding After Changes
```bash
# Rebuild main app
# Rebuild host TypeScript
pnpm run build
# Rebuild container (use --no-cache for clean rebuild)
# Rebuild the agent container image
./container/build.sh
# Or force full rebuild
# Force a truly clean rebuild (the buildkit cache retains stale COPY files)
docker builder prune -af
./container/build.sh
```
## Checking Container Image
## Clearing a Session
Conversation continuity lives in the container-owned `session_state` table in `outbound.db` (the provider's session/continuation id). The agent's `/clear` clears it. To reset a session from the host, remove the session folder so a fresh one is provisioned on the next message:
```bash
# List images
docker images
# Inspect first
ncl sessions get <session-id>
# Check what's in the image
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
echo "=== Node version ==="
node --version
echo "=== Claude Code version ==="
claude --version
echo "=== Installed packages ==="
ls /app/node_modules/
'
# Remove a single session's folder (host re-provisions both DBs on next message)
rm -rf data/v2-sessions/<group>/<session>/
```
## Session Persistence
Claude sessions are stored per-group in `data/sessions/{group}/.claude/` for security isolation. Each group has its own session directory, preventing cross-group access to conversation history.
**Critical:** The mount path must match the container user's HOME directory:
- Container user: `node`
- Container HOME: `/home/node`
- Mount target: `/home/node/.claude/` (NOT `/root/.claude/`)
To clear sessions:
```bash
# Clear all sessions for all groups
rm -rf data/sessions/
# Clear sessions for a specific group
rm -rf data/sessions/{groupFolder}/.claude/
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
```
To verify session resumption is working, check the logs for the same session ID across messages:
```bash
grep "Session initialized" logs/nanoclaw.log | tail -5
# Should show the SAME session ID for consecutive messages in the same group
```
## IPC Debugging
The container communicates back to the host via files in `/workspace/ipc/`:
```bash
# Check pending messages
ls -la data/ipc/messages/
# Check pending task operations
ls -la data/ipc/tasks/
# Read a specific IPC file
cat data/ipc/messages/*.json
# Check available groups (main channel only)
cat data/ipc/main/available_groups.json
# Check current tasks snapshot
cat data/ipc/{groupFolder}/current_tasks.json
```
**IPC file types:**
- `messages/*.json` - Agent writes: outgoing WhatsApp messages
- `tasks/*.json` - Agent writes: task operations (schedule, pause, resume, cancel, refresh_groups)
- `current_tasks.json` - Host writes: read-only snapshot of scheduled tasks
- `available_groups.json` - Host writes: read-only list of WhatsApp groups (main only)
## Quick Diagnostic Script
Run this to check common issues:
```bash
echo "=== Checking NanoClaw Container Setup ==="
echo "=== Checking NanoClaw v2 Setup ==="
echo -e "\n1. Authentication configured?"
[ -f .env ] && (grep -q "CLAUDE_CODE_OAUTH_TOKEN=sk-" .env || grep -q "ANTHROPIC_API_KEY=sk-" .env) && echo "OK" || echo "MISSING - add CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY to .env"
echo -e "\n2. Env file copied for container?"
[ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run"
echo -e "\n3. Container runtime running?"
echo -e "\n1. Container runtime running?"
docker info &>/dev/null && echo "OK" || echo "NOT RUNNING - start Docker Desktop (macOS) or sudo systemctl start docker (Linux)"
echo -e "\n4. Container image exists?"
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh"
echo -e "\n2. Agent image exists?"
docker run --rm --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh"
echo -e "\n5. Session mount path correct?"
grep -q "/home/node/.claude" src/container-runner.ts 2>/dev/null && echo "OK" || echo "WRONG - should mount to /home/node/.claude/, not /root/.claude/"
echo -e "\n3. OneCLI gateway reachable?"
curl -fsS http://127.0.0.1:10254/ >/dev/null 2>&1 && echo "OK" || echo "CHECK - gateway not responding on 127.0.0.1:10254"
echo -e "\n6. Groups directory?"
ls -la groups/ 2>/dev/null || echo "MISSING - run setup"
echo -e "\n4. Central DB present?"
[ -f data/v2.db ] && echo "OK" || echo "MISSING - run setup"
echo -e "\n7. Recent container logs?"
ls -t groups/*/logs/container-*.log 2>/dev/null | head -3 || echo "No container logs yet"
echo -e "\n5. Mount targets in container-runner?"
grep -q "containerPath: '/workspace'" src/container-runner.ts && echo "OK" || echo "CHECK - session mount target changed"
echo -e "\n8. Session continuity working?"
SESSIONS=$(grep "Session initialized" logs/nanoclaw.log 2>/dev/null | tail -5 | awk '{print $NF}' | sort -u | wc -l)
[ "$SESSIONS" -le 2 ] && echo "OK (recent sessions reusing IDs)" || echo "CHECK - multiple different session IDs, may indicate resumption issues"
echo -e "\n6. Single host instance running?"
N=$(ps aux | grep 'nanoclaw/dist/index.js' | grep -vc grep)
[ "$N" -le 1 ] && echo "OK ($N)" || echo "DUPLICATE - $N instances; stop the stale one (see issue 1)"
echo -e "\n7. Recent host errors?"
tail -n 5 logs/nanoclaw.error.log 2>/dev/null || echo "No error log yet"
```
+2 -2
View File
@@ -103,9 +103,9 @@ Wait for the user's reply. If they confirm receipt, the skill is done.
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
- `ls data/v2-sessions/<agent-group-id>/*/outbound.db` — confirm the session exists.
## Troubleshooting
+4 -4
View File
@@ -98,13 +98,13 @@ for i in $(seq 1 15); do
done
```
If it never becomes healthy, check if the gateway process is running:
If it never becomes healthy, check the gateway containers. The gateway is a Docker Compose stack (project `onecli`, compose file at `~/.onecli/docker-compose.yml`). Inspect it through Docker rather than the host process list:
```bash
ps aux | grep -i onecli | grep -v grep
docker ps -a --filter "label=com.docker.compose.project=onecli" --format '{{.Names}}\t{{.Status}}'
```
If it's not running, try starting it manually: `onecli start`. If that fails, show the error and stop — the user needs to debug their OneCLI installation.
Both services have `restart: unless-stopped`, so they come back automatically once the Docker daemon is up. If Docker isn't running, start it (`open -a Docker` on macOS) and they'll restart on their own. To bring the stack up manually: `docker compose -f ~/.onecli/docker-compose.yml up -d`. If that fails, show the error and stop — the user needs to debug their OneCLI installation.
## Phase 3: Migrate existing credentials
@@ -299,7 +299,7 @@ If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claud
## Troubleshooting
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed.
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. The most common cause is that Docker itself is down (the gateway is a Compose stack) — start Docker (`open -a Docker` on macOS) and the containers restart automatically. To bring them up manually: `docker compose -f ~/.onecli/docker-compose.yml up -d`.
**Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`).
+4 -4
View File
@@ -7,7 +7,7 @@ description: Wire channels to agent groups, manage isolation levels, add new cha
Wire messaging channels to agent groups. See `docs/isolation-model.md` for the full isolation model.
Privilege is a **user-level** concept, not a channel-level one (see `src/db/user-roles.ts`, `src/access.ts`). There is no "main channel" / "main group" — any user can be granted `owner` or `admin` (global or scoped to an agent group) via `grantRole()`, and messages from unknown senders are gated per-messaging-group by `unknown_sender_policy` (`strict` | `request_approval` | `public`).
Privilege is a **user-level** concept, not a channel-level one (see `src/modules/permissions/db/user-roles.ts`, `src/modules/permissions/access.ts`). There is no "main channel" / "main group" — any user can be granted `owner` or `admin` (global or scoped to an agent group) via `grantRole()`, and messages from unknown senders are gated per-messaging-group by `unknown_sender_policy` (`strict` | `request_approval` | `public`).
## Assess Current State
@@ -65,7 +65,7 @@ pnpm exec tsx setup/index.ts --step register -- \
--assistant-name "<name>"
```
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name — no separate destination step needed.
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
For separate agents, also ask for a folder name and optionally a different assistant name.
@@ -73,7 +73,7 @@ For separate agents, also ask for a folder name and optionally a different assis
When adding another group/chat on an already-configured platform (e.g. a second Telegram group):
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the CODE (follow the `REMINDER_TO_ASSISTANT` line in the `PAIR_TELEGRAM_ISSUED` block) and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
```bash
pnpm exec tsx setup/index.ts --step register -- \
@@ -83,7 +83,7 @@ When adding another group/chat on an already-configured platform (e.g. a second
--assistant-name "<name>"
```
2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register. No package or credential changes needed.
2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register.
## Change Wiring
+7 -3
View File
@@ -24,19 +24,23 @@ Ask which directories the user wants agents to access. For each path:
Build the JSON config and write it:
```bash
npx tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
```
Use `--force` to overwrite the existing config.
## Remove Directories
Read the current config, show it, ask which entry to remove, write the updated config.
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
```
## Reset to Empty
```bash
npx tsx setup/index.ts --step mounts --force -- --empty
pnpm exec tsx setup/index.ts --step mounts --force -- --empty
```
## After Changes

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