Compare commits

...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #2457

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Doug Daniels <ddaniels888@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:48:10 +03:00
github-actions[bot] 3b07c0ceaf chore: bump version to 2.0.40 2026-05-07 21:35:08 +00:00
gavrielc 1a358dc7e3 test(a2a): add tests documenting A2A routing bugs (#2332)
Three tests that exercise agent-to-agent routing and document the broken
behavior that #2332 describes:

1. A2A outbound lands in target session — basic happy path, passes.

2. A2A return path resolves to wrong session when source agent has
   multiple channel sessions. Researcher responds to PA, but
   findSessionByAgentGroup picks PA's newest session (Discord) instead
   of the Slack session that originated the A2A call. Test asserts the
   buggy behavior (response in Discord, nothing in Slack).

3. A2A-only session gets null session_routing. writeSessionRouting on a
   session with messaging_group_id=NULL writes all nulls — the target
   agent has no default routing for replies. Test asserts the nulls.

These tests pass today by asserting the broken state. When #2332 is
fixed (origin-aware return routing), these assertions should flip to
the correct behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:34:43 +03:00
github-actions[bot] 7da08b3327 docs: update token count to 147k tokens · 74% of context window 2026-05-07 21:26:57 +00:00
gavrielc 684a98d078 test: add host-side routing and session resolution tests
Host-side (vitest):
- Routed message preserves platformId/channelType/threadId on messages_in
- Fan-out gives each agent correct per-agent routing
- writeSessionRouting populates session_routing from messaging group
- writeSessionRouting writes null routing for agent-shared sessions
- Per-thread session includes thread_id in session_routing
- Agent-shared resolves to same session on repeated calls
- Agent-shared session has null messaging_group_id
- findSessionByAgentGroup returns channel-bound session (documents #2332)
- Skip: agent-shared/channel-bound coexistence (blocked on #2332 fix)

Container-side (bun:test):
- Internal tags stripped between message blocks
- Mixed task + chat batch with correct routing

The agent-shared tests uncovered the exact bug from #2332:
findSessionByAgentGroup doesn't distinguish agent-shared from
channel-bound sessions, so A2A resolution reuses a channel session
when one exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:26:41 +03:00
github-actions[bot] e1251da394 chore: bump version to 2.0.39 2026-05-07 21:23:34 +00:00
github-actions[bot] eb6502a1b2 docs: update token count to 147k tokens · 73% of context window 2026-05-07 21:23:30 +00:00
gavrielc 3af6e70c05 test(agent-runner): add dispatch, origin metadata, and thread resolution tests
Add 14 tests covering key routing and dispatch flows that previously had
zero direct coverage:

dispatchResultText:
- bare text produces no outbound (scratchpad only)
- unknown destination dropped, valid destination sent
- multiple <message> blocks each produce correct outbound
- internal tags stripped from scratchpad

originAttr / from= metadata:
- chat/task/webhook/system messages include from= when destination matches
- fallback to raw unknown:channel:platform when no match
- from= omitted when routing is null

resolveDestinationThread:
- null thread_id when no prior inbound from destination
- most recent thread_id wins with multiple inbound messages

Also fix merge issue: restore getAllDestinations import removed by our PR
but still needed by #2327's compaction reminder. Fix stale destinations
test assertion from #2328 ("no special wrapping needed" → "Every response
must be wrapped").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:23:03 +03:00
gavrielc 8a7311a7bb Merge pull request #2324 from alipgoldberg/setup/claude-auth-skip
setup: add "Skip — I'll connect later" option to Claude auth picker
2026-05-08 00:12:29 +03:00
github-actions[bot] 61ab60041c chore: bump version to 2.0.38 2026-05-07 21:12:22 +00:00
github-actions[bot] ca17683e32 docs: update token count to 145k tokens · 72% of context window 2026-05-07 21:12:12 +00:00
gavrielc 6a56b10ffc Merge pull request #2335 from adjohn/fix/container-pin-pnpm-10
fix(container): pin pnpm to 10.33.0 to match host
2026-05-08 00:11:58 +03:00
gavrielc 2754f7559a Merge pull request #2320 from ira-at-work/feat/skill-docs-updates
docs(skills): update SKILL.md for debug, init-onecli, add-gmail-tool, add-opencode, add-signal, add-vercel
2026-05-08 00:11:40 +03:00
gavrielc 1594a0c682 Apply suggestion from @gavrielc 2026-05-08 00:10:24 +03:00
github-actions[bot] a6995cc17e docs: update token count to 144k tokens · 72% of context window 2026-05-07 20:58:04 +00:00
github-actions[bot] 93732a4978 chore: bump version to 2.0.37 2026-05-07 20:57:42 +00:00
gavrielc 350d9631fa Merge pull request #2327 from glifocat/wip/compaction-destination-reminder
fix: inject destination reminder after SDK auto-compaction
2026-05-07 23:57:29 +03:00
gavrielc a90104b8e3 Merge pull request #2318 from ira-at-work/feat/add-mnemon
feat(skills): add /add-mnemon skill — persistent semantic memory
2026-05-07 23:49:35 +03:00
gavrielc 708f98e156 Merge pull request #2316 from alipgoldberg/setup/other-channel-back
setup: add back-to-channels exit to "Other…" channel prompt
2026-05-07 23:46:14 +03:00
github-actions[bot] b40d43725f chore: bump version to 2.0.36 2026-05-07 20:45:04 +00:00
gavrielc d92c676327 Merge pull request #2328 from glifocat/wip/destinations-default-to-origin
fix: default reply destination to message origin in multi-destination groups
2026-05-07 23:44:42 +03:00
Adam Johnson 6f0b8f1961 fix(container): pin pnpm to 10.33.0 to match host
Corepack with no version pin pulls latest pnpm (currently 11.0.8), which
silently stops honoring `only-built-dependencies[]=` in `.npmrc` for
global installs. The allowlist file ends up correctly written but
ignored, so:

  - `@anthropic-ai/claude-code`'s postinstall — which downloads the
    platform-native Claude binary — never runs. Agents then crash at
    runtime with "claude native binary not installed... postinstall did
    not run."
  - `agent-browser`'s postinstall, which chmods the linux-arm64 binary,
    is also skipped, so the binary fails with EPERM the first time it's
    invoked.

Pin the container's pnpm to 10.33.0 (the same version host's
package.json already pins via `packageManager`). Keep the two in
lockstep so a host bump triggers a deliberate container bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:37:15 -07:00
github-actions[bot] 1afbba6a91 docs: update token count to 143k tokens · 71% of context window 2026-05-07 19:53:47 +00:00
github-actions[bot] cd69bf5c45 chore: bump version to 2.0.35 2026-05-07 19:53:37 +00:00
gavrielc c3d1b3e976 Merge pull request #2333 from krejov100/fix/discord-gateway-backoff
fix(channels): exponential backoff for gateway listener restarts
2026-05-07 22:53:22 +03:00
johnnyfish 1240a0cf4f feat: fetch gateway skill from OneCLI API with static fallback 2026-05-07 22:16:48 +03:00
krejov100 42e8ae004e fix(channels): exponential backoff for gateway listener restarts
Without this, an unrecoverable failure such as TokenInvalid causes the
gateway listener to restart ~10x/sec, which Discord's Cloudflare layer
treats as abuse and answers with a multi-hour IP block. Both the clean-
expiry path and the error path now share a backoff that doubles up to
1h, with a >5min healthy run resetting the counter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:56:33 +00:00
github-actions[bot] 9ccafcda82 docs: update token count to 142k tokens · 71% of context window 2026-05-07 17:35:39 +00:00
github-actions[bot] 860d1310ca chore: bump version to 2.0.34 2026-05-07 17:35:26 +00:00
gavrielc 9ca3367229 Merge pull request #2329 from qwibitai/fix/explicit-destination-addressing
fix(agent-runner): require explicit destination addressing, fix per-destination threading
2026-05-07 20:35:11 +03:00
gavrielc e3645f799c address review: add thread resolution test, log catch, remove stray comment
- Add integration test for per-destination thread_id resolution: seeds two
  destinations with different thread IDs, verifies each outbound message
  carries the correct thread_id (not a global one from the batch routing).
- Add log line in resolveDestinationThread catch block for debuggability.
- Remove stray "(ensurePreCompactHook is defined after the main function.)"
  comment from group-init.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 20:33:06 +03:00
gavrielc 9db39b291d fix(agent-runner): require explicit destination addressing, fix per-destination threading
The poll loop had a bare-text routing fallback in dispatchResultText: when
the agent produced text without <message to="..."> wrapping, it would auto-
route to the session's originating channel (via a frozen RoutingContext) or
to the single configured destination. This caused three problems:

1. Routing drift: RoutingContext was extracted once from the initial batch
   and never refreshed. When the initial batch was a null-routed cron task
   and a real chat arrived mid-query, replies were silently dropped to
   scratchpad because the frozen routing had all-null fields.

2. Cross-channel thread bleed: sendToDestination applied a single
   routing.threadId to every outbound message regardless of destination.
   In agent-shared sessions (multiple channels sharing one session), one
   channel's thread ID was stamped onto messages to a different channel.

3. Inconsistent formatting: task, webhook, and system messages had no
   origin metadata in their formatted output, so the agent couldn't tell
   which destination they came from — even when the underlying messages_in
   rows carried routing fields.

Changes:

- Remove the bare-text routing fallbacks in dispatchResultText (both the
  routing-based and single-destination shortcuts). All agent output must
  be wrapped in <message to="name">...</message>. Bare text is scratchpad.

- Update buildDestinationsSection() to require explicit wrapping for all
  groups, including single-destination. No more "no special wrapping
  needed" shortcut.

- Resolve thread_id per-destination via resolveDestinationThread(), which
  queries messages_in for the most recent message matching the target
  channel+platform. Falls back to null (top-level channel message) when
  no prior inbound exists for that destination.

- Extract originAttr() helper in formatter.ts and apply it to all message
  types. Tasks now render as <task from="dest" time="...">, webhooks as
  <webhook from="dest" source="..." event="...">, system responses as
  <system_response from="dest" ...>. The agent always sees where a
  message originated.

- Add a PreCompact shell hook (compact-instructions.ts) that outputs
  custom compaction instructions, telling the compactor to preserve
  recent message XML structure and routing metadata in the summary.
  Wired via settings.json in the .claude-shared scaffold, with a
  migration path (ensurePreCompactHook) for existing groups.

Relation to open PRs:

- #2277 (mergeRouting) becomes unnecessary — the routing fallback it
  patches no longer exists. Can be closed.
- #2327 (post-compaction destination reminder) is complementary — it
  handles the post-compaction push, this handles pre-compaction
  instructions. Both can merge independently.
- #2328 (default routing instruction) is complementary — it adds "reply
  to the from= destination" guidance to the multi-destination section.
  Compatible with the unified instruction format here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 19:47:46 +03:00
gavrielc ba70ddf73a Merge pull request #2323 from qwibitai/fix/karpathy-wiki-v2-compat
fix(add-karpathy-llm-wiki): v2 compatibility — schedule_task MCP + remove build step
2026-05-07 18:50:43 +03:00
gavrielc f7c610ac4a Apply suggestion from @gavrielc 2026-05-07 18:49:57 +03:00
glifocat 12719be6e1 feat(poll-loop): inject destination reminder after SDK auto-compaction
Closes qwibitai/nanoclaw#2325.

When the Claude Code SDK auto-compacts the conversation context, the
compaction summary tends to drop the agent's learned <message to="…">
wrapping discipline. The destinations table is still populated and the
system prompt still lists them, but the behavioral pattern degrades —
A2A sends and multi-channel routing silently revert to bare-text or
single-channel delivery for the rest of the session, until the next
/clear.

Three small changes wire a reminder back into the live query when this
fires:

- New `compacted` event on ProviderEvent. Distinct from `result` so it
  doesn't mark the turn completed or get dispatched as a chat message
  (which is also why "Context compacted (N tokens compacted)." stops
  appearing as noise in user-facing chats — it was a side-effect of
  reusing the result event path).
- ClaudeProvider yields `compacted` instead of `result` for the SDK's
  compact_boundary system event.
- Poll-loop's event handler reacts by pushing a system-tagged reminder
  back into the active query when there are >1 destinations. Single-
  destination groups skip the push since they have a fallback that
  works without wrapping.

Tests cover both branches (multi-destination → reminder fires;
single-destination → no reminder) using a CompactingProvider that
emits the new event mid-stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:11:25 +02:00
glifocat 57dad14a01 fix(destinations): default to replying to the origin destination
When a multi-destination agent receives an inbound message, the model
had no explicit guidance about which destination to address by default
and would sometimes pick the wrong one — e.g. Casa replying to the
admin's group questions in Laura's DM instead of in the group itself.

The formatter already injects `from="<destname>"` on every inbound
<message> tag (formatter.ts:184), so the origin is right there in the
prompt — the system prompt just never told the agent to use it.

Added one line to buildDestinationsSection() that nudges the agent
toward replying via the same destination the message came from, with
an out for explicit cross-destination requests ("tell Laura that…").

Single-destination groups are unaffected (they take a separate
short-circuit path with a fallback that auto-replies to the origin).

Tests cover the multi-destination, single-destination, and
no-destination cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:11:25 +02:00
gavrielc 8d5d088108 Merge pull request #2315 from alipgoldberg/setup/imessage-handle-copy
setup: drop "E.164" jargon from iMessage handle card
2026-05-07 17:13:58 +03:00
Ali Goldberg 6d8d085f96 setup: add "Skip — I'll connect later" option to Claude auth picker
Today the Claude auth picker has only three real-auth options. A user
without a Pro/Max subscription, an OAuth token, or an API key has no
graceful escape — Ctrl-C kills setup entirely.

Add a fourth option that confirms the trade-off (no agent runtime + no
Claude debug help during setup) and, on Yes, marks auth skipped and
lets setup continue. On No, loop back to the picker. Existing
NANOCLAW_SKIP=auth env hatch is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:33:07 +00:00
glifocat 348e200c11 fix(add-karpathy-llm-wiki): update for v2 — schedule_task MCP + no build step 2026-05-07 13:09:40 +02:00
johnnyfish 4305c6a87d fix: slim credential docs in group CLAUDE.md and add onecli-gateway container skill 2026-05-07 13:25:27 +03:00
Ira Abramov 877d2a370a docs(skills): update SKILL.md for debug, init-onecli, add-gmail-tool, add-opencode, add-signal, add-vercel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:06:33 +03:00
Ira Abramov 8eff3e558c feat(skills): add /add-mnemon skill — persistent semantic memory for agent groups
Adds a skill that installs the mnemon CLI into agent containers, giving each
agent group a persistent, queryable knowledge graph across sessions.

Mnemon stores facts (insights) with categories, importance scores, and entity
tags, and connects them with typed edges (causal, semantic, temporal, entity).
The agent can remember, recall, search, link, and forget facts — surviving
container restarts and context compaction.

Installation: drops the mnemon binary from the channels branch, creates the
per-agent-group data directory, and configures the agent's CLAUDE.md to load
the skill on every spawn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:43:08 +03:00
Ali Goldberg 7e0c256fa0 setup: drop "E.164" jargon from iMessage handle card
Replace "full E.164, e.g. +15551234567" with plain-language guidance
mirroring the WhatsApp setup card: "start with + and your country code,
no spaces or dashes" plus a worked example. "E.164" is the technical
name for the format and means nothing to non-telecom users; the
explanation it stands in for is one sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:29:07 +00:00
Ali Goldberg 1eb55e85a0 setup: add back-to-channels exit to "Other…" channel-name prompt
After picking "Other…" from the channel picker, today's flow drops the
user straight into a free-text prompt with no way back. Replace it with
a brightSelect that offers either "Type the channel name" (existing
behavior) or "← Back to channel selection" — same back-affording pattern
the channel sub-flows already use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:12 +00:00
Daniel M d8d6f6bd65 Merge pull request #2313 from alipgoldberg/setup/teams-step-gate-back
setup: add back-to-channels exit at every Teams step gate
2026-05-07 11:16:26 +03:00
exe.dev user 88ff54cf83 setup: add back-to-channels exit at every Teams step gate
Teams setup is 6+ Azure steps over 30+ minutes. Today, every
"Done / Stuck / Show again" gate forces continuation; the only escape
is Ctrl-C, which kills setup entirely. Add a fourth option at each gate
that returns to the channel picker so a stuck operator can pick a
different channel without losing the rest of setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:05:26 +00:00
Gabi Simons 4d5fa0868b Merge pull request #2297 from alipgoldberg/setup/slack-card-link-position
setup: tidy Slack app-creation card
2026-05-07 10:49:07 +03:00
gavrielc aff3f58bc8 Merge pull request #2309 from glifocat/fix/skills-drop-sqlite3-cli-dep
fix(skills): replace sqlite3 CLI with in-tree better-sqlite3 wrapper
2026-05-06 21:13:17 +03:00
gavrielc 18635e7c7d fix(scripts/q): use stmt.reader instead of keyword sniffing for SELECT detection
The first-keyword check (`WITH` → SELECT path) was wrong for CTEs that
precede mutations (e.g. `WITH stale AS (...) DELETE FROM t WHERE ...`).
These would be routed through `db.prepare().all()` instead of executing
the mutation.

Use better-sqlite3's `stmt.reader` property, which asks SQLite's own
parser whether the statement returns data. Single mutations go through
`stmt.run()`; compound statements (which `prepare()` rejects) fall back
to `db.exec()`.

Add a regression test for WITH...DELETE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 21:12:25 +03:00
NanoClaw bot user 0d7458c6f3 fix(skills): replace sqlite3 CLI with in-tree better-sqlite3 wrapper
Setup deliberately avoids the sqlite3 CLI (`setup/verify.ts:5` calls
this out: "Uses better-sqlite3 directly (no sqlite3 CLI)") and never
installs or probes for the binary. Despite that, 13 skills shelled out
to `sqlite3 ...` directly, breaking on hosts where the CLI isn't
preinstalled — the same root cause as #2191 but spread across the
skill surface.

Add `scripts/q.ts`, a ~30-LOC wrapper over the `better-sqlite3` dep
that setup already installs and verifies. Default output matches
`sqlite3 -list` (pipe-separated, no header) so existing skill text
reads identically — only the binary changes. SELECT/WITH queries go
through `db.prepare().all()`; everything else (INSERT/UPDATE/DELETE,
including compound statements) goes through `db.exec()`.

Migrate every in-tree caller:

- 17 hardcoded invocations across 8 SKILL.md files (init-first-agent,
  add-deltachat, add-signal, add-emacs, add-whatsapp, add-ollama-provider,
  debug, add-parallel) plus add-deltachat/VERIFY.md.
- `manage-channels/SKILL.md` shows canonical SQL but never prescribed
  a tool, so the assistant defaulted to `sqlite3` and silently failed.
  Add a one-line wrapper hint above the SQL block.
- `migrate-v2.sh` schema/count probes (was the original #2191 case).
  Replace `.tables` with `SELECT name FROM sqlite_master`.
- Document the wrapper convention in root `CLAUDE.md` under "Central DB".

Add `scripts/q.test.ts` with 6 vitest cases covering both modes,
NULL rendering, empty-result, compound mutations, and arg validation.
Wire `scripts/**/*.test.ts` into `vitest.config.ts`.

Out of scope (flagged for follow-up):
- `debug` and `add-parallel` still reference the v1-only path
  `store/messages.db`. Routing through the wrapper now produces a
  cleaner "no such file" error, but the surrounding sections are
  v1-era throughout — a v1-content cleanup is its own PR.
- `cleanup-sessions.sh` is being addressed in #1889 (different style,
  hard-fail rather than wrap); left untouched here to avoid stepping
  on that author's work.

Closes #2191.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:38:33 +02:00
glifocat bdb8cf559c Merge branch 'qwibitai:main' into main 2026-05-06 16:25:59 +02:00
exe.dev user 5213c98506 setup: correct Slack member-ID card directions
Slack's profile button is in the bottom-left of the desktop sidebar (not
the top-right), and the "More" overflow icon next to "Copy member ID" is
the vertical kebab `⋮`, not the horizontal `⋯`. Match what users actually
see in Slack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:13:23 +00:00
exe.dev user a36acd3413 setup: tidy Slack app-creation card
- Move the "Get started: …" URL above the numbered instructions and
  render it in bright white so it pops against the brand-cyan body.
  (Headless-only — interactive runs still auto-open the URL in a
  browser, no card line.)
- Group the OAuth scope list vertically by family (im, channels,
  groups, chat, users, reactions) instead of one comma-run wall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:27:09 +00:00
gavrielc f2d2ce9aed Merge pull request #2290 from glifocat/fix/manage-channels-canonical-queries
fix(manage-channels): include canonical SQL queries in SKILL.md
2026-05-06 01:52:19 +03:00
gavrielc 22715c163a Update README.md 2026-05-06 01:36:13 +03:00
Ethan Munoz eacb93c4e5 fix(manage-channels): include canonical SQL queries in SKILL.md
The skill's "Assess Current State" step said only "query agent_groups,
messaging_groups, ..." without specifying columns. The `register` CLI
takes `--assistant-name "<name>"` (mentioned three times in the same
SKILL.md), but the schema column is `name`, not `assistant_name` — and
the SKILL.md never linked the two.

When the agent had to compose a SELECT against `agent_groups` from the
SKILL.md vocabulary alone, it extrapolated `--assistant-name` into a
column name and produced:

  SELECT id, folder, assistant_name FROM agent_groups;
  -> Error: in prepare, no such column: assistant_name

Replace the prose pointer with canonical SQL queries that match the
real schema. The `name AS assistant_name` alias preserves the familiar
term in the agent's output.

Verified locally as a drop-in: `/manage-channels` runs clean from end
to end with this version, no further inference needed.

Closes #2289
2026-05-06 00:29:54 +02:00
github-actions[bot] 2db5173f07 chore: bump version to 2.0.33 2026-05-05 21:56:17 +00:00
gavrielc 9b4860dd48 Merge pull request #2288 from glifocat/fix/host-sweep-tz-utc-parsing
fix(host-sweep): parse SQLite timestamps as UTC, not local time
2026-05-06 00:55:59 +03:00
Ethan Munoz ec23bd7a7e fix(host-sweep): parse SQLite timestamps as UTC, not local time
SQLite TIMESTAMP columns store UTC without a zone marker. `Date.parse`
treats timezoneless ISO strings as local time, so on any non-UTC host
every claim and processAfter looks (TZ offset) hours stale. That makes
fresh claims trip the kill-claim path on the first sweep tick — every
container gets killed within seconds of spawn.

Two affected sites in host-sweep.ts:

  - decideStuckAction reads claim.status_changed and computes claimAge.
    On a TZ=Europe/Madrid host (UTC+2), a claim made 5s ago looks
    7205s old and exceeds CLAIM_STUCK_MS (60s).

  - The orphan retry loop reads msg.processAfter and skips messages
    rescheduled into the future. On the same host, future timestamps
    look (TZ offset) hours in the past, so the skip is missed and
    tries gets bumped on every tick.

Fix: introduce parseSqliteUtc(s) which appends "Z" only when no zone
marker is present, then call it from both sites. Behavior under
TZ=UTC is unchanged.

Verified on a production v2 install on TZ=Europe/Madrid: with the
patch applied, an idle container survived 30+ minutes without being
killed (previously: killed within 60s of spawn).

Tests: 5 new cases covering the bare/Z/+offset/invalid input matrix
and a TZ-independence check. All 19 host-sweep tests pass and tsc
clears against main.
2026-05-05 23:49:18 +02:00
gavrielc 61caac0a04 Merge pull request #2287 from glifocat/fix/migrate-v2-health-endpoint
fix(migrate-v2): probe correct OneCLI health endpoint
2026-05-06 00:48:33 +03:00
gavrielc 3dc29bb674 Merge remote-tracking branch 'origin/main' into nc-cli 2026-05-06 00:46:53 +03:00
gavrielc 8771e259a8 style(cli): apply prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:42:33 +03:00
gavrielc a597b42648 feat(cli): add remaining resources, fix descriptions from code review
New read-only resources:
- destinations (agent-to-agent ACL + routing map)
- user-dms (DM channel cache)
- dropped-messages (audit trail for dropped messages)
- approvals (in-flight approval cards)

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:40:15 +03:00
Ethan Munoz 4d5af78d35 fix(migrate-v2): probe correct OneCLI health endpoint (/api/health)
migrate-v2.sh probes ${ONECLI_URL_CHECK}/health (with ONECLI_URL_CHECK
defaulting to http://127.0.0.1:10254, the OneCLI web port). That path
returns 404, so the detection branch never matches an already-running
OneCLI instance and the script falls through to the install path.

The web app's health endpoint is /api/health
(apps/web/src/app/api/health/route.ts) and has been since the OneCLI
repo was made public. /health was never exposed by the web on :10254
nor by the gateway on :10255 (the gateway uses /healthz).

Verified against a running OneCLI v1.21.0:
  GET :10254/api/health  -> 200 {"status":"ok","version":"1.21.0",...}
  GET :10254/health      -> 404 (Next.js fallback HTML)
  GET :10255/healthz     -> 200
  GET :10255/health      -> 400 (gateway parses non-/healthz as CONNECT)

Closes #2285
2026-05-05 23:34:14 +02:00
gavrielc 6865811147 feat(cli): add CRUD helper, resource definitions, and help command
Resource-first CLI: `nc groups list`, `nc wirings get <id>`, etc.
Seven resources defined (groups, messaging-groups, wirings, users,
roles, members, sessions) with full column documentation that serves
as the single source of truth for help output and arg validation.

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 23:48:39 +03:00
gavrielc 863c224d9e Merge pull request #2249 from alipgoldberg/setup-telegram-no-telegram-fallback
feat(setup): clearer "Open Telegram" card with mobile fallback
2026-05-05 23:40:59 +03:00
gavrielc 87f75eed79 Merge branch 'main' into setup-telegram-no-telegram-fallback 2026-05-05 23:40:48 +03:00
gavrielc fc09b900ef Merge pull request #2274 from alipgoldberg/setup-channel-back-nav-pr5-signal
setup: add ← Back option to Signal channel flow
2026-05-05 23:37:26 +03:00
gavrielc 1a2d004bad Merge pull request #2273 from alipgoldberg/setup-channel-back-nav-pr4-teams
setup: add ← Back option to Teams channel flow
2026-05-05 23:37:12 +03:00
gavrielc e25eae7e57 Merge pull request #2272 from alipgoldberg/setup-channel-back-nav-pr3-slack
setup: add ← Back option to Slack channel flow
2026-05-05 23:36:30 +03:00
gavrielc 4a10a455f9 Merge pull request #2271 from alipgoldberg/setup-channel-back-nav-pr2-telegram
setup: add ← Back option to Telegram channel flow
2026-05-05 23:36:14 +03:00
gavrielc eefbf4f61d Merge pull request #2269 from alipgoldberg/setup-channel-back-nav-pr1
setup: add ← Back option to Discord, WhatsApp, iMessage channel flows
2026-05-05 23:34:33 +03:00
gavrielc a9c8c841f6 Merge pull request #2275 from alipgoldberg/whatsapp-linked-devices-copy
setup: update WhatsApp link instructions to "You / Settings"
2026-05-05 23:33:32 +03:00
gavrielc 3d42ba6e3d Merge pull request #2281 from alipgoldberg/setup-signal-cli-auto-install
setup: auto-install signal-cli when missing
2026-05-05 23:32:49 +03:00
gavrielc 5277e12a48 Merge pull request #2284 from glifocat/fix/baileys-v7-pin-install-scripts
fix(setup): pin Baileys to 7.0.0-rc.9 in install-whatsapp scripts
2026-05-05 21:49:52 +03:00
glifocat a8e0a7f011 fix(setup): pin Baileys to 7.0.0-rc.9 in install-whatsapp scripts
PR #2259 (Baileys v6→v7) was merged into the channels branch instead of
main. PR #2260 was merged into main 28s later assuming v7 was already
in place. The v6 pin survived in three sites while the WhatsApp adapter
copied from origin/channels at install time was already on the v7 LID
API, breaking every fresh migrate-v2.sh run at 2c-install-whatsapp with
TS errors on remoteJidAlt/participantAlt/lid-mapping.update.

Bumps the pin to 7.0.0-rc.9 (the version v1 has been running on for
months) in:

- setup/install-whatsapp.sh
- setup/add-whatsapp.sh
- .claude/skills/add-whatsapp/SKILL.md (install instruction)

package.json + pnpm-lock.yaml are not touched here — install-whatsapp.sh
mutates them at runtime via pnpm install with the corrected pin.

Closes #2283
2026-05-05 20:47:36 +02:00
exe.dev user 291a1fc8a4 update Signal intro copy to reflect auto-install
Today's copy says "Check that signal-cli is installed (we'll guide
you if not)" but the auto-install PR (#2281) makes that misleading —
we don't guide, we just install. Update the intro list to match what
will actually happen, and add a "no input needed for any of it" lead
so users know to expect a hands-off run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:09:39 +00:00
exe.dev user 92a2347dc5 setup: auto-install signal-cli when missing
When a user picks Signal in setup and signal-cli isn't on PATH, today
NanoClaw bails with a GitHub releases link and tells them to re-run.
That's a hard wall for non-technical users — GitHub releases pages
are intimidating, and the Linux native build / Java decision isn't
obvious.

Replace the bail-out with a real install: a new install-signal-cli.sh
script that does `brew install signal-cli` on macOS or downloads the
native Linux release into ~/.local/bin (no Java, no sudo). Wired into
ensureSignalCli with a spinner; probe again after, fall back to the
original manual-install copy if anything fails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:04:53 +00:00
glifocat ff90c8f565 Merge branch 'qwibitai:main' into main 2026-05-05 17:29:57 +02:00
github-actions[bot] 73d45f8097 docs: update token count to 141k tokens · 71% of context window 2026-05-05 15:07:07 +00:00
github-actions[bot] 395139ce63 chore: bump version to 2.0.32 2026-05-05 15:04:19 +00:00
glifocat 644ad2f017 Merge pull request #2265 from glifocat/fix/send-card-bridge
fix(channels): support display cards (send_card) in Chat SDK bridge
2026-05-05 17:03:56 +02:00
glifocat 824f311e31 Merge pull request #2266 from glifocat/fix/bump-chat-adapter-cohort-4-27
fix(skills): bump @chat-adapter/* cohort to 4.27.0 (Discord card duplication)
2026-05-05 17:03:25 +02:00
gavrielc 13f6fc2093 merge: catch up nc-cli to main
Resolve conflict in src/index.ts shutdown sequence — keep both
stopCliServer() from nc-cli and try/finally + resetCircuitBreaker()
from main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:24:26 +03:00
glifocat c93e611228 Merge branch 'main' into fix/bump-chat-adapter-cohort-4-27 2026-05-05 15:35:19 +02:00
glifocat 4fc3273889 Merge branch 'main' into fix/send-card-bridge 2026-05-05 15:32:24 +02:00
gavrielc fa6f2da83e Merge pull request #2260 from qwibitai/fix/drop-whatsapp-lid-migration
fix(migrate): drop WhatsApp LID dual-row migration step
2026-05-05 16:16:20 +03:00
gavrielc 34982eaf31 Merge branch 'main' into fix/drop-whatsapp-lid-migration 2026-05-05 16:16:02 +03:00
github-actions[bot] 9df6a91b32 docs: update token count to 141k tokens · 70% of context window 2026-05-05 13:04:29 +00:00
gavrielc 81b2364336 Merge pull request #2182 from mnolet/fix/test-infra-openInboundDb
fix(test-infra): openInboundDb honors in-memory test DB
2026-05-05 16:04:13 +03:00
gavrielc 144c65e32d Merge branch 'main' into fix/test-infra-openInboundDb 2026-05-05 16:03:16 +03:00
gavrielc 6d6584d120 fix(test-infra): openInboundDb honors in-memory test DB
openInboundDb() always opened /workspace/inbound.db which doesn't exist
in CI. In test mode, return a thin wrapper over the in-memory singleton
that delegates prepare/exec but no-ops close(), so callers' try/finally
cleanup doesn't destroy the shared DB mid-test.

One flag (_testMode), no monkey-patching, no saved-close bookkeeping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 16:02:10 +03:00
github-actions[bot] 9ac1e6fd7b chore: bump version to 2.0.31 2026-05-05 12:57:49 +00:00
gavrielc 24d719fb88 Merge pull request #2209 from cfis/fix/host-sweep-test-uses-in-memory-db
fix(host-sweep): orphan-claim delete missed in tests (regression from #2183)
2026-05-05 15:57:31 +03:00
gavrielc a870e7ebf2 fix: keep resetStuckProcessingRows private, restore test wrapper
The test wrapper forwards the in-memory outDb as the writable handle,
avoiding the filesystem reopen that fails in CI. The function stays
private — the optional writableOutDb param is an internal detail, not
a public API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 15:56:08 +03:00
exe.dev user 7fdd7eaa1c setup: update WhatsApp link instructions to "You / Settings"
WhatsApp's mobile UI calls the menu "You" on iOS and "Settings" on
Android (depending on platform/version). Both QR-scan and pairing-code
captions only mentioned "Settings", so iOS users had to figure out the
iOS-specific path on their own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:14:12 +00:00
exe.dev user decf18049f setup: add ← Back option to Signal channel flow
Stacked on #2269 (back-nav scaffolding) plus the Telegram, Slack, and
Teams PRs. They share the same scaffolding file from #2269 — they
don't compile without it, so they have to stack.

Signal had no user-facing prompt before the install kicked off, so
there was nothing to attach a Back option to. This adds a brief "Set
up Signal" info card (what's about to happen, no new phone number
needed) followed by a Continue/Back brightSelect. The card serves
double duty — context for the install plus the Back gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:51:21 +00:00
exe.dev user c44c7a6669 setup: add ← Back option to Teams channel flow
Stacked on #2269 (back-nav scaffolding) plus the Telegram and Slack
PRs. They share the same scaffolding file from #2269 — they don't
compile without it, so they have to stack.

Both Teams paths already had a brightSelect at the right place, so we
just extend each with a Back option — no new prompts:

- Existing-credentials path: Yes/No confirm becomes Yes/No/Back
- Fresh-setup path: the very first stepGate ("How did that go?") gets
  a 4th option. Subsequent stepGates keep the original 3 options so
  we never lose mid-flow state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:47:17 +00:00
exe.dev user 6a54b69912 setup: add ← Back option to Slack channel flow
Stacked on the back-nav scaffolding from #2269 and the Telegram PR.

Slack's first prompt was already a single-purpose "Press Enter to open
Slack app settings" confirm. Replacing it with a 2-option brightSelect
(Open / ← Back) folds the Back gate into the existing screen — net
same number of prompts as before, just with a way out. The redundant
confirmThenOpen Press-Enter step is dropped; openUrl is called inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:32:34 +00:00
exe.dev user e1ecfb9c48 setup: add ← Back option to Telegram channel flow
Stacked on the back-nav scaffolding from the Discord/WhatsApp/iMessage
PR — depends on setup/lib/back-nav.ts and the auto.ts loop.

Telegram's "no existing token" path adds one extra prompt — a
brightSelect "Ready to paste your bot token?" between the BotFather
instructions and the token paste. Clack's p.password prompt doesn't
support menu options so we can't fold Back into the paste itself; the
cleanest fix is a separate gate immediately before. The "existing
token" path doesn't add noise — the Yes/No confirm becomes Yes/No/Back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:29:23 +00:00
exe.dev user c795ecff6e setup: add ← Back option to Discord, WhatsApp, iMessage channel flows
Picking the wrong messaging channel during setup left users with no way
to bail out — they had to either complete the chosen flow or kill setup
and start over. This adds a Back option to the first prompt of three
channel sub-flows that share the same simple shape (one leading
brightSelect that's easy to extend).

Mechanics:
- New `setup/lib/back-nav.ts` exports a BACK_TO_CHANNEL_SELECTION
  sentinel and ChannelFlowResult type.
- `setup/auto.ts` wraps the channel dispatch in a while-loop; channels
  return BACK_TO_CHANNEL_SELECTION to bounce back to the chooser
  without restarting setup. Channels not yet wired return void and the
  loop exits after one pass, so the change is backwards compatible.
- Discord, WhatsApp, iMessage each add a `← Back to channel selection`
  option to their first prompt.

Telegram, Slack, Teams, and Signal will follow as separate PRs — they
each need a slightly different shape (extra prompt insertions, gating
inside multi-step flows, etc.) and are easier to review independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:20:17 +00:00
Gabi Simons 48d2fab779 Merge branch 'main' into fix/send-card-bridge 2026-05-05 11:01:27 +03:00
gavrielc 948a0dcada fix: use nodeenv lts instead of pinned node 22
nodeenv doesn't support major-only version specifiers. Use lts
which resolves to the latest LTS release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 07:28:48 +00:00
gavrielc 3c5ae96cdd use node 22 with nvx 2026-05-05 07:23:37 +00:00
gavrielc c8163d16f3 Merge pull request #2268 from Koshkoshinsk/setup-memory-fix
setup: drop disk-space pre-flight check, keep RAM only
2026-05-05 10:14:19 +03:00
exe.dev user 3eec441b84 improve node install to use uvx 2026-05-05 07:11:26 +00:00
koshkoshinsk e753d09e64 setup: drop disk-space pre-flight check, keep RAM only
The disk threshold was unreliable on hosts with separate /home or /var
mounts where df underreports free space. Simplify the pre-flight to a
RAM-only check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:01:04 +00:00
glifocat a57bb8fec0 style: apply prettier to chat-sdk-bridge card branch 2026-05-05 00:42:04 +02:00
glifocat 9633788a1b fix(skills): bump @chat-adapter/* cohort to 4.27.0
@chat-adapter/discord@4.27.0 includes vercel/chat#256, which fixes the
Discord adapter unconditionally setting payload.content alongside
payload.embeds when posting a card. In 4.26.0 every Discord card
appeared twice (text content above the embed, identical content inside
the embed) — every new install reproduced this on the welcome tour and
on every approval card.

The other 7 skills bump in lockstep because @chat-adapter/discord@4.27.0
depends on chat@4.27.0 while @chat-adapter/<other>@4.26.0 depend on
chat@4.26.0. Mixing the cohort produces a TypeScript dual-version
conflict between the bridge and adapter ChatInstance types.

Files updated (one line per file in each pnpm install command):
- add-discord (the user-visible bug fix)
- add-gchat, add-github, add-linear, add-slack, add-teams, add-telegram,
  add-whatsapp-cloud (cohort consistency)

Out of scope: add-imessage, add-matrix, add-webex, add-resend use
third-party packages with independent versioning.

Closes #2264
2026-05-05 00:28:25 +02:00
glifocat 32dba601fe fix(channels): support display cards (send_card) in Chat SDK bridge
The send_card MCP tool wrote outbound rows with type='card' but the
chat-sdk-bridge deliver() had no branch for them, so the payload fell
through to the text fallback (where text is undefined) and silently
returned without calling the adapter. delivery.ts then marked the
message delivered with platformMsgId=undefined and the user saw nothing.

Add a dedicated card branch mirroring the ask_question structure:
- Build Card from title, description, and string-or-{text} children
- Render only URL actions as LinkButtons (send_card is fire-and-forget
  per its docstring, so callback buttons would have nowhere to land)
- Drop empty cards with a warn log instead of posting blank
- Fall back text: content.fallbackText > description > title

Affects every Chat SDK adapter that goes through the bridge: Discord,
Telegram, Slack, Teams, GChat, GitHub, Linear, WhatsApp Cloud, iMessage,
Matrix, Webex, Resend.

Tests: adds five cases covering normal render, action filtering,
link-button rendering, empty-card skip, and a regression check that
non-card chat-sdk payloads still flow through the text branch.

Closes #2263
2026-05-05 00:24:37 +02:00
glifocat 295275df69 Merge branch 'qwibitai:main' into main 2026-05-05 00:19:11 +02:00
exe.dev user 30a898508a fix(migrate): drop WhatsApp LID dual-row migration step
Remove step 2d (whatsapp-resolve-lids.ts) which pre-created duplicate
messaging_groups rows keyed by @lid alongside the phone-keyed rows.
This caused split sessions — the same contact got separate sessions
depending on which JID format arrived.

With the Baileys v7 upgrade (PR #2259 on channels), the adapter
resolves every LID to a phone JID via extractAddressingContext before
the message reaches the router, making dual rows unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 21:58:57 +00:00
exe.dev user 306fa6f014 feat(setup): clearer "Open Telegram" copy + mobile fallback hint
Two friction points in the Telegram channel's "Open Telegram" card,
both surfaced when running setup on a VM-via-SSH where the user's
local laptop has no Telegram client installed:

1. The opening sentence read "Opening @yourbot in Telegram so it's
   ready when the pairing code shows up." On a headless device that's
   misleading — nothing is auto-opened, the user has to click the
   link or use their phone. Rewrite as a direct, action-led
   instruction on the headless flow only:

     Open @yourbot in Telegram now — the pairing code is coming next,
     and that's where you'll send it.

   Plus a "Get started: <url>" line and a full-strength mobile
   fallback hint inside the card so headless users have all
   self-serve options visible.

   On non-headless the original status-style line stays accurate
   (`xdg-open` / `open` does fire for users with Telegram desktop
   installed), so the card stays a single line.

2. Clicking `https://t.me/yourbot` silently fails when the user's
   local device has no Telegram client. Non-headless gains:
     - a "(must be installed here)" qualifier on the confirm prompt
       so users without Telegram desktop know up-front;
     - a single combined dim fallback line below the prompt:
         "If browser does not appear, please visit: <url> — or
         search for @yourbot on your mobile."

   Direct `p.confirm` + `openUrl` instead of `confirmThenOpen` for
   the non-headless branch so we control the dim line fully (single
   combined line vs the helper's default URL-only line).

Headless layout drives the same self-serve content via the card body
itself; no confirm prompt fires there.
2026-05-04 17:45:43 +00:00
github-actions[bot] 1404f7feb6 chore: bump version to 2.0.30 2026-05-04 15:32:34 +00:00
gavrielc 657110cb0b Merge pull request #2251 from axxml/main
Add namespacedPlatformId exclusion for DeltaChat
2026-05-04 18:32:18 +03:00
gavrielc 7ed149057d Merge branch 'main' into main 2026-05-04 18:32:09 +03:00
github-actions[bot] 5f5f4fe62c chore: bump version to 2.0.29 2026-05-04 15:31:09 +00:00
gavrielc 8d489ee19e Merge pull request #2242 from mashkovtsevlx/fix/mcp-allowlist-sdk-filter
fix(agent-runner): derive MCP allowedTools from registered mcpServers
2026-05-04 18:30:54 +03:00
gavrielc dcf8d2096f Merge branch 'main' into fix/mcp-allowlist-sdk-filter 2026-05-04 18:30:43 +03:00
gavrielc 9e8f256dd2 Merge pull request #2245 from alipgoldberg/setup-windowed-fmt-duration
fix(setup): use fmtDuration in the container-build spinner
2026-05-04 17:57:44 +03:00
gavrielc 057f0d174c Merge branch 'main' into setup-windowed-fmt-duration 2026-05-04 17:57:35 +03:00
gavrielc 1c16b09c84 Merge pull request #2252 from Koshkoshinsk/g-check
feat(setup): warn when running on a Google Compute Engine VM
2026-05-04 17:56:34 +03:00
gavrielc cf71f961d3 Merge branch 'main' into g-check 2026-05-04 17:56:25 +03:00
koshkoshinsk 251b31cd78 feat(setup): warn when running on a Google Compute Engine VM
NanoClaw is known to not run reliably on GCE instances. Detect via DMI
during pre-flight (between the spec check and root warning) and let the
user abort before sinking time into bootstrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:42:11 +00:00
Axel McLaren 6262211af1 Add namespacedPlatformId exclusion for DeltaChat
(cherry picked from commit 5987fdc189)
2026-05-04 06:26:46 -07:00
gavrielc e0e4f0189b Merge pull request #2250 from Koshkoshinsk/install-specs
Warn when host is below recommended hardware specs
2026-05-04 16:15:24 +03:00
koshkoshinsk 9e4feb0800 feat(setup): warn when host is below recommended hardware specs
Pre-flight check in nanoclaw.sh that detects available RAM and free disk
on the project-root partition (Linux + macOS) before the bootstrap
spinner runs. Below 3700 MB RAM or 20 GB free disk, surfaces a "likely
cannot run" warning with a Try-anyway prompt defaulting to abort. The
3700 MB floor sits below 4 GB because "4 GB" VMs typically report
3700–3900 MB after kernel reserves (Hetzner CX21 ≈ 3814, AWS t3.medium
≈ 3800). Cheaper to fail here than to wait through pnpm install on a
host that can't run the agent container. Diagnostic events fire on
continue/abort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:54:54 +00:00
exe.dev user b33f6654fd fix(setup): use fmtDuration in the container-build spinner
setup/lib/windowed-runner.ts was the one place on main still printing
elapsed time as raw seconds (`(170s)`) instead of using the
minute-aware `fmtDuration` helper from #2108. Two spots — the live
spinner suffix that ticks during the build, and the
success/error completion suffix — both now go through `fmtDuration`,
so anything past 60 seconds renders as `Xm Ys` (e.g. `2m 50s`) like
the rest of the setup flow.

The miss happened because a separate PR (closed) was supposed to
remove the timer entirely from this file, so #2108 deliberately
skipped it. With that other PR closed, applying `fmtDuration` here
is the consistent fix.

Pure formatting change. The helper itself is unchanged from #2108;
behavior under 60s is identical (`Xs`); behavior past 60s now
matches everywhere else.
2026-05-04 09:23:43 +00:00
Gabi Simons 768980e874 Merge pull request #2243 from alipgoldberg/setup-telegram-botfather-qr
feat(setup): clarify @BotFather is Telegram's official bot
2026-05-04 12:08:38 +03:00
exe.dev user 34c3e90156 feat(setup): clarify @BotFather is Telegram's official bot
Step 1 of the Telegram channel's BotFather instructions used to read:

  1. Open Telegram and message @BotFather

Two small UX issues with that:
  - "BotFather" reads slightly sketchy without context — a first-time
    user has no way to know it's the official, sanctioned account
    rather than an impersonator.
  - Typing the username from memory leaves room for picking a typo'd
    impostor account (Telegram has many @BotF4ther / @BotFAther / etc.
    look-alikes).

Update the line so the official-bot framing is part of the instruction
itself:

  1. Open Telegram and message @BotFather — Telegram's official bot
     for creating and managing bots

One-line change in the existing note() body. No new dependencies, no
asset churn, no other behavior change.
2026-05-04 09:01:43 +00:00
Alex Mashkovtsev f68f6da406 fix(agent-runner): derive MCP allowedTools from registered mcpServers
Claude Code 2.1.116+ treats SDK `allowedTools` as a hard whitelist:
servers whose namespace isnt listed are filtered out before the agent
ever sees them, regardless of `permissionMode: bypassPermissions` or
any `permissions.allow` in settings. The static TOOL_ALLOWLIST only
contained `mcp__nanoclaw__*`, so any MCP wired via add_mcp_server (or
directly in container.json) was silently dropped.

Derive `mcp__<sanitized-name>__*` entries at the SDK call site from
the already-aggregated `this.mcpServers` map, mirroring the SDKs own
sanitization rule (chars outside [A-Za-z0-9_-] become _).

Prior diagnosis by @jsboige in #2028 (withdrawn, not upstreamed).
2026-05-04 16:49:53 +08:00
gavrielc ebb11a1127 Merge pull request #2222 from qwibitai/fix/update-nanoclaw-skill-v2
fix: update /update-nanoclaw skill for v2 architecture
2026-05-04 10:08:50 +03:00
gavrielc 9b067b2d8e Merge branch 'main' into fix/update-nanoclaw-skill-v2 2026-05-04 10:08:43 +03:00
gavrielc 517e719146 Merge pull request #2212 from alipgoldberg/setup-headless-auth-message
feat(setup): headless-aware Claude sign-in pre-message
2026-05-04 10:08:05 +03:00
gavrielc 5eda6c160e Merge branch 'main' into setup-headless-auth-message 2026-05-04 10:07:56 +03:00
gavrielc 2902d86ac8 Merge pull request #2235 from Koshkoshinsk/migration-fixes-combined
fix: migration UX improvements + legacy OneCLI container cleanup
2026-05-04 10:05:35 +03:00
Gabi Simons b2ed5a5fc0 Merge branch 'main' into fix/update-nanoclaw-skill-v2 2026-05-04 09:26:29 +03:00
Koshkoshinsk 37d6335ebc fix(setup): clean up legacy OneCLI containers before installer runs
The OneCLI installer (curl onecli.sh/install | sh) doesn't pass
--remove-orphans to docker compose up. After the upstream service rename
(app -> onecli), the legacy onecli-app-1 container keeps :10254 bound
and crashes the new bring-up. This breaks /migrate-v2.sh on any host
that has a pre-rename OneCLI installed.

Workaround: before invoking the installer, remove containers in the
"onecli" compose project whose service name isn't in the v2 set
({onecli, postgres}). Label-keyed and no-op on fresh installs.

Filed upstream; remove this once the installer adds --remove-orphans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:18:10 +00:00
Koshkoshinsk 5deccc44ea fix: direct users to exit Claude Code for migration instead of using ! prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:45:52 +00:00
Koshkoshinsk 6daa1a3ffe fix: preserve v1 service file for rollback instead of symlinking
The previous approach deleted the v1 unit file and symlinked it to v2,
making rollback impossible. Now we just disable v1 and leave the file
on disk so users can switch back with a single command.

Also adds rollback instructions to the migration summary output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:45:52 +00:00
Koshkoshinsk 58e4df44e2 fix: add hint to channel multiselect in migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:45:52 +00:00
Koshkoshinsk d88b0807e6 fix: retire legacy v1 service file after migration switchover
After migration keeps v2, the old unslugged `nanoclaw.service` (or
`com.nanoclaw.plist`) was only disabled — the unit file stayed on disk.
A `systemctl --user restart nanoclaw` would start v1 instead of v2.

Now the migration removes the old file and symlinks it to the v2 unit,
so the legacy name transparently starts v2. Handles systemd (Linux/WSL)
and launchd (macOS). Idempotent — skips if the symlink already exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:45:52 +00:00
Koshkoshinsk 6a05e41afe fix: require interactive terminal for migrate-v2.sh
The migration script has interactive prompts and streams progress
output that gets collapsed when run via Claude Code's Bash tool.
Add a TTY guard that exits early with instructions to use the !
prefix instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:45:52 +00:00
gavrielc 8bdc5c4217 Merge pull request #2229 from SebTardif/fix/verify-anthropic-auth-token
Recognize ANTHROPIC_AUTH_TOKEN in setup verification
2026-05-03 21:05:04 +03:00
Sebastien Tardif 5dc54194ab Recognize ANTHROPIC_AUTH_TOKEN in setup verification
The credential proxy already reads ANTHROPIC_AUTH_TOKEN (credential-proxy.ts
line 33) and uses it for OAuth-mode authentication, but setup/verify.ts did
not include it in its credential-detection regex.  Users with
ANTHROPIC_AUTH_TOKEN in .env saw 'CREDENTIALS: missing' even though their
credentials were valid at runtime.

Add ANTHROPIC_AUTH_TOKEN to the regex and add a matching test case.

Closes gh-853
2026-05-03 09:20:22 -07:00
Gabi Simons cf783385e7 fix: handle missing bun on host and dynamic systemd service name
Container typecheck and bun install gracefully skip when bun isn't
installed on the host. Linux service restart now detects the actual
systemd service name instead of hardcoding 'nanoclaw'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 15:45:54 +00:00
Gabi Simons faff9ac0e3 Merge branch 'main' into fix/host-sweep-test-uses-in-memory-db 2026-05-03 17:53:25 +03:00
Gabi Simons 64ad618089 Merge branch 'main' into fix/update-nanoclaw-skill-v2 2026-05-03 17:47:20 +03:00
Gabi Simons e432467066 fix: update /update-nanoclaw skill for v2 architecture
The skill was written for v1 and missed several v2 changes: container
rebuild after merge, dependency install for both pnpm and bun lockfiles,
container typecheck, channel/provider branch update awareness, and
platform-aware service restart instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 14:46:18 +00:00
github-actions[bot] 7fc68a1008 chore: bump version to 2.0.28 2026-05-03 14:04:59 +00:00
gavrielc c0a7538dbe Merge pull request #2213 from ziv-daniel/fix/media-only-messages
fix: accept media-only messages (photo/video/file without caption)
2026-05-03 17:04:42 +03:00
Gabi Simons fc1066a303 Merge branch 'main' into fix/host-sweep-test-uses-in-memory-db 2026-05-03 16:22:44 +03:00
exe.dev user e34380656c feat(setup): headless-aware Claude sign-in pre-message
The pre-message printed by setup/register-claude-token.sh used to
say "A browser window will open for you to sign in with your
Claude account." Accurate on a laptop or desktop, but a lie on
headless devices (Pi, SSH'd-into Linux server, CI) where the
browser auto-open never lands and the user actually has to copy
the URL `claude setup-token` prints to another device.

Add a small bash isHeadless check (mirrors `isHeadless()` in
setup/platform.ts: Linux without DISPLAY / WAYLAND_DISPLAY) and
swap the heredoc accordingly:

  - Headless: "A sign-in link will appear for you to sign in with
    your Claude account. When you finish, we'll save the token
    to your OneCLI vault automatically."
  - GUI: existing "A browser window will open…" copy, unchanged.

The trailing "Press Enter to continue, or edit the command first."
line and the actual `claude setup-token` invocation are unchanged
— only the leading sentence flips.
2026-05-03 12:48:37 +00:00
Gabi Simons 60526c971b Merge branch 'main' into fix/media-only-messages 2026-05-03 15:47:11 +03:00
gavrielc 6936e97fe2 Merge pull request #2206 from javexed/feat/setup-other-channel
feat(setup): add "Other…" option to channel picker
2026-05-03 15:42:11 +03:00
gavrielc dd055bbb8e Merge branch 'main' into feat/setup-other-channel 2026-05-03 15:42:00 +03:00
Ziv Daniel 0e9dadfaee fix: accept media-only messages with empty text in onNewMessage
/./ requires at least one character and silently drops messages with no
text (e.g. Telegram photo/video/file sent without a caption). Switching
to /[\s\S]*/ matches the empty string too, so media-only messages now
reach the router and then the agent.
2026-05-03 15:40:46 +03:00
gavrielc 63f88356eb Merge pull request #1467 from ingyukoh/docs/add-contributor-ingyukoh
docs: add ingyukoh to contributors
2026-05-03 12:55:29 +03:00
gavrielc b01b13323e Merge branch 'main' into docs/add-contributor-ingyukoh 2026-05-03 12:55:17 +03:00
Charlie Savage e4181f5451 fix(host-sweep): regression in #2183 — orphan-claim delete missed in tests
#2183 added orphan-claim cleanup that reopens `outbound.db` by session
path (`openOutboundDbRw(session.agent_group_id, session.id)`) so the
delete runs against a writable handle even when callers pass a readonly
one. That works for the production caller — there's a real on-disk
session DB at the expected path.

The test wrapper `_resetStuckProcessingRowsForTesting` (introduced in
the same series, #2151) is called with in-memory DBs that have no
on-disk path. The reopen creates a fresh empty file at
`<DATA_DIR>/v2-sessions/ag-test/sess-test/outbound.db`, runs the delete
against that, and leaves the in-memory `outDb` (which the test reads
afterward) untouched. The two `resetStuckProcessingRows — orphan claim
cleanup` tests assert `getProcessingClaims(outDb).toEqual([])` after
the call and fail on the row that's still there.

Fix: drop the `_…ForTesting` wrapper, export `resetStuckProcessingRows`
directly with an optional `writableOutDb` parameter. When omitted
(production), the function reopens `outbound.db` RW by session path —
existing behavior, existing safety guarantee. When provided (tests, or
any future caller that already holds a writable handle), the function
uses it directly and skips the reopen. The optional parameter has a
real meaning, not a "for tests" hack.

Public API surface change: `_resetStuckProcessingRowsForTesting` is
gone, `resetStuckProcessingRows` is now exported. No other callers
inside the repo besides the test.
2026-05-02 22:54:08 -07:00
javexed 58fc5728db feat(setup): add "Other…" option to channel picker
The first-time setup picker only listed seven channels with bash
installers. Users wanting to install one of the other channels (matrix,
github, linear, webex, etc.) had no entry point from the picker and had
to know to run /add-<name> from Claude Code afterwards.

Add an "Other…" option that prompts for a free-text name, normalizes it
(accepts "matrix", "add-matrix", or "/add-matrix"), and prints a hint
telling the user to run /add-<name> from Claude Code after setup
finishes. The verify step's "What's left" panel already covers the
empty-channels case, so the user is not left thinking the channel was
wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:06:24 -04:00
github-actions[bot] 953264e0d3 chore: bump version to 2.0.27 2026-05-02 18:37:43 +00:00
gavrielc 52051d4aa5 Merge pull request #2181 from mnolet/fix/slash-commands-on-warm-containers
fix(poll-loop): slash commands silently broken on warm containers
2026-05-02 21:37:31 +03:00
gavrielc 64769feae7 Merge branch 'main' into fix/slash-commands-on-warm-containers 2026-05-02 21:37:21 +03:00
github-actions[bot] eba5b78006 chore: bump version to 2.0.26 2026-05-02 18:23:39 +00:00
gavrielc 6b76c1a56c Merge pull request #2183 from cfis/fix/host-sweep-outbound-db-rw
fix(host-sweep): reopen outbound DB as writable for orphan claim cleanup
2026-05-02 21:23:27 +03:00
gavrielc cb1d8dd791 Merge branch 'main' into fix/host-sweep-outbound-db-rw 2026-05-02 21:23:20 +03:00
gavrielc 82216b536d Add /add-deltachat skill
Skill files only — copied from PR #2192 (channels branch).
Source adapter (src/channels/deltachat.ts) lives on the channels
branch and is installed by the skill.

Co-Authored-By: Axel McLaren <scm@axml.uk>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:21:58 +03:00
gavrielc 02650fa616 Merge pull request #1931 from qwibitai/feat/migrate-from-v1
feat: v1 → v2 migration to setup flow (experimental)
2026-05-02 19:14:25 +03:00
gavrielc 640303e4a9 Merge branch 'main' into feat/migrate-from-v1 2026-05-02 19:12:49 +03:00
Gavriel Cohen 3b5e5a24f4 fix(migrate-v2): reset auto-created messaging_group policy on re-run
If 1b-db is re-run after the v2 service has already started (e.g.
recovering from an earlier failure), the messaging_group it would
otherwise create may already exist — auto-created by the runtime router
on the first inbound message, with the router's default
unknown_sender_policy ('request_approval'), not the migration's intent
('public'). The previous reuse path skipped creation but never updated
the policy, so re-runs left the bot hanging every message waiting for
an approver that wasn't seeded yet.

When reusing an existing row that has zero wired agent_groups (signal
of a router auto-create), reset the policy to 'public'. Once any wiring
exists, the user has had a chance to tighten via the skill — leave it.

Also adds a CHANGELOG entry covering this and the two sibling fixes
(Discord DM resolution, symlink skip in copyTree).
2026-05-02 16:09:06 +00:00
Gavriel Cohen 7dbedad9bd fix(migrate-v2): skip symlinks in group copyTree
fs.copyFileSync follows symlinks, so a single broken/dangling link in v1
(e.g. .claude-shared.md → /app/CLAUDE.md, a container-side path that
doesn't resolve on the host) crashed the alphabetical traversal with
ENOENT — preventing later folders, including the actual registered
group, from being copied.

Check entry.isSymbolicLink() and skip with a one-line log. v2 uses
composed CLAUDE.md fragments, so v1's container-path symlinks have no v2
meaning and don't need to be carried forward.
2026-05-02 16:09:06 +00:00
Gavriel Cohen 8181054bdb fix(migrate-v2): resolve Discord DMs as discord:@me:<id>
The resolver only enumerated guild channels, so any v1 install whose
registered Discord chat was a DM (a common case for personal-bot
installs) failed 1b-db with "not found in any guild" — leaving the
migration without an agent_group or wiring, and the user with a bot that
received messages but had nowhere to route them.

Add an unresolved-channel classification pass: for any v1 channel id not
found in a guild, GET /channels/<id> and emit discord:@me:<id> when the
type is DM (1) or GROUP_DM (3). Matches the runtime adapter's
guild_id || "@me" encoding. Other types / 404 / 403 keep current
skip-with-warning behavior.

Caller passes the v1 channel id list (already on hand). Test coverage
extends the existing mock-fetch pattern with DM, GROUP_DM, orphan, and
dedupe cases.
2026-05-02 16:09:06 +00:00
Gavriel Cohen 7922a19af7 docs(migrate-from-v1): drop the blocker/deferred table
Trust the agent to figure out which failed steps actually stop
routing. The rule is the goal ("can the bot route one message?"),
not a hardcoded list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:07 +03:00
Gavriel Cohen 8c1b209aeb docs(migrate-from-v1): 2b-channel-auth and 3c-auth are blockers
2b-channel-auth: copies the Baileys keystore + channel-specific env
keys. Without it WhatsApp can't connect — saw this firsthand when
the original candidatePaths bug left env_keys=0,files=0.

3c-auth: registers Anthropic credentials in OneCLI. 3b installs the
gateway; 3c puts the secret in the vault. Without 3c every agent
request 401s regardless of 3b's status.

1c-groups stays deferred — agent runs on stock CLAUDE.md without it,
but routing works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:36:10 +03:00
Gavriel Cohen 2bc1279a12 docs(migrate-from-v1): trim Phase 0 to intent only
Previous version spelled out launchctl/systemctl commands, log lines
to grep for, diagnostic recipes — the agent reading this skill knows
all of that. Keep only the parts that aren't obvious from the rest of
the codebase: which steps are blocking vs deferred, the smoke-test
ordering, and the non-destructive framing for the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:31:23 +03:00
Gavriel Cohen 2617313f19 docs(migrate-from-v1): blockers-first + smoke test before deeper work
Phase 0 used to be "triage every failed step before doing anything
else", which front-loaded a bunch of fixes for things that don't
actually block the user from proving v2 works. Restructure:

- 0a — fix blockers only (1b/1d/2c/2d/3a/3b/3e). Defer non-blockers
  (1a, 1c, 1e, 2b, 3c) — most surface naturally in later phases.
- 0b — smoke test: switch v1 → v2, send a real message, verify the
  routing chain in logs/nanoclaw.log. AskUserQuestion gates whether
  to continue.
- Revert recipe (launchctl/systemctl) called out as always-available,
  not destructive — v1 process, data, and credentials are untouched.

Up-front list of what the script handled now also mentions the
WhatsApp LID resolution and Baileys keystore copy, so users see
exactly what continuity they're getting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:28:46 +03:00
Gavriel Cohen 8439a180be docs(migrate-v2): collapsible README section + skill preflight
README: replace the one-line v1 migration note with a collapsed
<details> block. Quick Start stays compact for the common case (fresh
install) while v1 users get the actual instructions. Calls out
explicitly that the script must be run from a real terminal — not from
inside a Claude session — so the channel-select / switchover prompts
and the Node/pnpm/Docker bootstrap all work.

migrate-from-v1 skill: add a Preflight section that aborts if
logs/setup-migration/handoff.json is missing. Without this, invoking
the skill before the script just leads Claude to start guessing /
running shell commands. The new message redirects them to the script
and tells them it'll hand back to Claude on completion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:22:50 +03:00
Gavriel Cohen dca02f5453 feat(migrate-v2): resolve WhatsApp LIDs from store/auth, alias DMs
v1 stored every WhatsApp DM as `<phone>@s.whatsapp.net`. v2's WA
adapter sometimes resolves the chat to `<lid>@lid` instead — when
WhatsApp delivers via the LID protocol and Baileys hasn't yet learned
a LID→phone mapping for that contact (cold cache after migration).
The router then can't find the phone-keyed messaging_group and
silently drops the message at router.ts:184.

Baileys persists every LID↔phone pair it has ever learned to disk as
`store/auth/lid-mapping-<phone>.json` (forward) and
`lid-mapping-<lid>_reverse.json` (reverse). v1 will already have these
populated for every contact it has talked to. New step 2d-whatsapp-lids
parses the reverse files and writes paired LID-keyed `messaging_groups`
+ `messaging_group_agents` rows so both `<phone>@s.whatsapp.net` and
`<lid>@lid` route to the same agent_group with the same engage rules.

No Baileys boot, no WhatsApp connectivity required — pure filesystem
read of files we've already copied via 2b-channel-auth. Step is
no-op-on-skip if either store/auth or whatsapp DM rows are missing.

Anything that slips through (a contact whose LID v1 never learned)
falls back to the runtime approval flow once the WA adapter sets
isMention=true on DMs — each unknown LID DM auto-creates an
approval-required messaging_group and the owner gets a one-tap
register prompt.

Verified end-to-end on a 12-group v1 install: 3 DM rows aliased,
inbound DM routed via the LID-keyed row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:39:36 +03:00
Gavriel Cohen 2a915e8af0 fix(migrate-v2): infer is_group from JID format
v1 didn't track is_group separately; db.ts hardcoded `is_group: 1` for
every messaging_group. v2 uses is_group=0 to collapse DM sub-thread
sessions and to drive routing decisions, so getting it wrong is latent
risk on otherwise-working installs.

New helper inferIsGroup(channelType, platformId) lives in shared.ts so
tasks.ts and any future migration step can reuse it. Inferred per
channel:
  - whatsapp: `<id>@g.us` is a group, anything else is a DM
  - telegram: negative chat IDs are groups, positive are DMs
  - everything else: default to 1 (least surprising for chats v1 chose
    to register, where DM auto-create paths weren't used)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:24:57 +03:00
Gavriel Cohen 416c283dcb fix(migrate-v2): bash 3.2 compatibility + reset coverage
migrate-v2.sh
  Replace `declare -A STEP_RESULTS` with two parallel indexed arrays
  (STEP_NAMES + STEP_STATUSES) plus a `record_step` helper. macOS ships
  bash 3.2 which has no associative arrays — `declare -A` errored out
  silently and every `STEP_RESULTS["1a-env"]=...` triggered a fatal
  bash arithmetic error (interpreting "1a" as a number). Visible
  symptom: `steps: {}` in handoff.json. Latent symptom: phase 2c's
  install loop sometimes bailed mid-iteration before invoking the
  channel install script, leaving channel code uninstalled while
  reporting `overall_status: success`.

migrate-v2-reset.sh
  Cover the gaps that left install side-effects in place between
  iterations:
    - Remove untracked adapter files in src/channels/ (mirror the
      pattern already used for container/skills/).
    - Restore tracked setup helpers that channel installs overwrite
      (setup/whatsapp-auth.ts, setup/pair-telegram.ts, setup/index.ts)
      and remove untracked ones they create (setup/groups.ts).
    - Restore package.json + pnpm-lock.yaml (channel installs add
      deps like @whiskeysockets/baileys).
  Setup/migrate-v2/* is intentionally not touched — that's where user
  WIP lives.

Verified end-to-end: reset → migrate → all 9 steps reported in
handoff.json with status "success", phase 2c install actually runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:50:21 +03:00
Gavriel Cohen aec7ddd099 fix(migrate-v2): correct JID parsing, Discord guildId lookup, silent failures
- shared.ts: parseJid now recognizes raw Baileys WhatsApp JIDs
  (`<id>@s.whatsapp.net`, `@g.us`, etc.); v2PlatformId returns the raw
  JID for whatsapp to match what the runtime adapter emits. Without this,
  every WhatsApp group in a v1 install was silently skipped.

- discord-resolver.ts: new helper that uses DISCORD_BOT_TOKEN to look up
  channelId → guildId via the Discord API, since v1 stored only the
  channel id but v2 needs `discord:<guildId>:<channelId>`. Best-effort:
  on missing/invalid token or network error, returns empty resolver and
  the affected groups are skipped with the reason surfaced per channel.

- db.ts, tasks.ts: route Discord groups through the resolver; other
  channels go through v2PlatformId unchanged. Resolver only built when
  at least one Discord group exists, so non-Discord installs incur no
  network.

- db.ts: when every v1 group is skipped, exit non-zero with a FAIL line
  instead of `OK:groups=N,...,skipped=N`, so the wrapper doesn't hide
  total failure under a successful-looking summary.

- migrate-v2.sh: run_step now surfaces ERROR: lines from successful
  steps (with count + first 3 + raw log path); phase 2c install loop
  populates STEP_RESULTS so install failures show in handoff.json
  instead of silently passing.

- sessions.ts: copyTree skips dangling symlinks (e.g. v1's
  `.claude/debug/latest`) instead of crashing the entire step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:32:34 +03:00
Mike Nolet ceb0b9cf5f fix(test-infra): openInboundDb honors in-memory test DB
initTestSessionDb() creates an in-memory inbound singleton, but
openInboundDb() always opened the hardcoded /workspace/inbound.db
path. Every test that exercised getPendingMessages — directly, or via
test fixtures that load data through it (e.g. poll-loop.test.ts:29
loads formatter test rows via getPendingMessages) — failed with
SQLITE_CANTOPEN under `bun test` outside a real container.

Baseline on main: 34 pass, 25 fail across 6 files. After this fix:
59 pass, 0 fail.

In test mode, openInboundDb returns the in-memory singleton. The
singleton's .close() is no-op'd in initTestSessionDb so caller
try/finally cleanup doesn't tear down the shared DB; closeSessionDb
invokes the saved original close to do the real teardown.

Production behavior is unchanged — _inboundIsTest only flips inside
initTestSessionDb, which is never called outside the test runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:45:23 +02:00
Charlie Savage 8d022fd9da fix(host-sweep): reopen outbound DB as writable for orphan claim cleanup
PR #2151 added deleteOrphanProcessingClaims() to resetStuckProcessingRows(),
but outDb is always opened readonly (openOutboundDb uses immutable: true).
The write silently failed, leaving orphan processing_ack rows that block
future message delivery for the session.

Fix: add openOutboundDbRw() alongside the existing readonly opener and use
it in resetStuckProcessingRows() to open a short-lived writable handle just
for the delete. The readonly handle is still used for all reads above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:44:07 -07:00
Mike Nolet 1ebb2dc8d2 fix(poll-loop): slash commands silently broken on warm containers
The follow-up poller filtered /clear out of every tick without acking
the row, and pushed every other slash command through plain
formatMessages() (XML wrapping). On a warm container the outer
while(true) loop never regains control, so:

  - /clear sat pending in messages_in forever (no response at all)
  - /compact, /cost, /context, /files, /remote-control arrived at the
    SDK as XML-wrapped user text and were never dispatched as commands

Both modes are invisible to host monitoring: rows are either left
pending without a processing_ack claim, or marked completed normally;
heartbeat keeps firing inside the SDK event loop.

When the follow-up poller observes any slash command (admin or
passthrough — categorizeMessage decides), end the active query so the
current turn winds down cleanly and the outer loop wakes, re-fetches
the same pending set, and runs them through the canonical path
(/clear handler + formatMessagesWithCommands raw dispatch). Leave the
rows untouched so the outer-loop fetch sees the same set the poller
saw.

Cost: each slash command on a warm container forces close+reopen of
the SDK stream — a few seconds of subprocess startup. The Anthropic
prompt cache is server-side with a 5-min TTL keyed on prefix hash, so
stream lifecycle does not affect cache lifetime; close+reopen within
5 min still gets cache hits.

Also corrects the warm-stream rationale comment on processQuery, which
implied keeping the stream open preserved cache warmth — it doesn't.

Testing evidence — cache stays warm across stream close+reopen:

  Turn 1 (warm session):
    Usage: in=6 out=245 cache_create=92 cache_read=22996
    Full cache hit (22996 tokens).

  Turn 2 — /clear arrives:
    Pending slash command — ending stream so outer loop can process
    Clearing session (resetting continuation)
    Usage: in=6 out=95 cache_create=9393 cache_read=13600
    System prompt + tool defs (~13600 tokens) still hit cache;
    conversation history is gone (continuation reset) so the new turn
    writes fresh context.

  Turn 3 — /cost arrives:
    Pending slash command — ending stream so outer loop can process
    Usage: in=0 out=0 cache_create=0 cache_read=0 wall=0.0s api=0.0s
    /cost is a CLI built-in: dispatched locally by the SDK, no API
    call. Pre-fix this would have arrived as XML-wrapped user text
    and never dispatched — confirms the broader fix works.

  Turn 4 (next chat after /cost):
    Usage: in=6 out=142 cache_create=328 cache_read=22993
    Full cache hit again (22993 tokens read, 328 written). Despite the
    /cost-induced stream close+reopen, the server-side prompt cache
    survived: the new sdkQuery() resumed the same continuation, the
    request prefix matched the cached entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:35:14 +02:00
exe.dev user ce9f175238 fix: reorder phase 3 — Docker before OneCLI
OneCLI runs in a Docker container, so Docker must be installed first.
Reordered: Docker (3a) → OneCLI (3b) → Auth (3c) → Skills (3d) →
Build (3e). OneCLI install now skips with a clear message if Docker
isn't available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 20:28:45 +00:00
exe.dev user cf3fcc18d4 fix: install Docker if missing, don't skip container build
migrate-v2.sh now runs setup/install-docker.sh when Docker isn't
found instead of just printing a message. The container build step
reports failure (not skip) when Docker is unavailable so the skill
can triage it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 20:28:04 +00:00
exe.dev user 00a30e3eff docs: update changelog, remove experimental label from migration
The migration is no longer experimental — it's been tested end-to-end
with service switchover, session continuity, and revert. Updated the
changelog entry to reflect the new migrate-v2.sh flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 20:24:39 +00:00
exe.dev user f35be24aef chore: move shared helpers to migrate-v2/, delete migrate-v1/
Extracted the helpers we use (JID parsing, trigger mapping, channel
auth registry, generateId, v2PlatformId) into setup/migrate-v2/shared.ts.
Deleted setup/migrate-v1/ entirely — no code references it anymore.

Updated README, CLAUDE.md, docs/v1-to-v2-changes.md, and
docs/migration-dev.md to reference the new paths and migrate-v2.sh
entry point.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 20:23:34 +00:00
exe.dev user 67eb85d818 chore: remove old setup-embedded migration steps
The old migration flow (detect → validate → db → groups → env →
channel-auth → channels → tasks) ran inside `bash nanoclaw.sh` via
setup/auto.ts. Replaced by the standalone `bash migrate-v2.sh` flow.

Deleted:
- setup/migrate-v1.ts (orchestrator)
- setup/migrate-v1/{detect,validate,db,env,groups,channel-auth,channels,tasks}.ts

Kept:
- setup/migrate-v1/shared.ts (used by new migrate-v2/ steps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 20:20:06 +00:00
exe.dev user 1d73b2986a feat: add migrate-v2.sh — standalone v1 → v2 migration script
New entry point: `bash migrate-v2.sh` from the v2 checkout.
Replaces the old setup-embedded migration flow with a standalone
4-phase script + rewritten Claude skill for the interactive parts.

Phase 0: Bootstrap (Node/pnpm/deps via setup.sh) + find v1
Phase 1: Core state (env, DB, groups, sessions, tasks)
Phase 2: Channels (clack multiselect, auth copy, code install)
Phase 3: Infrastructure (OneCLI, auth, Docker, skills, container build)
Service switchover: stop v1 → start v2 → test → keep or revert
Phase 4: Handoff → exec claude "/migrate-from-v1"

The skill handles: owner seeding, access policy, CLAUDE.local.md
cleanup, container config validation, fork customization porting.

Key fixes found during testing:
- triggerToEngage: requires_trigger=0 must override non-empty pattern
- unknown_sender_policy defaults to 'public' (strict drops all msgs
  before owner is seeded)
- Service revert must stop v2 (parse unit name from step log, not
  early tsx one-liner that can fail)
- Session continuity: copy JSONL from -workspace-group/ to
  -workspace-agent/ and write continuation:claude into outbound.db
- container_config.additionalMounts written directly to container.json
  (same shape in v1 and v2)
- EXIT trap writes handoff.json; explicit write_handoff before exec

Includes migrate-v2-reset.sh for dev iteration and docs/migration-dev.md
for testing/debugging reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 20:13:38 +00:00
exe.dev user 1b08b58fcd setup: drop redundant agent ping; harden auth detection and OAuth paste
- verify: remove the CLI ping; cli-agent step earlier in setup already
  proved the round-trip works, and the test agent gets cleaned up before
  verify runs — so the ping was guaranteed to fail on installs that wired
  a messaging app instead of staying CLI-only. Status now collapses to
  service-running ∧ credentials ∧ ≥1 wired group.
- agent-ping: catch Claude Code's "Please run /login" / "Not logged in" /
  "Invalid API key" banners so a successfully-spawned agent that has no
  credentials no longer reports as 'ok'.
- auth paste: validate the full sk-ant-oat…AA shape; when the cleaned
  input is under 90 chars, surface a truncation-specific hint pointing at
  terminal wrap as the likely cause. Strip internal whitespace at both
  validate and assignment so multi-line pastes that survive clack also
  go through cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:03:02 +00:00
github-actions[bot] 897b770296 chore: bump version to 2.0.25 2026-05-01 16:03:19 +00:00
github-actions[bot] a71d2a4e2c docs: update token count to 140k tokens · 70% of context window 2026-05-01 16:03:16 +00:00
gavrielc 39c579ba2a Merge pull request #2151 from glifocat/fix/host-sweep-orphan-processing-ack
fix(host-sweep): clear orphan processing_ack rows on kill to prevent claim-stuck respawn loop
2026-05-01 19:03:00 +03:00
gavrielc dab4fb497b Merge branch 'main' into fix/host-sweep-orphan-processing-ack 2026-05-01 18:42:04 +03:00
github-actions[bot] 663d9a4091 docs: update token count to 139k tokens · 70% of context window 2026-05-01 13:30:25 +00:00
github-actions[bot] a590fbd830 chore: bump version to 2.0.24 2026-05-01 13:30:19 +00:00
gavrielc 20a17cbc44 Merge pull request #2160 from kky/pr/inbound-db-fresh-open
fix(agent-runner): open inbound.db fresh per messages_in read
2026-05-01 16:30:07 +03:00
gavrielc 0d836220d9 Merge branch 'main' into pr/inbound-db-fresh-open 2026-05-01 16:29:46 +03:00
gabi-simons 36e731c02d Merge branch 'main' into feat/migrate-from-v1
Resolve import conflict in setup/auto.ts — keep runMigrateV1 import,
deduplicate runWindowedStep and getLaunchdLabel/getSystemdUnit imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 04:52:41 +00:00
github-actions[bot] 8c962d3f73 chore: bump version to 2.0.23 2026-04-30 23:00:24 +00:00
exe.dev user 28c38ae28b fix(container): pin vercel to 52.2.1 to dodge broken 53.0.1 publish
vercel@53.0.1 declares a dep on @vercel/static-build@2.9.22 which is not
published on npm (only 2.9.21 exists), breaking every fresh container
build that resolves vercel@latest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:00:02 +00:00
github-actions[bot] 7ac8dd0f6d docs: update token count to 139k tokens · 69% of context window 2026-04-30 22:28:25 +00:00
gavrielc 7814e45570 Merge pull request #2001 from Hinotoi-agent/fix/outbox-path-confinement
[security] fix(container): prevent host file read/delete via container-controlled outbox paths
2026-05-01 01:28:07 +03:00
gavrielc fc3c11b6b9 fix(session-manager): apply outbox path-confinement to inbound attachments
Mirrors the four defenses on the outbound side onto extractAttachmentFiles:

  1. Reject unsafe messageId via isSafeAttachmentName before any inbox path
     is built. WhatsApp passes msg.key.id through raw and that field is
     client generated, so a peer can craft it; future end to end encrypted
     adapters will have the same property.
  2. lstatSync on the inbox dir refuses a pre placed symlink before
     mkdirSync would silently follow it.
  3. realpathSync + isPathInside contains the resolved dir under the
     session inbox root.
  4. writeFileSync uses the wx flag so a pre placed symlink at the file
     path is refused atomically by the kernel; EEXIST surfaces as a
     logged skip.

Threat: the session dir is mounted writable into the container at
/workspace, so a compromised agent can pre place inbox/<future msgId>/
as a symlink and wait for a chat message with a matching id to redirect
the host write. The four guards together close that window.

Consolidates with the existing isSafeAttachmentName helper from
attachment-safety.ts rather than introducing a duplicate basename
validator inside session-manager.

Co-Authored-By: Daisuke Tsuji <dim0627@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:27:09 +03:00
hinotoi-agent 852009dcb1 fix(container): confine outbound attachment paths 2026-05-01 01:27:09 +03:00
gavrielc 212281ba8e Merge pull request #2055 from dooha333/pr/setup-local-bin-path
fix(setup): inject ~/.local/bin into PATH so post-install onecli is reachable
2026-05-01 01:20:07 +03:00
gavrielc 6db6bf9c40 Merge branch 'main' into pr/setup-local-bin-path 2026-05-01 01:19:58 +03:00
github-actions[bot] 8977f0d0be chore: bump version to 2.0.22 2026-04-30 21:57:45 +00:00
gavrielc d13f338af9 Merge pull request #2114 from robbyczgw-cla/fix/poll-loop-prescripts-on-followups
fix(poll-loop): apply pre-task scripts to follow-up injections too
2026-05-01 00:57:34 +03:00
gavrielc 5ab1a2733c review: catch follow-up poll errors + re-check done before push
Two fixes on top of the follow-up pre-task-script work:

1. The void async IIFE inside the interval handler had no catch, so a
   throw from the dynamic import or applyPreTaskScripts escaped as an
   unhandled rejection — terminating the container. The initial-batch
   path is wrapped by processQuery's outer try/catch; the follow-up
   path needs its own. Now logs the error and lets the next tick retry.

2. Re-check `done` immediately before query.push. The flag can flip
   true while applyPreTaskScripts is awaited (outer stream finishes
   during the script execution); without the re-check we'd push into a
   closed query. Claimed messages get released by the host's
   processing-claim sweep — same recovery posture as the rest of the
   poller.

Co-Authored-By: Michael Zazon <mzazon@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:55:46 +03:00
gavrielc 7d29888e59 Merge branch 'main' into fix/poll-loop-prescripts-on-followups 2026-05-01 00:34:45 +03:00
github-actions[bot] 58d875b3c3 chore: bump version to 2.0.21 2026-04-30 21:31:18 +00:00
gavrielc 3e7fea0fde Merge pull request #2142 from mnolet/fix/schedule-task-routing
fix(scheduling): include routing in schedule_task content JSON
2026-05-01 00:31:04 +03:00
gavrielc d418f830db Merge branch 'main' into fix/schedule-task-routing 2026-05-01 00:30:11 +03:00
Mohamed Khedr 32daf607c1 Merge branch 'main' into pr/setup-local-bin-path 2026-04-30 21:57:55 +01:00
gavrielc 524ac221e1 Merge pull request #2111 from qwibitai/setup-scratch-agent-cleanup
feat(setup): delete scratch agent after ping-pong, simplify flow
2026-04-30 23:20:54 +03:00
gavrielc 69b4225916 Merge branch 'main' into setup-scratch-agent-cleanup 2026-04-30 23:20:32 +03:00
gavrielc 3d6a9b74f3 review: surface ping-test cleanup failures + restore copy
Routes the post-ping `_ping-test` cleanup through `spawnQuiet` +
`setupLog.step` so a non-zero exit from `delete-cli-agent.ts` lands
in `logs/setup-steps/cleanup-cli-agent.log` and the progression log,
and prints a one-line warn to the user. Previously the spawnSync was
fire-and-forget with `stdio: 'ignore'`, leaving an orphan agent group
silently if cleanup failed.

Restores the original copy on the cli-agent step labels, the ping
explainer paragraph, and the post-ping spinner stop line — those
copy changes are out of scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:16:34 +03:00
gavrielc dcc625f2b8 Merge pull request #2155 from qwibitai/setup-root-warning-v2
Add root user warning gate to Linux setup
2026-04-30 23:09:36 +03:00
gavrielc 99a8559b14 Merge remote-tracking branch 'origin/main' into setup-root-warning-v2
# Conflicts:
#	setup/auto.ts
2026-04-30 23:07:38 +03:00
gavrielc 3dc772cca0 Merge branch 'main' into setup-scratch-agent-cleanup 2026-04-30 23:05:09 +03:00
gavrielc 5ebad280ce Merge pull request #1502 from Koshkoshinsk/docs/pr-hygiene-check
Add PR hygiene check to CLAUDE.md and contributing guidelines
2026-04-30 23:00:43 +03:00
gavrielc d73b9e14ad Merge branch 'main' into docs/pr-hygiene-check 2026-04-30 23:00:10 +03:00
gavrielc 681a5b51c8 Merge pull request #2157 from qwibitai/setup-lazy-env-reuse
refactor(setup): per-step env var reuse instead of upfront all-or-nothing
2026-04-30 22:59:03 +03:00
gavrielc 8e45f4e964 Merge branch 'main' into setup-lazy-env-reuse 2026-04-30 22:58:53 +03:00
gavrielc eb9a5d706d Merge branch 'main' into setup-scratch-agent-cleanup 2026-04-30 22:54:48 +03:00
github-actions[bot] 46cd91c306 docs: update token count to 138k tokens · 69% of context window 2026-04-30 19:54:27 +00:00
github-actions[bot] 0218159ef0 chore: bump version to 2.0.20 2026-04-30 19:54:21 +00:00
gavrielc 3ee07effea Merge pull request #2105 from qwibitai/feat/channel-approval-flow
feat: richer channel-approval flow with agent selection and free-text naming
2026-04-30 22:54:08 +03:00
gavrielc 462b9581b2 Merge branch 'main' into feat/channel-approval-flow 2026-04-30 22:54:00 +03:00
gavrielc a359f2555f Merge pull request #2158 from alipgoldberg/setup-splash-screen
feat(setup): show under-the-sea lobster splash at boot
2026-04-30 22:51:35 +03:00
gavrielc 6525926ca9 Merge branch 'main' into setup-splash-screen 2026-04-30 22:51:01 +03:00
gavrielc 35d35fefc3 Merge pull request #2154 from alipgoldberg/setup-fallback-url-in-prompt
feat(setup): move URL fallback into the open-browser prompt
2026-04-30 22:50:44 +03:00
gavrielc eab9110232 Merge branch 'main' into setup-fallback-url-in-prompt 2026-04-30 22:48:47 +03:00
gavrielc 2c0d0e9d44 Merge pull request #2146 from alipgoldberg/setup-headless-link-copy
feat(setup): label headless URL fallback with "Get started:"
2026-04-30 22:48:26 +03:00
Claw ccfdf2dd75 fix(agent-runner): open inbound.db fresh per messages_in read
Cached singleton can return stale rows on virtiofs/NFS mounts,
causing follow-up messages to silently never be polled. Add
openInboundDb() with mmap_size=0 and switch the three messages_in
readers to it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:14:04 -04:00
gavrielc 17823dffae Merge branch 'main' into setup-headless-link-copy 2026-04-30 17:14:25 +03:00
gavrielc 941a75f65d Merge pull request #2145 from alipgoldberg/setup-headless-skip-browser
feat(setup): skip browser-open prompts on headless devices
2026-04-30 17:13:57 +03:00
gavrielc c2ee2b7c91 Merge branch 'main' into setup-headless-skip-browser 2026-04-30 17:11:35 +03:00
gavrielc ef62f57326 Merge pull request #2108 from alipgoldberg/setup-fmt-duration
feat(setup): switch elapsed-time suffixes to "Xm Ys" past 60s
2026-04-30 17:10:40 +03:00
exe.dev user e51f6e0c41 feat(setup): show under-the-sea lobster splash at boot
Replaces the single-line `NanoClaw` wordmark printed by
nanoclaw.sh with a multi-line splash frame: the lobster mascot
rendered as truecolor braille, drifting bubbles on either side,
the figlet wordmark below (Nano in bold, Claw in cyan bold),
three taglines — "Small.", "Runs on your machine.", "Yours to
modify." — and a navy seafloor line.

The frame is pre-rendered into `assets/setup-splash.txt` (built
from `assets/nanoclaw-icon.png` via chafa for the lobster +
figlet for the wordmark). nanoclaw.sh just streams the literal
bytes — no runtime dependency on chafa, figlet, or
ImageMagick.

Total height: 30 lines. Visible width: ~40 columns (fits any
terminal). Truecolor ANSI codes are used directly; terminals
without truecolor support will see a degraded but still
readable frame.

Also removes the standalone "Small. Runs on your machine.
Yours to modify." tagline line that nanoclaw.sh used to print
above the bootstrap spinner — those taglines now appear inside
the splash, so showing them again would duplicate.

The wordmark-suppression flow downstream (`setup:auto` honoring
`NANOCLAW_BOOTSTRAPPED=1`) is unchanged: the splash prints once
in nanoclaw.sh, setup:auto's `printIntro()` sees the flag and
keeps the clack `p.intro` line clean ("Let's get you set up.").
2026-04-30 16:46:43 +03:00
exe.dev user cb15e606c3 feat(setup): move URL fallback into the open-browser prompt
On GUI devices the URL was previously rendered dim inside the
instructional `note(...)` card, then `confirmThenOpen` printed
its prompt below: read the card, see the URL, then a separate
"Press Enter to open the X" prompt with no link near it. Two
visual moments for what's really one decision.

This PR pulls the URL out of the card on GUI devices and
relocates it directly under the action line of the confirm
prompt, separated only by a dim "If browser does not appear,
please visit: <url>" line:

    │
    ◆  Press Enter to open the Developer Portal
    │  If browser does not appear, please visit: …  (dim)
    │  ● Yes / ○ No
    │

Action and fallback live as one prompt block — the user sees
both at the same time, no need to scroll back up to grab the
URL if the auto-open misses.

Headless behavior is unchanged: `formatNoteLink` still emits
"Get started: <url>" inside the card on headless devices (per
#2146), and `confirmThenOpen` still no-ops on headless (per
#2145). The only thing that changed for headless is the leading
`\n` in the helper output, which acts as a visual separator from
the steps above.

Five call sites adjusted (Discord ×3, Slack ×1, Telegram ×1) to
use `.filter((line) => line !== null)` so the now-nullable
`formatNoteLink` cleanly drops out of GUI-rendered cards.
2026-04-30 16:46:29 +03:00
exe.dev user 6863e0f63b feat(setup): label headless URL fallback with "Get started:"
When a card's auto-open is gated on `confirmThenOpen`, the URL also
appears inside the surrounding `note(...)` as a copy-paste fallback —
rendered dim because on a GUI device the auto-open is doing the
heavy lifting and the printed URL is just an incidental backup.

On headless devices the auto-open doesn't run (per #2145), so the
URL inside the note is the user's *only* path forward. A dim URL
reads as "incidental reference" exactly when it should be reading
as "this is the action."

Adds `formatNoteLink(url)` to setup/lib/browser.ts:
  - GUI device → `k.dim(url)` (unchanged from today)
  - Headless device → `Get started: <url>` at full strength

Replaces five call sites (Discord ×3, Slack ×1, Telegram ×1).
Single helper, atomic switch via the same `isHeadless()` plumbing
introduced in #2145, so the headless behavior across all five
flows stays in sync.
2026-04-30 16:46:16 +03:00
exe.dev user 4d42bb95fb feat(setup): skip browser-open prompts on headless devices
Wires the existing `isHeadless()` from setup/platform.ts into
`confirmThenOpen`. When the helper detects a headless device
(Linux without `DISPLAY`/`WAYLAND_DISPLAY`), both the
"Press Enter to open your browser" prompt and the actual
`openUrl(...)` call are skipped — there's no browser to launch
and the user can't usefully press Enter to summon one.

Why this is enough — the surrounding flow already supports the
headless path implicitly:

  - Every `confirmThenOpen` call site sits beneath a `note(...)`
    that prints the URL and the steps the user needs to take.
    The URL is already visible to copy-paste onto another
    device.

  - Every site is followed by an explicit confirmation prompt
    ("Got your bot token?", "Done with the X?", etc.) that
    naturally serves as the headless user's "I finished the
    thing on my other device" signal.

So the headless branch becomes: read the note, do the thing,
answer the next prompt — without a useless "Press Enter to
open your browser" detour in between.

Coverage rationale (~95% accurate for the cases that actually
cause user confusion today):

  - Linux + no `DISPLAY`/`WAYLAND_DISPLAY` → headless. Catches:
      • Raspberry Pi headless installs
      • Bare-metal Linux servers
      • SSH'd into Linux without X11 forwarding
      • CI environments on Linux
      • Linux containers (which have no display)
  - macOS → never headless. Even SSH'd Macs can usually still
    open URLs through the local user's session, so treating
    them as GUI-capable is the right default.
  - Windows → never headless (effectively always GUI in
    practice).

The remaining ~5% are edge cases (someone manually unset
`DISPLAY` on a desktop Linux session, etc.) that almost never
happen accidentally and recover gracefully — the URL is still
visible in the surrounding note.

Six call sites in channel adapters (Discord ×3, Slack ×1,
Telegram ×1, Teams ×1) all change behavior atomically through
the single helper. No per-site copy changes needed; consistency
is enforced by the central wiring.
2026-04-30 16:45:59 +03:00
exe.dev user a66cd545d5 feat(setup): switch elapsed-time suffixes to "Xm Ys" past 60s
Adds a `fmtDuration(ms)` helper in `setup/lib/theme.ts` that returns
`47s` under a minute and `1m 34s` from 60s onward, then routes every
elapsed-time spinner suffix in the setup flow through it. Replaces
the inline `Math.round((Date.now() - start) / 1000)` + `(${elapsed}s)`
pattern at every site.

Format is consistent past 60s — `1m 0s` over `1m` — so the live
spinner doesn't change shape at every whole-minute crossing.

Sites updated: setup/auto.ts, setup/lib/{runner,tz-from-claude,
claude-assist}.ts, and setup/channels/{signal,whatsapp,telegram,
discord,slack}.ts. Pre-allocated suffix budgets in `fitToWidth`
calls bumped from `' (999s)'` to `' (99m 59s)'` so long-running
steps don't blow past the reserved width.
2026-04-30 16:45:21 +03:00
Gabi Simons cfb737d681 Merge branch 'main' into feat/channel-approval-flow 2026-04-30 15:54:55 +03:00
Gabi 1db98ee614 refactor(setup): check env vars per-step instead of upfront all-or-nothing
Remove the grouped detectExistingEnv() block that asked "reuse all or
start fresh" at the top of setup. Each channel step now reads credentials
directly from .env on disk via readEnvKey() and offers to reuse them
individually at the point of use.

- Add readEnvKey() helper in setup/environment.ts
- Remove ENV_KEY_GROUPS, ExistingEnvGroup, detectExistingEnv from auto.ts
- Move detectRegisteredGroups skip to right before cli-agent step
- Switch all channel files (telegram, discord, slack, teams, imessage)
  from process.env to readEnvKey()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 12:36:25 +00:00
gavrielc bb1b41800c Merge pull request #2156 from qwibitai/fix/telegram-spinner-overflow
fix: prevent telegram pairing spinner from flooding terminal
2026-04-30 15:30:54 +03:00
gabi-simons 5be15be139 fix: prevent telegram pairing spinner from flooding the terminal
The spinner label exceeded terminal width, breaking clack's cursor-up
redraw and causing each animation tick to print a new line instead of
updating in-place. Wrap with fitToWidth() like other setup spinners.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 12:07:53 +00:00
Koshkoshinsk e56132d04a Remove SSH key copy step from root warning instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 11:33:25 +00:00
Gabi Simons 5cf5840426 Merge branch 'main' into feat/channel-approval-flow 2026-04-30 14:11:21 +03:00
Ethan 7ce9922cde fix(host-sweep): clear orphan processing_ack on kill to prevent claim-stuck loop
When the host kills a container (absolute-ceiling, claim-stuck, or crashed),
resetStuckProcessingRows reset messages_in but left orphan rows in
processing_ack. The next sweep tick spawned a fresh container and, on the
same tick, ran enforceRunningContainerSla against outbound.db that still
contained the previous container's claim with a hours-old status_changed
timestamp — instant kill-claim, before the agent-runner could open
outbound.db to run its own clearStaleProcessingAcks(). Loop until tries
hit MAX_TRIES.

Add deleteOrphanProcessingClaims() in session-db and call it at the end of
resetStuckProcessingRows. Safe to write outbound.db here because the host
only enters this path after killContainer (or when no container is running).

Tests in host-sweep.test.ts cover the helper plus the regression: orphan
claim from a 2h-old kill is now removed atomically with the messages_in
reset, so the next sweep tick sees an empty claims list and the freshly
respawned container survives long enough to start its agent-runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:54:42 +02:00
Koshkoshinsk 35f8e9d2f5 Move SSH hint above user-creation steps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:40:45 +00:00
Koshkoshinsk d5388a168b Replace web terminal instructions with SSH setup hint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:37:42 +00:00
Koshkoshinsk 23a3fea868 Add passwordless sudo step to root warning instructions
Setup steps like install-node.sh and install-docker.sh run sudo
non-interactively. Without NOPASSWD, password prompts can silently
hang when piped through the setup runner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:51 +00:00
Koshkoshinsk 72837c1643 Fix sg docker re-exec restarting setup from scratch
When maybeReexecUnderSg() re-launches setup:auto under `sg docker`,
the new process had no memory of completed steps — it re-prompted the
welcome menu, re-ran environment and container checks, and then failed
on onecli because the earlier run's state was lost.

Pass NANOCLAW_SKIP with completedStepNames() so the re-exec'd process
skips already-finished steps, suppress the welcome menu and existing-env
prompts on re-exec since the user already answered them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
Koshkoshinsk d07cd7afa0 Remove redundant root login step from user-creation instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
Koshkoshinsk 3d29965413 Update root warning instructions: add SSH key copy, remove extra step
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
Koshkoshinsk 0a18c1d21a Ensure user is in docker group before sg docker, revert workarounds
The root cause of broken keyboard navigation was sg docker prompting
for the (unset) group password when the user wasn't in the docker
group. Fix by running sudo usermod -aG docker before sg docker.

This makes the stty sane calls and p.confirm workaround unnecessary,
so revert those. Also remove the manual docker group instruction from
nanoclaw.sh since container.ts handles it automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
Koshkoshinsk dec1be6adc Add clone step to root warning user-creation instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
Koshkoshinsk 030ee8a46f Update root warning instructions: add root login step, fix ssh user
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
Koshkoshinsk c4f654083d Change root warning from y/N prompt to numbered menu options
Clearer UX: option 1 shows user creation instructions,
option 2 explicitly continues as root (not recommended).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
Koshkoshinsk 7755082a4c Add root user warning gate to Linux setup pre-flight
Users running setup as root hit permission issues with containers,
services, and file ownership. Warn early with an interactive prompt
and provide step-by-step instructions to create a regular user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:31:30 +00:00
gabi-simons 8a205808e0 fix(setup): wrap scratch agent cleanup in transaction, remove session data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 08:19:18 +00:00
Gabi Simons d7c76ac12b Merge branch 'main' into setup-scratch-agent-cleanup 2026-04-30 11:07:01 +03:00
github-actions[bot] f828e2971c chore: bump version to 2.0.19 2026-04-30 07:40:21 +00:00
github-actions[bot] 43f49b988e docs: update token count to 135k tokens · 68% of context window 2026-04-30 07:40:16 +00:00
gavrielc 012292d063 Merge pull request #2115 from robbyczgw-cla/fix/session-manager-attachment-extensions
fix(session-manager): derive attachment extension from mimeType and att.type
2026-04-30 10:40:01 +03:00
gavrielc d2151ae848 Merge branch 'main' into fix/session-manager-attachment-extensions 2026-04-30 10:39:50 +03:00
github-actions[bot] 15f286b73d chore: bump version to 2.0.18 2026-04-30 07:34:23 +00:00
gavrielc 6e5e568da1 sanitize agent sent file names to prevent path traversal 2026-04-30 10:33:46 +03:00
gavrielc 2a3be9ec7f extract attachment-naming, harden mimeType guard, add tests
Move the MIME/type-to-extension maps and derivation helpers out of
session-manager.ts into a dedicated attachment-naming module — keeps
session-manager focused on session lifecycle and gives the helpers
a natural home for unit tests alongside the existing attachment-safety
module.

Two small fixes alongside the extraction:

- extForMime now guards `typeof mime !== 'string'` before .split, so a
  buggy bridge passing `mimeType: { ... }` (object) no longer crashes
  the inbound write loop.
- deriveAttachmentName computes Date.now() once per call instead of
  twice, and tightens the explicit-name check to a string-and-truthy
  guard so non-string values fall through to derivation.

Adds attachment-naming.test.ts with 11 cases covering MIME normalization
(case + parameters), Telegram type fallback, the non-string defensive
guard, and the bare-timestamp fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:41:24 +03:00
Gabi Simons 2b1b138a44 Merge branch 'main' into feat/channel-approval-flow 2026-04-30 09:37:54 +03:00
Gabi Simons 3c7b971f1b Merge branch 'main' into setup-scratch-agent-cleanup 2026-04-30 09:37:39 +03:00
Mike Nolet 8dd004ca75 fix(scheduling): include routing in schedule_task content JSON
The schedule_task MCP tool wrote routing fields (platform_id, channel_type,
thread_id) onto the outbound system message's row columns, but
handleSystemAction (src/delivery.ts) parses content JSON and forwards only
that to handlers. handleScheduleTask (src/modules/scheduling/actions.ts)
reads content.platformId/channelType/threadId — which the writer never
populated — so every kind='task' row landed in messages_in with all-null
routing.

When host-sweep wakes a scheduled task, dispatchResultText's fast path
requires routing on the message and bails when it's null, falling through
to the "Routing recovery" retry prompt. End-user delivery still works
because the agent can pick a destination from its destinations table on
retry — so the bug went undetected, silently costing one extra LLM turn
per scheduled-task wake. Sessions whose destinations table has no channel
row (e.g. agent-only destinations) fail outright with a recovery loop.

Fix: add the routing fields to the content JSON so the writer matches the
contract handleScheduleTask already expects. cancel/pause/resume/update_task
operate by id alone and don't need routing.
2026-04-30 08:13:59 +02:00
github-actions[bot] 34f3612877 docs: update token count to 135k tokens · 67% of context window 2026-04-29 15:30:23 +00:00
github-actions[bot] 1452ed262b chore: bump version to 2.0.17 2026-04-29 15:30:20 +00:00
gavrielc 597e282f88 Merge pull request #2110 from qwibitai/fix/credential-failure-ux
fix(credentials): require OneCLI gateway for container spawn
2026-04-29 18:30:05 +03:00
gavrielc 33a03f25a9 Merge remote-tracking branch 'origin/main' into fix/credential-failure-ux 2026-04-29 18:28:57 +03:00
gavrielc e31a6c7e34 revert(credentials): drop auth-required login-message handling
Removing the "Not logged in · Please run /login" detection and
substitution from this PR — narrowing scope to just the OneCLI
gateway transient-retry change. The login-message handling will be
addressed separately.

Reverts:
- AgentProvider.isAuthRequired / authRequiredMessage
- ClaudeProvider auth-required regex, classifier, and remediation text
- poll-loop writeAuthRequiredMessage helper + call sites
- claude.test.ts (auth-only test file)

OneCLI/wakeContainer changes (the remaining content of the PR) are
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:26:04 +03:00
github-actions[bot] ee165d09c2 docs: update token count to 134k tokens · 67% of context window 2026-04-29 15:13:42 +00:00
github-actions[bot] 70cb35f58b chore: bump version to 2.0.16 2026-04-29 15:13:37 +00:00
gavrielc d1a2505d20 Merge pull request #2116 from robbyczgw-cla/fix/compact-window-operator-override
fix(claude-provider): respect operator-set CLAUDE_CODE_AUTO_COMPACT_WINDOW (closes #1820)
2026-04-29 18:13:23 +03:00
robbyczgw-cla 9889848932 fix(claude-provider): respect operator-set CLAUDE_CODE_AUTO_COMPACT_WINDOW
Closes #1820.

The container agent-runner sets CLAUDE_CODE_AUTO_COMPACT_WINDOW
unconditionally on the container process env, with no way to override
it per-deployment without editing source. Read process.env first and
fall back to the existing 165000 literal when unset.

Default behavior is unchanged for installs that do not set the env
var. Operators running 1M-context models or emergency-tuning a live
deployment can now raise or lower the threshold from the host env.
2026-04-29 15:07:26 +00:00
gavrielc 594d1b4055 style(cli): apply prettier formatting
Pre-commit hook ran prettier on the prior commit but left the reformats
unstaged. Folding them in here so the branch is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:03:47 +03:00
gavrielc beb5e049ed fix(credentials): move auth-required remediation message into provider
Adds a paired `authRequiredMessage()` method to AgentProvider so
per-provider auth-failure remediation can differ. Claude returns the
Anthropic/`claude` instruction; future providers (Codex, OpenCode, …)
can return their own remediation text. The poll-loop calls
`provider.authRequiredMessage?.()` and falls back to a generic message
if a provider implements `isAuthRequired` without supplying its own
remediation.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:03:16 +03:00
robbyczgw-cla b9d302524e fix(session-manager): derive attachment extension from mimeType and att.type
When a channel bridge passes an attachment without an explicit `name`,
extractAttachmentFiles fell back to `attachment-<ts>` with no extension.
Agents could not tell whether the file was a JPEG, PDF, or audio clip,
and tools keyed on extension (image viewers, exiftool, etc.) misbehaved.

Two cases are now covered:

1. Channels that set `mimeType` but no `name` (Discord/Slack documents,
   Telegram document uploads). A small MIME-to-extension table covers
   the common content types — image/*, audio/*, video/*, pdf, zip,
   txt, json. Unknown MIMEs fall back to the unsuffixed name.

2. Channels that set `att.type` but no `mimeType` (Telegram photos,
   stickers, voice, animations). The chat-sdk bridge sets a coarse
   media-class (`photo` / `sticker` / `voice` / `video` /
   `animation`) which is reliable enough to derive a canonical
   extension. Telegram GIFs are MP4 under the hood.

The existing isSafeAttachmentName security guard is preserved — the
derived name still passes through it before disk I/O. The new lookup
tables emit static values from internal maps and cannot construct a
path-traversal payload; attacker-controlled att.name continues to flow
through the same validator.
2026-04-29 15:01:09 +00:00
robbyczgw-cla ef8e3aa1b8 fix(poll-loop): apply pre-task scripts to follow-up injections too
Tasks arriving during an active query were pushed into the stream as
follow-ups without running their `script` gate — so a wakeAgent=false
pre-script that was supposed to suppress the tick silently leaked
through and woke the agent every time. Evidence: monitoring cron
firing every 10 min with [task-script] log lines never showing.

Run applyPreTaskScripts on the follow-up batch too: wakeAgent=false
tasks get marked completed and dropped; wakeAgent=true tasks have
scriptOutput enriched exactly like the initial-batch path. Added a
pollInFlight guard to serialize async runs and avoid overlapping
script executions when the interval fires while one is still going.

Wrapped in a MODULE-HOOK:scheduling-pre-task-followup marker block
to match the existing initial-batch hook convention.
2026-04-29 14:55:47 +00:00
gavrielc 3c620bc8d0 Merge branch 'fix/credential-failure-ux' of https://github.com/qwibitai/nanoclaw into fix/credential-failure-ux 2026-04-29 17:52:17 +03:00
gavrielc d5b48e4742 fix(credentials): address review feedback
- wakeContainer now never throws — returns Promise<boolean>, catches
  internally. Closes the regression risk for the 5 awaited callers in
  agent-to-agent, interactive, and approvals/response-handler that the
  previous version left unwrapped. Router uses the boolean to stop the
  typing indicator on transient failure; host-sweep just awaits.
- Tighten AUTH_REQUIRED_RE: anchor to start-of-string with the specific
  `·` (U+00B7) separator the CLI uses, so an agent that quotes the
  banner mid-sentence in a normal reply doesn't trip the classifier.
- Log a one-line note from writeAuthRequiredMessage so substitutions
  are visible when debugging "user got the credentials message but I
  don't see why."
- Add unit tests for ClaudeProvider.isAuthRequired covering both banner
  variants, trailing content, mid-sentence quoting, leading-prose
  quoting, alternate separators, and unrelated text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:51:32 +03:00
gabi-simons 8542c484f6 fix(setup): isolate scratch agent with hardcoded _ping-test folder
- Scratch agent uses fixed folder `_ping-test` so it can never collide
  with a real agent on re-runs
- Added --folder flag to init-cli-agent.ts and cli-agent step wrapper
- Delete always targets `_ping-test` exactly — no re-derivation needed
- Removed normalizeName coupling and FOLDER status field (no longer needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 14:45:42 +00:00
gavrielc 1dd8fabde9 Merge branch 'main' into fix/credential-failure-ux 2026-04-29 17:42:25 +03:00
gabi-simons 8c5d67cc78 fix(setup): dynamic FK cleanup, remove normalizeName coupling
- delete-cli-agent.ts discovers tables with agent_group_id dynamically
  instead of hardcoding a list
- cli-agent step emits FOLDER in its status block so setup/auto.ts
  reads it from the step result instead of re-deriving via normalizeName

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 14:27:03 +00:00
gabi-simons d86051805b feat(setup): delete scratch agent after ping-pong, simplify flow
The "Terminal Agent" created for the connection test is now silently
deleted after a successful ping. If the user chooses to chat, a new
agent is auto-created as "{name}'s Terminal" — no name prompt needed.
Condensed the three-line ping section into a single "Connection verified."
status line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 14:10:53 +00:00
gavrielc 5f34e26240 fix(credentials): translate auth errors and require OneCLI for spawn
Two related fixes for the case where credentials aren't usable:

1. Replace Claude Code's "Not logged in / Invalid API key · Please run
   /login" output with a host-aware message. The user can't run /login
   from chat, so the raw text is unhelpful. Provider gains an optional
   isAuthRequired() classifier; the poll-loop substitutes the message
   on both result-text and error paths.

2. Treat OneCLI gateway failure as a transient hard error instead of
   spawning a credential-less container. The catch in container-runner
   now propagates; router and host-sweep wrap wakeContainer to log and
   leave the inbound row pending so the next 60s sweep tick retries.
   Router also stops the typing indicator on failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:02:15 +03:00
Gabi Simons 5dd15c0014 Merge branch 'main' into feat/channel-approval-flow 2026-04-29 16:34:31 +03:00
Gabi Simons db19837740 feat(permissions): richer channel-approval flow with agent selection and free-text naming
Replace the hardcoded Approve/Ignore card with a multi-step flow:
- Single agent: "Connect to [name]" / "Connect new agent" / "Reject"
- Multiple agents: "Choose existing agent" (follow-up list) / "Connect new agent" / "Reject"
- "Connect new agent" prompts for a free-text name via DM, creates immediately on reply
- Add setMessageInterceptor router hook for capturing free-text replies
- Add resolveChannelName optional method to ChannelAdapter interface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 13:34:10 +00:00
gavrielc 9e45845000 Merge pull request #2104 from alipgoldberg/setup-assistant-green
feat(setup): paint "assistant" green in the agent-name prompt
2026-04-29 15:36:26 +03:00
gavrielc 9a919f4148 Merge branch 'main' into setup-assistant-green 2026-04-29 15:36:14 +03:00
exe.dev user 4608836953 feat(setup): paint "assistant" green in the agent-name prompt
Wraps the word "assistant" in `accentGreen` (#3fba50, added in #2103)
across the six channel adapters that ask "What should your assistant
be called?" — Discord, iMessage, Signal, Slack, Telegram, WhatsApp.
Mirrors the green emphasis on "you" in the display-name prompt: the
green word names the subject of the question (assistant vs operator)
so the operator parses it at a glance.
2026-04-29 12:32:25 +00:00
gavrielc 1bf903a64d Merge pull request #2103 from alipgoldberg/setup-pronoun-green
feat(setup): paint "you" green in the display-name prompt
2026-04-29 15:25:12 +03:00
gavrielc 0044bba0e5 Merge branch 'main' into setup-pronoun-green 2026-04-29 15:25:02 +03:00
exe.dev user 26594d2c54 feat(setup): paint "you" green in the display-name prompt
Adds an `accentGreen` helper (#3fba50) with the same TTY/NO_COLOR/
truecolor gating as the rest of the palette, then wraps the word
"you" in the "What should your assistant call you?" prompt so the
operator parses at a glance who the question is about — the user,
not the assistant. The mirror prompt that asks for the assistant's
name ("What should your assistant be called?") is left for a
follow-up.
2026-04-29 12:16:15 +00:00
gavrielc 01131521ff Merge pull request #2102 from alipgoldberg/setup-color-choices
feat(setup): cyan highlight on active and submitted choices
2026-04-29 15:07:56 +03:00
gavrielc 3742165708 Merge branch 'main' into setup-color-choices 2026-04-29 15:07:00 +03:00
exe.dev user 4c791a41b2 feat(setup): cyan highlight on active and submitted choices
Customize `brightSelect`'s render function so the focused option's
label paints in brand cyan during selection and the submitted answer
paints in dim cyan after the user moves on. Inactive options keep
their default rendering — only the cursor and submitted state pick
up the color, matching the body-text emphasis added in #2101.

Also migrate the one remaining `p.select` call site (the "What next?"
prompt after the first chat) to `brightSelect` so every menu in the
setup flow goes through the same render path. The shape of the call
matches what `brightSelect` already supports — message + options
with value/label/hint — so no feature is lost in the swap.

Reuses `brandBody` from #2101 for the cyan, so the prompt highlight
and the body prose share one definition of the brand body color.
2026-04-29 12:01:35 +00:00
gavrielc 6ef147bc89 Merge pull request #2101 from alipgoldberg/setup-color-body
feat(setup): paint card and log bodies in brand cyan
2026-04-29 14:58:27 +03:00
gavrielc 7d153df710 Merge branch 'main' into setup-color-body 2026-04-29 14:58:02 +03:00
exe.dev user ab2d509671 feat(setup): paint card and log bodies in brand cyan
Adds a `brandBody` helper in setup/lib/theme.ts that wraps prose in
brand cyan (#2BB7CE), with the same TTY/NO_COLOR/truecolor gating used
by `brand`/`brandBold`/`brandChip`. The helper splits multi-line input
and colors each line independently so the SGR sequence doesn't bleed
across clack's gutter prefix.

Routing:
  - `note()` (the un-dim card wrapper from #2095) now passes
    `brandBody` as its `format` callback, so card bodies render
    cyan line-by-line.
  - Every prose `p.log.{message,info,success,step,warn}` call in the
    setup flow wraps its body argument in `brandBody`. Calls whose
    body is explicitly `k.dim(...)` (failure transcript tails, log
    paths, claude-assist response previews) are left alone — those
    are the "preview/debug" cases the dim-policy comment in
    theme.ts already carves out.
  - Spinner-finish lines in windowed-runner / claude-assist color
    only the message portion; the `(5s)` elapsed suffix stays dim.

Brand cyan accents (chips, wordmark, inline emphasis) are unchanged.
This PR only adds the body color.

A follow-up will add OSC 11 dark/light detection so light-mode
terminals get a brand blue (#2b6fdc) variant — opt-in upgrade with
no regression for the dark-mode default.
2026-04-29 11:43:30 +00:00
gavrielc 57a959028d Merge pull request #2098 from Koshkoshinsk/setup-token-headless
fix claude setup-token flow for headless/remote systems
2026-04-29 14:02:53 +03:00
gavrielc 9f564650c6 Merge branch 'main' into setup-token-headless 2026-04-29 14:02:45 +03:00
gavrielc 2acd71731a Merge pull request #2094 from qwibitai/fix/setup-reuse-existing-env
Detect existing .env and credentials on setup re-run
2026-04-29 14:01:03 +03:00
Daniel M b7f099db96 Merge branch 'main' into setup-token-headless 2026-04-29 13:59:24 +03:00
gavrielc c8e960314a Merge remote-tracking branch 'upstream/main' into fix/setup-reuse-existing-env
# Conflicts:
#	setup/channels/imessage.ts
#	setup/channels/telegram.ts
2026-04-29 13:58:21 +03:00
gavrielc ec3aa0f139 Merge pull request #2096 from qwibitai/fix/password-clear-on-error
Clear password field after validation error
2026-04-29 13:54:36 +03:00
Gabi Simons d4868a5e01 Merge branch 'main' into fix/password-clear-on-error 2026-04-29 13:35:48 +03:00
Gabi Simons a014a67556 fix password fields not clearing after validation error
When pasting an invalid token, the old value stayed in the input
field. Pasting a new token appended to the old one instead of
replacing it, causing repeated validation failures.

Add clearOnError: true to all 8 password prompts across setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 10:34:58 +00:00
gavrielc e0f813603e Merge pull request #2095 from alipgoldberg/setup-undim-cards
fix(setup): stop dimming card bodies in setup flow
2026-04-29 13:29:06 +03:00
Gabi Simons aa390b3fd0 detect existing .env and credentials on setup re-run
When re-running setup on a machine that already has a .env with
channel tokens or OneCLI config, detect them early and offer to
reuse instead of prompting the user to paste everything again.

- Add detectExistingEnv() to parse .env and group known keys
- Add detectExistingDisplayName() to read display name from v2.db
- Defer display name prompt until actually needed (cli-agent or channel)
- Skip cli-agent and first-chat when groups are already wired
- Add token reuse checks to Telegram, Discord, Slack, Teams, iMessage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 10:20:54 +00:00
exe.dev user 9c8f680ca8 fix: stop dimming setup card bodies
Clack's `p.note` defaults to `format: e => styleText("dim", e)`, which
fades note bodies regardless of the project's stated readability stance
(see comment on `dimWrap` in setup/lib/theme.ts: "prose renders at the
terminal's regular weight"). The dim styling makes body copy hard to
read on dark terminals and visibly washes out brand-colored segments
embedded in cards (e.g. the chip + bold heading rows).

Add a `note()` helper in setup/lib/theme.ts that wraps `p.note` with a
pass-through formatter, and route every setup-flow `p.note` call site
through it: setup/auto.ts, every setup/channels/*.ts adapter, and the
two setup/lib/claude-* helpers.

Pre-styled segments (brandBold, brandChip, formatPairingCard,
formatCodeCard) now render at full strength instead of being faded
alongside surrounding prose.
2026-04-29 10:20:10 +00:00
exe.dev user 93be2d15f0 fix claude setup-token flow for headless/remote systems
Use script(1) to capture PTY output and extract OAuth token when
browser-based auth isn't available, with fallback code-paste flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 10:18:38 +00:00
exe.dev user 89738917ae offer to install and authenticate Claude CLI before diagnosis
When setup fails and claude-assist kicks in, instead of silently
skipping when the CLI is missing or unauthenticated, interactively
offer to install it (via install-claude.sh) and sign in (via
claude setup-token) so the user can get diagnostic help immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:18:29 +00:00
github-actions[bot] ede6c01da8 chore: bump version to 2.0.15 2026-04-28 19:53:23 +00:00
gavrielc 4d6f9b70f4 Merge pull request #2080 from Koshkoshinsk/circuit-breaker
Add startup circuit breaker for crash loop protection
2026-04-28 22:53:06 +03:00
gavrielc 336e01d2a1 fix circuit-breaker off-by-one, ENOENT, and reset-on-throw + tests
- getDelay indexed by attempt (1-based) into a 0-indexed array, so the
  leading 0 was unreachable and every "after a crash" delay was shifted
  up one slot. Use attempt - 1 so the documented schedule (0s → 0s →
  10s → 30s → 2min → 5min → 15min cap) actually holds.
- enforceStartupBackoff runs before initDb (which creates DATA_DIR), so
  on a fresh checkout fs.writeFileSync hit ENOENT. write() now
  mkdirSync's DATA_DIR first.
- shutdown() didn't run resetCircuitBreaker if teardownChannelAdapters
  threw, so a graceful exit with a teardown error would be counted as a
  crash on the next start. Wrap teardown in try/finally.
- Adds src/circuit-breaker.test.ts: state transitions, full schedule
  (parameterized), reset-window expiry, malformed file, and the
  fresh-install path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:51:11 +03:00
Daniel Milliner 2bf296b04a add startup circuit breaker and troubleshooting docs
Backs off on rapid restarts to avoid exhausting Discord gateway identify
limits and triggering Cloudflare IP bans. Resets on clean shutdown so only
crashes accumulate the counter. Also adds a troubleshooting section to
CLAUDE.md with the most useful diagnostic locations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 14:07:24 +00:00
gavrielc ae9bcb7c33 Merge pull request #2075 from qwibitai/fix/slack-setup-wiring
fix(setup): complete Slack setup wiring with welcome DM
2026-04-28 15:37:54 +03:00
Gabi Simons 99869105ba Merge branch 'main' into fix/slack-setup-wiring 2026-04-28 15:35:20 +03:00
Gabi Simons c5d0243417 fix(setup): add Interactivity & Shortcuts step to Slack setup
Slack interactive buttons (channel approval cards) require Interactivity
to be enabled in the app settings. Without it, button clicks silently
fail to reach the host. Added the step to both the setup wizard
post-install checklist and the add-slack SKILL.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 12:19:44 +00:00
Gabi Simons c36f0c6b36 fix(setup): wire Slack agent during setup like Discord/Telegram
Slack setup previously stopped after installing the adapter, leaving
users to manually discover /init-first-agent. When they DM'd the bot,
the channel-approval flow silently failed because no owner existed.

Now the Slack setup flow matches Discord/Telegram:
- Collects the operator's Slack member ID
- Opens a DM channel via conversations.open (requires im:write scope)
- Runs init-first-agent to establish ownership, wiring, and welcome DM
- Updates post-install note to focus on webhook URL (the only remaining step)

The welcome DM is delivered via chat.postMessage (outbound), which works
before Event Subscriptions are configured. The user sees the greeting
immediately; inbound replies require webhooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 11:35:51 +00:00
github-actions[bot] 45d3016bce docs: update token count to 133k tokens · 67% of context window 2026-04-28 10:27:34 +00:00
gavrielc 7e37b13aab Fix path traversal in attachment handling on channel-inbound path 2026-04-28 13:26:44 +03:00
dooha333 a80f095174 fix(setup): inject ~/.local/bin into PATH so post-install onecli is reachable
setup/auto.ts spawned register-claude-token.sh via runInheritScript, which
inherits the parent Node process's PATH. When OneCLI was installed earlier
in the same setup run, its installer wrote the binary to ~/.local/bin and
appended a PATH line to the user's shell rc — but rc updates do not reach
an already-running process. The script's first guard, `command -v onecli`,
failed instantly (~3ms), and the auth step reported "Couldn't complete the
Claude sign-in" even though the real blocker was OneCLI not on PATH.

Patch process.env.PATH at the top of main() so every subsequent shell-out
sees ~/.local/bin. Idempotent — no-op if already present. Also drops a
duplicate `pollHealth` import that was lurking in the import block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:31:29 +00:00
github-actions[bot] f8c3d02348 docs: update token count to 133k tokens · 66% of context window 2026-04-26 21:39:26 +00:00
github-actions[bot] b808ab4fd2 chore: bump version to 2.0.14 2026-04-26 21:39:18 +00:00
gavrielc b9b186c9cf Merge pull request #2023 from KeXin95/fix/custom-anthropic-base-url
feat: pass ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into agent containers
2026-04-27 00:39:07 +03:00
gavrielc b44bcf5dcf Merge branch 'main' into fix/custom-anthropic-base-url 2026-04-27 00:38:53 +03:00
gavrielc be86bd3c2d fix(setup): remove duplicate pollHealth import in auto.ts
Slipped through during the #2035 rebase resolution — both #2030's import
and ours landed in the merge. TypeScript dedups by symbol so it didn't
fail the typecheck, but it's noise and would've eventually tripped a
linter rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:35:55 +03:00
gavrielc 6591062fbb refactor: route custom Anthropic endpoint through OneCLI vault
The original approach passed ANTHROPIC_AUTH_TOKEN into the container
as an env var and disabled the proxy for the custom host (NO_PROXY) —
which works, but bypasses OneCLI entirely for that credential. The
container holds the raw secret, the gateway loses audit/rotation, and
we lose the rest of the vault's protections for this cohort.

OneCLI-native version: store the token as a generic secret with header
injection (--header-name Authorization --value-format 'Bearer {value}'
+ host-pattern matching the base URL hostname). The container only
needs ANTHROPIC_BASE_URL plus a placeholder ANTHROPIC_AUTH_TOKEN — the
proxy rewrites the Authorization header on the wire.

setup/lib/setup-config.ts — adds --anthropic-auth-token alongside the
existing --anthropic-base-url.

setup/auto.ts — runAuthStep short-circuits the auth-method prompt when
both NANOCLAW_ANTHROPIC_BASE_URL and NANOCLAW_ANTHROPIC_AUTH_TOKEN are
set: creates the OneCLI generic secret, writes ANTHROPIC_BASE_URL to
.env (so the runtime reads it), and appends `import './claude.js';` to
src/providers/index.ts (so the provider only registers when the user
has configured a custom endpoint — no branching for everyone else).

src/providers/claude.ts — drops ANTHROPIC_AUTH_TOKEN/NO_PROXY
passthrough. Reads ANTHROPIC_BASE_URL from .env, sets a placeholder
ANTHROPIC_AUTH_TOKEN in container env so the SDK includes an
Authorization header for OneCLI to overwrite.

src/providers/index.ts — removes the unconditional import; setup
appends it on demand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:34:31 +03:00
KeXin95 26fc3ff322 feat: pass ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into agent containers
Users with a custom Anthropic-compatible endpoint (ANTHROPIC_BASE_URL) were
getting 401s because the OneCLI proxy injects ANTHROPIC_API_KEY=placeholder
and forwards to api.anthropic.com, overriding the custom endpoint and key.

Add a claude provider host config that reads ANTHROPIC_BASE_URL,
ANTHROPIC_AUTH_TOKEN, and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC from .env
and passes them into the container. Also sets NO_PROXY for the custom host so
the OneCLI proxy doesn't intercept those requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:32:16 +03:00
gavrielc 4ebf56e2a3 Merge pull request #2035 from qwibitai/feat/setup-advanced-settings
feat(setup): advanced settings flow with remote OneCLI support
2026-04-27 00:15:05 +03:00
gavrielc 7693a20970 feat(setup): validate onecli api token starts with oc_
Matches the OneCLI CLI's own format expectation ("oc_... format" per
`onecli auth login --help`) so a malformed token gets caught at setup
time rather than at first vault call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:13:07 +03:00
gavrielc e706dcac00 feat(setup): default OneCLI remote URL to https://app.onecli.sh
Replaces the example.internal placeholder with the hosted gateway URL
so the advanced screen and --help suggest the canonical destination
out of the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:13:07 +03:00
gavrielc f048447ec5 feat(setup): authenticate onecli CLI for remote vault setup
Without `onecli auth login`, setup-time CLI calls (e.g. `secrets list`
inside anthropicSecretExists, `secrets create` in runPasteAuth) hit a
secured remote vault unauthenticated and fail silently — the auth step
sees no existing Anthropic credential and prompts the user to add one
even when it's already in the remote vault.

Two auth surfaces matter here: the CLI's persistent store via
`onecli auth login --api-key`, and ONECLI_API_KEY in .env that the
runtime SDK reads at request time. We need both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:13:07 +03:00
gavrielc efdd05a7ef feat(setup): advanced settings registry with remote OneCLI support
Adds a single config registry that drives both CLI flags and an opt-in
advanced-settings screen, so power users can override defaults like
remote OneCLI host/token or alt Anthropic endpoints without burdening
the standard linear flow with extra prompts.

Why: advanced configurations didn't fit cleanly into the existing
sequenced setup. PR #2030 took the "add another prompt step" route for
remote OneCLI; this approach instead routes those overrides through a
single source of truth so adding the next knob (alt endpoint, custom
host pattern, …) doesn't mean another prompt-or-skip decision.

setup/lib/setup-config.ts — schema (typed entry list with surface
'flag' | 'flag+ui'), name derivation (camelCase → NANOCLAW_UPPER_SNAKE
+ --kebab-case), seeded with --onecli-api-host, --onecli-api-token,
--anthropic-base-url, plus existing NANOCLAW_SKIP / NANOCLAW_DISPLAY_NAME
as flag-only entries.

setup/lib/setup-config-parse.ts — argv parser (--key value, --key=value,
--no-bool, -- terminator), env reader, applyToEnv() bridge that writes
resolved values back to process.env so existing step code keeps reading
env vars unchanged. Also --help printer.

setup/lib/setup-config-screen.ts — interactive menu loop. Entries
render with current value as hint; selecting one opens the right prompt
type (text / password for secrets / confirm / brightSelect for enums);
"Done" returns to the main flow.

setup/auto.ts — parses argv first (--help short-circuits before any
render), folds env+flags into process.env, then offers a welcome menu:
"Standard setup" (default) vs "Advanced". The onecli step branches on
NANOCLAW_ONECLI_API_HOST: if set, skips the local-vs-fresh prompt
entirely, runs pollHealth pre-flight, then calls runQuietStep with
--remote-url. Token, when provided, writes through to ONECLI_API_KEY in
.env. Welcome copy tightened (drops the duplicate wordmark/tagline) so
the bash → clack handoff reads as one flow.

setup/onecli.ts — cherries the --remote-url implementation from PR
run()) and generalizes writeEnvOnecliUrl into a writeEnvVar helper so
ONECLI_API_KEY follows the same upsert path.

nanoclaw.sh — forwards "$@" to setup:auto so flags reach the parser;
trims the redundant "Setting up your personal AI assistant" subtitle
and the bootstrap teach line so the pre-clack section isn't competing
with the clack intro for the same role.

Token plumbing only fires in --remote-url mode; local installs are
unauthenticated against localhost and don't need it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:13:07 +03:00
gavrielc 7de1fc1b3c Merge pull request #2030 from evenisse/feat/onecli-remote
v2: feat(setup): add remote OneCLI option in setup flow
2026-04-27 00:11:18 +03:00
Emmanuel Venisse 6b431c195d feat(setup): add remote OneCLI option in setup flow
Allow connecting to an OneCLI gateway running on another host instead
of installing one locally. Adds a third choice ('Connect to a remote
OneCLI') alongside reuse/fresh in the setup wizard, prompts for the
remote URL, validates reachability before proceeding, and passes
--remote-url to the onecli step.

In onecli.ts: extracts installOnecliCliOnly() for the remote path
(installs the CLI binary but skips the gateway), exports pollHealth
for use by auto.ts, and handles --remote-url to configure api-host
and write ONECLI_URL to .env without running the full gateway install.
2026-04-26 18:33:19 +02:00
Gabi Simons 5812422321 Merge branch 'main' into feat/migrate-from-v1 2026-04-26 12:26:04 +03:00
gavrielc 0bc082a17c Merge pull request #2010 from ira-at-work/pr/add-signal-v2
docs(skills): enrich /add-signal with v2 lessons learned, drop redundant v2 skill
2026-04-25 17:35:45 +03:00
Ira Abramov b6be3b9bf4 docs(skills): merge add-signal-v2 lessons into add-signal, drop v2
Absorbs battle-tested knowledge from the v2 skill into the upstream
add-signal: registration paths (new number + linked device), CAPTCHA
flow, VoIP SMS-first timing, Java prereq, config-lock warning, wiring
SQL for groups, not_member silent-drop fix, GroupV2 groupId extraction
note, and UUID-based platform ID format.

Corrects a factual error in the upstream: DM platform IDs are
signal:{UUID} (ACI), not phone numbers.

Removes the now-redundant add-signal-v2 skill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:52:20 +03:00
Ira Abramov 7e99d0eaf7 Merge branch 'main' into pr/add-signal-v2
* main: (185 commits)
  chore: bump version to 2.0.13
  chore: bump version to 2.0.12
  docs: update token count to 132k tokens · 66% of context window
  fix(register): wire channels with correct engage fields, skip prefix for native IDs
  skill(add-gcal-tool): OneCLI-native Google Calendar MCP tool
  docs(providers): note that container.json provider is what the runner reads
  Revert src changes; skill applies them at install time
  chore: bump version to 2.0.11
  fix(workflows): label PRs from forks that follow the contributing template
  chore(format): apply prettier to chat-sdk-bridge.ts
  chore(setup): minimize verify diff
  test(setup): cover CLI-only verify status
  fix(setup): accept CLI-only verify success
  detect setup auth ping failures
  fix(agent-route): reject unsafe attachment filenames to prevent path traversal
  fix(agent-route): forward file attachments between agents
  refactor(session-state): key continuations per provider to survive provider switches
  skill(add-gmail-tool): OneCLI-native Gmail MCP tool
  fix(setup): register step uses engage_mode columns dropped by migration 010
  docs: update token count to 130k tokens · 65% of context window
  ...
2026-04-25 16:44:42 +03:00
glifocat b92fdb5771 Merge remote-tracking branch 'upstream/main' 2026-04-24 17:12:34 +02:00
github-actions[bot] 8d8522202a chore: bump version to 2.0.13 2026-04-24 14:20:58 +00:00
gavrielc 0df647be74 Merge pull request #1963 from grtwrn/fix/register-channel-wiring
fix(register): wire channels with correct engage fields, skip prefix for native JIDs
2026-04-24 17:20:41 +03:00
gavrielc 2825f657ca Merge branch 'main' into fix/register-channel-wiring 2026-04-24 17:20:29 +03:00
github-actions[bot] 15a6950b5b chore: bump version to 2.0.12 2026-04-24 14:13:36 +00:00
github-actions[bot] 226fc93795 docs: update token count to 132k tokens · 66% of context window 2026-04-24 14:13:32 +00:00
gavrielc 15e2ac7649 Merge pull request #1967 from IamAdamJowett/fix/session-state-per-provider-and-agent-route-files
Two independent correctness fixes: per-provider continuations + agent-route file forwarding
2026-04-24 17:13:16 +03:00
gavrielc f804ebf2e9 Merge branch 'main' into fix/session-state-per-provider-and-agent-route-files 2026-04-24 17:13:06 +03:00
grtwrn fc375ca72b fix(register): wire channels with correct engage fields, skip prefix for native IDs
setup/register.ts had two bugs that prevented new channels from being
registered via `/manage-channels`:

1. createMessagingGroupAgent was called with the legacy field names
   `trigger_rules` and `response_scope`. The SQL INSERT expects
   `engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy`
   (migration 010). Every register call failed with
   `RangeError: Missing named parameter "engage_mode"` after the agent
   and messaging group were partially created — leaving an orphaned pair.

   Now mirrors scripts/init-first-agent.ts:wireIfMissing:
   - Groups (is_group=1) default to engage_mode='mention' (bot only
     responds when addressed).
   - DMs (is_group=0) default to engage_mode='pattern' with '.' (respond
     to every message).
   - An explicit --trigger overrides the pattern regex.

2. The "normalize platform_id" block unconditionally prefixed
   "<channel>:" even for native IDs like WhatsApp JIDs
   ("120363408974444974@g.us"), iMessage emails ("user@example.com"),
   or Signal phones ("+15551234567") / Signal groups ("group:abc"). But
   the router (src/router.ts:158) looks up messaging_groups by the raw
   event.platformId from the adapter, which for these native adapters
   never has a prefix. So the prefixed row was never matched — the
   message was silently dropped with no "Message routed" log.

   Extracted scripts/init-first-agent.ts:namespacedPlatformId into
   src/platform-id.ts so both setup paths use the same heuristic (skip
   the prefix for IDs containing '@', starting with '+', or starting
   with 'group:'). Prevents future drift between the two paths.

Tested by: re-running `setup/index.ts --step register` for a WhatsApp
group JID, confirming the row is created with correct engage fields
and matching platform_id, then sending a test message and observing
"Message routed" with the right agent group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:06:10 +03:00
gavrielc 88d3da76c3 Merge pull request #1964 from grtwrn/skill/add-gcal-tool
skill(add-gcal-tool): OneCLI-native Google Calendar MCP tool
2026-04-24 16:51:26 +03:00
gavrielc 6d35c85129 skill(add-gcal-tool): OneCLI-native Google Calendar MCP tool
Adds /add-gcal-tool — a sibling of /add-gmail-tool that installs
@cocal/google-calendar-mcp with the same OneCLI stub-file pattern. Skill
applies the Dockerfile + TOOL_ALLOWLIST changes at install time; trunk
stays clean so users who never run the skill don't carry the calendar
MCP in their image.

Dropped the Phase 5 dry-run section since it hardcoded a per-install
image tag slug and duplicated Phase 4's live agent test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:49:40 +03:00
gavrielc f0ebc8d6e1 Merge pull request #1961 from grtwrn/skill/add-gmail-tool
skill(add-gmail-tool): OneCLI-native Gmail MCP tool
2026-04-24 16:42:09 +03:00
gavrielc c7f8e98471 Merge branch 'main' into skill/add-gmail-tool 2026-04-24 16:41:59 +03:00
exe.dev user 52f8661f0c docs(providers): note that container.json provider is what the runner reads
The upstream precedence fix (5845a5a) made agent_groups.agent_provider and
sessions.agent_provider authoritative for host-side provider contribution
(per-session mount, env passthrough), but those DB values don't propagate
into the group's container.json — and the in-container runner reads
`provider` from container.json, not from the DB. That caused a confusing
failure mode: flipping the DB column to 'codex', rebuilding, and
restarting still spawned a Claude runner because container.json had no
provider field. The old skill wording ("container receives AGENT_PROVIDER
from the resolved value") overstated the integration.

Update add-codex and add-opencode "Per group / per session" sections to
say: set `"provider": "<name>"` in the group's container.json — that's
the source the runner reads. Keep the DB columns documented for the
host-side contribution they actually drive, and spell out the
session → group → container.json → 'claude' fallback so the precedence
is still discoverable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:38:05 +00:00
gavrielc f37e775358 Revert src changes; skill applies them at install time
Phase 2 of the SKILL.md already contains the Dockerfile + TOOL_ALLOWLIST
edit instructions with an "ALREADY APPLIED" short-circuit. Keeping those
edits out of trunk means users who never run /add-gmail-tool don't carry
the Gmail MCP package in their image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:30:14 +03:00
gavrielc 41162517d9 Merge pull request #1960 from shock99-samantha/fix/register-engage-mode-schema
fix(setup): register step uses engage_mode columns dropped by migration 010
2026-04-24 15:35:35 +03:00
gavrielc 2afcee3a4f Merge pull request #1970 from pankajkgarg/codex/detect-auth-errors-in-setup
[codex] detect setup auth ping failures
2026-04-24 15:27:57 +03:00
gavrielc 9bb416c157 Merge branch 'main' into codex/detect-auth-errors-in-setup 2026-04-24 15:27:35 +03:00
gavrielc beb73d792a Merge pull request #1975 from glifocat/chore/label-pr-fork-support
fix(workflows): label PRs from forks that follow the contributing template
2026-04-24 15:26:34 +03:00
gavrielc 8b783daa67 Merge branch 'main' into chore/label-pr-fork-support 2026-04-24 15:26:25 +03:00
github-actions[bot] 5cbfccec05 chore: bump version to 2.0.11 2026-04-24 12:25:45 +00:00
gavrielc 8637143216 Merge pull request #1974 from glifocat/chore/format-chat-sdk-bridge
chore(format): apply prettier to chat-sdk-bridge.ts
2026-04-24 15:25:31 +03:00
gavrielc 44067e73cb Merge branch 'main' into chore/format-chat-sdk-bridge 2026-04-24 15:25:20 +03:00
gavrielc 72d0134d0a Merge pull request #1972 from glifocat/fix/cli-only-verify
v2: fix setup verify for CLI-only installs
2026-04-24 15:24:58 +03:00
glifocat d3581bc65e Merge remote-tracking branch 'upstream/main' 2026-04-24 13:11:51 +02:00
glifocat 2b51a4e707 fix(workflows): label PRs from forks that follow the contributing template
On a fork PR, GITHUB_TOKEN is demoted to read-only regardless of the
workflow's permissions: block, so issues.addLabels() returns 403. The
label workflow silently works for PRs that skip the template (no
checkboxes ticked → no API call) and fails for PRs that actually
follow it — a hostile incentive against contributors who do the right
thing.

pull_request_target runs in the context of the base branch with full
declared permissions, which is the documented fix for this case. Safe
here because the workflow is metadata-only: it reads
context.payload.pull_request.body and calls addLabels. No checkout,
no PR-supplied code executes. A SECURITY comment is added above the
trigger to keep it that way.

Refs:
- https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target
- https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:50:25 +02:00
glifocat 3d6837c411 chore(format): apply prettier to chat-sdk-bridge.ts
Two long-line violations introduced in d121cd1 (isGroup plumbing)
exceed the printWidth limit. CI format:check fails on every PR
opened against main until this is fixed; the fix is isolated here
so no behavior change is mixed in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:12:05 +02:00
glifocat 9fd694c763 chore(setup): minimize verify diff 2026-04-24 11:49:04 +02:00
glifocat 4fc2c4275c test(setup): cover CLI-only verify status 2026-04-24 11:44:58 +02:00
glifocat 1de5a0356b fix(setup): accept CLI-only verify success 2026-04-24 11:44:35 +02:00
Pankaj Garg f41c162009 detect setup auth ping failures 2026-04-24 09:23:18 +02:00
Adam fd03b89333 fix(agent-route): reject unsafe attachment filenames to prevent path traversal
Filenames in forwardAttachedFiles arrived from the source agent's
messages_out content and were used directly in path.join on both
source outbox read and target inbox write. A value like `../evil.sh`
could escape `inbox/<a2a-id>/` on the target session (and similarly
the source outbox on read), breaking session isolation — an
adversarial or hallucinating sub-agent could overwrite files in
a sibling session.

Adds isSafeAttachmentName(name) — exported so it's unit-testable —
which rejects empty, `.`, `..`, anything containing `/`, `\`, or
NUL, and anything path.basename would strip. Guard runs before any
I/O. Unsafe names are dropped with a warning log, same pattern as
missing-source-file handling; a bad filename in one attachment
doesn't kill the whole route's text delivery.

Addresses Codex Review P1 on qwibitai/nanoclaw#1967.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:45:08 +10:00
Adam 672e228876 fix(agent-route): forward file attachments between agents
Before: `send_file(to='parent')` from a sub-agent wrote the bytes to
the sub-agent's own session outbox, but agent-to-agent routing copied
only the content JSON — the target's inbound message referenced
`files: ['x.png']` but the bytes lived in a session directory the
target couldn't mount. Parent agents orchestrating sub-agents (e.g.
Design Team delegating illustration work to an Illustrator sub-agent
on Codex) received file-reference messages with nothing to forward.

Fix: on route, if the source's content has `files`, copy each referenced
file from `<source>/outbox/<src-msg-id>/` to
`<target>/inbox/<a2a-msg-id>/`, and emit `attachments` (the existing
formatter convention — see formatter.ts:223) with `localPath` relative
to `/workspace/`. The target formatter already renders these as
`[file: <name> — saved to /workspace/inbox/<a2a-id>/<name>]`, so the
target agent sees the path and can call `send_file(path=…, to=…)` to
forward onward.

Convention matches what session-manager.ts:256 already does for
base64-encoded channel-inbound attachments — same inbox layout, same
content shape. Nothing on the formatter/agent side needed to change.

## Scope

- `forwardAttachedFiles(source, target)` — pure-ish helper that copies
  files and returns the attachments array.
- `forwardFileAttachments(msg, …)` — wraps the helper for the route
  path: parses content, copies files if present, merges into any
  existing `attachments`, re-serialises.
- `routeAgentMessage` — uses the rewritten content when writing the
  target's inbound row.
- Log line now includes `forwardedFileCount` for observability.

Missing source files are skipped with a warning rather than killing
the route — a bad filename in a batch shouldn't drop the
accompanying text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:34:29 +10:00
Adam 81ef193e69 refactor(session-state): key continuations per provider to survive provider switches
Before, every provider stored its opaque continuation id under the
single outbound.db key `sdk_session_id`. Flipping a session's
agent_provider (e.g. Codex → Claude) meant the new provider read the
old provider's id at wake, handed it to its own SDK, and got a
"No conversation found" error that cost the user one sacrificed
message before the stale-session recovery path cleared the id.

This reshapes session_state so continuations are keyed
`continuation:<provider>` instead. Consequences:

- Per-provider continuations coexist. Flipping Claude → Codex → Claude
  resumes the Claude thread exactly where it left off, with the
  intervening Codex thread also still on file.
- No provider ever reads another provider's id. Switching costs no
  sacrificed message and emits no transient error.
- Legacy installs are migrated forward on first startup:
  migrateLegacyContinuation() adopts any pre-existing `sdk_session_id`
  row into the current provider's slot (best guess — it was whichever
  provider ran last), then deletes the legacy row unconditionally so
  it can't poison a future provider's read.

runPollLoop now takes providerName alongside the provider instance,
and threads it through processQuery to setContinuation on init.

Tests: 9 new tests covering set/get isolation across providers,
clear-specificity, legacy-adoption, legacy-always-deleted,
prefer-existing-slot-over-legacy, and idempotency of a second
migration call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:34:28 +10:00
grtwrn 9e33274e2a skill(add-gmail-tool): OneCLI-native Gmail MCP tool
Adds /add-gmail-tool — a Utility skill that installs Gmail as an MCP tool
in NanoClaw v2 using OneCLI for credential injection. No raw OAuth tokens
ever reach the container; the gateway swaps the "onecli-managed" stub
bearer for the real token at request time.

Scope (3 files):
- container/Dockerfile: pnpm global-install of
  @gongrzhe/server-gmail-autoauth-mcp@1.1.11, pinned behind GMAIL_MCP_VERSION.
  Also pins zod-to-json-schema@3.22.5 to avoid an ERR_PACKAGE_PATH_NOT_EXPORTED
  crash: the MCP server's loose zod range resolves zod@3.24.x while
  zod-to-json-schema@3.25.x imports the zod/v3 subpath that only exists in
  zod>=3.25.
- container/agent-runner/src/providers/claude.ts: adds 'mcp__gmail__*' to
  TOOL_ALLOWLIST so the agent can invoke the server's tools.
- .claude/skills/add-gmail-tool/SKILL.md: pre-flight checks (OneCLI Gmail app
  connected, stubs present, mount allowlist covers ~/.gmail-mcp, agent
  secret-mode), per-group wiring in container.json (mount + mcpServers),
  verification steps, troubleshooting, removal instructions. Credits to
  gongrzhe for the MCP server and the add-atomic-chat-tool / add-vercel
  skill patterns.

Addresses #1500 (proxy Gmail OAuth through credential proxy) on the Gmail
side. Overlaps in intent with #1810 but stays surgical — no bundled
unrelated changes.

Tested end-to-end on Linux/Docker: CLI and WhatsApp self-chat agents can
list labels, search/read/send mail via OneCLI-injected tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:43:02 -04:00
Samantha d0c608c751 fix(setup): register step uses engage_mode columns dropped by migration 010
Migration 010-engage-modes (replace trigger_rules + response_scope with
engage_mode/engage_pattern/sender_scope/ignored_message_policy) updated
the schema and the production code paths, but missed setup/register.ts.

The step still constructed a payload with the dropped columns. On any
fresh v2 install, attempting to register a channel via:

  pnpm exec tsx setup/index.ts --step register -- --platform-id ...

fails with: `Missing named parameter "engage_mode"`. This affects every
flow that calls the register step — the /add-<channel> skills,
/manage-channels, and the setup auto driver.

Map old → new:
- trigger_rules.pattern (string) → engage_mode='pattern',
  engage_pattern=<pattern>
- requiresTrigger=false (no pattern) → engage_mode='pattern',
  engage_pattern='.' (the "always" sentinel from migration 010)
- requiresTrigger=true (no pattern) → engage_mode='mention'
- response_scope='all' → sender_scope='all',
  ignored_message_policy='drop' (conservative default matching the
  migration backfill rule)

Tested by registering three Telegram channels (one DM, two groups) on a
fresh v2 install — all succeeded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:44:47 -04:00
github-actions[bot] a4346f566c docs: update token count to 130k tokens · 65% of context window 2026-04-23 22:54:40 +00:00
gavrielc 1df8dec9bd Merge pull request #1958 from qwibitai/fix/provider-db-precedence
fix(container-runner): honor agent_provider DB columns with session override
2026-04-24 01:54:25 +03:00
gavrielc 82baa39f20 Merge branch 'main' into fix/provider-db-precedence 2026-04-24 01:54:16 +03:00
exe.dev user 5845a5a980 fix(container-runner): honor agent_provider DB columns with session override
resolveProviderContribution read only containerConfig.provider (from each
group's container.json) and ignored both agent_groups.agent_provider and
sessions.agent_provider. The provider-install skills (opencode, codex)
and CLAUDE.md document those DB columns as the source of truth with
session-overrides-group precedence, but the code never consulted them —
so setting `agent_provider = 'codex'` on a group had no effect, and the
only way to route to a non-default provider was to edit the per-group
JSON directly. Discovered while wiring up Codex: DB update landed but
the spawned container kept running Claude.

Extract a pure `resolveProviderName(session, group, containerConfig)`
with the documented precedence:

    sessions.agent_provider
      → agent_groups.agent_provider
      → container.json `provider`
      → 'claude'

`resolveProviderContribution` now calls it. The container.json fallback
stays so existing installs that only set provider in JSON keep working.
Empty strings treated as unset to avoid footguns when a DB-backed form
writes '' for "no override."

Added unit tests covering precedence, null-fallthrough, empty-string
fallthrough, and case normalization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:47:10 +00:00
gavrielc ce28e7f558 docs(add-codex): bump CODEX_VERSION to 0.124.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:27:20 +03:00
gavrielc 9e480a0624 Merge pull request #1954 from qwibitai/feat/setup-signal
feat(setup): wire Signal into the auto setup flow
2026-04-23 23:37:37 +03:00
gavrielc 3fa001409e feat(setup): wire Signal into the auto setup flow
`bash nanoclaw.sh` can now offer Signal as a channel choice, scan the
signal-cli link QR in the terminal, and wire up the first agent end to
end — mirroring the WhatsApp and Telegram flows.

Pieces:

- setup/add-signal.sh — non-interactive installer. Fetches
  src/channels/signal.ts + signal.test.ts from the channels branch,
  appends the self-registration import, installs qrcode (for the
  setup-flow QR render), and builds. Idempotent and standalone-runnable.

- setup/signal-auth.ts — step runner. Spawns `signal-cli link --name
  NanoClaw`, watches stdout for the `sgnl://linkdevice?…` (or legacy
  `tsdevice://`) URL, emits SIGNAL_AUTH_QR with it. On exit 0, runs
  `signal-cli -o json listAccounts` and reports the new account via
  SIGNAL_AUTH STATUS=success. Pre-check via listAccounts returns
  STATUS=skipped if an account is already linked.

- setup/channels/signal.ts — interactive driver. Probes for signal-cli
  (offering `brew install signal-cli` on macOS or linking GitHub
  releases on Linux if missing), runs add-signal.sh, renders each
  SIGNAL_AUTH_QR block as a terminal QR inside a clack spinner,
  persists SIGNAL_ACCOUNT to .env + data/env/env, restarts the
  service, then wires the first agent via init-first-agent.

- setup/index.ts: register `signal-auth` in the STEPS map.
- setup/auto.ts: add 'signal' to ChannelChoice, import the driver,
  add it to the channel picker (after WhatsApp, hint "needs signal-cli
  installed"), branch the dispatch, and map channelDmLabel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:20:47 +03:00
github-actions[bot] 78b0ad68f6 chore: bump version to 2.0.10 2026-04-23 20:05:01 +00:00
gavrielc e3f4a8b0d8 Merge pull request #1932 from Koshkoshinsk/main
v2: Fix Discord approval card bugs
2026-04-23 23:04:45 +03:00
gavrielc c1d0395d11 Merge branch 'main' into main 2026-04-23 23:04:35 +03:00
gavrielc 0eeeecf75e Merge pull request #1953 from ddaniels/skill/signal
feat(skill): Add Signal channel adapter (V2)
2026-04-23 23:01:34 +03:00
gavrielc 7a628bfb3c Merge branch 'main' into skill/signal 2026-04-23 23:01:02 +03:00
gavrielc 2fd2bf3bde chore(signal): move adapter source to channels branch
Signal adapter source (src/channels/signal.ts + signal.test.ts) now
lives on the `channels` branch alongside all other channel adapters,
per the trunk/channels split documented in CLAUDE.md and CONTRIBUTING.md
("Trunk does not ship any specific channel adapter"). The /add-signal
skill fetches the file from origin/channels like every other channel.

This PR to main therefore carries only:
- .claude/skills/add-signal/{SKILL,VERIFY,REMOVE}.md — the skill itself
- scripts/init-first-agent.ts — unrelated infra fix that benefits any
  native-ID channel (Signal, WhatsApp) by skipping the channel-prefix
  on platform IDs that already have their own format

The fixed adapter source + tests were pushed to the channels branch in
a parallel commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:56:31 +03:00
gavrielc f351e46008 refactor(approvals): persist title+options on channel/sender approval tables
getAskQuestionRender used to hardcode the card title and option labels
for pending_channel_approvals and pending_sender_approvals in the
DB-access layer, duplicating wording that already lived in the approval
modules. That caused a visible drift between the initial card title —
picked per event in channel-approval.ts ("📣 Bot mentioned in new chat"
vs. "💬 New direct message") — and the post-click render, which
always showed the constant "📣 Channel registration".

Mirror the pattern already used by pending_approvals: add title /
options_json columns on both pending_*_approvals tables via migration
013, have the approval modules write them at creation time, and let
getAskQuestionRender just SELECT.

- Migration 013 ALTERs the two tables to add title + options_json.
- PendingChannelApproval / PendingSenderApproval types and their
  create functions grow the two fields.
- channel-approval.ts / sender-approval.ts normalize options once
  and pass both title and options_json into the insert.
- getAskQuestionRender drops the hardcoded render objects and reads
  the stored values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:54:47 +03:00
gavrielc 5f3bd9c880 fix(signal): address review feedback from #1953
Correctness fixes:
- parseSignalStyles now uses a recursive walker so nested styles (e.g.
  **bold with `code` inside**) produce correct offsets against the final
  plain text. Previous impl recorded styles against intermediate text and
  didn't reindex when later passes stripped prefix characters.
- *single-asterisk* maps to ITALIC (was BOLD, divergent from standard
  Markdown). _underscore_ also maps to ITALIC.
- EchoCache keys on (platformId, text) so an outbound "hi" to Alice no
  longer drops a real "hi" inbound from Bob.
- On TCP socket close, flip adapter connected=false and log a warning so
  operators see lost daemon connections instead of silently failing sends.
- signalTcpCheck clears its 5s timeout on success so successful checks
  don't leak a setTimeout handle.

Config hygiene:
- Rename SIGNAL_HTTP_HOST/PORT to SIGNAL_TCP_HOST/PORT (transport is TCP
  JSON-RPC, not HTTP) and add SIGNAL_CLI_PATH for non-PATH installs.
- Remove unused readFileSync import.
- Log a warning in deliver() when outbound files are dropped (native
  adapter doesn't forward attachments to signal-cli yet).

Tests:
- Nested style offset correctness
- *italic* and _italic_ ITALIC mapping
- Cross-recipient echo isolation
- Same-recipient echo still suppressed
- isConnected() flips on socket close
- Outbound-files warn-and-drop path

SKILL.md realigned to the add-telegram / add-whatsapp template: fetches
from the `channels` branch (not a `skill/*` branch), lists pre-flight
idempotency checks, adds Features / Troubleshooting sections. Added
VERIFY.md and REMOVE.md siblings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:54:27 +03:00
github-actions[bot] 5d32efbce4 chore: bump version to 2.0.9 2026-04-23 19:37:49 +00:00
gavrielc 7eda2628fa Merge pull request #1943 from qwibitai/fix/pending-rows-idempotent
fix(delivery): make pending_questions/approvals insert idempotent
2026-04-23 22:37:34 +03:00
gavrielc ffd38f660a Merge branch 'main' into fix/pending-rows-idempotent 2026-04-23 22:37:22 +03:00
gavrielc 57eeed6cb6 Merge branch 'main' into skill/signal 2026-04-23 22:36:17 +03:00
github-actions[bot] 2861009d95 docs: update token count to 129k tokens · 64% of context window 2026-04-23 19:36:05 +00:00
github-actions[bot] bd032c2b83 chore: bump version to 2.0.8 2026-04-23 19:35:59 +00:00
gavrielc 0e0794ca10 Merge pull request #1942 from qwibitai/fix/telegram-callback-data-size
fix(chat-sdk-bridge): encode option index in callback_data for Telegram 64-byte cap
2026-04-23 22:35:48 +03:00
gavrielc 83254b12b4 Merge branch 'main' into fix/telegram-callback-data-size 2026-04-23 22:35:34 +03:00
gavrielc cf2b1c9755 Merge pull request #1940 from cheats1314/fix/setup-v2-registered-groups
fix(setup): detect registered groups from v2 central db
2026-04-23 22:20:41 +03:00
gavrielc f3524a33bb Merge branch 'main' into fix/setup-v2-registered-groups 2026-04-23 22:20:31 +03:00
Doug Daniels c6d2f45f93 feat: add Signal channel adapter
Native Signal adapter using signal-cli TCP JSON-RPC daemon. No Chat SDK
bridge or npm dependencies — uses only Node.js builtins.

Features:
- DM and group message support
- Voice message detection (placeholder text; transcription via
  /add-voice-transcription skill)
- Typing indicators (DMs only)
- Mention detection via text match
- Managed daemon lifecycle (auto-start/stop signal-cli)
- Echo suppression for outbound messages

Also fixes init-first-agent.ts to skip channel-prefixing for phone
numbers (+...) and Signal group IDs (group:...), which are native
platform IDs that adapters send without a channel prefix.

Install via /add-signal skill. Uses /init-first-agent for channel wiring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:15:42 -04:00
gavrielc e5a7a33084 docs(add-codex): fix Dockerfile install step — separate RUN block, not combined list
The prior instruction told users to append "@openai/codex@${CODEX_VERSION}" to
a single combined `pnpm install -g` block. That block no longer exists on
main — the Dockerfile splits each global CLI (vercel, agent-browser,
claude-code) into its own RUN layer for cache granularity. Update the skill
to add a standalone RUN block for Codex that matches the existing pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:38:16 +03:00
gavrielc 0ec56b732d docs(add-codex): add skill for installing Codex provider from providers branch
Mirrors the /add-opencode and /add-ollama-provider pattern. Copies the
add-codex SKILL.md from the providers branch onto trunk so the skill is
discoverable without a manual branch copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:35:00 +03:00
exe.dev user 97868af5a7 fix(delivery): make pending_questions/approvals insert idempotent
createPendingQuestion and createPendingApproval both run before the
adapter delivery call. When delivery fails and the retry loop reinvokes
deliverMessage with the same questionId/approvalId, the second attempt
hit UNIQUE constraint on the pending_questions.question_id (or
pending_approvals.approval_id) and threw — so the retry never reached
the send step, and every subsequent retry failed the same way until
max-attempts marked the message permanently failed.

Switch both inserts to INSERT OR IGNORE. Return bool indicating whether
a new row was actually inserted so delivery.ts can avoid logging
"Pending question created" twice for the same card.

Symptom that surfaced this: a send-layer ValidationError on one attempt
followed by SqliteError on every subsequent attempt, with the user
seeing neither the card nor a follow-up. Seen in conjunction with the
Telegram 64-byte callback_data limit (fixed separately in
#1942/chat-sdk-bridge), but the idempotency gap applies to any
transient delivery failure — rate limits, network blips, adapter 5xx —
and is worth fixing on its own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:05:41 +00:00
exe.dev user ff277c0d49 fix(chat-sdk-bridge): encode option index in callback_data for Telegram 64-byte cap
ask_question cards failed to deliver on Telegram whenever any option had
a non-trivial value (e.g. an ISO datetime, a URL, or a long token).
Telegram limits inline-keyboard callback_data to 64 bytes, and the
previous encoding embedded both the questionId and the full option
value in each button's actionId plus a second copy as value, producing
payloads well over the cap. The adapter threw ValidationError, delivery
was marked permanently failed, and the agent sat waiting on an answer
that never reached the user.

Fix:
  - Button id is now `ncq:<questionId>:<index>` and button value is the
    stringified index. Callback payloads shrink from ~100 bytes to ~40
    and fit Telegram's cap for any option list with <100 items.
  - Both callback-decode sites (Chat SDK `onAction` for Telegram/Slack/
    etc., and the Discord Gateway interaction handler) resolve the
    index back to the real option value via
    `getAskQuestionRender(questionId)` before dispatching to the host's
    onAction — so response handlers (pending_questions, pending_approvals)
    are unchanged and still receive the canonical value.
  - `resolveSelectedOption` helper has a backward-compat fallback:
    non-numeric tails are treated as literal values so any card
    delivered under the old encoding still resolves if the user clicks
    it after deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:56:21 +00:00
gavrielc a67b4abd79 Merge pull request #1941 from qwibitai/fix/container-restart-recovery
fix: container restart recovery — stale heartbeat + orphan claim loop
2026-04-23 19:01:36 +03:00
gavrielc 500353c182 Merge branch 'main' into fix/container-restart-recovery 2026-04-23 19:01:23 +03:00
Gabi Simons a8eb82d529 Merge branch 'main' into main 2026-04-23 18:24:24 +03:00
exe.dev user 237876c2c6 chore(format): wrap session-manager import in container-runner
Pre-commit prettier reformatted this in the working tree but didn't
re-stage. Keeping it in a separate commit to avoid amending a prior
commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:12:56 +00:00
exe.dev user 209061f54f fix(sweep): wake before reset + idempotent retry for orphan claims
When a container exits with an unresolved processing_ack claim, the
sweep's crashed-container cleanup would reset the matching inbound
message with tries++ and a future process_after. dueCount then dropped
to 0, so the wake step never fired — and the next sweep tick found the
same orphan claim, bumped tries again, and pushed process_after further
out. The message reached MAX_TRIES and was marked failed without any
container ever being spawned.

Two changes:

1. Reorder sweep so the wake step runs before crashed-container
   cleanup. A fresh container clears orphan 'processing' rows on its
   own startup (container/agent-runner/src/db/connection.ts), so once
   we get it running the claim resolves itself.

2. Make resetStuckProcessingRows idempotent: if a message already has
   process_after set to a future time, skip the retry bump. The wake
   path will pick it up when the backoff elapses. Requires returning
   process_after from getMessageForRetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:12:16 +00:00
exe.dev user bee80b0072 fix(container): clear orphan heartbeat before spawn
After a container exits, its .heartbeat file is left behind with the
mtime of its last SDK activity. When the same session spawns a new
container, the host sweep's ceiling check reads that stale mtime and
kills the freshly-spawned container within seconds — before the new
instance has had time to touch the file itself.

The sweep already has a carve-out for "no heartbeat file" (treated as a
fresh spawn, given grace), so simply removing the orphan at spawn time
restores the intended semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:12:02 +00:00
cheats1314 539af750d4 fix(setup): detect registered groups from v2 central db
Align the environment check with the v2 setup flow so existing wired agent groups are detected from data/v2.db instead of the retired v1 store. This prevents setup from reporting no registered groups on valid v2 installs and adds regression coverage for both v2 and pre-migration state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 22:22:18 +08:00
Gabi Simons a65ee2e55c Merge branch 'main' into feat/migrate-from-v1 2026-04-23 16:38:36 +03:00
github-actions[bot] 438dedad77 chore: bump version to 2.0.7 2026-04-23 13:30:51 +00:00
gavrielc 6475e0f0b5 Merge pull request #1933 from qwibitai/fix/atomic-chat-skill-restructure
refactor(add-atomic-chat-tool): ship MCP file in skill folder, revert src edits
2026-04-23 16:30:33 +03:00
gavrielc dd5bc85b02 refactor(skill/atomic-chat-tool): ship MCP file in skill folder, revert src edits
The initial /add-atomic-chat-tool merge added src edits directly to main.
That conflicts with the utility-skill pattern used elsewhere (e.g. /claw):
the skill folder should ship the file and SKILL.md should instruct copy +
idempotent edits at install time, not a git merge that carries src diffs.

- Move container/agent-runner/src/atomic-chat-mcp-stdio.ts →
  .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts
- Revert the atomic_chat mcpServers entry in agent-runner index.ts
- Revert mcp__atomic_chat__* from TOOL_ALLOWLIST in providers/claude.ts
- Revert ATOMIC_CHAT_* env forwarding and [ATOMIC] log elevation in
  src/container-runner.ts
- Empty .env.example back out
- Rewrite SKILL.md: copy the shipped file, then apply deterministic Edits
  (index.ts, providers/claude.ts, container-runner.ts, .env.example)
  with exact before/after snippets the installer agent can match.

Main is now back to its pre-PR state for the tool; /add-atomic-chat-tool
re-applies everything at install time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:29:10 +03:00
github-actions[bot] 97e356d243 chore: bump version to 2.0.6 2026-04-23 13:21:49 +00:00
gavrielc 94d33bcc1d Merge pull request #1802 from Vect0rM/skill/atomic-chat-tool
feat: add Atomic Chat MCP tool skill
2026-04-23 16:21:33 +03:00
gavrielc cca22e9270 Merge branch 'main' into skill/atomic-chat-tool 2026-04-23 16:21:24 +03:00
Misha Skvortsov 3a9b98f1a4 feat: add Atomic Chat MCP tool skill
Exposes local Atomic Chat models (OpenAI-compatible API at
127.0.0.1:1337/v1) as tools to the container agent. Adds
atomic_chat_list_models and atomic_chat_generate alongside
the existing Ollama skill.

Rebased on current main:
- MCP server registered in agent-runner index.ts using bun (no tsc
  step in-image), sibling path to index.ts, env: {} with ATOMIC_CHAT_*
  forwarded when set.
- allowedTools entry moved to providers/claude.ts TOOL_ALLOWLIST.
- SKILL.md: drop obsolete per-group copy step (single RO mount
  supersedes it); use pnpm build.

Made-with: Cursor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:18:34 +03:00
gabi-simons 04e0e18e8e chore: retrigger CI (pre-existing flaky container test) 2026-04-23 13:13:25 +00:00
gabi-simons 9faa8a9a2c fix(migrate-v1): splice guild_id into Discord platform_id during seed
v2's Chat SDK Discord adapter emits `platform_id` as
`discord:<guild_id>:<channel_id>` at runtime, but v1 only stored
`dc:<channel_id>` (no guild). Before this fix `migrate-db` wrote
`discord:<channel_id>` into `messaging_groups.platform_id`, which didn't
match what v2 saw on incoming messages — v2 treated every message as a
new channel and fired its channel-registration approval flow instead of
routing to the migrated agent_group.

Now `migrate-db` fetches the bot's guilds once per channel_type via
`GET /users/@me/guilds`. When the bot is in exactly one guild (the
common case), the guild id is spliced into every Discord platform_id at
seed time — matching v2's runtime format. Multi-guild bots fall back to
the v1-format id; v2's channel-registration flow repairs on first
message.

Cost: one extra Discord API call per migration run (not per channel).
No new failure modes — network/auth issues return null, fall through to
the existing behavior.

## Surface

- `v2PlatformId(channelType, jid, { guildId })` — new optional `extra`
  parameter. Back-compat with existing callers.
- `fetchBotGuilds(channelType, lookup)` — new helper in `shared.ts`,
  same pattern as `autoResolveV2Keys`. Handles Discord today; extending
  to other channels is a case-by-case API check.
- `migrate-db` pre-loop: builds `v1EnvMap`, fetches guilds per channel
  type, caches single-guild IDs for the row loop.

## Testing

Verified on a 300-channel Discord v1 install:
- Fresh run produced `discord:<guild>:<channel>` platform_ids from the
  start
- Incoming messages now route to the migrated agent_group instead of
  firing the unwire approval flow

Rate-limit note: `/users/@me/guilds` is a single call. Per-channel
`/guilds/<id>/channels` lookups for multi-guild bots would need proper
rate-limit handling — deferred.
2026-04-23 13:06:14 +00:00
gabi-simons e1c8876a72 feat(migrate-v1): auto-resolve missing v2 channel keys via adapter APIs
`migrate-channel-auth` now tries to derive v2-required keys that v1 never
stored by calling the channel's API with the credential v1 did have. When
the gap can be closed automatically, the keys land in v2 `.env` before
the missing-required check, and the step reports `success` instead of
`partial`. When it can't, the existing followup fires unchanged.

## Discord

v1 used raw `discord.js` (bot token only). v2's Chat SDK needs
`DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY`. Both can be fetched with
the bot token via:

    GET /oauth2/applications/@me
    Authorization: Bot <DISCORD_BOT_TOKEN>
    → { id, verify_key, … }

For a stock v1 Discord user, this means `bash nanoclaw.sh` now produces
a fully working v2 Discord adapter with zero manual key-setting — just
stop v1, and v2 takes over.

## Surface

- `autoResolveV2Keys(channelType, lookup)` in `setup/migrate-v1/shared.ts`
  — pluggable per-channel resolver, returns a `{key: value}` map. Never
  throws; returns `{}` on any failure (network, auth, unexpected shape).
  Logs keys resolved, never values.
- `migrate-channel-auth` wiring: build a lookup over v1 + v2 .env, call
  the resolver, append resolved keys to v2 .env (never overwriting), sync
  to `data/env/env`, then re-check `requiredV2Keys` to compute the real
  gap. Sidecar annotation `(auto-resolved)` on `env_keys_copied` in the
  handoff so the skill can tell which came from v1 vs derived.

## Extending to other channels

Slack has `/auth.test` (bot token → team/app info), Telegram has `/getMe`,
Matrix has `/whoami`. Most don't cover the full required-key set v2 needs
(e.g. Slack's `SLACK_SIGNING_SECRET` lives only in app config and has no
API equivalent). Add resolvers case-by-case when the API supports it; the
registry's `requiredV2Keys` + followup fallback covers the rest.

## Testing

- Stripped `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY` from v2 `.env`
- Re-ran migration (wired-only, 301 groups): resolver populated both keys
  via the API; `migrate-channel-auth: success` (was `partial`);
  `overall_status: success`
- Restarted v2: Discord adapter booted clean, Gateway connected,
  `GUILD_CREATE` received
- v1 stopped, v2 handling Discord traffic
2026-04-23 13:06:14 +00:00
gabi-simons 3ee7d2147e feat: add v1 → v2 migration to setup flow (experimental)
`bash nanoclaw.sh` detects a v1 install before channel pairing and does a
best-effort automated port of operationally important state. Hands off to
a new `/migrate-from-v1` skill for owner seeding and fork customizations.

Between the timezone and channel steps, `setup/auto.ts` calls
`runMigrateV1()` which orchestrates these registered sub-steps (each a
separate entry in the progression log with its own raw log + status
block — failures never abort the chain):

- **migrate-detect** — scans siblings of the v2 checkout + common $HOME
  locations; `$NANOCLAW_V1_PATH` overrides authoritatively. Relaxed
  `package.json` check lets forks + partial installs still match; DB
  presence is the strongest signal.
- **migrate-validate** — asserts v1 DB shape (tables + required
  columns); writes `schema-mismatch.json` on failure. Subsequent steps
  short-circuit their DB-dependent parts but still run.
- **migrate-db** — seeds `agent_groups` + `messaging_groups` +
  `messaging_group_agents` from v1's `registered_groups`. JID
  decomposition (`dc:123` → `channel_type='discord'`,
  `platform_id='discord:123'`); `trigger_pattern` + `requires_trigger`
  → `engage_mode` + `engage_pattern` (mirrors migration 010 backfill).
  Users + user_roles are NOT seeded — the skill does that with an owner
  interview. Idempotent: existing rows reused, not duplicated.
- **migrate-groups** — rsync group folders. v1 `CLAUDE.md` → v2
  `CLAUDE.local.md` (v2 composes `CLAUDE.md` at container spawn); v1
  `container_config` JSON → `.v1-container-config.json` sidecar for the
  skill to translate. Tight v1-pattern scan (`/workspace/ipc/tasks`,
  `store/messages.db`, `[PR_CONTEXT:`, etc.) flags files referencing
  v1-specific infrastructure — content is NOT modified, just flagged in
  the handoff.
- **migrate-env** — merges v1 `.env` into v2 `.env`, never overwriting
  existing v2 keys.
- **migrate-channel-auth** — per-channel registry tracks v1 env keys,
  v2 required keys (with source-of-key instructions — e.g. Discord
  needs `DISCORD_PUBLIC_KEY` which v1 never stored), and candidate
  on-disk auth state paths (Baileys keystore, matrix sync state,
  etc.). Missing required v2 keys surface as actionable followups and
  flip the step to `partial`.
- **migrate-channels** — runs `setup/install-<channel>.sh` for each
  detected channel in non-interactive mode. Install-script output is
  captured to `logs/setup-migration/install-<channel>.log` sidecars
  (silent under the parent spinner). Channels with no v2 adapter get
  a `not_supported` followup but don't degrade status.
- **migrate-tasks** — v1 `scheduled_tasks` → `messages_in` rows with
  `kind='task'` in each session's `inbound.db`. `schedule_type`
  mapping (cron / interval / once → v2 cron). Idempotent: skips v1
  task ids already present. Inactive rows dumped to
  `inactive-tasks.json` for reference.

Everything writes to `logs/setup-migration/handoff.json` — the source
of truth the skill consumes.

`.claude/skills/migrate-from-v1/SKILL.md`:

- **Phase A** (always): owner seeding + v1 access policy flip
  (`unknown_sender_policy` public/strict) via `AskUserQuestion`. Pulls
  sender candidates from v1's `messages` table as hints.
- **Phase B** (if followups exist): walks
  `handoff.followups` — translates `.v1-container-config.json`
  sidecars, handles `not_supported` channels, fills in missing
  required keys with instructions on where to get them.
- **Phase C** (fork-aware): `git log <upstream>..HEAD` in v1. Empty →
  "no customizations to port." Non-empty → scope choice (mechanical /
  full interview / reference-only). Portable categories
  (`container/skills/*`, `.claude/skills/*`, docs) scan+copy with
  `scanForV1Patterns`. Non-portable (`src/*`,
  `container/agent-runner/src/*`) stash to `docs/v1-fork-reference/`
  — explicit "don't translate v1 infra to v2" warning because v1's
  IPC file queue / single DB don't exist in v2.

Clearly marked in README, CLAUDE.md, SKILL.md header, and via a `p.warn`
that fires once per run when v1 is detected. Users with no v1 install
see a silent skip — no prompts, no noise.

Verified end-to-end against a live v1 install (300 discord + 1
discord-supervisor groups, fork with ~15 commits of PR-factory work):
- Detect → validate → db (301 rows seeded) → groups (301 CLAUDE.local.md
  + 178 other files + 1 container_config sidecar) → env (4 keys copied)
  → channel-auth (flagged missing `DISCORD_APPLICATION_ID` +
  `DISCORD_PUBLIC_KEY`) → channels (discord installed, discord-supervisor
  → not_supported) → tasks (0 rows, skipped)
- Idempotent re-run: 0 rows created, 903 rows reused; tasks skip if
  id already present
- Fresh-user case: silent skip, no prompts, straight to "You're ready!"
- Schema-mismatch case: recorded to `schema-mismatch.json`, chain
  continues

- Unit tests for the pure transforms (`parseJid`,
  `inferChannelType`, `triggerToEngage`, `scanForV1Patterns`,
  `looksLikeV1Install`)
- Validate `requiredV2Keys` for telegram/slack/matrix/teams/webex/
  resend/linear against the actual Chat SDK packages (Discord was
  verified from real error output)
- Widen candidate auth file paths for WhatsApp/Matrix/iMessage based
  on real non-Discord v1 installs once we have some

See docs/v1-to-v2-changes.md for the v1 → v2 architecture diff.
2026-04-23 13:06:14 +00:00
gavrielc 677cc47bd1 Merge pull request #1929 from qwibitai/add-slack-imessage
Add Slack and iMessage channel flows (experimental)
2026-04-23 16:00:09 +03:00
exe.dev user 40f5683c36 fix(approvals): show correct post-click labels on channel/sender cards
getAskQuestionRender only checked pending_questions and
pending_approvals, missing the channel and sender approval tables.
Approval button clicks showed the raw value ("approve") instead of
the selectedLabel (" Wired"). Extend the lookup to also check
pending_channel_approvals and pending_sender_approvals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:23:45 +00:00
exe.dev user 15f30682d7 fix(approvals): show human-readable names in approval cards
Channel and sender approval cards showed raw platform IDs
(e.g. discord:1475578393738219540:...) instead of readable context.
Extract sender name from the event content for channel approvals,
and use the channel type name for sender approvals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:23:34 +00:00
exe.dev user d121cd1cd6 fix(router): pass isGroup from adapter through to messaging group creation
The router hardcoded is_group=0 when auto-creating messaging groups,
causing channel mentions to be misclassified as DMs. The Chat SDK
bridge knows which handler fired (onDirectMessage vs onNewMention)
so thread the signal through InboundMessage → InboundEvent → router.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:23:23 +00:00
exe.dev user 61ca43d193 fix(discord): resolve user ID from DM interactions for approval clicks
Discord puts the clicking user at interaction.member.user for guild
interactions but interaction.user for DM interactions. The Gateway
handler only checked interaction.member, so DM button clicks resolved
to an empty user ID and were silently rejected as unauthorized.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:23:12 +00:00
gavrielc 3101f65a72 feat(setup): add Slack and iMessage channel flows (experimental)
Slack: interactive driver walks through app creation, validates the
bot token via auth.test, installs the adapter, and prints a
post-install checklist for the webhook URL + Event Subscriptions
config. No welcome DM since Slack needs a public URL before inbound
events work — the driver's own "finish in Slack" note replaces the
outro "check your DMs" banner.

iMessage: picks local (macOS) vs remote (Photon) mode. Local mode
opens the node binary's directory in Finder so the user can drag it
into Full Disk Access. Remote mode prompts for Photon URL + API key.
Asks for the operator's phone/email, then wires the first agent
including a welcome iMessage.

Both marked "(experimental)" in the askChannelChoice picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:26:06 +03:00
github-actions[bot] d8b1f52f2b chore: bump version to 2.0.5 2026-04-23 09:52:56 +00:00
gavrielc c84a6ba80e Merge pull request #1928 from lazer-sketch/fix/scoped-container-reaper
fix(container): scope orphan reaper by install label; detect unhealthy peers
2026-04-23 12:52:42 +03:00
gavrielc 73c931594a Merge branch 'main' into fix/scoped-container-reaper 2026-04-23 12:52:32 +03:00
Lazer Cohen 2383bde80f fix(container): scope orphan reaper by install label so peers don't kill each other
Two installs on the same host could trash each other's containers: the
reaper used `docker ps --filter name=nanoclaw-`, a substring match that
picked up every install's containers. A crash-looping peer (e.g. a legacy
v1 plist respawning ~6k times) would call cleanupOrphans on every boot and
kill the healthy install's session containers within seconds of spawn.

- Stamp `--label nanoclaw-install=<slug>` onto every spawned container.
- cleanupOrphans filters by that label; healthy peers are left alone.
- Setup preflight enumerates `com.nanoclaw*` launchd plists / nanoclaw
  user systemd units, probes state/runs, and unloads any that are
  crash-looping (state != running AND runs > 10) before installing
  this install's service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:12:30 +03:00
glifocat ae2c09cbde docs: add fork-specific notes in FORK.md 2026-04-23 10:33:54 +02:00
gavrielc dee7e0be32 feat(setup): Yes-default + session-persist on claude-assist, quieter first-chat
Three UX tweaks after watching a user walk through setup:

1. Claude-assist "Run this command?" now defaults to Yes. After Claude has already been asked to diagnose + explained the fix, the vast majority of users want to run it — the No-default added friction without proportional safety.

2. claude-assist persists its session across failures in one setup run. First invocation captures session_id from the stream-json init event; subsequent invocations pass --resume <id>. Claude sees prior failures as conversation history instead of treating each hiccup as a blank-slate ticket.

3. First-chat flow no longer drops the user into a free-text chat loop by default. Instead: explain what the ping/pong check is doing, wait for the pong, then offer "Continue with setup" (recommended, default) or "Pause here and chat with your agent from the terminal" (opt-in). The free-text loop is still reachable, just not the default path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:23:37 +03:00
gavrielc 990d243dbd fix(setup): bypass rate-limited GitHub API when installing onecli CLI
The upstream onecli.sh/cli/install script resolves the latest release via
api.github.com/repos/onecli/onecli-cli/releases/latest — anonymous callers
get throttled to 60 req/hour per IP, and once exhausted the installer dies
with "curl: (56) 403 / Error: could not determine latest release". Shared
IPs (corporate NAT, public Wi-Fi) hit this without ever running the
installer themselves. Reproduced locally: rate_limit remaining=0 → upstream
installer returns the exact user error.

Fallback path when upstream fails:
1. Resolve version via `curl -fsSL -o /dev/null -w '%{url_effective}' \
   https://github.com/onecli/onecli-cli/releases/latest`. That endpoint
   302s to /tag/vX.Y.Z — parses the version without an API call.
2. If the redirect probe also fails, install a pinned fallback version
   (ONECLI_CLI_FALLBACK_VERSION, currently 1.3.0).
3. Download the archive from /releases/download/vX.Y.Z/… directly (the
   CDN path isn't API-throttled), extract, and install to /usr/local/bin
   or ~/.local/bin mirroring upstream's install-dir logic.

Gateway install (onecli.sh/install, docker-compose based) is untouched —
it doesn't hit the API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:10:30 +03:00
gavrielc 910342fd80 style(setup): lift text weight — prose becomes regular, outcomes bold
Dimmed explanatory prose blocks were hard to read against dark terminals. Shift the weight ladder up a notch:

- dimWrap() no longer dims. Multi-line prose (the step-intro copy, etc.) renders at the terminal's regular weight.
- Spinner outcome labels (done/failed/skipped) are now bold via runUnderSpinner, so each step's headline reads stronger than the body copy around it.
- Un-dim two command-hint blocks in auto.ts (docker-group setfacl + service restart; the socket-error remediation commands) — those are commands the user may need to type.

Dim is still used where it helps — (Ns) spinner timings, URLs, short inline parentheticals — and for the preview/debug blocks dim is explicitly reserved for: dumpTranscriptOnFailure tail and claude-assist streams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:59:12 +03:00
gavrielc 7f4583d0fe fix(setup): add npm global prefix bin to PATH after fallback install
When corepack enable fails with EACCES (common when Node is installed to a system-writable prefix like /usr/local that the user doesn't own), we fall back to `npm install -g pnpm`. But npm's global prefix isn't always on the shell's PATH — users often set `npm config set prefix ~/.npm-global` to avoid sudo, and the resulting bin dir isn't picked up by `command -v`. Install succeeded, but pnpm "wasn't there" for the follow-up `pnpm install`.

Now after the npm fallback we query `npm config get prefix` and prepend `<prefix>/bin` to PATH. Mirror the same lookup in nanoclaw.sh right before `exec pnpm run setup:auto` — setup.sh's PATH mutation doesn't propagate back, and the hand-off needs pnpm visible too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:50:21 +03:00
gavrielc 092f16dfaa Merge pull request #1927 from qwibitai/setup-feedback-fixes
Clarify setup flow from user-feedback session
2026-04-23 10:43:27 +03:00
gavrielc 4ff4cc75b9 Merge remote-tracking branch 'origin/main' into setup-feedback-fixes
# Conflicts:
#	setup/auto.ts
#	setup/channels/whatsapp.ts
2026-04-23 10:39:35 +03:00
gavrielc 56ef5b4461 feat(setup): clarify setup flow from user-feedback session
- Container step: duration hint + 3-line rolling output window with
  60s stall detector that offers "keep waiting" vs "ask Claude"
- First chat: reframed as a try-out with sandbox-model explainer
  (wakes on message, sleeps when idle, context persists)
- Timezone: auto-detected non-UTC zones now get an explicit
  confirm from the user instead of silent persist
- Outro: added always-on warning + prominent "check your DM" banner
  when a channel was configured; directive last line
- Discord: always show token-location reminder even when user says
  they have one; new "do you have a server?" branch walks through
  server creation if not
- All select prompts: custom brightSelect renderer keeps inactive
  option labels at full brightness (was dim gray); adds @clack/core
  as a direct dep

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:35:12 +03:00
github-actions[bot] 8a19ad019a chore: bump version to 2.0.4 2026-04-23 07:11:04 +00:00
gavrielc 5f1b3e5cad style: apply prettier formatting to install-slug additions 2026-04-23 10:10:48 +03:00
github-actions[bot] 72aba8c7ba docs: update token count to 128k tokens · 64% of context window 2026-04-23 07:10:31 +00:00
github-actions[bot] 3d44001633 chore: bump version to 2.0.3 2026-04-23 07:10:26 +00:00
gavrielc 7a9401ddf2 feat(setup): per-checkout service name and docker image tag
Two NanoClaw installs on the same host used to fight over the shared `com.nanoclaw` launchd label / `nanoclaw.service` systemd unit and the `nanoclaw-agent:latest` docker tag — the second install silently rewrote the service pointer and rebuilt the image out from under the first. Introduces a deterministic per-checkout slug (sha1(projectRoot)[:8]) and namespaces everything off it:

- Service: `com.nanoclaw-v2-<slug>` / `nanoclaw-v2-<slug>.service`
- Image:   `nanoclaw-agent-v2-<slug>:latest` (base), `nanoclaw-agent-v2-<slug>:<agentGroupId>` (per-group)

New shared helpers: src/install-slug.ts (host) + setup/lib/install-slug.sh (bash). Both compute the same slug so verify/probe/add-*.sh/build.sh/container-runner all agree. Any v1 `com.nanoclaw` service left on the host stays untouched and can coexist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:10:09 +03:00
gavrielc 4f6d62a65e docs(readme-zh): align Chinese README with v2 English
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:55:34 +03:00
gavrielc 564000dcae docs(readme-ja): align Japanese README with v2 English
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:55:28 +03:00
gavrielc 601fc7c396 docs(readme): split Quick Start into separate commands
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:33:39 +03:00
gavrielc cdb9442796 docs(readme): clone into nanoclaw-v2 in Quick Start
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:31:49 +03:00
gavrielc 8326b4c0be fix(setup): offer to reuse an existing OneCLI instead of clobbering it
Before: setup/onecli.ts ran `curl -fsSL onecli.sh/install | sh` unconditionally. For users with OneCLI already running and bound to a specific listener (host-accessible, shared with other apps), re-running the installer rebound the gateway and broke those consumers.

Now: auto.ts probes for an existing install (`onecli version` + `onecli config get api-host`). If detected, clack asks: use the existing instance (recommended) or install a fresh one. The new --reuse flag in the onecli step skips the installer, reads the configured api-host, writes ONECLI_URL to .env, and moves on without touching the running gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:48:24 +03:00
github-actions[bot] 22c2beff3c chore: bump version to 2.0.2 2026-04-22 22:05:25 +00:00
gavrielc 6cd261a26d chore(container): loosen /home/node to 0777
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:05:03 +03:00
gavrielc d97a0e1484 fix(setup): resolve channels remote dynamically, don't assume origin
Forks that keep the upstream nanoclaw repo under a non-origin remote name (typically `upstream`, with `origin` pointing at the user's fork) hit "git fetch origin channels failed" when adding a channel, because the fork doesn't carry the channels branch. New setup/lib/channels-remote.sh walks `git remote -v` for a url matching qwibitai/nanoclaw, auto-adds `upstream` if none matches, and honors NANOCLAW_CHANNELS_REMOTE as an override. Wired into the four add-*.sh scripts that setup:auto invokes (discord, telegram, whatsapp, teams).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:21:15 +03:00
gavrielc 16421cc022 fix(setup): fall back to npm install when corepack is missing
Some Node installs (older nvm, node@22 keg-only on brew, minimal distro packages) don't ship corepack, so the bootstrap was dying with "corepack: command not found" before pnpm could land on PATH. Now guards the corepack call and falls back to `npm install -g pnpm@<pinned>`, reading the version from package.json's packageManager field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:20:38 +03:00
gavrielc 469dd9af7e refactor(skills): collapse setup skill to one instruction — run bash nanoclaw.sh
Deletes the Claude-orchestrated /setup and /new-setup flows. The scripted installer (bash nanoclaw.sh → setup:auto) now handles bootstrap, container, OneCLI, auth, service, first agent, and optional channel wiring end-to-end with inline Claude-assisted recovery on failure. Keeps /setup as a one-line redirect so the trigger still resolves. Drops the opt-out diagnostics files that belonged to the old flow and updates cross-refs in add-wechat, migrate-nanoclaw, and update-nanoclaw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:20:38 +03:00
github-actions[bot] dbb859bfec docs: update token count to 127k tokens · 64% of context window 2026-04-22 16:50:18 +00:00
github-actions[bot] dbb82440bd chore: bump version to 2.0.1 2026-04-22 16:50:10 +00:00
gavrielc c16052ed4d Merge pull request #1919 from qwibitai/v2
v2: ground-up architectural rewrite
2026-04-22 19:49:51 +03:00
Ira Abramov ad97829151 docs(add-signal-v2): replace inline voice section with reference to add-voice-transcription-free-whisper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 13:17:57 +03:00
Ira Abramov bc0b559461 docs(add-signal-v2): document XDG attachment path behaviour and voice fix
signal-sdk launches signal-cli without --config, so attachments land at
~/.local/share/signal-cli/attachments/ (XDG default) rather than
data/signal/. Document this in the Channel Info section and add a
troubleshooting entry explaining the symptom (voice messages silently
skipped, no transcript), how to confirm (ps aux | grep signal-cli), and
the automatic fallback the adapter uses.
2026-04-20 12:27:10 +03:00
Ira Abramov 06918f35e0 feat(channels): add Signal channel adapter (v2) — skill and docs
Adds the /add-signal-v2 skill: a native Signal channel adapter wrapping
signal-sdk (signal-cli under the hood). No bot API — NanoClaw registers
as a full Signal account on a dedicated number or as a linked device.

Features: text, group & DM routing, voice transcription via whisper.cpp,
attachments, emoji reactions, @mention detection, quote-reply detection.

Troubleshooting note updated: GroupV2 group ID lives at
envelope.dataMessage.groupV2.id — not groupInfo.groupId (GroupV1/legacy).
2026-04-20 12:11:51 +03:00
Daniel M 6ef479ddf7 Merge branch 'main' into docs/pr-hygiene-check 2026-03-29 11:17:37 +03:00
NanoClaw 0c420cffca docs: align contributing guidelines with updated PR hygiene wording
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:00:13 +00:00
NanoClaw 5ed74c3a3f docs: scope PR hygiene check to PR creation only, improve wording
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:00:13 +00:00
NanoClaw ad507fa426 docs: clarify PR hygiene check wording
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:00:13 +00:00
NanoClaw 94689fcb36 docs: consolidate PR hygiene check from 3 commands to 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:00:13 +00:00
NanoClaw 4743513018 docs: add PR hygiene check to CLAUDE.md and contributing guidelines
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:00:13 +00:00
ingyukoh 0320e3fe26 docs: add ingyukoh to contributors 2026-03-26 16:53:07 +09:00
401 changed files with 32640 additions and 7211 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
```
@@ -0,0 +1,253 @@
---
name: add-atomic-chat-tool
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
---
# Add Atomic Chat Integration
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
Tools exposed:
- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
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 (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
### Check if already applied
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
### Check prerequisites
Verify Atomic Chat is installed and its local API server is running. On the host:
```bash
curl -s http://127.0.0.1:1337/v1/models | head
```
If the request fails:
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
2. Open the app.
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
## Phase 2: Apply Code Changes
### 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
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
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: {},
},
};
```
Add an `atomic_chat` entry alongside `nanoclaw`:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
atomic_chat: {
command: 'bun',
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
env: {
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
},
},
};
```
`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
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());
```
`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
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('[ATOMIC]')) {
log.info(line, { container: agentGroup.folder });
} else {
log.debug(line, { container: agentGroup.folder });
}
}
});
```
### Add env-var stubs to `.env.example`
Append to `.env.example`:
```bash
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
# Default: http://host.docker.internal:1337 (with fallback to localhost)
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
# ATOMIC_CHAT_API_KEY=
```
### 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/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 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
### Set Atomic Chat host (optional)
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
```bash
ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337
```
### Set API key (optional)
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
```bash
ATOMIC_CHAT_API_KEY=sk-...
```
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## Phase 4: Verify
### Test inference
Tell the user:
> Send a message like: "use atomic chat to tell me the capital of France"
>
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i atomic
```
Look for:
- `[ATOMIC] Listing models...` — list request started
- `[ATOMIC] Found N models` — models discovered
- `[ATOMIC] >>> Generating with <model>` — generation started
- `[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
## Troubleshooting
### Agent says "Atomic Chat is not installed" or tries to run a CLI
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` (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"
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
2. Confirm the Local API Server is enabled in Atomic Chat's settings
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
### `model not found` / 404 on generate
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
### Slow first response
Atomic Chat 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 Atomic Chat tools
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
### Context window or output size issues
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
@@ -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,229 @@
/**
* Atomic Chat MCP Server for NanoClaw
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
*/
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 ATOMIC_CHAT_HOST =
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
function log(msg: string): void {
console.error(`[ATOMIC] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
} catch {
/* best-effort */
}
}
async function atomicFetch(
apiPath: string,
options?: RequestInit,
): Promise<Response> {
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
const headers: Record<string, string> = {
...((options?.headers as Record<string, string>) || {}),
};
if (ATOMIC_CHAT_API_KEY) {
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
}
const finalOptions: RequestInit = { ...options, headers };
try {
return await fetch(url, finalOptions);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, finalOptions);
}
throw err;
}
}
const server = new McpServer({
name: 'atomic_chat',
version: '1.0.0',
});
server.tool(
'atomic_chat_list_models',
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing available models');
try {
const res = await atomicFetch('/v1/models');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
data?: Array<{ id: string; owned_by?: string }>;
};
const models = data.data || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
},
],
};
}
const list = models
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
.join('\n');
log(`Found ${models.length} models`);
return {
content: [
{ type: 'text' as const, text: `Available models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'atomic_chat_generate',
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
),
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.'),
max_tokens: z
.number()
.optional()
.describe('Maximum number of tokens to generate in the response.'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const messages: Array<{ role: string; content: string }> = [];
if (args.system) {
messages.push({ role: 'system', content: args.system });
}
messages.push({ role: 'user', content: args.prompt });
const body: Record<string, unknown> = {
model: args.model,
messages,
stream: false,
};
if (args.temperature !== undefined) body.temperature = args.temperature;
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
const startedAt = Date.now();
const res = await atomicFetch('/v1/chat/completions', {
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: `Atomic Chat error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
const response = data.choices?.[0]?.message?.content ?? '';
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
const completionTokens = data.usage?.completion_tokens;
const meta = `\n\n[${args.model} | ${elapsedSec}s${
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
}]`;
log(
`<<< Done: ${args.model} | ${elapsedSec}s | ${
completionTokens ?? '?'
} tokens | ${response.length} chars`,
);
writeStatus(
'done',
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
);
return { content: [{ type: 'text' as const, text: response + meta }] };
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
@@ -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.
+186
View File
@@ -0,0 +1,186 @@
---
name: add-codex
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
---
# Codex agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
## Install
### Pre-flight
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`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch the providers branch
```bash
git fetch origin providers
```
### 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: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.
`src/providers/index.ts`:
```typescript
import './codex.js';
```
`container/agent-runner/src/providers/index.ts`:
```typescript
import './codex.js';
```
### 4. Add the Codex CLI to the container Dockerfile
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
```dockerfile
ARG CODEX_VERSION=0.124.0
```
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
### 5. Build and validate
```bash
pnpm run build # host
pnpm exec vitest run src/providers/codex-registration.test.ts # host registration guard
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
cd container/agent-runner && bun test src/providers/codex-registration.test.ts && cd - # container registration guard
cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts && cd - # Dockerfile structural guard
./container/build.sh # agent image
```
All must be clean before proceeding.
- The **host** `codex-registration.test.ts` imports the real host barrel (`src/providers/index.ts`) and asserts `listProviderContainerConfigNames()` contains `codex`. It goes red if the `import './codex.js';` line is deleted or drifts, or if the barrel fails to evaluate.
- The **container** `codex-registration.test.ts` imports the real container barrel (`container/agent-runner/src/providers/index.ts`) and asserts `listProviderNames()` contains `codex`. Same failure surface for the container-side import line.
- The **Dockerfile** `codex-dockerfile.test.ts` reads `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` and `@openai/codex@${CODEX_VERSION}` install lines are present — red if either edit is dropped.
The `@openai/codex` CLI binary is guarded by the Dockerfile structural test plus the container build (`./container/build.sh` fails if the install line is bad), **not** by the registration test — Codex is a CLI binary, not an importable package, so nothing imports it for the registration guard to trip on. To confirm the binary is actually present after the image rebuild, probe it inside a running container with `docker exec <container> codex --version`.
The host-side provider also consumes core APIs (per-session `~/.codex` mount, env passthrough); that typed core-API consumption is guarded by `pnpm run build`.
## Configuration
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
### Option A — ChatGPT subscription (recommended for individuals)
On the host (not inside the container), run Codex's OAuth login:
```bash
codex login
```
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
### Per group / per session
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
## Operational notes
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
## Next Steps
The registration and Dockerfile guards in **Build and validate** confirm the wiring. For a live end-to-end check, set `agent_provider = 'codex'` on a test group and send a message after the image rebuild. A successful round-trip looks like:
- `init` event with a stable thread ID as continuation
- One or more `activity` / `progress` events during the turn
- `result` event with the model's reply
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. To confirm the CLI binary itself landed in the image, `docker exec <container> codex --version`.
To back this provider out, follow [REMOVE.md](REMOVE.md).
@@ -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\}"/);
});
});
+38 -52
View File
@@ -28,61 +28,34 @@ NanoClaw (pusher) Dashboard (npm package)
pnpm install @nanoco/nanoclaw-dashboard
```
### 2. Copy the pusher module
### 2. Copy the pusher module and its tests
Copy the resource file into src:
Copy all three resource files into `src/`. The tests ship with the skill and run against the composed project — they're how you confirm the skill works and is wired in correctly.
```
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.test.ts → src/dashboard-pusher.test.ts
.claude/skills/add-dashboard/resources/dashboard-wiring.test.ts → src/dashboard-wiring.test.ts
```
### 3. Add exports to src/db/index.ts
- `dashboard-pusher.test.ts` — behavior: starts the pusher, posts a real snapshot to a fake dashboard.
- `dashboard-wiring.test.ts` — the code edit in step 3: asserts (via the TS AST) that `index.ts` dynamically imports `./dashboard-pusher.js` and `await`s `startDashboard()` as colocated statements of `main()`, after DB init and before the boot-complete log. Delete or misplace the edit and this goes red.
Add these two export blocks if not already present:
### 3. Wire into src/index.ts
This is the skill's one integration point, and it's deliberately minimal and self-contained: all the startup logic lives in `dashboard-pusher.ts`, and the import is **colocated** with the call so the whole edit is a single block in one place — there's no separate top-of-file import to add (or to remember to remove).
Add this block inside `main()`, just before the `log.info('NanoClaw running')` line:
```typescript
// After the messaging-groups exports, add:
export {
getMessagingGroupsByAgentGroup,
} from './messaging-groups.js';
// Before the credentials exports, add:
export {
createDestination,
getDestinations,
getDestinationByName,
getDestinationByTarget,
hasDestination,
deleteDestination,
} from './agent-destinations.js';
// Dashboard (optional; no-ops without DASHBOARD_SECRET)
const { startDashboard } = await import('./dashboard-pusher.js');
await startDashboard();
```
### 4. Wire into src/index.ts
`startDashboard()` reads `DASHBOARD_SECRET`/`DASHBOARD_PORT` itself and no-ops if the secret is unset, so nothing else in core needs to change.
Add the `readEnvFile` import at the top if not already present:
```typescript
import { readEnvFile } from './env.js';
```
Add after step 7 (OneCLI approval handler), before the `log.info('NanoClaw running')` line:
```typescript
// 8. Dashboard (optional)
const dashboardEnv = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const dashboardSecret = process.env.DASHBOARD_SECRET || dashboardEnv.DASHBOARD_SECRET;
const dashboardPort = parseInt(process.env.DASHBOARD_PORT || dashboardEnv.DASHBOARD_PORT || '3100', 10);
if (dashboardSecret) {
const { startDashboard } = await import('@nanoco/nanoclaw-dashboard');
const { startDashboardPusher } = await import('./dashboard-pusher.js');
startDashboard({ port: dashboardPort, secret: dashboardSecret });
startDashboardPusher({ port: dashboardPort, secret: dashboardSecret, intervalMs: 60000 });
} else {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
}
```
### 5. Add environment variables to .env
### 4. Add environment variables to .env
```
DASHBOARD_SECRET=<generate-a-random-secret>
@@ -91,15 +64,23 @@ DASHBOARD_PORT=3100
Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"`
### 6. Build and restart
### 5. Build, test, and restart
Run from your NanoClaw project root:
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
pnpm exec vitest run src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts # behavior + wiring
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### 7. Verify
Run `build` **before** the tests: it's what guards the `@nanoco/nanoclaw-dashboard` dependency. `dashboard-pusher.ts` reaches the package through `await import('@nanoco/nanoclaw-dashboard')`, so if step 4 was skipped, `pnpm run build` fails with `TS2307: Cannot find module`. The behavior test deliberately *mocks* that package — its `startDashboard` binds a real dashboard port, a side effect we don't want in a test — so the test alone would pass with the dependency missing. Build is therefore the leg that verifies the dependency is installed; keep it ahead of the tests in the validate step.
### 6. Verify (runtime smoke check)
Once the service is restarted, confirm the dashboard is live:
```bash
curl -s http://localhost:3100/api/status
@@ -129,10 +110,15 @@ Open `http://localhost:3100/dashboard` in a browser.
## Removal
Reverse the apply steps. Safe to re-run even if some pieces are already gone.
```bash
rm -f src/dashboard-pusher.ts src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts
pnpm uninstall @nanoco/nanoclaw-dashboard 2>/dev/null || true
```
Then, by hand, remove the single dashboard block the skill added to `main()` in `src/index.ts` (the `// Dashboard (optional…)` comment, the `await import('./dashboard-pusher.js')` line, and the `await startDashboard();` call), and remove `DASHBOARD_SECRET` and `DASHBOARD_PORT` from `.env`.
```bash
pnpm uninstall @nanoco/nanoclaw-dashboard
rm src/dashboard-pusher.ts
# Remove the dashboard block from src/index.ts
# Remove DASHBOARD_SECRET and DASHBOARD_PORT from .env
pnpm run build
```
@@ -0,0 +1,124 @@
/**
* Integration test for the add-dashboard skill's integration point —
* `startDashboard()`, the single call wired into src/index.ts.
*
* Archetype: in-process seam. It drives the *real* entry point against a
* *real* (in-memory) central DB and a *fake* dashboard HTTP endpoint. The
* only things stubbed are the external dashboard package (not needed to prove
* the wiring) and env-file reads (so the test doesn't depend on the real
* .env). This proves the skill works once applied: with a secret set it
* collects a DB snapshot and posts it; with no secret it does nothing.
*
* Ships with the add-dashboard skill; apply copies it to src/ alongside the
* pusher so it runs against the composed project.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import http from 'http';
import type { AddressInfo } from 'net';
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-dashboard', ASSISTANT_NAME: 'TestBot' };
});
// The dashboard server package isn't needed to prove the integration point.
vi.mock('@nanoco/nanoclaw-dashboard', () => ({ startDashboard: vi.fn() }));
// Don't read the real .env — the test controls config via process.env only.
vi.mock('./env.js', () => ({ readEnvFile: () => ({}) }));
const TEST_DIR = '/tmp/nanoclaw-test-dashboard';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
import { startDashboard, stopDashboardPusher } from './dashboard-pusher.js';
function now(): string {
return new Date().toISOString();
}
interface CapturedPost {
path: string;
auth: string | undefined;
body: Record<string, unknown>;
}
/** A fake dashboard server that captures the bodies the pusher POSTs. */
function startFakeDashboard(): Promise<{ port: number; posts: CapturedPost[]; close: () => Promise<void> }> {
const posts: CapturedPost[] = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', () => {
let body: Record<string, unknown> = {};
try { body = JSON.parse(raw); } catch { /* leave empty */ }
posts.push({ path: req.url || '', auth: req.headers.authorization, body });
res.writeHead(200);
res.end('ok');
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({ port, posts, close: () => new Promise<void>((r) => server.close(() => r())) });
});
});
}
async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!pred()) {
if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition');
await new Promise((r) => setTimeout(r, 20));
}
}
describe('add-dashboard integration point (startDashboard)', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
});
afterEach(() => {
stopDashboardPusher();
closeDb();
delete process.env.DASHBOARD_SECRET;
delete process.env.DASHBOARD_PORT;
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('posts a snapshot of the seeded state when DASHBOARD_SECRET is set', async () => {
createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now() });
const dash = await startFakeDashboard();
process.env.DASHBOARD_SECRET = 'test-secret';
process.env.DASHBOARD_PORT = String(dash.port);
await startDashboard();
await waitFor(() => dash.posts.some((p) => p.path === '/api/ingest'));
const ingest = dash.posts.find((p) => p.path === '/api/ingest')!;
expect(ingest.auth).toBe('Bearer test-secret');
expect(ingest.body.assistant_name).toBe('TestBot');
const groups = ingest.body.agent_groups as Array<{ id: string }>;
expect(groups.map((g) => g.id)).toContain('ag-1');
for (const key of ['timestamp', 'sessions', 'channels', 'users', 'tokens', 'context_windows', 'activity', 'messages']) {
expect(ingest.body).toHaveProperty(key);
}
await dash.close();
});
it('does nothing when DASHBOARD_SECRET is not set', async () => {
const dash = await startFakeDashboard();
// no DASHBOARD_SECRET in env, and readEnvFile is stubbed to {}
await startDashboard();
await new Promise((r) => setTimeout(r, 100));
expect(dash.posts).toHaveLength(0);
await dash.close();
});
});
@@ -10,15 +10,17 @@ import Database from 'better-sqlite3';
import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js';
import { getSessionsByAgentGroup } from './db/sessions.js';
import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js';
import { getDestinations } from './db/agent-destinations.js';
import { getMembers } from './db/agent-group-members.js';
import { getAllUsers, getUser } from './db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './db/user-roles.js';
import { getUserDmsForUser } from './db/user-dms.js';
import { getDestinations } from './modules/agent-to-agent/db/agent-destinations.js';
import { getMembers } from './modules/permissions/db/agent-group-members.js';
import { getAllUsers, getUser } from './modules/permissions/db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './modules/permissions/db/user-roles.js';
import { getUserDmsForUser } from './modules/permissions/db/user-dms.js';
import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js';
import { DATA_DIR, ASSISTANT_NAME } from './config.js';
import { getDb } from './db/connection.js';
import { getContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { readEnvFile } from './env.js';
interface PusherConfig {
port: number;
@@ -56,6 +58,26 @@ export function stopDashboardPusher(): void {
}
}
/**
* Skill entry point — the single call wired into the host boot sequence.
*
* All of the dashboard's startup logic lives here, in the skill's own file,
* so the integration point in src/index.ts is just `await startDashboard()`.
* No-ops (and says so) when DASHBOARD_SECRET is unset.
*/
export async function startDashboard(): Promise<void> {
const env = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const secret = process.env.DASHBOARD_SECRET || env.DASHBOARD_SECRET;
const port = parseInt(process.env.DASHBOARD_PORT || env.DASHBOARD_PORT || '3100', 10);
if (!secret) {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
return;
}
const { startDashboard: startServer } = await import('@nanoco/nanoclaw-dashboard');
startServer({ port, secret });
startDashboardPusher({ port, secret, intervalMs: 60000 });
}
/** Fire-and-forget POST to the dashboard. */
function postJson(config: PusherConfig, urlPath: string, data: unknown): void {
const body = JSON.stringify(data);
@@ -157,7 +179,7 @@ function collectAgentGroups() {
name: g.name,
folder: g.folder,
agent_provider: g.agent_provider,
container_config: g.container_config ? JSON.parse(g.container_config) : null,
container_config: getContainerConfig(g.id) ?? null,
sessionCount: sessions.length,
runningSessions: running.length,
wirings,
@@ -0,0 +1,81 @@
/**
* Wiring test for the add-dashboard skill's code-edit integration point.
*
* The skill inserts one colocated block into src/index.ts (a dynamic
* `import('./dashboard-pusher.js')` + `await startDashboard()` in main()). A
* behavioral test of the pusher can't see whether that edit is actually
* present and correctly placed — booting the real host is too heavy — so this
* asserts the edit *structurally*, via the TypeScript AST. It verifies not
* just that the call exists, but that:
* - the pusher module is dynamically imported by its correct path,
* - startDashboard() is awaited,
* - both are DIRECT statements of main()'s body (right scope/level, not
* nested or stranded in another function),
* - the import precedes the call, and the whole block sits after DB init
* and before the boot-complete log (right place).
*
* Delete or misplace the edit and this goes red. Combined with the unit test
* (behavior of startDashboard) and the build (the call still type-checks),
* the three together cover deletion, misplacement, drift, and behavior — for
* a true code edit, with no registry required.
*
* Ships with the skill; apply copies it to src/.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
const indexPath = path.resolve(process.cwd(), 'src/index.ts');
const source = fs.readFileSync(indexPath, 'utf8');
const sf = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
function mainBody(): ts.NodeArray<ts.Statement> {
let body: ts.NodeArray<ts.Statement> | undefined;
sf.forEachChild((n) => {
if (ts.isFunctionDeclaration(n) && n.name?.text === 'main' && n.body) {
body = n.body.statements;
}
});
if (!body) throw new Error('main() not found in src/index.ts');
return body;
}
function isAwaitedStartDashboard(s: ts.Statement): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isAwaitExpression(s.expression) &&
ts.isCallExpression(s.expression.expression) &&
ts.isIdentifier(s.expression.expression.expression) &&
s.expression.expression.expression.text === 'startDashboard'
);
}
/** `const { ... } = await import('./dashboard-pusher.js')` as a statement. */
function isDynamicImportOfPusher(s: ts.Statement): boolean {
if (!ts.isVariableStatement(s)) return false;
const init = s.declarationList.declarations[0]?.initializer;
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
const call = init.expression;
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
const arg = call.arguments[0];
return !!arg && ts.isStringLiteral(arg) && arg.text === './dashboard-pusher.js';
}
describe('add-dashboard wiring in src/index.ts', () => {
it('dynamically imports the pusher and awaits startDashboard(), colocated in main(), after DB init and before the boot-complete log', () => {
const stmts = mainBody();
const importIdx = stmts.findIndex(isDynamicImportOfPusher);
const callIdx = stmts.findIndex(isAwaitedStartDashboard);
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
expect(importIdx, "dynamic import('./dashboard-pusher.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
expect(callIdx, 'await startDashboard() must be a statement of main()').toBeGreaterThanOrEqual(0);
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
expect(importIdx, 'the dynamic import must come after DB init').toBeGreaterThan(migrateIdx);
expect(callIdx, 'the call must come after its import (colocated)').toBeGreaterThan(importIdx);
expect(callIdx, 'startDashboard() must run before the boot-complete log').toBeLessThan(runningIdx);
});
});
+71
View File
@@ -0,0 +1,71 @@
# Remove DeltaChat
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
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
Remove the `DC_*` lines from `.env`:
```bash
DC_EMAIL
DC_PASSWORD
DC_IMAP_HOST
DC_IMAP_PORT
DC_SMTP_HOST
DC_SMTP_PORT
```
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
## 4. Remove account data (optional)
To fully remove all account data including DeltaChat encryption keys:
```bash
rm -rf dc-account/
```
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
To keep the account for later reinstall, leave `dc-account/` intact.
## 5. Remove the package (optional)
```bash
pnpm remove @deltachat/stdio-rpc-server
```
## Verification
After removal, confirm the adapter is no longer starting:
```bash
grep "deltachat" logs/nanoclaw.log | tail -5
```
Expected: no `Channel adapter started` entry after the last restart.
+265
View File
@@ -0,0 +1,265 @@
---
name: add-deltachat
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
---
# Add DeltaChat Channel
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
## Install
### Pre-flight (idempotent)
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
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 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-registration.test.ts > src/channels/deltachat-registration.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
import './deltachat.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 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.
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
To find the correct hostnames for a domain:
```bash
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
```
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
## Credentials
Add to `.env`:
```bash
DC_EMAIL=bot@example.com
DC_PASSWORD=your-app-password
DC_IMAP_HOST=imap.example.com
DC_IMAP_PORT=993
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
DC_SMTP_HOST=smtp.example.com
DC_SMTP_PORT=587
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
```
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
| Variable | Default | Description |
|----------|---------|-------------|
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
## Wiring
### DMs
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
#### Step 1 — Get the invite link
After the service starts, the adapter logs the invite URL and writes a QR SVG:
```bash
grep "invite link" logs/nanoclaw.log | tail -1
# url field contains the https://i.delta.chat/... invite link
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
```
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
#### Step 2 — Add the bot in DeltaChat
Two options for the user to connect:
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
After accepting, DeltaChat exchanges keys and creates the chat automatically.
#### Step 3 — Wire the chat to an agent
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
```bash
pnpm exec tsx scripts/init-first-agent.ts \
--channel deltachat \
--user-id deltachat:user@example.com \
--platform-id <platform_id from above> \
--display-name "Your Name"
```
### Groups
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `deltachat`
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
- **supports-threads**: no — DeltaChat has no thread model
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
- **user-id-format**: `deltachat:{email}` — the contact's email address
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
### Connectivity model
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
| Range | Meaning |
|-------|---------|
| 10001999 | Not connected |
| 20002999 | Connecting |
| 30003999 | Working (IMAP fetching) |
| ≥ 4000 | Fully connected (IMAP IDLE) |
## Troubleshooting
### Adapter not starting — credentials missing
```bash
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
```
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
### Account configure fails
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
```
Common causes:
- Wrong IMAP/SMTP hostnames — double-check provider docs
- App password not generated — Gmail and some others require this when 2FA is enabled
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
### Messages not arriving
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
### Stale lock file after crash
```bash
rm -f dc-account/accounts.lock
systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"
```
### Bot not responding after restart
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
```bash
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
```
### Messages received but agent not responding
The messaging group exists but may not be wired to an agent group. Run:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
```
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
+37 -4
View File
@@ -1,7 +1,40 @@
# Remove Discord
1. Comment out `import './discord.js'` in `src/channels/index.ts`
2. Remove `DISCORD_BOT_TOKEN` from `.env`
3. Rebuild and restart
Every step is idempotent — safe to re-run.
No package to uninstall — Discord is built in.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './discord.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/discord.ts src/channels/discord-registration.test.ts
```
## 2. Remove credentials
Remove `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/discord
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+9 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter i
Skip to **Credentials** if all of these are already in place:
- `src/channels/discord.ts` exists
- `src/channels/discord-registration.test.ts` exists
- `src/channels/index.ts` contains `import './discord.js';`
- `@chat-adapter/discord` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
```
### 3. Append the self-registration import
@@ -44,15 +46,18 @@ import './discord.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/discord@4.26.0
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';"
```
+19 -21
View File
@@ -24,6 +24,8 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and
Skip to **Enable** if all of these are already in place:
- `src/channels/emacs.ts` exists
- `src/channels/emacs.test.ts` exists
- `src/channels/emacs-registration.test.ts` exists
- `emacs/nanoclaw.el` exists
- `src/channels/index.ts` contains `import './emacs.js';`
@@ -39,9 +41,10 @@ git fetch origin channels
```bash
mkdir -p emacs
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:src/channels/emacs-registration.test.ts > src/channels/emacs-registration.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
```
### 3. Append the self-registration import
@@ -52,13 +55,16 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './emacs.js';
```
### 4. Build
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/emacs-registration.test.ts
```
No npm package to install — the adapter uses only Node builtins (`http`).
Both must be clean before proceeding. `emacs-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `emacs`. It goes red if the `import './emacs.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter uses only Node builtins (`http`), so there is no npm dependency to guard for this channel.
End-to-end message delivery from a real Emacs buffer is verified manually once the service is running — see Verify and Troubleshooting.
## Enable
@@ -162,10 +168,13 @@ If you changed `EMACS_CHANNEL_PORT` from the default:
## Restart NanoClaw
Run from your NanoClaw project root:
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# systemctl --user restart nanoclaw # Linux
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
```
## Verify
@@ -240,8 +249,8 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
### No response from agent
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
1. NanoClaw running: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
If no messaging group row exists, run the `register` command above.
@@ -282,15 +291,4 @@ If an agent outputs org-mode directly, markers get double-converted and render i
## Removal
```bash
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
# Remove the `import './emacs.js';` line from src/channels/index.ts
# Remove EMACS_* lines from .env
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# systemctl --user restart nanoclaw # Linux
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
sqlite3 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
```
+233
View File
@@ -0,0 +1,233 @@
---
name: add-gcal-tool
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
---
# Add Google Calendar Tool (OneCLI-native)
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
## Phase 1: Pre-flight
### Verify OneCLI has Google Calendar connected
```bash
onecli apps get --provider google-calendar
```
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
### Verify stub credentials exist
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
```bash
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
```
If both exist with `onecli-managed`:
```bash
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
```
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
If absent, write them:
```bash
mkdir -p ~/.calendar-mcp
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.calendar-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
}
EOF
chmod 600 ~/.calendar-mcp/*.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject the Google Calendar token:
```bash
onecli agents list
```
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
## Phase 2: Apply Code Changes
### Check if already applied
```bash
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
```dockerfile
ARG CALENDAR_MCP_VERSION=2.6.1
```
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
`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
```bash
./container/build.sh
```
## Phase 3: Wire Per-Agent-Group
For each agent group, persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.calendar` entry and an `additionalMounts` entry for `.calendar-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
### Register the MCP server
For each chosen `<group-id>` (use `ncl groups list` to enumerate):
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name calendar \
--command google-calendar-mcp \
--args '[]' \
--env '{"GOOGLE_OAUTH_CREDENTIALS":"/workspace/extra/.calendar-mcp/gcp-oauth.keys.json","GOOGLE_CALENDAR_MCP_TOKEN_PATH":"/workspace/extra/.calendar-mcp/credentials.json"}'
```
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
### Add the `.calendar-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
## Phase 4: Build and Restart
```bash
pnpm run build
```
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Kill any existing agent containers so they respawn with the new mcpServers config:
```bash
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
```
## Phase 5: Verify
### Test from a wired agent
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
>
> First call takes 23s while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
```
Common signals:
- `command not found: google-calendar-mcp` → image not rebuilt.
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
- Agent says "I don't have calendar tools" → the `calendar` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (`./container/build.sh`, `--no-cache` if suspicious).
## Removal
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:** `@gongrzhe/server-calendar-autoauth-mcp` only supports the primary calendar with 5 event-level tools. The cocal server supports multi-account and multi-calendar with the full tool surface.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
@@ -0,0 +1,36 @@
/**
* Dependency guard for the Google Calendar MCP server (host/vitest tree).
*
* `@cocal/google-calendar-mcp` is a stdio CLI installed globally in the image,
* not an imported module, so no behavior test can drive it and `tsc` never sees
* it. The only in-tree footprint of this skill is the Dockerfile edit, so the
* guard is structural: assert the pinned `ARG` and the pnpm global-install line
* both exist. Drop either Phase 2 Dockerfile edit and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
const p = path.resolve(process.cwd(), 'container/Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs @cocal/google-calendar-mcp', () => {
const text = dockerfile();
it('pins the version via an ARG', () => {
expect(text).toMatch(/^\s*ARG\s+CALENDAR_MCP_VERSION=/m);
});
it('installs the package pinned to that ARG in a pnpm global-install block', () => {
// Match `pnpm install -g ... "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"`,
// tolerating line continuations between `install -g` and the package.
const installsCalendar =
/pnpm\s+install\s+-g[\s\S]*?@cocal\/google-calendar-mcp@\$\{CALENDAR_MCP_VERSION\}/.test(
text,
);
expect(installsCalendar).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove Google Chat Channel
# Remove Google Chat
1. Comment out `import './gchat.js'` in `src/channels/index.ts`
2. Remove `GCHAT_CREDENTIALS` from `.env`
3. `pnpm uninstall @chat-adapter/gchat`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './gchat.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/gchat.ts src/channels/gchat-registration.test.ts
```
## 2. Remove credentials
Remove `GCHAT_CREDENTIALS` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/gchat
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+11 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapt
Skip to **Credentials** if all of these are already in place:
- `src/channels/gchat.ts` exists
- `src/channels/gchat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './gchat.js';`
- `@chat-adapter/gchat` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
```
### 3. Append the self-registration import
@@ -44,15 +46,20 @@ import './gchat.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/gchat@4.26.0
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)
```
+22 -7
View File
@@ -20,6 +20,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/github.ts` exists
- `src/channels/github-registration.test.ts` exists
- `src/channels/index.ts` contains `import './github.js';`
- `@chat-adapter/github` is listed in `package.json` dependencies
@@ -31,10 +32,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
```
### 3. Append the self-registration import
@@ -48,15 +50,20 @@ import './github.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/github@4.26.0
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)
@@ -136,7 +143,15 @@ Use `per-thread` session mode so each PR/issue gets its own agent session.
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
## Channel Info
-3
View File
@@ -1,3 +0,0 @@
# Verify GitHub Channel
@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds.
+57
View File
@@ -0,0 +1,57 @@
# Remove Gmail Tool
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied tests
```bash
rm -f container/agent-runner/src/providers/gmail-dockerfile.test.ts \
container/agent-runner/src/providers/gmail-allow-pattern.test.ts
```
## 2. Unregister the MCP server (per group)
`ncl groups list` shows the groups. For each group that had Gmail wired:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
## 3. Remove the `.gmail-mcp` mount (per group)
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
```bash
GROUP_ID='<group-id>'
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
## 4. Remove the Dockerfile install
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## 6. (Optional) Drop the host stubs and disconnect
```bash
rm -rf ~/.gmail-mcp/ # only if no other host tool needs the stubs
onecli apps disconnect --provider gmail # revoke the OneCLI Gmail connection
```
+262
View File
@@ -0,0 +1,262 @@
---
name: add-gmail-tool
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
---
# Add Gmail Tool (OneCLI-native)
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
## Phase 1: Pre-flight
### Verify OneCLI has Gmail connected
```bash
onecli apps get --provider gmail
```
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
### Verify stub credentials exist
```bash
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
```
If both exist and contain `"onecli-managed"`:
```bash
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
...skip to Phase 2.
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
If both files are absent, write them now:
```bash
mkdir -p ~/.gmail-mcp
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.gmail-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
}
EOF
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
```bash
onecli agents list
```
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first):
```bash
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
onecli agents secrets --id <agent-id>
```
## Phase 2: Apply Code Changes
### Check if already applied
```bash
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.154
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
```
Add a new line:
```dockerfile
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 directly after it (before the `# ---- ncl CLI wrapper` section):
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
**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`.
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
```bash
./container/build.sh
```
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
## Phase 3: Wire Per-Agent-Group
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.gmail` entry and an `additionalMounts` entry for `.gmail-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
### List groups, pick which ones get Gmail
```bash
ncl groups list
```
### Register the MCP server
For each chosen `<group-id>`:
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name gmail \
--command gmail-mcp \
--args '[]' \
--env '{"GMAIL_OAUTH_PATH":"/workspace/extra/.gmail-mcp/gcp-oauth.keys.json","GMAIL_CREDENTIALS_PATH":"/workspace/extra/.gmail-mcp/credentials.json"}'
```
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
### Add the `.gmail-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
## Phase 4: Build, 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
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
## Phase 5: Verify
### Test from the wired agent
Tell the user:
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
>
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
# Per-container logs — session-scoped:
ls data/v2-sessions/*/stderr.log | head
```
Common signals:
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
- Agent says "I don't have Gmail tools" → the `gmail` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (rebuild with `./container/build.sh`, with `--no-cache` if suspicious).
## Removal
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
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
## Credits & references
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/nanocoai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/nanocoai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
@@ -0,0 +1,55 @@
/**
* Guard for the dynamic MCP allow-pattern derivation this skill depends on.
*
* Registering `gmail` in a group's mcpServers map is the *only* wiring needed to expose
* `mcp__gmail__*` to the agent — there is no static TOOL_ALLOWLIST edit. That holds solely
* because `claude.ts` derives the allow-pattern from the registered servers at query time:
*
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* so we assert the derivation structurally. Delete or rename the derivation and this goes
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
*
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function source(): { sf: ts.SourceFile; text: string } {
const p = path.join(import.meta.dir, 'claude.ts');
const text = fs.readFileSync(p, 'utf8');
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
}
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
function expectedPattern(name: string): string {
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
const { sf, text } = source();
it('defines an mcpAllowPattern function', () => {
let found = false;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === 'mcpAllowPattern') found = true;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
expect(found).toBe(true);
});
it('spreads Object.keys(this.mcpServers).map(mcpAllowPattern) into allowedTools', () => {
// Normalize whitespace so formatting changes don't break the assertion.
const flat = text.replace(/\s+/g, ' ');
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
});
it('maps a gmail server name to mcp__gmail__*', () => {
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
});
});
@@ -0,0 +1,36 @@
/**
* Structural guard for the Gmail MCP package-install integration point (container image).
*
* `@gongrzhe/server-gmail-autoauth-mcp` is a CLI binary installed into the image via the
* Dockerfile — it is not importable or typed from this tree, so the build leg can't catch
* its removal and there's no runtime seam to behavior-test. This asserts the Dockerfile
* still carries the ARG and the pinned pnpm global-install line. Drop either and this goes
* red, signalling the agent would boot without the `gmail-mcp` binary on PATH.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
function dockerfile(): string {
// container/agent-runner/src/providers/ -> ../../../Dockerfile == container/Dockerfile
const p = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs the Gmail MCP server', () => {
const text = dockerfile();
it('declares the GMAIL_MCP_VERSION ARG', () => {
expect(/ARG\s+GMAIL_MCP_VERSION=/.test(text)).toBe(true);
});
it('pnpm-installs @gongrzhe/server-gmail-autoauth-mcp pinned to the ARG', () => {
expect(text).toContain('pnpm install -g');
expect(/@gongrzhe\/server-gmail-autoauth-mcp@\$\{GMAIL_MCP_VERSION\}/.test(text)).toBe(true);
});
it('pins the zod-to-json-schema workaround version', () => {
expect(/zod-to-json-schema@3\.22\.5/.test(text)).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove iMessage Channel
# Remove iMessage
1. Comment out `import './imessage.js'` in `src/channels/index.ts`
2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env`
3. `pnpm uninstall chat-adapter-imessage`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './imessage.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/imessage.ts src/channels/imessage-registration.test.ts
```
## 2. Remove credentials
Remove `IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, and `IMESSAGE_API_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall chat-adapter-imessage
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+11 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter
Skip to **Credentials** if all of these are already in place:
- `src/channels/imessage.ts` exists
- `src/channels/imessage-registration.test.ts` exists
- `src/channels/index.ts` contains `import './imessage.js';`
- `chat-adapter-imessage` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './imessage.js';
pnpm install chat-adapter-imessage@0.1.1
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
## Credentials
### Local Mode (macOS)
@@ -75,7 +82,7 @@ Stop and wait for the user to confirm before continuing.
### Remote Mode (Photon API)
1. Set up a [Photon](https://photon.im) account
1. Set up a [Photon](https://photon.codes) account
2. Get your server URL and API key
### Configure environment
-3
View File
@@ -1,3 +0,0 @@
# Verify iMessage Channel
Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds.
@@ -0,0 +1,38 @@
# Remove Karpathy LLM Wiki
Every step is idempotent — safe to re-run.
## 1. Remove the shared container skill
The wiki container skill lives in the shared `container/skills/` mount, which is auto-discovered and symlinked into every agent group. Delete it so it stops appearing in all containers:
```bash
rm -rf container/skills/wiki
```
## 2. Remove the wiki section from the group CLAUDE.md
The wiki section is wrapped in marker comments. Delete the block (markers included) from the group's CLAUDE.md — find it under `groups/<folder>/CLAUDE.md`:
```bash
# Replace <folder> with the group folder you set up the wiki for.
perl -0pi -e 's/\n?<!-- BEGIN karpathy-llm-wiki -->.*?<!-- END karpathy-llm-wiki -->\n?//s' groups/<folder>/CLAUDE.md
```
If the markers are absent, nothing is removed (the block was already gone or never added).
## 3. Restart so containers drop the skill
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## User content is preserved
The per-group `groups/<folder>/wiki/` and `groups/<folder>/sources/` directories hold the user's own knowledge base and ingested sources. They are left in place. Delete them by hand only if the user explicitly wants their wiki content gone:
```bash
rm -rf groups/<folder>/wiki groups/<folder>/sources
```
+24 -35
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`)
@@ -71,40 +84,16 @@ AskUserQuestion: "Want periodic wiki health checks?"
2. **Monthly**
3. **Skip** — lint manually
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation.
## Step 6: Restart
Run from your NanoClaw project root:
```bash
pnpm exec tsx -e "
const Database = require('better-sqlite3');
const { CronExpressionParser } = require('cron-parser');
const db = new Database('store/messages.db');
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
const nextRun = interval.next().toISOString();
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
'wiki-lint',
'<group_folder>',
'<chat_jid>',
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
'cron',
'<cron-expr>',
'group',
nextRun,
'active',
new Date().toISOString()
);
db.close();
"
```
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
## Step 6: Build and restart
```bash
pnpm run build
./container/build.sh
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Tell the user to test by sending a source to the wiki group.
+48 -5
View File
@@ -1,6 +1,49 @@
# Remove Linear Channel
# Remove Linear
1. Comment out `import './linear.js'` in `src/channels/index.ts`
2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @chat-adapter/linear`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './linear.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
```
## 2. Remove credentials
Remove the Linear env vars from `.env`, then re-sync to the container:
```bash
LINEAR_CLIENT_ID
LINEAR_CLIENT_SECRET
LINEAR_API_KEY
LINEAR_WEBHOOK_SECRET
LINEAR_BOT_USERNAME
LINEAR_TEAM_KEY
```
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/linear
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+24 -39
View File
@@ -22,16 +22,16 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/linear.ts` exists
- `src/channels/linear-registration.test.ts` exists
- `src/channels/index.ts` contains `import './linear.js';`
- `@chat-adapter/linear` is listed in `package.json` dependencies
- `src/channels/chat-sdk-bridge.ts` contains `catchAll`
Otherwise continue. Every step below is safe to re-run.
@@ -41,10 +41,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
```
### 3. Append the self-registration import
@@ -55,47 +56,23 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './linear.js';
```
### 4. Patch the Chat SDK bridge for catch-all message forwarding
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
```typescript
/**
* Forward ALL messages in unsubscribed threads, not just @-mentions.
* Use for platforms where the bot identity can't be @-mentioned (e.g.
* Linear OAuth apps). The thread is auto-subscribed on first message.
*/
catchAll?: boolean;
```
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
```typescript
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
if (config.catchAll) {
chat.onNewMessage(/.*/, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
await thread.subscribe();
});
}
```
### 5. Install the adapter package (pinned)
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.26.0
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)
@@ -156,7 +133,15 @@ The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
## Channel Info
-3
View File
@@ -1,3 +0,0 @@
# Verify Linear Channel
@mention the bot in a Linear issue comment. The bot should respond within a few seconds.
@@ -0,0 +1,22 @@
# Remove macOS Menu Bar Status Indicator
Every step is idempotent — safe to re-run.
## 1. Unload the launchd service
```bash
launchctl bootout gui/$(id -u)/com.nanoclaw.statusbar 2>/dev/null \
|| launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist 2>/dev/null \
|| true
```
## 2. Delete the produced files
```bash
rm -f ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist \
dist/statusbar \
logs/statusbar.log \
logs/statusbar.error.log
```
The menu bar icon disappears once the service is unloaded.
+1 -7
View File
@@ -124,10 +124,4 @@ Tell the user:
>
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
## Removal
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm dist/statusbar
```
To uninstall, follow [REMOVE.md](REMOVE.md).
+54 -5
View File
@@ -1,6 +1,55 @@
# Remove Matrix Channel
# Remove Matrix
1. Comment out `import './matrix.js'` in `src/channels/index.ts`
2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env`
3. `pnpm uninstall @beeper/chat-adapter-matrix`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './matrix.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
```
## 2. Remove credentials
Remove the `MATRIX_*` lines from `.env`:
```bash
MATRIX_BASE_URL
MATRIX_USERNAME
MATRIX_PASSWORD
MATRIX_USER_ID
MATRIX_BOT_USERNAME
MATRIX_ACCESS_TOKEN
MATRIX_INVITE_AUTOJOIN
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
MATRIX_RECOVERY_KEY
MATRIX_DEVICE_ID
```
Then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @beeper/chat-adapter-matrix
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/matrix.ts` exists
- `src/channels/matrix-registration.test.ts` exists
- `src/channels/index.ts` contains `import './matrix.js';`
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
```
### 3. Append the self-registration import
@@ -69,12 +71,17 @@ node -e '
Re-run this after every `pnpm install` that touches the adapter.
### 6. Build
### 6. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/matrix-registration.test.ts
```
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
## Credentials
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
-3
View File
@@ -1,3 +0,0 @@
# Verify Matrix Channel
Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds.
+60
View File
@@ -0,0 +1,60 @@
# Remove Mnemon
Every step is idempotent — safe to run even if some steps were never applied.
## 1. Strip the Dockerfile install layer
Open `container/Dockerfile` and delete the mnemon block (the `# ---- mnemon` comment, the `ARG MNEMON_VERSION`, the `RUN` that downloads the binary, and the `ENV MNEMON_DATA_DIR` line):
```dockerfile
# ---- mnemon — persistent agent memory ----------------------------------------
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
If the block is already gone, skip this step.
## 2. Strip the entrypoint setup line
Open `container/entrypoint.sh` and delete the `mnemon setup` line that follows `set -e`:
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
If the line is already gone, skip this step.
## 3. Delete the copied test files
```bash
rm -f src/mnemon-dockerfile.test.ts src/mnemon-entrypoint.test.ts
```
## 4. Rebuild and restart
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## 5. Delete stored memory (optional)
Mnemon's graph lives at `/home/node/.claude/mnemon/` in each container, which maps to the per-agent-group `.claude/` directory on the host. To find the host path and clear it:
```bash
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
```
Stop the container, then delete the `mnemon/` subdirectory from that path.
+177
View File
@@ -0,0 +1,177 @@
---
name: add-mnemon
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
---
# Add Mnemon — Persistent Memory
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
## Provider Compatibility
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
```
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
### Check if already applied
```bash
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
```
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
```bash
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
```
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
## Phase 2: Apply Changes
### 1. Dockerfile — install mnemon binary
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 ----------------------------------------
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
```
`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. 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
# NanoClaw agent container entrypoint.
#
# ...existing header comment...
set -e
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
cat > /tmp/input.json
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. 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
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Phase 3: Restart and Verify
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### Confirm mnemon hooks are registered
After the next container starts, check that setup ran:
```bash
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
```
Then inspect the hooks inside the running container:
```bash
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
cat /home/node/.claude/settings.json | grep -A5 mnemon
```
### Test memory recall
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.
## 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:
```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}}'
```
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
## Troubleshooting
### `mnemon: command not found` in container
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
### Memory not persisting across restarts
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
```bash
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
```
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
### Agent not using past memory
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
```bash
docker exec <container> cat /home/node/.claude/settings.json
```
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
### Setup fails at container start
Run setup manually inside a running container to see the full error:
```bash
docker exec -it <container> mnemon setup --target claude-code --yes --global
```
@@ -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/);
});
});
+8 -5
View File
@@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh`
Ask the user (plain text, not AskUserQuestion):
1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"`
1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"`
2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'`
3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts.
@@ -111,7 +111,7 @@ Read the agent group's shared Claude settings:
```bash
# Find the agent group ID
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
```
@@ -130,12 +130,15 @@ file, not from env vars. This file is bind-mounted into the container as `~/.cla
## 5. Build and restart
Run from your NanoClaw project root:
```bash
export PATH="/opt/homebrew/bin:$PATH"
pnpm run build
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux: systemctl --user restart nanoclaw
source setup/lib/install-slug.sh
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux: systemctl --user restart $(systemd_unit)
```
## 6. Verify
+49
View File
@@ -0,0 +1,49 @@
# Remove Ollama
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied files (both trees)
```bash
rm -f container/agent-runner/src/ollama-mcp-stdio.ts \
container/agent-runner/src/ollama-registration.test.ts \
src/ollama-env.ts \
src/ollama-wiring.test.ts
```
## 2. Unregister the MCP server
In `container/agent-runner/src/index.ts`, remove the `ollama: { … }` entry from the `mcpServers` object (leave `nanoclaw` and any other entries).
## 3. Revert the host-side edits in `src/container-runner.ts`
- Remove the `import { ollamaEnvArgs } from './ollama-env.js';` import.
- Remove the `args.push(...ollamaEnvArgs());` line that follows the `TZ` env line.
- Remove the `[OLLAMA]` branch from the `container.stderr` logger. If `[OLLAMA]` was the only prefix branch, restore the logger to its single-line `log.debug(line, …)` form; if other local-model tools still have branches there, just drop the `[OLLAMA]` one and leave the rest intact.
## 4. Remove env vars
Remove the Ollama block from `.env.example`, and the `OLLAMA_HOST` / `OLLAMA_ADMIN_TOOLS` lines from `.env` if you set them.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Verification
After removal, confirm the tool is gone — in a wired agent, asking it to "list ollama models" should report no such tool, and the logs should show no `[OLLAMA]` lines after the last restart:
```bash
grep "\[OLLAMA\]" logs/nanoclaw.log | tail -5
```
+170 -66
View File
@@ -5,17 +5,19 @@ description: Add Ollama MCP server so the container agent can call local models
# Add Ollama Integration
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
This skill adds a stdio-based MCP server that exposes local [Ollama](https://ollama.com) models as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by the Ollama daemon on the host, and can optionally manage the model library directly. Ollama runs locally and is keyless — there are no credentials to thread; the only configuration is the daemon's base URL.
Core tools (always available):
- `ollama_list_models` — list installed Ollama models with name, size, and family
- `ollama_generate` — send a prompt to a specified model and return the response
- `ollama_list_models` — list installed models with name, size, and family (`GET /api/tags`)
- `ollama_generate` — send a prompt to a specified model and return the response (`POST /api/generate`)
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
- `ollama_pull_model` — pull (download) a model from the Ollama registry
- `ollama_delete_model` — delete a locally installed model to free disk space
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
- `ollama_pull_model` — pull (download) a model from the Ollama registry (`POST /api/pull`)
- `ollama_delete_model` — delete a locally installed model to free disk space (`DELETE /api/delete`)
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info (`POST /api/show`)
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type (`GET /api/ps`)
The skill ships the MCP server source (and its tests) in this folder and copies them into the agent-runner tree at install time, then registers the server in `index.ts` and forwards host env vars in `container-runner.ts`. Registering the server is enough to expose its tools — the agent's allow-pattern (`mcp__ollama__*`) is derived from the registered server name.
## Phase 1: Pre-flight
@@ -25,77 +27,173 @@ Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, sk
### Check prerequisites
Verify Ollama is installed and running on the host:
Verify Ollama is installed and its daemon is reachable. On the host:
```bash
ollama list
curl -s http://127.0.0.1:11434/api/tags | head
```
If Ollama is not installed, direct the user to https://ollama.com/download.
If the request fails:
1. Install Ollama from https://ollama.com/download.
2. Start it (the desktop app runs the daemon, or run `ollama serve`).
3. Confirm the daemon answers: `curl -s http://127.0.0.1:11434/api/tags`.
If no models are installed, suggest pulling one:
> You need at least one model. I recommend:
> You need at least one model. For example:
>
> ```bash
> ollama pull gemma3:1b # Small, fast (1GB)
> ollama pull llama3.2 # Good general purpose (2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
> ollama pull gemma3:1b # Small, fast (~1GB)
> ollama pull llama3.2 # Good general purpose (~2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (~18GB)
> ```
## Phase 2: Apply Code Changes
### Ensure upstream remote
### Copy the skill's source and tests into both trees
This skill reaches into both the container (Bun) tree and the host (Node) tree, so its
files go into both, alongside the integration points they cover.
```bash
git remote -v
S=.claude/skills/add-ollama-tool
# Container (Bun) tree — the MCP server and the registration wiring test
cp $S/ollama-mcp-stdio.ts container/agent-runner/src/ollama-mcp-stdio.ts
cp $S/ollama-registration.test.ts container/agent-runner/src/ollama-registration.test.ts
# Host (Node) tree — the env-forwarding helper and the wiring test
cp $S/ollama-env.ts src/ollama-env.ts
cp $S/ollama-wiring.test.ts src/ollama-wiring.test.ts
```
If `upstream` is missing, add it:
### Register the MCP server in the agent-runner
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
};
```
### Merge the skill branch
Add an `ollama` entry alongside `nanoclaw`:
```bash
git fetch upstream skill/ollama-tool
git merge upstream/skill/ollama-tool
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
ollama: {
command: 'bun',
args: ['run', path.join(__dirname, 'ollama-mcp-stdio.ts')],
env: {
...(process.env.OLLAMA_HOST ? { OLLAMA_HOST: process.env.OLLAMA_HOST } : {}),
...(process.env.OLLAMA_ADMIN_TOOLS ? { OLLAMA_ADMIN_TOOLS: process.env.OLLAMA_ADMIN_TOOLS } : {}),
},
},
};
```
This merges in:
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
- `scripts/ollama-watch.sh` (macOS notification watcher)
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
- `OLLAMA_HOST` in `.env.example`
`ollama-registration.test.ts` asserts this entry is present and points at the server module — the tool only appears to the agent if it is registered here.
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Forward host env vars into the container
### Copy to per-group agent-runner
The container receives `TZ` and OneCLI networking vars by default; any other host env
var the MCP subprocess needs must be forwarded explicitly. The forwarding logic lives in
the copied `src/ollama-env.ts` (`ollamaEnvArgs()`) — `OLLAMA_HOST` (the daemon base URL)
and `OLLAMA_ADMIN_TOOLS` (the library-management opt-in flag). Both are configuration, not
credentials, so they are passed through plainly; Ollama itself is local and keyless.
Existing groups have a cached copy of the agent-runner source. Copy the new files:
Import it in `src/container-runner.ts` (alongside the other local imports):
```ts
import { ollamaEnvArgs } from './ollama-env.js';
```
Then, in `buildContainerArgs`, find the `TZ` env line and add the call right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
args.push(...ollamaEnvArgs());
```
`ollama-wiring.test.ts` asserts this `args.push(...ollamaEnvArgs())` call exists inside `buildContainerArgs`.
### Surface `[OLLAMA]` log lines at info level
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-atomic-chat-tool` for `[ATOMIC]`) also edit to surface their own prefix. Touch only the `[OLLAMA]` branch and leave the rest of the block intact, so the edits coexist and removal restores it cleanly.
In the same file, find the stderr logger:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
```
Replace it with:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (!line) continue;
if (line.includes('[OLLAMA]')) {
log.info(line, { container: agentGroup.folder });
} else {
log.debug(line, { container: agentGroup.folder });
}
}
});
```
If `add-atomic-chat-tool` (or another local-model tool) has already turned this into a
multi-branch block, just add an `else if (line.includes('[OLLAMA]'))` branch instead of
replacing it.
### Add env-var stubs to `.env.example`
Append to `.env.example`:
```bash
for dir in data/sessions/*/agent-runner-src; do
cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
cp container/agent-runner/src/index.ts "$dir/"
done
# Ollama MCP tool (.claude/skills/add-ollama-tool)
# Override the host where the Ollama daemon listens.
# Default: http://host.docker.internal:11434 (with fallback to localhost)
# OLLAMA_HOST=http://host.docker.internal:11434
# Opt in to library-management tools (pull, delete, show, list-running).
# Leave unset to expose only list + generate.
# OLLAMA_ADMIN_TOOLS=true
```
### Validate code changes
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
# Host tree: buildContainerArgs wiring
pnpm exec vitest run src/ollama-wiring.test.ts
# Container tree: index.ts registration
(cd container/agent-runner && bun test src/ollama-registration.test.ts)
./container/build.sh
```
Build must be clean before proceeding.
All must be clean before proceeding. The wiring and registration tests confirm the two
integration points — the `buildContainerArgs` call and the `index.ts` registration — are
actually in place; a failure means one drifted. (The MCP server's own request/response
behavior against the Ollama daemon is the author's build-time concern, not part of these
tests — verify it manually in Phase 4.)
## Phase 3: Configure
### Enable model management tools (optional)
### Enable library-management tools (optional)
Ask the user:
@@ -110,7 +208,7 @@ If the user wants management tools, add to `.env`:
OLLAMA_ADMIN_TOOLS=true
```
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
If they decline (or don't answer), leave the variable unset — only list + generate are exposed.
### Set Ollama host (optional)
@@ -122,9 +220,12 @@ OLLAMA_HOST=http://your-ollama-host:11434
### Restart the service
Run from your NanoClaw project root:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## Phase 4: Verify
@@ -145,14 +246,6 @@ If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
>
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
### Monitor activity (optional)
Run the watcher script for macOS notifications when Ollama is used:
```bash
./scripts/ollama-watch.sh
```
### Check logs if needed
```bash
@@ -160,34 +253,45 @@ tail -f logs/nanoclaw.log | grep -i ollama
```
Look for:
- `[OLLAMA] >>> Generating` — generation started
- `[OLLAMA] <<< Done` — generation completed
- `[OLLAMA] Listing models...` — list request started
- `[OLLAMA] Found N models` — models discovered
- `[OLLAMA] >>> Generating with <model>` — generation started
- `[OLLAMA] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
- `[OLLAMA] Deleted:` — model removed (management tools)
## Troubleshooting
### Agent says "Ollama is not installed"
### Agent says "Ollama is not installed" or tries to run a CLI
The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
2. The per-group source wasn't updated — re-copy files (see Phase 2)
The agent is looking for an `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/ollama-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Ollama"
1. Verify Ollama is running: `ollama list`
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
3. If using a custom host, check `OLLAMA_HOST` in `.env`
1. Verify the daemon is reachable: `curl http://127.0.0.1:11434/api/tags`
2. Confirm Ollama is running (`ollama list` on the host)
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
4. If using a custom host, check `OLLAMA_HOST` in `.env`
### `model not found` / 404 on generate
The model name passed to `ollama_generate` must exactly match one of the names returned by `ollama_list_models` (including any `:tag` suffix, e.g. `gemma3:1b`). Ask the agent to list models first, then pick one from that list.
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until the pull completes — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`.
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it. The management tools are only registered when that flag is present in the container's environment.
### Slow first response
Ollama lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
### Agent doesn't use Ollama tools
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.
@@ -0,0 +1,22 @@
/**
* Host-side env forwarding for the Ollama MCP tool. Returns the Docker `-e`
* arguments that pass any `OLLAMA_*` host overrides into the container.
*
* Ollama is local and keyless — these are configuration, not credentials:
* `OLLAMA_HOST` is the base URL of the host's Ollama daemon, and
* `OLLAMA_ADMIN_TOOLS` is the opt-in flag for the library-management tools.
*
* Lives in its own file so the reach-in in `container-runner.ts` is a single
* call (`args.push(...ollamaEnvArgs())`) and this logic is behavior-testable in
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
*/
export function ollamaEnvArgs(): string[] {
const args: string[] = [];
if (process.env.OLLAMA_HOST) {
args.push('-e', `OLLAMA_HOST=${process.env.OLLAMA_HOST}`);
}
if (process.env.OLLAMA_ADMIN_TOOLS) {
args.push('-e', `OLLAMA_ADMIN_TOOLS=${process.env.OLLAMA_ADMIN_TOOLS}`);
}
return args;
}
@@ -0,0 +1,482 @@
/**
* Ollama MCP Server for NanoClaw
* Exposes local Ollama models (native Ollama REST API, /api/*) as tools for the
* container agent. Uses host.docker.internal to reach the host's Ollama daemon
* from inside the container.
*
* Ollama runs locally and is keyless — there are no credentials to thread. The
* only configuration is the base URL (OLLAMA_HOST) and an opt-in flag for the
* library-management tools (OLLAMA_ADMIN_TOOLS).
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
const OLLAMA_HOST =
process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
const OLLAMA_ADMIN_TOOLS = process.env.OLLAMA_ADMIN_TOOLS === 'true';
const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
function log(msg: string): void {
console.error(`[OLLAMA] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
} catch {
/* best-effort */
}
}
async function ollamaFetch(
apiPath: string,
options?: RequestInit,
): Promise<Response> {
const url = `${OLLAMA_HOST}${apiPath}`;
try {
return await fetch(url, options);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (OLLAMA_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, options);
}
throw err;
}
}
function formatBytes(bytes?: number): string {
if (bytes === undefined || bytes === null) return '?';
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(1)}GB`;
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(0)}MB`;
}
const server = new McpServer({
name: 'ollama',
version: '1.0.0',
});
server.tool(
'ollama_list_models',
'List all models installed in the local Ollama daemon. Use this to see which models are available before calling ollama_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing installed models');
try {
const res = await ollamaFetch('/api/tags');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Ollama API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
models?: Array<{
name: string;
size?: number;
details?: { family?: string; parameter_size?: string };
}>;
};
const models = data.models || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models installed. Pull one on the host with `ollama pull <model>` (e.g. `ollama pull llama3.2`).',
},
],
};
}
const list = models
.map((m) => {
const family = m.details?.family ? ` ${m.details.family}` : '';
const params = m.details?.parameter_size
? ` ${m.details.parameter_size}`
: '';
return `- ${m.name} (${formatBytes(m.size)}${family}${params})`;
})
.join('\n');
log(`Found ${models.length} models`);
return {
content: [
{ type: 'text' as const, text: `Installed models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_generate',
'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model name as returned by ollama_list_models (e.g. "llama3.2" or "gemma3:1b")',
),
prompt: z.string().describe('The prompt to send to the model'),
system: z
.string()
.optional()
.describe('Optional system prompt to set model behavior'),
temperature: z
.number()
.optional()
.describe('Sampling temperature (0.02.0). Defaults to model default.'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const body: Record<string, unknown> = {
model: args.model,
prompt: args.prompt,
stream: false,
};
if (args.system) body.system = args.system;
if (args.temperature !== undefined) {
body.options = { temperature: args.temperature };
}
const startedAt = Date.now();
const res = await ollamaFetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
response?: string;
eval_count?: number;
};
const response = data.response ?? '';
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
const evalCount = data.eval_count;
const meta = `\n\n[${args.model} | ${elapsedSec}s${
evalCount !== undefined ? ` | ${evalCount} tokens` : ''
}]`;
log(
`<<< Done: ${args.model} | ${elapsedSec}s | ${
evalCount ?? '?'
} tokens | ${response.length} chars`,
);
writeStatus(
'done',
`${args.model} | ${elapsedSec}s | ${evalCount ?? '?'} tokens`,
);
return { content: [{ type: 'text' as const, text: response + meta }] };
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
// Library-management tools — opt-in via OLLAMA_ADMIN_TOOLS=true. These mutate
// the host's model library (pull/delete) or inspect it, so they are gated
// behind an explicit flag rather than exposed by default.
if (OLLAMA_ADMIN_TOOLS) {
server.tool(
'ollama_pull_model',
'Pull (download) a model from the Ollama registry into the local daemon. Blocks until the download completes — large models can take several minutes.',
{
model: z
.string()
.describe('The model name to pull (e.g. "llama3.2" or "qwen3-coder:30b")'),
},
async (args) => {
log(`Pulling model: ${args.model}`);
writeStatus('pulling', `Pulling ${args.model}`);
try {
const res = await ollamaFetch('/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model, stream: false }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama pull error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as { status?: string };
log(`Pulled: ${args.model} (${data.status ?? 'ok'})`);
return {
content: [
{
type: 'text' as const,
text: `Pulled ${args.model}: ${data.status ?? 'success'}`,
},
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to pull ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_delete_model',
'Delete a locally installed model from the Ollama daemon to free disk space.',
{
model: z.string().describe('The model name to delete (e.g. "gemma3:1b")'),
},
async (args) => {
log(`Deleting model: ${args.model}`);
writeStatus('deleting', `Deleting ${args.model}`);
try {
const res = await ollamaFetch('/api/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama delete error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
log(`Deleted: ${args.model}`);
return {
content: [
{ type: 'text' as const, text: `Deleted ${args.model}.` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to delete ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_show_model',
'Show details for a locally installed model: modelfile, parameters, template, and architecture info.',
{
model: z
.string()
.describe('The model name to inspect (e.g. "llama3.2")'),
},
async (args) => {
log(`Showing model: ${args.model}`);
try {
const res = await ollamaFetch('/api/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Ollama show error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
parameters?: string;
template?: string;
details?: {
family?: string;
parameter_size?: string;
quantization_level?: string;
};
};
const parts: string[] = [`Model: ${args.model}`];
if (data.details) {
const d = data.details;
parts.push(
`Family: ${d.family ?? '?'} | Params: ${d.parameter_size ?? '?'} | Quant: ${d.quantization_level ?? '?'}`,
);
}
if (data.parameters) parts.push(`Parameters:\n${data.parameters}`);
if (data.template) parts.push(`Template:\n${data.template}`);
return {
content: [{ type: 'text' as const, text: parts.join('\n\n') }],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to show ${args.model}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'ollama_list_running',
'List models currently loaded in memory, with memory usage and processor type (CPU/GPU). Use this to see what is warm and consuming resources.',
{},
async () => {
log('Listing running models...');
try {
const res = await ollamaFetch('/api/ps');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Ollama API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
models?: Array<{
name: string;
size?: number;
size_vram?: number;
}>;
};
const models = data.models || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models currently loaded in memory.',
},
],
};
}
const list = models
.map((m) => {
const vram = m.size_vram ?? 0;
const total = m.size ?? 0;
const processor =
vram === 0
? 'CPU'
: vram >= total
? 'GPU'
: `${Math.round((vram / total) * 100)}% GPU`;
return `- ${m.name} (${formatBytes(total)}, ${processor})`;
})
.join('\n');
return {
content: [
{ type: 'text' as const, text: `Loaded models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
}
const transport = new StdioServerTransport();
await server.connect(transport);
@@ -0,0 +1,66 @@
/**
* Wiring test for the MCP-server registration integration point (container/Bun tree).
*
* The handlers are exercised against a live Ollama daemon at build time, but that does
* not prove the server is registered — delete the index.ts entry and the tool simply
* never appears, yet any handler check stays green. index.ts is the container boot entry
* and is not cheaply invocable, so we assert the registration structurally: the
* `mcpServers` object literal has an `ollama` property whose command runs
* `ollama-mcp-stdio.ts`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.join(import.meta.dir, 'index.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
/** Find the object literal assigned to `const mcpServers = { ... }`. */
function mcpServersLiteral(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
let found: ts.ObjectLiteralExpression | undefined;
const visit = (node: ts.Node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'mcpServers' &&
node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
) {
found = node.initializer;
}
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
return obj.properties.find(
(p): p is ts.PropertyAssignment =>
ts.isPropertyAssignment(p) &&
((ts.isIdentifier(p.name) && p.name.text === name) ||
(ts.isStringLiteral(p.name) && p.name.text === name)),
);
}
describe('index.ts registers the ollama MCP server', () => {
const obj = mcpServersLiteral(sourceFile());
it('finds the mcpServers object literal', () => {
expect(obj).toBeDefined();
});
it('has an ollama entry', () => {
expect(obj && property(obj, 'ollama')).toBeDefined();
});
it('points ollama at ollama-mcp-stdio.ts', () => {
const entry = obj && property(obj, 'ollama');
const text = entry ? entry.getText() : '';
expect(text).toContain('ollama-mcp-stdio.ts');
});
});
@@ -0,0 +1,69 @@
/**
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
*
* The env helper is skill-owned and could be unit-tested directly, but that does not prove
* buildContainerArgs actually uses it — a direct unit test stays green even if the reach-in
* is deleted. buildContainerArgs is entangled with OneCLI and not cheaply invocable, so we
* assert the integration structurally: inside buildContainerArgs there is an
* `args.push(...ollamaEnvArgs())` call. Delete the reach-in and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.resolve(process.cwd(), 'src/container-runner.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
function findFunction(sf: ts.SourceFile, name: string): ts.FunctionDeclaration | undefined {
let found: ts.FunctionDeclaration | undefined;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/** Is this node `args.push(...ollamaEnvArgs())`? */
function isSpreadPushOfEnvArgs(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) return false;
const callee = node.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
callee.name.text !== 'push' ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== 'args'
) {
return false;
}
return node.arguments.some(
(arg) =>
ts.isSpreadElement(arg) &&
ts.isCallExpression(arg.expression) &&
ts.isIdentifier(arg.expression.expression) &&
arg.expression.expression.text === 'ollamaEnvArgs',
);
}
describe('container-runner.ts wires in ollamaEnvArgs', () => {
const sf = sourceFile();
const fn = findFunction(sf, 'buildContainerArgs');
it('finds buildContainerArgs', () => {
expect(fn).toBeDefined();
});
it('calls args.push(...ollamaEnvArgs()) inside buildContainerArgs', () => {
let wired = false;
const visit = (node: ts.Node) => {
if (isSpreadPushOfEnvArgs(node)) wired = true;
if (!wired) ts.forEachChild(node, visit);
};
if (fn?.body) visit(fn.body);
expect(wired).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
# Remove OpenCode provider
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, the agent-runner dependency, and the Dockerfile CLI install.
## 1. Delete the barrel import lines (both trees)
Delete (do not comment out) the `import './opencode.js';` line from each barrel:
- `src/providers/index.ts`
- `container/agent-runner/src/providers/index.ts`
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
## 2. Delete the copied files (both trees)
```bash
rm -f src/providers/opencode.ts \
src/providers/opencode-registration.test.ts \
src/opencode-dockerfile.test.ts \
container/agent-runner/src/providers/opencode.ts \
container/agent-runner/src/providers/mcp-to-opencode.ts \
container/agent-runner/src/providers/mcp-to-opencode.test.ts \
container/agent-runner/src/providers/opencode.factory.test.ts \
container/agent-runner/src/providers/opencode-registration.test.ts
```
## 3. Remove the agent-runner dependency
`@opencode-ai/sdk` is an importable package in the container tree (agent-runner is a Bun package, not a pnpm workspace — use `bun remove`):
```bash
cd container/agent-runner && bun remove @opencode-ai/sdk && cd -
```
## 4. Revert the Dockerfile CLI install
In `container/Dockerfile`, remove both OpenCode edits (skip whichever is already gone):
**(a)** Delete the version ARG from the "Pin CLI versions" block:
```dockerfile
ARG OPENCODE_VERSION=1.4.17
```
**(b)** Delete the standalone OpenCode install layer:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "opencode-ai@${OPENCODE_VERSION}"
```
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
## 5. Clean up per-group overlays
Any group that had the OpenCode files copied into its live source overlay still carries them — remove the OpenCode-specific files from each overlay (the barrel `index.ts` is re-synced from the cleaned tree, not deleted):
```bash
for overlay in data/v2-sessions/*/agent-runner-src/providers/; do
[ -d "$overlay" ] || continue
rm -f "$overlay/opencode.ts" "$overlay/mcp-to-opencode.ts"
[ -f container/agent-runner/src/providers/index.ts ] && \
cp container/agent-runner/src/providers/index.ts "$overlay"
echo "Cleaned: $overlay"
done
```
## 6. Unset OpenCode env vars
Remove any OpenCode-specific lines you added to `.env` (`OPENCODE_PROVIDER`, `OPENCODE_MODEL`, `OPENCODE_SMALL_MODEL`, and `ANTHROPIC_BASE_URL` if no other integration uses it) if no other integration needs them, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
Switch any group still on OpenCode back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
## 7. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build && ./container/build.sh
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
> If the rebuild still reports OpenCode after these steps, the buildkit COPY cache may be stale. Prune the builder and rebuild: `docker builder prune -f && ./container/build.sh`.
## Verification
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
```bash
grep -R "opencode.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
grep "@opencode-ai/sdk" container/agent-runner/package.json # no output
grep "opencode-ai" container/Dockerfile # no output
```
In a wired agent, requesting `agent_provider = 'opencode'` should fall back to the default provider since `opencode` is no longer in the registry.
+57 -31
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.
@@ -132,12 +159,15 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
```bash
# Find the agent id and secret id, then:
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
```
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
```bash
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
onecli agents secrets --id "$AGENT_ID"
```
#### Example: DeepSeek
@@ -208,7 +238,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
### Per group / per session
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
@@ -218,12 +248,8 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder.
- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI).
## Verify
## Next Steps
```bash
grep -q "./opencode.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./opencode.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@opencode-ai/sdk" container/agent-runner/package.json && echo "agent-runner dep: OK"
grep -q "opencode-ai@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/ && cd -
```
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
To remove this provider, see [REMOVE.md](REMOVE.md).
@@ -0,0 +1,47 @@
/**
* Dependency guard for the OpenCode CLI integration point (host tree, vitest).
*
* add-opencode installs the `opencode-ai` CLI globally in the agent container
* image via `container/Dockerfile`. A globally-installed CLI binary is not
* importable or typed, so neither `tsc` nor a runtime import can catch its
* removal only the container image build would, and the skill's validate step
* does not rebuild the image in CI. This structural test stands in for that
* build leg: it parses the Dockerfile and asserts both halves of the install are
* present the pinned `ARG OPENCODE_VERSION=...` and the
* `pnpm install -g "opencode-ai@${OPENCODE_VERSION}"` line. Drop or drift either
* and this goes red.
*
* Pinning matters here beyond reproducibility: the `opencode-ai` CLI version
* must match the `@opencode-ai/sdk` version the container provider imports. An
* unpinned `latest` would silently upgrade the CLI past the SDK's compatible
* range and break sessions. The test therefore also rejects `@latest`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
// Walk up from this test file to the repo root (the dir holding container/Dockerfile),
// so the test works wherever it is copied (src/ on the host, or the skill folder).
let dir = __dirname;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, 'container', 'Dockerfile');
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8');
dir = path.dirname(dir);
}
throw new Error('container/Dockerfile not found walking up from ' + __dirname);
}
describe('container/Dockerfile installs the OpenCode CLI', () => {
const text = dockerfile();
it('declares a pinned OPENCODE_VERSION build arg (not latest)', () => {
expect(text).toMatch(/^ARG\s+OPENCODE_VERSION=\S+/m);
expect(text).not.toMatch(/^ARG\s+OPENCODE_VERSION=latest\s*$/m);
});
it('globally installs the pinned opencode-ai package via pnpm', () => {
expect(text).toMatch(/pnpm install -g\s+"?opencode-ai@\$\{OPENCODE_VERSION\}"?/);
});
});
-290
View File
@@ -1,290 +0,0 @@
# Add Parallel AI Integration
Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.
## What This Adds
- **Quick Search** - Fast web lookups using Parallel Search API (free to use)
- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission)
- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking)
## Prerequisites
User must have:
1. Parallel AI API key from https://platform.parallel.ai
2. NanoClaw already set up and running
3. Docker installed and running
## Implementation Steps
Run all steps automatically. Only pause for user input when explicitly needed.
### 1. Get Parallel AI API Key
Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?`
**If they have one:**
Collect it now.
**If they need one:**
Tell them:
> 1. Go to https://platform.parallel.ai
> 2. Sign up or log in
> 3. Navigate to API Keys section
> 4. Create a new API key
> 5. Copy the key and paste it here
Wait for the API key.
### 2. Add API Key to Environment
Add `PARALLEL_API_KEY` to `.env`:
```bash
# Check if .env exists, create if not
if [ ! -f .env ]; then
touch .env
fi
# Add PARALLEL_API_KEY if not already present
if ! grep -q "PARALLEL_API_KEY=" .env; then
echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env
echo "✓ Added PARALLEL_API_KEY to .env"
else
# Update existing key
sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env
echo "✓ Updated PARALLEL_API_KEY in .env"
fi
```
Verify:
```bash
grep "PARALLEL_API_KEY" .env | head -c 50
```
### 3. Update Container Runner
Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`:
Find the line:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
```
Replace with:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
```
### 4. Configure MCP Servers in Agent Runner
Update `container/agent-runner/src/index.ts`:
Find the section where `mcpServers` is configured (around line 237-252):
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
```
Add Parallel AI MCP servers after the nanoclaw server:
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
// Add Parallel AI MCP servers if API key is available
const parallelApiKey = process.env.PARALLEL_API_KEY;
if (parallelApiKey) {
mcpServers['parallel-search'] = {
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
url: 'https://search-mcp.parallel.ai/mcp',
headers: {
'Authorization': `Bearer ${parallelApiKey}`
}
};
mcpServers['parallel-task'] = {
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
url: 'https://task-mcp.parallel.ai/mcp',
headers: {
'Authorization': `Bearer ${parallelApiKey}`
}
};
log('Parallel AI MCP servers configured');
} else {
log('PARALLEL_API_KEY not set, skipping Parallel AI integration');
}
```
Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248):
```typescript
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'mcp__nanoclaw__*',
'mcp__parallel-search__*',
'mcp__parallel-task__*'
],
```
### 5. Add Usage Instructions to CLAUDE.md
Add Parallel AI usage instructions to `groups/main/CLAUDE.md`:
Find the "## What You Can Do" section and add after the existing bullet points:
```markdown
- Use Parallel AI for web research and deep learning tasks
```
Then add a new section after "## What You Can Do":
```markdown
## Web Research Tools
You have access to two Parallel AI research tools:
### Quick Web Search (`mcp__parallel-search__search`)
**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts.
**Examples:**
- "Who invented the transistor?"
- "What's the latest news about quantum computing?"
- "When was the UN founded?"
- "What are the top programming languages in 2026?"
**Speed:** Fast (2-5 seconds)
**Cost:** Low
**Permission:** Not needed - use whenever it helps answer the question
### Deep Research (`mcp__parallel-task__create_task_run`)
**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research.
**Examples:**
- "Explain the development of quantum mechanics from 1900-1930"
- "Compare the literary styles of Hemingway and Faulkner"
- "Research the evolution of jazz from bebop to fusion"
- "Analyze the causes of the French Revolution"
**Speed:** Slower (1-20 minutes depending on depth)
**Cost:** Higher (varies by processor tier)
**Permission:** ALWAYS use `AskUserQuestion` before using this tool
**How to ask permission:**
```
AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed?
```
**After permission - DO NOT BLOCK! Use scheduler instead:**
1. Create the task using `mcp__parallel-task__create_task_run`
2. Get the `run_id` from the response
3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`:
```
Prompt: "Check Parallel AI task run [run_id] and send results when ready.
1. Use the Parallel Task MCP to check the task status
2. If status is 'completed', extract the results
3. Send results to user with mcp__nanoclaw__send_message
4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done
If status is still 'running' or 'pending', do nothing (task will run again in 30s).
If status is 'failed', send error message and complete the task."
Schedule: interval every 30 seconds
Context mode: isolated
```
4. Send acknowledgment with tracking link
5. Exit immediately - scheduler handles the rest
### Choosing Between Them
**Use Search when:**
- Question needs a quick fact or recent information
- Simple definition or clarification
- Verifying specific details
- Current events or news
**Use Deep Research (with permission) when:**
- User wants to learn about a complex topic
- Question requires analysis or comparison
- Historical context or evolution of concepts
- Structured, comprehensive understanding needed
- User explicitly asks to "research" or "explain in depth"
**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis.
```
### 6. Rebuild Container
Build the container with updated agent runner:
```bash
./container/build.sh
```
Verify the build:
```bash
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```
### 7. Restart Service
Rebuild the main app and restart:
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Wait 3 seconds for service to start, then verify:
```bash
sleep 3
launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
```
### 8. Test Integration
Tell the user to test:
> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?`
>
> The assistant should use Parallel Search API to find current information.
>
> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?`
>
> The assistant should ask for permission before using the Task API.
Check logs to verify MCP servers loaded:
```bash
tail -20 logs/nanoclaw.log
```
Look for: `Parallel AI MCP servers configured`
## Troubleshooting
**Container hangs or times out:**
- Check that `type: 'http'` is specified in MCP server config
- Verify API key is correct in .env
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
**MCP servers not loading:**
- Ensure PARALLEL_API_KEY is in .env
- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars
- Check agent-runner logs for "Parallel AI MCP servers configured" message
**Task polling not working:**
- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"`
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
- Ensure task prompt includes proper Parallel MCP tool names
## Uninstalling
To remove Parallel AI integration:
1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env`
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && pnpm run build`
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
+38 -4
View File
@@ -1,6 +1,40 @@
# Remove Resend Email Channel
1. Comment out `import './resend.js'` in `src/channels/index.ts`
2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @resend/chat-sdk-adapter`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './resend.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/resend.ts src/channels/resend-registration.test.ts
```
## 2. Remove credentials
Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, and `RESEND_WEBHOOK_SECRET` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @resend/chat-sdk-adapter
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+8 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/resend.ts` exists
- `src/channels/resend-registration.test.ts` exists
- `src/channels/index.ts` contains `import './resend.js';`
- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,15 @@ import './resend.js';
pnpm install @resend/chat-sdk-adapter@0.1.1
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/resend-registration.test.ts
```
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
1. Go to [resend.com](https://resend.com) and create an account.
-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
```
+61
View File
@@ -0,0 +1,61 @@
# Remove Signal
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 './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`.
+335
View File
@@ -0,0 +1,335 @@
---
name: add-signal
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
## Prerequisites
### Java
signal-cli requires Java 17+:
```bash
java -version
```
If missing:
- **macOS:** `brew install --cask temurin@17`
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
Java 1725 all work.
### signal-cli
- **macOS:** `brew install signal-cli`
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
```bash
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar -xz -C ~/.local
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
signal-cli --version
```
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
## Registration
Two paths. The new-number path is recommended and battle-tested.
### Path A: Register a new number (recommended)
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
**Step 1: Solve the CAPTCHA**
Signal requires a CAPTCHA on first registration:
1. Open `https://signalcaptchas.org/registration/generate.html` in a browser
2. Solve the captcha
3. Right-click the **"Open Signal"** button → **Copy Link**
4. The link starts with `signalcaptcha://` — the token is everything after that prefix
**Step 2: Request SMS verification**
```bash
signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE"
```
**Step 3: Voice call fallback (if your number can't receive SMS)**
Wait ~60 seconds after the SMS request, then:
```bash
signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN"
```
Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one.
> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…`
**Step 4: Verify**
```bash
signal-cli -a +1YOURNUMBER verify CODE
```
No output = success.
**Step 5: Set profile name (optional)**
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
# optionally: --avatar /path/to/avatar.jpg
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux
systemctl --user stop $(systemd_unit)
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start $(systemd_unit)
```
### Path B: Link as secondary device
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
```bash
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
```
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `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.
### 1. Fetch the channels branch
```bash
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-registration.test.ts > src/channels/signal-registration.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './signal.js';
```
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/signal-registration.test.ts
```
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
Add to `.env`:
```bash
SIGNAL_ACCOUNT=+1YOURNUMBER
```
### Optional settings
```bash
# TCP daemon host and port (default: 127.0.0.1:7583)
SIGNAL_TCP_HOST=127.0.0.1
SIGNAL_TCP_PORT=7583
# Path to the signal-cli binary (default: resolved on PATH)
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# Whether NanoClaw manages the daemon lifecycle (default: true).
# Set to false if you run signal-cli daemon externally.
SIGNAL_MANAGE_DAEMON=true
# signal-cli data directory (default: ~/.local/share/signal-cli)
SIGNAL_DATA_DIR=~/.local/share/signal-cli
```
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Wiring
### DMs
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
### Groups
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
"
```
### Grant user access
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
"
```
Find the UUID from `messaging_groups.platform_id` or the `users` table.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
- **supports-threads**: no
- **platform-id-format**:
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
- Quoted replies — `replyTo*` fields populated from Signal quotes
- Typing indicators — DMs only (Signal doesn't support group typing)
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Troubleshooting
### Daemon not reachable
```bash
grep "Signal" logs/nanoclaw.log | tail
```
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
### Bot not responding
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
3. Service running: `launchctl print gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
### Messages delivered but never arrive (null platformMsgId)
Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend.
### Lost connection mid-session
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish.
### Messages dropped with `not_member`
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
### Captcha required
Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`.
### `Invalid verification method: Before requesting voice verification…`
You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token.
### Config file in use / daemon lock
signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward.
### Group replies going to DM instead of group
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
### Java not found
Install Java 17+ — see the Prerequisites section above.
### QR code expired (Path B)
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
+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)
```
+19 -6
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
@@ -44,15 +46,20 @@ import './slack.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/slack@4.26.0
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
@@ -60,7 +67,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
@@ -76,7 +83,13 @@ pnpm run build
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Interactivity
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
### Configure environment
-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)
```
+52 -4
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
@@ -44,17 +46,63 @@ import './teams.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/teams@4.26.0
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)
```
+10 -3
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
@@ -58,15 +60,20 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/telegram@4.26.0
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
```
+23 -10
View File
@@ -90,30 +90,43 @@ onecli secrets list | grep -i vercel
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
```bash
# For each agent, add the Vercel secret to its assigned secrets list.
# First get current assignments, then set them with the new secret appended.
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//')
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
# set-secrets replaces the entire list — read and merge for each agent.
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
for agent in $(onecli agents list | jq -r '.data[].id'); do
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
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.
+25 -17
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,11 +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
systemctl --user restart nanoclaw # Linux
# or
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+17 -7
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.
@@ -82,12 +89,15 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
Restart NanoClaw:
Restart NanoClaw.
Run from your NanoClaw project root:
```bash
systemctl --user restart nanoclaw # Linux
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
@@ -167,4 +177,4 @@ Otherwise, restart the service to pick up the new channel and wiring.
- **supports-threads**: no (WeChat has no reply threads)
- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed.
- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot.
- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running inside `/new-setup`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above.
- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running as part of `bash nanoclaw.sh`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above.
+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)
```
+11 -4
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
@@ -44,15 +46,20 @@ import './whatsapp-cloud.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/whatsapp@4.26.0
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.
+35 -18
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
@@ -57,15 +62,20 @@ groups: () => import('./groups.js'),
### 5. Install the adapter packages (pinned)
```bash
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
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**
@@ -200,7 +214,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **type**: `whatsapp`
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **supports-threads**: no
- **typical-use**: Interactive chat — direct messages or small groups
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
@@ -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,28 +250,31 @@ 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
Signal sessions corrupted from rapid restarts. Clear sessions:
Signal sessions corrupted from rapid restarts. Clear sessions.
Run from your NanoClaw project root:
```bash
systemctl --user stop nanoclaw
source setup/lib/install-slug.sh
systemctl --user stop $(systemd_unit)
rm store/auth/session-*.json
systemctl --user start nanoclaw
systemctl --user start $(systemd_unit)
```
### Bot not responding
1. Auth exists: `test -f store/auth/creds.json`
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
4. Service running: `systemctl --user status nanoclaw`
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
4. Service running: `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"`
### "conflict" disconnection
@@ -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,212 +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/qwibitai/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
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
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` |
+63 -47
View File
@@ -9,102 +9,118 @@ 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
Always tell the user:
Always tell the user.
Run from your NanoClaw project root:
```bash
# Rebuild and restart
pnpm run build
source setup/lib/install-slug.sh
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux:
# systemctl --user restart nanoclaw
# systemctl --user restart $(systemd_unit)
```
## Example Interaction
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.
+179 -231
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,300 +60,238 @@ 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. "Claude Code process exited with code 1"
### 1. "No adapter for channel type" / Messages silently lost (null platform_message_id)
**Check the container log file** in `groups/{folder}/logs/container-*.log`
Common causes:
#### Missing Authentication
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
```
Invalid API key · Please run /login
WARN No adapter for channel type channelType="telegram"
WARN No adapter for channel type channelType="signal"
```
**Fix:** Ensure `.env` file exists with either OAuth token or API key:
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 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
cat .env # Should show one of:
# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... (subscription)
# ANTHROPIC_API_KEY=sk-ant-api03-... (pay-per-use)
# Check for duplicate running instances
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
# Check which services are active (Linux)
systemctl --user list-units 'nanoclaw*' --all
# Confirm channel adapters registered by the current process
grep "Channel adapter started" logs/nanoclaw.log | tail -10
```
#### 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`.
**Fix:**
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
systemctl --user disable nanoclaw.service
```
3. If the remaining service unit is missing `EnvironmentFile`, add it:
```bash
# Edit the service unit — add this line under [Service]:
# EnvironmentFile=/home/[user]/nanoclaw/.env
systemctl --user daemon-reload
systemctl --user restart nanoclaw-v2-<id>.service
```
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
### 2. Environment Variables Not Passing
Messages marked delivered with a null `platform_message_id` are not automatically retried. Ask the user to resend.
**Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin).
### 2. Container exits immediately / agent produces no reply
**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.
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).
To verify env vars are reaching the container:
**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
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"'
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`.
**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)
sqlite3 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"
```
+4 -4
View File
@@ -9,7 +9,7 @@ Stand up the first NanoClaw agent for a channel and verify end-to-end delivery b
## Prerequisites
- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
- **Service running.** Check: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) or `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux). If stopped, tell the user to run `/setup` first.
- **Target channel installed.** At least one `/add-<channel>` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`.
- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel.
@@ -54,7 +54,7 @@ Tell the user:
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
```bash
sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues.
@@ -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):
- `sqlite3 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
+45 -7
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
@@ -236,9 +236,12 @@ pnpm run build
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first.
Restart the service:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux (systemd): `systemctl --user restart nanoclaw`
Restart the service.
Run from your NanoClaw project root:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
- Linux (systemd): `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
## Phase 5: Verify
@@ -259,9 +262,44 @@ Tell the user:
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
- To add rate limits or policies: `onecli rules create --help`
## Granting secrets to agents (safe merge)
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
```bash
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
onecli agents secrets --id "$AGENT_ID"
```
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
- `<new-secret-id>` — the `id` from `onecli secrets list`
- Multiple new secrets: append them comma-separated before the `printf` step
### git over HTTPS
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
```json
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "credential.helper",
"GIT_CONFIG_VALUE_0": "",
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
```
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
## 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`).
+20 -5
View File
@@ -7,11 +7,26 @@ 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
Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`).
Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "<query>"
```
```sql
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
```
Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
@@ -50,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.
@@ -58,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 -- \
@@ -68,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
+15 -6
View File
@@ -24,24 +24,33 @@ 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
Restart the service so containers pick up the new config:
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```

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