Compare commits

..

58 Commits

Author SHA1 Message Date
gavrielc 8137440698 test(channels): add behavior registration tests for the remaining channel fleet
discord, gchat, github, imessage, linear, matrix, resend, teams, telegram,
webex, whatsapp-cloud, signal, wechat, whatsapp, emacs.

Same behavior shape as the slack/deltachat exemplars: import the real
src/channels/index.ts barrel and assert getRegisteredChannelNames() contains
the channel. Red if the barrel import line is deleted/drifts, if the barrel
fails to evaluate, or (for channels with an npm adapter) if the adapter package
is not installed — so each test also implicitly guards the skill's dependency.
signal and emacs have no npm adapter (signal-cli binary / http builtin), so
their tests guard the single barrel reach-in only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:44:14 +03:00
gavrielc 7ceb06cc8a test(channels): switch deltachat + slack registration tests to behavior
Replace the structural barrel-parse with a behavior test that imports the real
barrel and asserts the registry actually contains the channel. This reflects
host boot: it goes red if the `import './<ch>.js';` line is deleted, if the
barrel fails to evaluate (channel genuinely won't register), or if the adapter
package isn't installed (the unmocked import throws) — so it also implicitly
verifies the dependency-install integration point. A structural check would
falsely pass in the latter two cases.

Importing is safe: registration is a pure top-level call; deltachat instantiates
DeltaChatOverJsonRpc only in setup(), and slack builds its SDK adapter/bridge
only in its factory — neither at import. Requires the adapter package installed,
which holds in the composed install (the skill's pnpm install runs first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:05:11 +03:00
gavrielc d011752c67 test(slack): add channel registration integration test
Guards the single reach-in a slack install makes into core — the
`import './slack.js';` line in the src/channels/index.ts barrel that triggers
the adapter's top-level registerChannelAdapter call. Structural (parses the
barrel) rather than behavior, so it stays hermetic and does not pull
@chat-adapter/slack into the test process; the adapter's createChatSdkBridge
core-API consumption is a typed call guarded by the build leg. Red-on-delete
of the barrel line. Template for the Chat SDK channel family.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:55:59 +03:00
gavrielc 2e6f10cdd7 test(deltachat): add channel registration integration test
Guards the single reach-in a deltachat install makes into core — the
`import './deltachat.js';` line in the src/channels/index.ts barrel that
triggers the adapter's top-level registerChannelAdapter call. Structural
(parses the barrel) rather than behavior, so it stays hermetic and does not
pull the native @deltachat/stdio-rpc-server into the test process; the build
leg covers that the import resolves. Red-on-delete of the barrel line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:02:43 +03:00
glifocat 8906105825 Merge pull request #2633 from maschenborn/fix/whatsapp-self-destruct-and-shutdown-auth-wipe
Fix/whatsapp self destruct and shutdown auth wipe
2026-06-04 15:49:28 +03:00
Michael Aschenborn ef9e7d5f99 fix(whatsapp): preserve auth on clean shutdown — only clear on server logout
The auth-cleanup branch ran on every `shouldReconnect=false` path, which
is true for *both* `DisconnectReason.loggedOut` *and* `shuttingDown=true`.
That meant every clean `systemctl restart nanoclaw` would log "WhatsApp
logged out" and `fs.rmSync(authDir)` — wiping a perfectly good session
and forcing a fresh re-pair on next start.

Split the branch: only clear `authDir` when the disconnect reason is
actually `DisconnectReason.loggedOut`. On clean shutdown, log
"WhatsApp adapter stopped (auth preserved)" and exit cleanly. The next
process boot will pick up the preserved auth via
`useMultiFileAuthState` and reconnect transparently.

Symptom in logs that this fixes:

  WhatsApp connection closed reason=undefined shuttingDown=true
  WhatsApp logged out
  WhatsApp auth cleared           (wrong — this was a clean shutdown)
  ... next restart ...
  WhatsApp pairing code: XXXXXXXX (because auth is gone)

This pair of bugs together (the previous commit + this one) is why
WhatsApp installs with `WHATSAPP_PHONE_NUMBER` set on Baileys 7.x feel
unstable: any restart wipes auth, then the next restart's adapter
self-destructs while trying to re-pair, leaving the bot offline until
a human re-runs the pair flow. With both fixes a `systemctl restart`
is now a transparent ~3s connection blip.
2026-05-28 15:09:10 +02:00
Michael Aschenborn 051b895b3c fix(whatsapp): don't self-destruct paired session after stream restart
Baileys 7.x does not reliably flip `state.creds.registered` back to
true after the post-pair stream-restart (statusCode 515). The adapter
then sees `registered=false` on an already-paired socket and queues a
fresh `requestPairingCode()` 3 seconds later. The WhatsApp server sees
two conflicting auth flows for the same account, rejects with 401, and
the adapter wipes `authDir` — every restart forces a re-pair from
scratch.

Use `state.creds.me` instead. It is set during QR / pair-code handshake
and is the authoritative "this socket has an account" signal — it does
not toggle on stream restarts.

Symptom in logs that this fixes:

  Connected to WhatsApp
  WhatsApp pairing code: XXXXXXXX        (3s later — the bug)
  WhatsApp connection closed reason=401
  WhatsApp logged out
  WhatsApp auth cleared

Reproduced on Baileys 7.0.0-rc.9. Affects any install with
WHATSAPP_PHONE_NUMBER set, which is the recommended config for
dedicated bot numbers.
2026-05-28 15:08:44 +02:00
gavrielc 43adb1998a Merge pull request #2552 from IamAdamJowett/fix/whatsapp-mentions-and-shutdown-race
Thanks @IamAdamJowett!
2026-05-22 23:17:35 +03:00
gavrielc 5ba4735fe9 merge: resolve conflict with channels branch in whatsapp.test.ts
Combine both test suites — inbound bot mention detection (#2560 from
channels) and outbound parseWhatsAppMentions (this PR).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 23:16:48 +03:00
gavrielc 3986ce0e11 Merge pull request #2579 from cfis/fix/whatsapp-clear-auth-on-logout
fix(whatsapp): clear auth credentials immediately on 401 logout
2026-05-22 22:31:10 +03:00
Charlie Savage 3777a9b614 fix(whatsapp): clear auth credentials immediately on 401 logout
When WhatsApp issues a forced logout (status 401), the adapter now
deletes store/auth/ immediately rather than leaving stale credentials
on disk. Previously, stale credentials persisted across service
restarts — the next startup would re-attempt authentication with the
dead session, receive a second 401, and contribute to WhatsApp's
temporary re-link cooldown ("can't link new devices now. try again
later").

After clearing, a log message instructs the operator to set
WHATSAPP_ENABLED=true and restart in order to re-link. Without that
env var, the adapter stays dormant on subsequent restarts (existing
guard at adapter startup: no creds + no WHATSAPP_ENABLED → return
null), avoiding any further auth attempts until the operator is ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:18:52 -07:00
glifocat 36fb78092c Merge pull request #2565 from glifocat/fix/whatsapp-group-mention-ismention
fix(whatsapp): detect group @-mentions via contextInfo.mentionedJid
2026-05-20 03:50:03 +02:00
nanoclaw-coder c52591f68f fix(whatsapp): detect group @-mentions via contextInfo.mentionedJid
Before this change, the inbound construction site hard-coded
`isMention: !isGroup ? true : undefined`, which meant group messages
that explicitly @-mentioned the bot never set the field. The router
then never woke the agent on a mention-only trigger.

Detection lives in a new pure helper `isBotMentionedInGroup` which
scans `contextInfo.mentionedJid` across the four message types that
can host mentions (extendedTextMessage + image/video/document
captions), matching against both the bot's phone JID and LID since
modern WhatsApp clients increasingly emit the LID for phone-number
mentions. A second helper `computeIsMention` wraps the DM/group
ternary so both branches of the fix are unit-testable.

Tests in src/channels/whatsapp.test.ts cover phone-JID detection,
LID-only detection, image-caption mentions, the negative cases, and
the call-site isMention semantics for DMs vs groups vs no-mention.

Fixes #2560
2026-05-20 03:06:05 +02:00
Adam e372f05d2e fix(whatsapp): render @<phone> as real mentions; prevent shutdown-race creds wipe
Two bugs in src/channels/whatsapp.ts that compound each other:

1) Outbound `@<digits>` never rendered as a WhatsApp tag because every
   send path called `sock.sendMessage(jid, { text })` with no `mentions`
   array. Baileys copies `mentions` into `contextInfo.mentionedJid`
   (lib/Utils/messages.js:477), and WhatsApp clients use that list to
   draw the bold/clickable tag. Without it the `@<digits>` is plain
   text with no notification.

   Fix: new `parseWhatsAppMentions` scans outbound text for
   `@<5-15 digits>` (with optional leading `+`, stripped so the literal
   matches the JID), and `formatWhatsApp` now returns `{ text, mentions }`.
   All four outbound paths — `sendRawMessage`, `flushOutgoingQueue`,
   media caption, and the normal-text branch in `deliver` — pass the
   `mentions` array through. Code-block protected regions are exempt
   so phone-like sequences inside fenced code aren't tagged.

2) On SIGTERM the `connection.update` close handler unconditionally
   called `connectSocket()` (because `shouldReconnect` was only false
   for `loggedOut`). The fresh `useMultiFileAuthState` it initialized
   could truncate `creds.json` mid-write as the process exited, leaving
   a 0-byte creds file and forcing a fresh QR pairing on next start.

   Fix: new `shuttingDown` flag set by `teardown()`; the close handler
   skips the reconnect when shutting down.

Adds src/channels/whatsapp.test.ts covering the new mention parser
(10 cases: basic extraction, `+` stripping, multiple/dedup mentions,
edge cases like emails, short sequences, parens, punctuation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:20:52 +10:00
gavrielc 8e91d37bc9 Merge pull request #2302 from Koshkoshinsk/fix/whatsapp-self-chat
fix(whatsapp): allow self-chat messages through fromMe filter
2026-05-07 00:09:59 +03:00
koshkoshinsk bba8213cbd fix(whatsapp): allow self-chat messages through fromMe filter
The blanket `if (fromMe) continue` filter dropped all messages from the
linked device, including user-typed messages in self-chat. Use the
existing sentMessageCache to distinguish bot echoes from user messages
when chatJid matches the bot's own phone JID.

Introduced in c02ac06 (Apr 14 2026).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 11:15:57 +00:00
gavrielc 5f069221b2 Merge pull request #2259 from qwibitai/fix/whatsapp-baileys-v7-lid
fix(whatsapp): upgrade Baileys v6→v7 for proper LID handling
2026-05-05 16:15:51 +03:00
exe.dev user 151091f384 chore: update lockfile for Baileys 7.0.0-rc.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 22:35:38 +00:00
exe.dev user 5ada950982 fix(whatsapp): fail fast when WA Web version can't be fetched
Never fall back to Baileys' hardcoded stale version — it will just
get rejected with 405 at the Noise layer. Throw a clear error
instead so the problem is visible immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 22:26:18 +00:00
exe.dev user 6c455330e4 fix(whatsapp): fetch current WA Web version from wppconnect tracker
Baileys' fetchLatestWaWebVersion scrapes sw.js which WhatsApp
aggressively rate-limits (429). When it fails, Baileys falls back
to a hardcoded version (2.3000.1027934701) that goes stale within
weeks — WhatsApp rejects connections with a mismatched buildHash
(405 at Noise protocol layer, before QR/pairing code can start).

Add resolveWaWebVersion() that fetches the current version from
wppconnect.io/whatsapp-versions first, then falls back to Baileys'
own fetch, then to the hardcoded default. Applied to both the
adapter and whatsapp-auth.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 22:25:21 +00:00
exe.dev user 27af41d9b0 fix(whatsapp): upgrade Baileys v6→v7 for proper LID handling
v1 ran on Baileys 7.0.0-rc.9 which has proper LID support via
extractAddressingContext (participantAlt/remoteJidAlt on every inbound
message) and signalRepository.lidMapping.getPNForLID. The v2 adapter
was mistakenly downgraded to v6 where getPNForLID doesn't exist,
making the call dead code behind an `as any` cast. This caused:

- Unresolvable LIDs leaking to the router as @lid platform IDs
- Split sessions when dual messaging_groups rows were created
- Silent message drops on cold start before mappings were learned

Changes:
- Pin @whiskeysockets/baileys to exact 7.0.0-rc.9 (last release)
- Import proto directly (ESM named export in v7, no createRequire hack)
- Remove getPlatformId monkey-patch (bug fixed in v7)
- translateJid: use msg.key.remoteJidAlt/participantAlt first, then
  real signalRepository.lidMapping.getPNForLID (no as any cast)
- Replace chats.phoneNumberShare with lid-mapping.update event
- proto.Message.fromObject → proto.Message.create (v7 migration)
- Resolve sender JID in groups via participantAlt
- Mark DM inbound messages with isMention=true (subsumes #2213 fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 21:55:49 +00:00
gavrielc ea68aa810b Merge pull request #2192 from axxml/channels
Add DeltaChat channel adapter
2026-05-02 21:14:33 +03:00
Axel McLaren 5987fdc189 Add namespacedPlatformId exclusion for DeltaChat 2026-05-02 09:57:25 -07:00
Axel McLaren 0ef8757f50 Add DeltaChat channel adapter 2026-05-02 09:57:21 -07:00
exe.dev user 878d3706b4 telegram: redirect post-pairing chat message back to the installer
The previous wording promised a welcome DM "shortly", but in practice the
welcome can be delayed (cold container start, OneCLI selective-secret
mode, etc.) until well after the user has more terminal interactions to
complete. Telling them to wait in Telegram pulls attention away from the
installer at the worst moment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:04:45 +00:00
gavrielc b52ab850b2 Merge pull request #2040 from ddaniels/feat/signal-outbound-attachments
feat(signal): support outbound attachments
2026-05-01 01:02:42 +03:00
gavrielc 7b4dfd28c3 Merge pull request #2112 from robbyczgw-cla/fix/telegram-maxtextlength-wiring
fix(channels/telegram): wire maxTextLength to engage splitter from #1900
2026-05-01 00:32:26 +03:00
gavrielc 106c21a567 Merge pull request #2107 from qwibitai/feat/slack-resolve-channel-name
feat: implement resolveChannelName for Slack and Telegram
2026-04-30 22:54:30 +03:00
gabi-simons 221c4948cd feat(telegram): implement resolveChannelName via getChat API
Enables the channel-approval flow to show the Telegram group name
in the approval card instead of a generic "a telegram channel".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 12:56:12 +00:00
robbyczgw-cla c6b21e7493 fix(channels/telegram): wire maxTextLength to engage splitter from #1900
PR #1900 added the splitForLimit helper and maxTextLength config option,
but explicitly flagged that channel adapters need a follow-up to wire it.
This commit completes the wiring for Telegram.

Without this, the telegram adapter silently truncates outbound messages
>4096 chars via legacy truncateMessage() behavior. With this fix, the
splitter engages and posts chunks sequentially. The returned id is the
first chunk's id so edits/reactions still target the reply head.
2026-04-29 14:20:25 +00:00
Gabi Simons 4a8887636c feat(slack): implement resolveChannelName via fetchThread
Adds resolveChannelName to the Slack adapter so the channel-approval
flow can show the actual channel name in the approval card. Uses the
existing fetchThread → conversations.info path.

Depends on: qwibitai/nanoclaw#2105 (adds resolveChannelName to ChannelAdapter interface)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 13:19:49 +00:00
gavrielc 7789fcc67a Update stale /new-setup references to /setup
The /new-setup skill was collapsed into /setup on main. Update doc
comments and one user-facing HINT to match.
2026-04-28 17:17:15 +03:00
gavrielc 6ec5f06d51 Merge main into channels
Bring channels up to date with main, including the channel-inbound
attachment path-traversal fix.

Resolved conflicts:
- package.json: kept channels-specific deps + added @clack/core from main
- pnpm-lock.yaml: regenerated against merged package.json
- setup/index.ts: union STEPS map (pair-telegram, whatsapp-auth, signal-auth)
- setup/whatsapp-auth.ts: took main's version (deliberate fork for setup-auto)
2026-04-28 13:50:36 +03:00
gavrielc 8f4c79dcaa Fix path traversal in WhatsApp attachment handling 2026-04-28 13:30:17 +03:00
Doug Daniels b672e8271e feat(signal): support outbound attachments via signal-cli attachments
The native Signal adapter previously logged-and-dropped any
OutboundFile entries with a TODO. This wires through to signal-cli's
already-supported `send` JSON-RPC `attachments` parameter:

  - New `sendAttachments(platformId, files)` helper writes each
    OutboundFile.data Buffer to a temp file in os.tmpdir(), passes
    the paths to `tcp.rpc('send', { attachments: [...] })`, then
    cleans up the temp files in finally{}.
  - `deliver()` no longer drops files — sends accompanying text
    first via the existing sendText (preserving chunking + textStyle),
    then attachments as a single send call.
  - Filename sanitization replaces `/`, `\`, and `\0` with `_` so
    operator-supplied filenames can't escape tmpdir (CWE-22).
  - Tests cover: single attachment, text+attachment ordering,
    multiple attachments in one send, group destinations, and a
    structural invariant proving sanitized paths stay inside tmpdir().

Total signal tests: 33 → 37 (one stale "drops files" test replaced
by the positive-behavior tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:19:28 -04:00
gavrielc de448ef22f Merge pull request #1962 from jorgenclaw/feat/signal-improvements
feat(signal): replyTo shape fix + voice transcription, images, mentions, groupV2
2026-04-24 15:45:18 +03:00
Scott Jorgensen 53513db5bc feat(signal): replyTo shape fix + voice transcription, images, mentions, groupV2
The Signal adapter from #1953 had several gaps that left a meaningful set of
inbound message types unreachable to the agent. This change fills those gaps
and fixes one quiet contract bug between the adapter and the agent-runner
formatter, all in the existing factory shape so the wiring stays compatible.

Bug fix
- Quote-reply context was never reaching the agent. The adapter wrote
  `replyToSenderName` / `replyToMessageContent` / `replyToMessageId` at the
  top level of `content`, but the formatter at
  `container/agent-runner/src/formatter.ts:formatReplyContext` reads a
  nested `content.replyTo: { sender, text }`, requiring both sender and
  text or it omits the `<quoted_message>` block entirely. The two halves
  disagreed; this commit aligns them.

Capability adds (additive — defaults preserve existing behavior)
- Voice notes are now transcribed when `WHISPER_BIN` (local whisper.cpp)
  or `OPENAI_API_KEY` is set, surfacing as `[Voice: <transcript>]`. With
  neither set, behavior matches the prior `[Voice Message]` placeholder.
- Image attachments are forwarded as `[Image: <path>]` lines plus a
  structured `attachments` array on `content`, so vision-capable models
  actually see the picture instead of nothing.
- `@<mention>` placeholders are resolved to display names from the
  envelope's `mentions` array, so the agent reads "@Bob" instead of a UUID.
- Modern Signal groups (groupV2) are routed correctly. The previous code
  read only `groupInfo.groupId` and treated v2-only groups as DMs.

Tests
- Updated quote-context test to assert the nested `replyTo` shape.
- Replaced the "skips messages with attachments but no text" test with a
  positive assertion that image attachments are forwarded.
- Added tests for groupV2 routing and mention resolution.
- All 268 tests pass; build clean.

Compat
- Factory signature, env var names, daemon-management flag, EchoCache,
  text-style handling, and chunkText are unchanged. Operators who do not
  set `WHISPER_BIN` or `OPENAI_API_KEY` get exactly the prior voice-note
  UX. No changes to the channel-registry barrel are required.
2026-04-23 17:48:25 -07:00
gavrielc f0a0939860 feat(signal): add native Signal channel adapter
Ships the Signal channel adapter code corresponding to the
/add-signal skill. Native adapter speaking JSON-RPC to a
signal-cli TCP daemon — no Chat SDK bridge, no npm deps.

- src/channels/signal.ts — adapter implementation with DM and group
  support, echo suppression, Markdown → Signal text-style conversion,
  quoted-reply extraction, typing indicators (DMs only), Note to Self
  routing, voice-attachment detection, managed daemon lifecycle.
- src/channels/signal.test.ts — 31 vitest tests covering connection
  lifecycle, inbound/outbound paths, nested style offsets, italic
  mapping, cross-recipient echo isolation, socket-close handling,
  and file-drop warnings.
- src/channels/index.ts — signal entry added as a commented-out import
  for parity with other native channels; /add-signal uncomments it
  during install.

Env vars: SIGNAL_ACCOUNT (required), SIGNAL_TCP_HOST / SIGNAL_TCP_PORT,
SIGNAL_CLI_PATH, SIGNAL_MANAGE_DAEMON, SIGNAL_DATA_DIR.

Originally contributed in #1953; the adapter lives on this branch per
the channels/providers split (trunk doesn't ship channel adapters).

Co-Authored-By: Doug Daniels <ddaniels888@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:55:45 +03:00
gavrielc c91168bd74 style(telegram): apply prettier formatting to HR test 2026-04-23 01:50:44 +03:00
gavrielc 68352351e4 fix(telegram): flatten Markdown horizontal rules in the sanitizer
Bare --- / *** / ___ HR lines confuse Telegram's legacy Markdown parser and (for ***/___) unbalance the delimiter count the sanitizer relies on, which causes the fallback to strip all formatting. Replace them with a plain Unicode divider (⎯⎯⎯) before the delimiter pass — same approach the bullet conversion already uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:50:21 +03:00
gavrielc 22ed951f05 fix(channels): use named pino import for NodeNext compat
pino 9.x's .d.ts exports `{ pino as default, pino }` where `pino` merges a
function declaration with a namespace. Under `moduleResolution: NodeNext`,
TypeScript resolves the default export to the namespace type (`typeof pino`)
rather than the callable function — `pino({ level: 'silent' })` fails with
"typeof import(...) has no call signatures" at `pnpm run build` time.

Switching to the named import resolves to the callable function directly,
sidestepping the quirk. Same zero-runtime change, but the build succeeds.

Fixed in both src/channels/whatsapp.ts and setup/whatsapp-auth.ts for
consistency; same pattern hit both files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:36:58 +03:00
gavrielc 4dfc2e3a24 style: apply prettier to whatsapp.ts 2026-04-22 12:02:22 +03:00
gavrielc 3a29674b46 fix(channels): adapt adapters to v2 interface changes
v2's adapter.ts + chat-sdk-bridge.ts rewrites left four branch-only
adapters with type errors. Minimal adaptations to the new contract:

- whatsapp.ts: drop the conversations-filter scheme. The v2 router
  owns messaging-group routing now, so the adapter no longer needs
  hostConfig.conversations, buildConversationMap, or the
  updateConversations method. ConversationConfig is gone from the
  contract — syncConversations still returns ConversationInfo.
- linear.ts: drop catchAll from the chat-sdk bridge config. The
  bridge's default onNewMessage(/./) handler always forwards.
- emacs.test.ts: replace conversations: [] in the ChannelSetup
  fixture with onInboundEvent: vi.fn() (the new admin-transport hook).
- wechat.ts: dep wechat-ilink-client@0.1.0 was referenced by the
  file but never added to package.json — pinned it at ^0.1.0. Types
  now flow from the package, clearing the four implicit-any errors.
2026-04-22 12:02:00 +03:00
gavrielc e8b01bdb07 style: apply prettier to merged files 2026-04-22 11:56:59 +03:00
gavrielc 6ed228f9a8 Merge v2 into channels
Picks up 105 commits from v2 (engage modes, sender/channel approval flows,
host-sweep heartbeat lifecycle, setup/onecli refactor, setup-flow docs,
DeliveryAddress/InboundEvent adapter contract changes).

Retires 9 deprecated skills that moved out of this branch's scope:
add-compact, add-gmail, add-image-vision, add-pdf-reader, add-reactions,
add-telegram-swarm, add-voice-transcription, channel-formatting,
use-local-whisper.

Preserves channels-branch code: all 20 channel adapters (discord, slack,
telegram, whatsapp, wechat, matrix, emacs, iMessage, github, linear, teams,
gchat, webex, resend, whatsapp-cloud + helpers) plus chat-sdk deps.

Conflicts resolved:
- package.json: combined channels' adapter deps with v2's telegram bump
  (^4.24.0 → 4.26.0) and new @clack/prompts + kleur.
- pnpm-lock.yaml: regenerated from v2 baseline via pnpm install.
- setup/pair-telegram.ts: took v2's version (new PAIR_TELEGRAM_CODE block
  protocol; channels' older PAIR_TELEGRAM_ISSUED design superseded).

Note: host TS build will fail on adapter type drift (adapter.ts renamed
InboundMessage → InboundEvent; chat-sdk-bridge.ts rewritten). Fix in
follow-up commits.
2026-04-22 11:56:30 +03:00
Gabi Simons fb2790a5d5 feat(channels/wechat): personal WeChat adapter via Tencent iLink Bot API
Native adapter (no Chat SDK bridge) for personal WeChat, using
Tencent's official iLink Bot API at ilinkai.weixin.qq.com — the same
protocol @tencent-weixin/openclaw-weixin uses. No webhook, no ban
risk, no paid tokens.

Lifecycle:
- Factory gated on WECHAT_ENABLED=true in .env.
- On setup, resume from data/wechat/auth.json if present; otherwise
  run QR login (URL written to data/wechat/qr.txt and logged) and
  persist botToken, accountId, baseUrl, operatorUserId on success.
- Long-poll via WeChatClient.start() with sync-buf persistence so
  no messages are dropped across restarts.
- Inbound routes to setupConfig.onInbound with platform_id =
  wechat:<from_user_id|group_id> and a log hint pointing at the
  /add-wechat wire-dm.ts helper for post-login wiring.
- Outbound via sendText (context_token auto-cached by the client).

Region-restricted to mainland 微信 accounts — the iLink QR flow
doesn't complete from international WeChat clients. This is a
platform-side restriction, not an adapter bug.

Pairs with the /add-wechat skill on v2 (installs this file, adds
the self-registration import on the user's install, pins
wechat-ilink-client@0.1.0).

Addresses https://github.com/qwibitai/nanoclaw/issues/1901.

Co-Authored-By: ythx-101 <226337373+ythx-101@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 17:24:56 +00:00
Gabi Simons 74c9c9e27a feat(channels): extend Matrix adapter with DM user-handle resolution
Self-contained — no changes to shared src. All Matrix quirks handled
in this file:

- Access-token or username/password auth via env (adapter reads
  process.env directly)
- Resolves DM user handles (matrix:@user:server) to room IDs on
  outbound via adapter.openDM; rewrites inbound room IDs back to
  user handles so the router matches the messaging group wired at
  init time
- Synchronous isDM() based on room member count (Chat SDK requires
  it; the upstream adapter only has async isDirectRoom)
- Prefixes senderId with "matrix:" so permissions module matches
  init-first-agent's channel-prefixed user IDs
- Awaits liveSyncReady before returning from setup() — prevents
  host delivery polls from starving the SDK's sync generator
  microtask queue
- Defaults MATRIX_INVITE_AUTOJOIN=true so DMs work without manual
  joins
2026-04-20 11:03:38 +00:00
Gabi Simons 91400f9f66 fix(channels/linear): OAuth app auth, userName, team-based channel ID, catchAll
Support client credentials auth (LINEAR_CLIENT_ID/SECRET) alongside
personal API key. Pass userName from env for self-message detection.
Override channelIdFromThreadId to use LINEAR_TEAM_KEY instead of
per-issue UUIDs. Enable catchAll for platforms where @-mention isn't
possible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:38 +00:00
Gabi Simons 46c8829f2f fix(channels/github): pass userName for @-mention detection
The Chat SDK adapter defaults userName to "github-bot" and only
auto-detects botUserId during initialize(), not userName. This
causes mention detection to fail — the SDK looks for @github-bot
instead of the actual bot account name. Read GITHUB_BOT_USERNAME
from env and pass it through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:47:19 +00:00
gavrielc 12f50281c2 Merge remote-tracking branch 'origin/v2' into channels-sync
# Conflicts:
#	src/channels/index.ts
2026-04-18 22:06:44 +03:00
gavrielc 100e556ee9 fix(channels/telegram): update user/user-roles import paths after PR #5
PR #5 moved src/db/users.ts and src/db/user-roles.ts into the permissions
module. The channels branch's telegram adapter still imported from the
old paths — update to src/modules/permissions/db/*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:15:28 +03:00
gavrielc 2444ab171f Merge branch 'v2' into channels 2026-04-18 19:14:00 +03:00
gavrielc 09a3b48dae Merge remote-tracking branch 'origin/v2' into channels 2026-04-18 15:58:47 +03:00
gavrielc cec6768f4b fix(channels): restore channel-specific setup scripts
Same failure mode as 303a5c7 — the Phase 1 v2 sync re-applied v2's
deletion of setup/groups.ts, setup/pair-telegram.ts, and
setup/whatsapp-auth.ts (v2 commit 437ba63 moved them off trunk because
they're channel-specific).

The /add-telegram and /add-whatsapp skills explicitly do
`git show origin/channels:setup/<file> > setup/<file>` on install, so
these files must exist on the channels branch.

Restored:
- setup/groups.ts (whatsapp group-sync)
- setup/pair-telegram.ts (telegram pairing)
- setup/whatsapp-auth.ts (whatsapp auth)
- STEPS entries in setup/index.ts: 'groups', 'pair-telegram',
  'whatsapp-auth'

Verified providers branch separately — no similar losses there
(channel adapters on providers were obsolete duplicates that correctly
got removed; opencode files are intact per dd53875).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:44:21 +03:00
gavrielc 303a5c7100 fix(channels): restore channel adapters deleted during v2 sync
Phase 1 boundary sync (5454bae) inadvertently re-applied v2's channel-
adapter deletions (v2 commit 437ba63 "move channel adapters off v2
trunk") to the channels branch. 17 adapter files and their package.json
deps were wiped:

- discord, gchat, github, imessage, linear, matrix, resend, slack,
  teams, telegram + telegram-markdown-sanitize + telegram-pairing,
  webex, whatsapp, whatsapp-cloud
- @chat-adapter/* packages, @whiskeysockets/baileys, @resend/...,
  qrcode, pino, chat-adapter-imessage, @beeper/...

Caught when testing PR #3 — the service had no channels to bind to.

Root cause: the sync merge commit message ("No channel adapter changes
required") was wrong. I checked the registry surface but not file
presence. Providers had the same failure mode during its sync, but
there it surfaced immediately via a test import; channels has no test
that imports adapter files directly, so it slipped through.

Fix: restore src/channels/*.ts and the matching package.json /
pnpm-lock.yaml entries from 0d75ca2 (last pre-sync commit). Tests pass
(198/198 vs 137/137 pre-restore — the restored telegram-pairing and
markdown-sanitize tests are back).

Going forward: channel/provider branches that carry files v2 has
deleted need `git checkout origin/<branch> -- <paths>` applied after
any v2 sync merge that touches those paths, or a merge strategy that
ignores deletions under the branch-owned directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:38:59 +03:00
gavrielc 5454bae426 chore: sync channels branch with v2 (through PR #2)
Merge v2 → channels. Picks up v1 deletion and the module-registry
scaffolding (PR #1, PR #2). Resolves src/channels/index.ts by keeping
the full channel import list — channels branch is the fully-loaded
runnable branch.

No channel adapter changes required: the scaffolding only added new
registries with empty defaults. Existing `registerChannelAdapter()` /
`ChannelAdapter` interface is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:53:48 +03:00
gavrielc 0d75ca26f4 feat(channels): add emacs channel adapter
Native HTTP bridge on 127.0.0.1: POST /api/message fires onInbound,
GET /api/messages serves an outbound ring buffer. Single-user, single-chat
(platform_id = "default"); gated by EMACS_ENABLED. No threads, no cold DM.

Ships emacs/nanoclaw.el unchanged from v1 — the HTTP protocol is identical,
so the existing client works against the v2 adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:26:44 +03:00
gavrielc fbd8af618d fix(channels): drop @chat-adapter/shared dep in registry
channel-registry.ts imported NetworkError from a package that wasn't
declared as a direct dep, so tests blew up with ERR_MODULE_NOT_FOUND on
fresh installs. Mirrors the duck-type fix already on v2 trunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:26:27 +03:00
263 changed files with 11961 additions and 13225 deletions
+3 -3
View File
@@ -140,7 +140,7 @@ After accepting, DeltaChat exchanges keys and creates the chat automatically.
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 \
sqlite3 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"
```
@@ -226,7 +226,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
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'"`
4. Verify the messaging group is wired: `sqlite3 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
@@ -248,7 +248,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -20
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'"
sqlite3 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`.
+2 -2
View File
@@ -37,7 +37,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -10
## 4. Check messaging group was created
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
sqlite3 data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
@@ -48,7 +48,7 @@ If a row appears, the inbound routing is working. If not, the adapter isn't rece
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.
+1 -1
View File
@@ -44,7 +44,7 @@ import './discord.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/discord@4.27.0
pnpm install @chat-adapter/discord@4.26.0
```
### 5. Build
+2 -2
View File
@@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
### No response from agent
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
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'"`
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'"`
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.
@@ -292,5 +292,5 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
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';"
```
+1 -1
View File
@@ -44,7 +44,7 @@ import './gchat.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/gchat@4.27.0
pnpm install @chat-adapter/gchat@4.26.0
```
### 5. Build
+1 -1
View File
@@ -48,7 +48,7 @@ import './github.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/github@4.27.0
pnpm install @chat-adapter/github@4.26.0
```
### 5. Build
+5 -8
View File
@@ -82,14 +82,11 @@ For each target agent group, confirm OneCLI will inject Gmail secrets into its c
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):
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:
```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>
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
```
## Phase 2: Apply Code Changes
@@ -228,5 +225,5 @@ Common signals:
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/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.
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
+30 -3
View File
@@ -71,11 +71,38 @@ AskUserQuestion: "Want periodic wiki health checks?"
2. **Monthly**
3. **Skip** — lint manually
If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation.
## Step 6: Restart
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:
```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
```
+1 -1
View File
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.27.0
pnpm install @chat-adapter/linear@4.26.0
```
### 6. Build
-208
View File
@@ -1,208 +0,0 @@
---
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 only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
Check your provider:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
```
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
## Phase 1: Pre-flight
### Check if already applied
```bash
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
```
If already applied, skip 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 (Claude Code path)
### 1. Dockerfile — install mnemon binary
Add after the AWS CLI block, before the Bun runtime section:
```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. No extra volume mounts needed.
### 2. Entrypoint — run mnemon setup on each container start
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
```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. 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
```bash
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # 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.
## Phase 2 (OpenCode path) — context injection
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
Then rebuild: `./container/build.sh`
### Verify (OpenCode)
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
```bash
# Also confirm the binary is present in the image:
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Memory Storage
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
```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.
## Migration Guide Update
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
**Dockerfile — after AWS CLI, before Bun runtime:**
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
**`container/entrypoint.sh` — add after `set -e`:**
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
## Troubleshooting
### `mnemon: command not found` in container
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
```
+2 -2
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: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"`
1. **Which agent group?** List available groups: `sqlite3 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=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
```
+1 -1
View File
@@ -54,7 +54,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
+4 -7
View File
@@ -132,16 +132,13 @@ 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:
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
```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"
# Find the agent id and secret id, then:
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
```
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
#### Example: DeepSeek
```env
+1 -1
View File
@@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured`
- Check agent-runner logs for "Parallel AI MCP servers configured" message
**Task polling not working:**
- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"`
- 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
+4 -9
View File
@@ -200,7 +200,7 @@ systemctl --user restart nanoclaw
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 \
sqlite3 data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
@@ -212,7 +212,7 @@ Add the Signal number to a group from your phone, send any message, then wire th
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
sqlite3 data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
@@ -226,7 +226,7 @@ New Signal users (including the owner's Signal identity) are silently dropped wi
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
sqlite3 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)
@@ -282,13 +282,8 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA
### 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'"`
2. 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='signal'"`
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (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
+3 -9
View File
@@ -44,7 +44,7 @@ import './slack.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/slack@4.27.0
pnpm install @chat-adapter/slack@4.26.0
```
### 5. Build
@@ -60,7 +60,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions: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,13 +76,7 @@ pnpm run build
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
### 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
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Configure environment
+1 -1
View File
@@ -44,7 +44,7 @@ import './teams.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/teams@4.27.0
pnpm install @chat-adapter/teams@4.26.0
```
### 5. Build
+1 -1
View File
@@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/telegram@4.27.0
pnpm install @chat-adapter/telegram@4.26.0
```
### 6. Build
+6 -6
View File
@@ -90,12 +90,12 @@ 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
# 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"
# 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"
done
```
+1 -1
View File
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/whatsapp@4.27.0
pnpm install @chat-adapter/whatsapp@4.26.0
```
### 5. Build
+2 -2
View File
@@ -200,7 +200,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 `pnpm exec tsx scripts/q.ts 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 `sqlite3 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.
@@ -256,7 +256,7 @@ systemctl --user start nanoclaw
1. Auth exists: `test -f store/auth/creds.json`
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
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`
### "conflict" disconnection
@@ -58,7 +58,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
+2 -45
View File
@@ -57,50 +57,7 @@ Debug level shows:
## Common Issues
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
```
WARN No adapter for channel type channelType="telegram"
WARN No adapter for channel type channelType="signal"
```
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
**Root cause: two NanoClaw service instances running simultaneously.**
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
**Diagnosis:**
```bash
# Check for duplicate running instances
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
# Check which services are active
systemctl --user list-units 'nanoclaw*' --all
# Confirm channel adapters registered by the current process
grep "Channel adapter started" logs/nanoclaw.log | tail -10
```
**Fix:**
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
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`
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
### 2. "Claude Code process exited with code 1"
### 1. "Claude Code process exited with code 1"
**Check the container log file** in `groups/{folder}/logs/container-*.log`
@@ -322,7 +279,7 @@ rm -rf data/sessions/
rm -rf data/sessions/{groupFolder}/.claude/
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
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:
+2 -2
View File
@@ -54,7 +54,7 @@ Tell the user:
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
```bash
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"
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"
```
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,7 +103,7 @@ Wait for the user's reply. If they confirm receipt, the skill is done.
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
- `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.
- `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.
-35
View File
@@ -259,41 +259,6 @@ 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.
+1 -16
View File
@@ -11,22 +11,7 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user
## Assess Current State
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.
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.
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
-232
View File
@@ -1,232 +0,0 @@
---
name: migrate-from-v1
description: Finish migrating a NanoClaw v1 install into v2. Run after `bash migrate-v2.sh` completes. Seeds the owner, cleans up CLAUDE.local.md files, reconciles container configs, and helps port custom v1 code. Triggers on "migrate from v1", "finish migration", "v1 migration".
---
# Finish v1 → v2 migration
`bash migrate-v2.sh` already ran the deterministic migration. It handled:
- .env keys merged
- v2 DB seeded (agent_groups, messaging_groups, wiring)
- Group folders copied (v1 CLAUDE.md → v2 CLAUDE.local.md)
- Session data copied with conversation continuity (incl. Claude Code memory + JSONL transcripts)
- Scheduled tasks ported
- Channel code installed and auth state copied (incl. WhatsApp Baileys keystore)
- WhatsApp LIDs resolved from `store/auth` and aliased into `messaging_groups`
- Container skills copied
- Container image built
Your job is the parts that need human judgment: triage any failed steps, seed the owner, clean up CLAUDE.local.md files, reconcile configs, and port any fork customizations.
Read `logs/setup-migration/handoff.json` first — it has `overall_status`, per-step results in `steps`, and a `followups` list.
## Preflight: was the script run?
Before anything else, check that `logs/setup-migration/handoff.json` exists. If it doesn't, the user is invoking this skill before `migrate-v2.sh` ran. Stop and tell them, verbatim:
> This skill finishes a migration that `migrate-v2.sh` started. Run that first, in your terminal — not from inside Claude:
>
> ```bash
> bash migrate-v2.sh
> ```
>
> It needs interactive prompts (channel selection, service switchover) and runs Node/pnpm bootstrap, Docker, OneCLI setup, and a container build that don't fit inside a Claude session. When it finishes, it'll hand control back to Claude automatically — at which point this skill picks up.
Do not attempt to run the script yourself, simulate its effects, or pick up the migration mid-stream. The deterministic side has dependencies on a real interactive shell.
Once `handoff.json` exists, proceed to Phase 0.
## Phase 0: Get v2 routing real messages
Before any deeper migration work, prove v2 actually answers messages on the user's real channels. v1 is paused, not touched — flipping back is a service restart.
### 0a — Fix blockers only
Walk `handoff.steps`. Fix only the failures that would stop the bot from routing one message; defer the rest to its later phase.
### 0b — Smoke test, then continue
Tell the user the switch is non-destructive (v1 is paused, not modified; reverting is one command). Help them stop v1's service unit and start v2's, tail the host log for a clean boot, and have them send a real test message. Use `AskUserQuestion` to confirm the bot responded.
If yes, continue to Phase 1. If no, diagnose from `logs/nanoclaw.log` and re-test — don't proceed to deeper work on a broken router.
### Deferred failures
Re-visit anything you skipped in 0a before declaring the migration done. Most surface naturally in later phases (`1c-groups` ↔ Phase 2, `1e-tasks` ↔ task verification).
## Phase 1: Owner and access
v2 auto-creates a `users` row for every sender it sees (via `extractAndUpsertUser` in `src/modules/permissions/index.ts`). By the time this skill runs, the owner's row likely already exists — it just needs the `owner` role granted.
**User ID format**: always `<channel_type>:<platform_handle>`. Each channel populates this differently:
- **Telegram**: `telegram:<numeric_user_id>` (e.g. `telegram:6037840640`)
- **Discord**: `discord:<snowflake_user_id>` (e.g. `discord:123456789012345678`)
- **WhatsApp**: `whatsapp:<phone>@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`)
- **Slack**: `slack:<user_id>` (e.g. `slack:U04ABCDEF`)
- **Others**: `<channel_type>:<platform_id>`
**Steps:**
1. Query `users` table: `SELECT id, kind, display_name FROM users`.
2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `<display_name>` (`<id>`) you?" — Yes / No, let me type it.
3. If multiple users exist, present them as options in `AskUserQuestion`.
4. If no users exist yet (service hasn't received a message), ask the user to send a test message first, then re-query.
5. Once confirmed, check `user_roles` — if the owner role already exists, skip. Otherwise insert:
```sql
INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('<user_id>', 'owner', NULL, NULL, datetime('now'))
```
Use the DB helpers in `src/db/user-roles.ts` — they keep indexes correct. Init the DB first:
```ts
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { DATA_DIR } from '../src/config.js';
import path from 'path';
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
```
### Access policy
After seeding the owner, discuss the access policy. v2's `messaging_groups.unknown_sender_policy` controls who can interact with the bot. `migrate-v2.sh` set it to `public` so the bot would respond during the switchover test, but the user may want to tighten it.
Present the options via `AskUserQuestion`:
1. **Public** (current) — anyone can message the bot. Good for personal DM bots.
2. **Known users only** — only users in `agent_group_members` can trigger the bot. Others are silently dropped.
3. **Approval required** — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members.
If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at `<handoff.v1_path>/store/messages.db`. It has a `messages` table with `sender` and `sender_name` columns. For each group:
```sql
-- v1: unique senders per chat (excluding bot messages)
SELECT DISTINCT sender, sender_name
FROM messages
WHERE chat_jid = '<v1_jid>' AND is_from_me = 0 AND sender IS NOT NULL
```
The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v2/shared.ts`) and combining: `<channel_type>:<sender>`.
For each sender:
1. Upsert into `users(id, kind, display_name)` if not already present.
2. Insert into `agent_group_members(user_id, agent_group_id)` for each agent group wired to that messaging group.
Show the user the list of senders being imported and let them deselect any they don't want.
Then update the messaging groups:
```sql
UPDATE messaging_groups SET unknown_sender_policy = '<chosen_policy>'
WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN (<migrated_channels>))
```
## Phase 2: Clean up CLAUDE.local.md
The migration copied v1's entire CLAUDE.md into CLAUDE.local.md for each group. This file now contains v1 boilerplate that v2 handles through its own composed fragments (`container/CLAUDE.md` + `.claude-fragments/module-*.md`). The user's customizations are buried inside.
For each group that has a `CLAUDE.local.md`:
1. Read the file.
2. Read the v1 template it was based on. Determine which template by checking the v1 install:
- If the group had `is_main=1` in v1's `registered_groups`, the template was `groups/main/CLAUDE.md`
- Otherwise, the template was `groups/global/CLAUDE.md`
- The v1 path is in `handoff.json``v1_path`
3. Diff the file against the template. Identify sections that are:
- **Stock boilerplate** (identical to template) — remove. v2's fragments cover this.
- **User customizations** (added sections, modified sections) — keep.
4. The following v1 sections are now handled by v2 fragments and should be removed even if slightly modified:
- "What You Can Do" → v2 runtime system prompt
- "Communication" / "Internal thoughts" / "Sub-agents" → `container/CLAUDE.md` + `module-core.md`
- "Your Workspace" / workspace path references → `container/CLAUDE.md`
- "Memory" (the stock version) → `container/CLAUDE.md`
- "Message Formatting" → `container/CLAUDE.md`
- "Admin Context" → v2 uses `user_roles`, not is_main
- "Authentication" → v2 uses OneCLI
- "Container Mounts" → v2 mounts are different
- "Managing Groups" / "Finding Available Groups" / "Registered Groups Config" → v2 entity model, no IPC
- "Global Memory" → v2 has `.claude-shared.md` symlink
- "Scheduling for Other Groups" → `module-scheduling.md`
- "Task Scripts" → `module-scheduling.md`
- "Sender Allowlist" → v2 uses `unknown_sender_policy` + `user_roles`
5. Fix path references in kept sections:
- `/workspace/group/``/workspace/agent/`
- `/workspace/project/` → these paths don't exist in v2; discuss with the user
- `/workspace/ipc/` → gone; remove references
- `/workspace/extra/` → v2 uses `container.json` `additionalMounts`; keep but note the path may change
6. Keep the `# Name` heading and first paragraph (identity) — this is the user's agent personality.
7. Show the user the proposed new CLAUDE.local.md before writing it. Use `AskUserQuestion`: "Here's what I'd keep — look right?" with options to approve, edit, or keep the original.
If a CLAUDE.local.md has no user customizations (pure template copy), write a minimal file with just the identity heading.
## Phase 3: Container config
`migrate-v2.sh` writes `container.json` directly from v1's `container_config` (the `additionalMounts` shape is identical). If the v1 config was unparseable, it falls back to a `.v1-container-config.json` sidecar.
For each group, check:
1. If `container.json` exists, read it and verify the `additionalMounts` host paths are still valid on this machine. Flag any that don't exist.
2. If `.v1-container-config.json` exists (parse failure fallback), read it, discuss with the user, and write a proper `container.json`. Then delete the sidecar.
3. Check for `env` or `packages` fields — `env` may overlap with OneCLI vault, `packages` (apt/npm) are portable.
## Phase 4: Fork customizations
Check whether the user's v1 install was a customized fork.
```bash
cd <v1_path>
git remote -v
git log --oneline <upstream>/main..HEAD 2>/dev/null
```
If no commits ahead of upstream: stock v1, skip this phase.
If there are commits:
1. Show the commit list to the user.
2. `AskUserQuestion`: "How do you want to handle your v1 customizations?"
- **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v2/shared.ts`.
- **Full walkthrough** — go commit by commit, decide together.
- **Reference only** — stash to `docs/v1-fork-reference/` for later.
3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate.
## Principles
- **v1 checkout is read-only.** Never modify files under `handoff.v1_path`.
- **Show before writing.** Show diffs/proposed content before modifying CLAUDE.local.md or container.json.
- **Mask credentials** when displaying (first 4 + `...` + last 4 characters).
- **`handoff.json` is the recovery point.** If context gets compacted, re-read it and `git status` to recover state.
## Setup steps you can run
The setup flow at `setup/index.ts` has individual steps you can invoke if something is missing or failed:
```bash
pnpm exec tsx setup/index.ts --step <name>
```
| Step | When to use |
|------|-------------|
| `onecli` | OneCLI not installed or not healthy |
| `auth` | No Anthropic credential in vault |
| `container` | Container image needs rebuild |
| `service` | Service not installed or not running |
| `mounts` | Mount allowlist missing |
| `verify` | End-to-end health check (run after everything else) |
| `environment` | System check (Node, dirs) |
## When done
1. Run the verify step to confirm everything works:
```bash
pnpm exec tsx setup/index.ts --step verify
```
2. Delete `logs/setup-migration/handoff.json` — offer to save as `docs/migration-<date>.md` first.
3. Restart the service if running so changes take effect:
```bash
# Linux
systemctl --user restart nanoclaw-v2-*
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-*
```
+1 -1
View File
@@ -34,7 +34,7 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/nanocoai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
+15 -54
View File
@@ -11,15 +11,14 @@ Run `/update-nanoclaw` in Claude Code.
## How it works
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/nanocoai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.
**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories:
- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill
- **Host source** (`src/`): may conflict if you modified the same files
- **Container** (`container/`): triggers container rebuild
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Source** (`src/`): may conflict if you modified the same files
- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed
**Update paths** (you pick one):
- `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass.
@@ -31,7 +30,7 @@ Run `/update-nanoclaw` in Claude Code.
**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact.
**Validation**: runs `pnpm run build` and `pnpm test`. If container files changed, also runs the container typecheck and `./container/build.sh`.
**Validation**: runs `pnpm run build` and `pnpm test`.
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.
@@ -69,7 +68,7 @@ If output is non-empty:
Confirm remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Add it: `git remote add upstream <user-provided-url>`
- Then: `git fetch upstream --prune`
@@ -109,10 +108,9 @@ Show file-level impact from upstream:
Bucket the upstream changed files:
- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
- **Host source** (`src/`): may conflict if user modified the same files
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Other**: docs, tests, setup scripts, misc
- **Source** (`src/`): may conflict if user modified the same files
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`, `container/`, `launchd/`): review needed
- **Other**: docs, tests, misc
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
@@ -175,31 +173,11 @@ If it gets messy (more than 3 rounds of conflicts):
- `git rebase --abort`
- Recommend merge instead.
# Step 4.5: Install dependencies (if lockfiles changed)
Check if the merge changed any lockfiles or package manifests:
- `git diff <backup-tag-from-step-1>..HEAD --name-only | grep -E '^(pnpm-lock\.yaml|package\.json)$'`
- If matched: `pnpm install`
- `git diff <backup-tag-from-step-1>..HEAD --name-only | grep -E '^container/agent-runner/(bun\.lock|package\.json)$'`
- If matched AND `command -v bun` succeeds: `cd container/agent-runner && bun install`
- If bun is not installed on the host, skip — container deps will be installed during `./container/build.sh`
Skip this step if neither lockfile changed.
# Step 5: Validation
Check which areas changed to determine what to validate:
- `CHANGED_FILES=$(git diff --name-only <backup-tag-from-step-1>..HEAD)`
**Host build** (always):
Run:
- `pnpm run build`
- `pnpm test` (do not fail the flow if tests are not configured)
**Container typecheck** (only if `container/agent-runner/src/` files are in CHANGED_FILES AND bun types are available):
- Check: `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`
- If this fails because bun types are missing (`Cannot find type definition file for 'bun'`), skip with a note — type errors will surface at container runtime instead
**Container image rebuild** (only if any `container/` files are in CHANGED_FILES):
- `./container/build.sh`
If build fails:
- Show the error.
- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
@@ -231,10 +209,8 @@ If one or more `[BREAKING]` lines are found:
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
# Step 7: Check for skill and channel/provider updates
## 7a: Skill branches
Check if skills are distributed as branches in this repo:
# Step 7: Check for skill updates
After the summary, check if skills are distributed as branches in this repo:
- `git branch -r --list 'upstream/skill/*'`
If any `upstream/skill/*` branches exist:
@@ -242,21 +218,7 @@ If any `upstream/skill/*` branches exist:
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
- If user selects yes, invoke `/update-skills` using the Skill tool.
## 7b: Channel and provider updates
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
- List the installed channels/providers.
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
- "Skip — I'll update them later"
- Set `multiSelect: true`
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
If no channels/providers are installed, skip silently.
Proceed to Step 8.
- After the skill completes (or if user selected no), proceed to Step 8.
# Step 8: Summary + rollback instructions
Show:
@@ -270,10 +232,9 @@ Show:
Tell the user:
- To rollback: `git reset --hard <backup-tag-from-step-1>`
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
- Restart the service to apply changes. Detect platform with `uname -s`:
- **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- **Linux**: detect the service name with `systemctl --user list-units --type=service | grep nanoclaw | awk '{print $1}'`, then `systemctl --user restart <detected-name>`
- **Manual** (no service found): restart `pnpm run dev`
- Restart the service to apply changes:
- If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
- If running manually: restart `pnpm run dev`
## Diagnostics
+1 -1
View File
@@ -42,7 +42,7 @@ Check remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- `git remote add upstream <url>`
Fetch:
@@ -40,7 +40,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
bump-version:
if: github.repository == 'nanocoai/nanoclaw'
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
update-tokens:
if: github.repository == 'nanocoai/nanoclaw'
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
-4
View File
@@ -1,5 +1 @@
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
pnpm run format:fix
if [ -n "$staged" ]; then
echo "$staged" | xargs git add
fi
-17
View File
@@ -4,23 +4,6 @@ All notable changes to NanoClaw will be documented in this file.
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
## [2.0.54] - 2026-05-10
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
## [2.0.48] - 2026-05-09
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
## [2.0.45] - 2026-05-08
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md``CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
## [2.0.0] - 2026-04-22
Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work.
+8 -85
View File
@@ -6,8 +6,8 @@ This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It ca
**Do this instead:**
1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed).
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Exit Claude Code (or open a separate terminal) and run `bash migrate-v2.sh` from the shell."*
3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and cannot be run from within Claude Code.
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."*
3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself.
If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below.
@@ -53,8 +53,6 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts <db> "<sql>"`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically.
## Key Files
| File | Purpose |
@@ -72,43 +70,13 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
| `container/skills/` | Container skills mounted into every agent session |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
## Admin CLI (`ncl`)
`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`).
```
ncl <resource> <verb> [<id>] [--flags]
ncl <resource> help
ncl help
```
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| sessions | list, get | Active sessions (read-only) |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
## Channels and Providers (skill-installed)
@@ -123,35 +91,13 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
One tier of agent self-modification today:
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`.
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`.
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
## Container Config
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
## Container Restart
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
## Secrets / Credentials / OneCLI
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
### Gotcha: auto-created agents start in `selective` secret mode
@@ -195,7 +141,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`).
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
| Skill | When to Use |
|-------|-------------|
@@ -211,17 +157,6 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
## PR Hygiene
Before creating a PR, run these checks:
```bash
git diff upstream/main --stat HEAD
git log upstream/main..HEAD --oneline
```
Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
## Development
Run commands directly — don't tell the user to run them.
@@ -251,17 +186,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
systemctl --user start|stop|restart nanoclaw
```
## Troubleshooting
Check these first when something goes wrong:
| What | Where |
|------|-------|
| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain |
| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) |
| Session DBs | `data/v2-sessions/<agent-group>/<session>/``inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) |
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here).
## Supply Chain Security (pnpm)
@@ -286,8 +211,6 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
| [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow |
| [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture |
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
## Container Build Cache
+4 -5
View File
@@ -4,8 +4,8 @@
1. **Check for existing work.** Search open PRs and issues before starting:
```bash
gh pr list --repo nanocoai/nanoclaw --search "<your feature>"
gh issue list --repo nanocoai/nanoclaw --search "<your feature>"
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
```
If a related PR or issue exists, build on it rather than duplicating effort.
@@ -43,7 +43,7 @@ Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setu
3. Claude walks through interactive setup (env vars, bot creation, etc.)
**Contributing a feature skill:**
1. Fork `nanocoai/nanoclaw` and branch from `main`
1. Fork `qwibitai/nanoclaw` and branch from `main`
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
4. Open a PR. We'll create the `skill/<name>` branch from your work
@@ -123,8 +123,7 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
| Checkbox | Label |
|----------|-------|
-1
View File
@@ -16,7 +16,6 @@ Thanks to everyone who has contributed to NanoClaw!
- [flobo3](https://github.com/flobo3) — Flo
- [edwinwzhe](https://github.com/edwinwzhe) — Edwin He
- [scottgl9](https://github.com/scottgl9) — Scott Glover
- [ingyukoh](https://github.com/ingyukoh) — Ingyu Koh
- [cschmidt](https://github.com/cschmidt) — Carl Schmidt
- [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler
- [moktamd](https://github.com/moktamd)
+1 -26
View File
@@ -26,36 +26,13 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
## Quick Start
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.
<details>
<summary><strong>Migrating from NanoClaw v1?</strong></summary>
Run from a fresh v2 checkout next to your v1 install:
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
`migrate-v2.sh` finds your v1 install (sibling directory, or `NANOCLAW_V1_PATH=/path/to/nanoclaw`), migrates state into the v2 checkout, then `exec`s into Claude Code to finish the parts that need judgment (owner seeding, CLAUDE.local.md cleanup, fork-customisation replay).
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different and [docs/migration-dev.md](docs/migration-dev.md) for development notes.
</details>
## Philosophy
**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.
@@ -215,5 +192,3 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist
## License
MIT
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClawは同じコア機能を提供しますが、理解できる規模のコ
## クイックスタート
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能
## 快速开始
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
-30
View File
@@ -1,30 +0,0 @@
⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⢹⡆⢸⡆⠀ °
⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀
⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧
o ⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿
⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇
⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o
⢠⣿⡃⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀
° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀
⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀
⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀ O
⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀
⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀
⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀
o ⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀
⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀
 _ _  ___ _ 
| \| |__ _ _ _ ___  / __| |__ ___ __ __
| .` / _` | ' \/ _ \| (__| / _` \ V V /
|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/ 
Small.
Runs on your machine.
Yours to modify.
════════════════════════════════════════
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
#
# ncl — NanoClaw CLI launcher.
#
# Resolves the project root from this script's location, cd's there so the
# host-resolved DATA_DIR matches the running host, and execs the TS entry
# via tsx. Symlink this file into a directory on your PATH (or alias `ncl`
# to its full path) to invoke from anywhere:
#
# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl
# # or
# alias ncl="$(pwd)/bin/ncl"
set -euo pipefail
SCRIPT="${BASH_SOURCE[0]}"
# Resolve symlinks so PROJECT_ROOT points at the real checkout.
while [ -h "$SCRIPT" ]; do
DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
SCRIPT="$(readlink "$SCRIPT")"
[[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT"
done
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
exec pnpm exec tsx src/cli/client.ts "$@"
+3 -14
View File
@@ -19,9 +19,9 @@ ARG INSTALL_CJK_FONTS=false
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.128
ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG VERCEL_VERSION=latest
ARG BUN_VERSION=1.3.12
# ---- System dependencies -----------------------------------------------------
@@ -91,13 +91,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
# the SDK fails at spawn time with "native binary not found".
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped
# honoring `only-built-dependencies[]=` in .npmrc for global installs, which
# silently skips claude-code's native-binary postinstall and agent-browser's
# bin chmod — the agent then crashes at runtime with "native binary not
# installed". Keep this in lockstep with package.json's `packageManager`.
ARG PNPM_VERSION=10.33.0
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
RUN corepack enable
RUN --mount=type=cache,target=/root/.cache/pnpm \
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
@@ -110,11 +104,6 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
# ---- ncl CLI wrapper ----------------------------------------------------------
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \
chmod +x /usr/local/bin/ncl
# ---- Entrypoint --------------------------------------------------------------
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
+10 -10
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
@@ -18,23 +18,23 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.138", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
-254
View File
@@ -1,254 +0,0 @@
#!/usr/bin/env bun
/**
* ncl NanoClaw CLI client (container edition).
*
* Same interface as the host-side `bin/ncl`. Detects that it's inside a
* container (the session DBs exist at /workspace/) and uses a DB transport
* instead of the Unix socket transport.
*
* Writes a cli_request system message to outbound.db, polls inbound.db
* for the response. Self-contained no imports from agent-runner.
*/
import { Database } from 'bun:sqlite';
// ---------------------------------------------------------------------------
// Frame types (mirrors src/cli/frame.ts on the host)
// ---------------------------------------------------------------------------
type RequestFrame = {
id: string;
command: string;
args: Record<string, unknown>;
};
type ResponseFrame =
| { id: string; ok: true; data: unknown }
| { id: string; ok: false; error: { code: string; message: string } };
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const INBOUND_DB = '/workspace/inbound.db';
const OUTBOUND_DB = '/workspace/outbound.db';
// ---------------------------------------------------------------------------
// DB transport
// ---------------------------------------------------------------------------
function generateId(): string {
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Write a cli_request to outbound.db.
*
* Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq),
* preventing seq collisions with concurrent agent-runner writes.
*/
function writeRequest(req: RequestFrame): void {
const db = new Database(OUTBOUND_DB);
db.exec('PRAGMA journal_mode = DELETE');
db.exec('PRAGMA busy_timeout = 5000');
const inDb = new Database(INBOUND_DB, { readonly: true });
inDb.exec('PRAGMA busy_timeout = 5000');
try {
db.exec('BEGIN IMMEDIATE');
const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
const max = Math.max(maxOut, maxIn);
const nextSeq = max % 2 === 0 ? max + 1 : max + 2;
db.prepare(
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
).run({
$id: req.id,
$seq: nextSeq,
$content: JSON.stringify({
action: 'cli_request',
requestId: req.id,
command: req.command,
args: req.args,
}),
});
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
} finally {
inDb.close();
db.close();
}
}
/**
* Poll inbound.db for a cli_response matching our requestId.
* Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility.
*/
function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const inDb = new Database(INBOUND_DB, { readonly: true });
inDb.exec('PRAGMA busy_timeout = 5000');
inDb.exec('PRAGMA mmap_size = 0');
try {
const row = inDb
.prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null;
if (row) {
// Mark as completed via processing_ack so agent-runner skips it
const outDb = new Database(OUTBOUND_DB);
outDb.exec('PRAGMA journal_mode = DELETE');
outDb.exec('PRAGMA busy_timeout = 5000');
outDb
.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
)
.run(row.id);
outDb.close();
const parsed = JSON.parse(row.content);
return parsed.frame as ResponseFrame;
}
} finally {
inDb.close();
}
Bun.sleepSync(500);
}
return null;
}
// ---------------------------------------------------------------------------
// Arg parsing (mirrors host-side client.ts)
// ---------------------------------------------------------------------------
function parseArgv(argv: string[]): {
command: string;
args: Record<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
let json = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--json') {
json = true;
continue;
}
if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
args[key] = true;
} else {
args[key] = next;
i++;
}
continue;
}
positional.push(a);
}
if (positional.length === 0) {
process.stderr.write('ncl: missing command\n');
printUsage();
process.exit(2);
}
// Join all positionals with dashes. The dispatcher trims the last
// segment as a target ID if the full name isn't a registered command.
const command = positional.join('-');
return { command, args, json };
}
function printUsage(): void {
process.stdout.write(
['Usage: ncl <command> [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'),
);
}
// ---------------------------------------------------------------------------
// Formatting (mirrors src/cli/format.ts on the host)
// ---------------------------------------------------------------------------
function formatHuman(resp: ResponseFrame): string {
if (!resp.ok) {
return `error (${resp.error.code}): ${resp.error.message}\n`;
}
const data = resp.data;
if (!Array.isArray(data) || data.length === 0) {
return JSON.stringify(data, null, 2) + '\n';
}
const isFlat = data.every(
(r) =>
typeof r === 'object' &&
r !== null &&
!Array.isArray(r) &&
Object.values(r as Record<string, unknown>).every((v) => typeof v !== 'object' || v === null),
);
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
const keys = Object.keys(data[0] as Record<string, unknown>);
const widths = keys.map((k) =>
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[k] ?? '').length)),
);
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
const rows = data.map((r) =>
keys
.map((k, i) => String((r as Record<string, unknown>)[k] ?? '').padEnd(widths[i]))
.join(' '),
);
return [header, sep, ...rows, ''].join('\n');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const argv = process.argv.slice(2);
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
printUsage();
process.exit(0);
}
const { command, args, json } = parseArgv(argv);
const requestId = generateId();
const req: RequestFrame = { id: requestId, command, args };
writeRequest(req);
const resp = pollResponse(requestId, 30_000);
if (!resp) {
process.stderr.write('ncl: command timed out after 30s\n');
process.exit(2);
}
if (json) {
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
} else {
const output = formatHuman(resp);
if (!resp.ok) {
process.stderr.write(output);
process.exit(1);
}
process.stdout.write(output);
}
@@ -1,34 +0,0 @@
/**
* PreCompact hook script outputs custom compaction instructions to stdout.
*
* Claude Code captures the stdout of PreCompact shell hooks and passes it
* as `customInstructions` to the compaction prompt. This ensures the
* compaction summary preserves message routing context that the agent needs
* to correctly address responses.
*
* Invoked by the PreCompact hook in .claude-shared/settings.json:
* "command": "bun /app/src/compact-instructions.ts"
*/
import { getAllDestinations } from './destinations.js';
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name);
const instructions = [
'Preserve the following in the compaction summary:',
'',
'1. For recent messages, keep the full XML structure including all attributes:',
' - <message from="..." sender="..." time="..."> for chat messages',
' - <task from="..." time="..."> for scheduled tasks',
' - <webhook from="..." source="..." event="..."> for webhooks',
' The message content can be summarized if long, but the XML tags and attributes must remain.',
'',
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. At the END of the compaction summary, include this verbatim reminder:',
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
];
console.log(instructions.join('\n'));
-4
View File
@@ -16,8 +16,6 @@ export interface RunnerConfig {
agentGroupId: string;
maxMessagesPerPrompt: number;
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
}
const DEFAULT_MAX_MESSAGES = 10;
@@ -45,8 +43,6 @@ export function loadConfig(): RunnerConfig {
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
};
return _config;
@@ -1,29 +0,0 @@
/**
* Per-batch context the poll loop publishes for downstream consumers
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
*
* Today the only field is `inReplyTo` the id of the first inbound
* message in the batch the agent is currently processing. MCP tools like
* `send_message` and `send_file` read this and stamp it onto the outbound
* row so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This is module-level state on purpose: the agent-runner is single-process
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
* before invoking the provider and `clearCurrentInReplyTo` after the batch
* completes (or errors out).
*/
let currentInReplyTo: string | null = null;
export function setCurrentInReplyTo(id: string | null): void {
currentInReplyTo = id;
}
export function clearCurrentInReplyTo(): void {
currentInReplyTo = null;
}
export function getCurrentInReplyTo(): string | null {
return currentInReplyTo;
}
+2 -39
View File
@@ -27,46 +27,12 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat';
let _inbound: Database | null = null;
let _outbound: Database | null = null;
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
let _testMode = false;
/**
* Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled.
*
* Use this (not getInboundDb) for readers that need to see host-written rows
* promptly e.g. messages_in polling. Caller must .close() the returned
* connection (try/finally).
*
* Needed for mounts where host writes don't reliably invalidate
* SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple
* Container), NFS.
*
* Cost is microseconds per query, so safe for universal use.
*/
export function openInboundDb(): Database {
// In test mode return a thin wrapper over the in-memory singleton.
// Callers do try/finally { db.close() } — the wrapper no-ops close()
// so the singleton survives for the rest of the test.
if (_testMode && _inbound) {
const db = _inbound;
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
}
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA mmap_size = 0');
return db;
}
/**
* Inbound DB long-lived singleton, OK for tables the host writes once
* at spawn and never again (destinations, session_routing). For
* messages_in polling where the host writes continuously and a stale
* view causes the pollHandle hang use `openInboundDb()` instead.
*/
/** Inbound DB — container opens read-only (host is the sole writer). */
export function getInboundDb(): Database {
if (!_inbound) {
_inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
_inbound.exec('PRAGMA busy_timeout = 5000');
_inbound.exec('PRAGMA mmap_size = 0');
}
return _inbound;
}
@@ -178,7 +144,6 @@ export function clearStaleProcessingAcks(): void {
/** For tests — creates in-memory DBs with the session schemas. */
export function initTestSessionDb(): { inbound: Database; outbound: Database } {
_testMode = true;
_inbound = new Database(':memory:');
_inbound.exec('PRAGMA foreign_keys = ON');
_inbound.exec(`
@@ -196,8 +161,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL,
on_wake INTEGER NOT NULL DEFAULT 0
content TEXT NOT NULL
);
CREATE TABLE delivered (
message_out_id TEXT PRIMARY KEY,
@@ -256,7 +220,6 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
export function closeSessionDb(): void {
_inbound?.close();
_inbound = null;
_testMode = false;
_outbound?.close();
_outbound = null;
}
+32 -60
View File
@@ -8,20 +8,7 @@
* processing_ack. The host reads processing_ack to sync message lifecycle.
*/
import { getConfig } from '../config.js';
import { openInboundDb, getOutboundDb } from './connection.js';
// Cache whether inbound.db has the on_wake column (added in v2.0.48).
// The container opens inbound.db read-only, so it can't ALTER —
// gracefully degrade when running against an older session DB.
let _hasOnWake: boolean | null = null;
function hasOnWakeColumn(db: ReturnType<typeof openInboundDb>): boolean {
if (_hasOnWake !== null) return _hasOnWake;
const cols = new Set(
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
);
_hasOnWake = cols.has('on_wake');
return _hasOnWake;
}
import { getInboundDb, getOutboundDb } from './connection.js';
export interface MessageInRow {
id: string;
@@ -62,38 +49,32 @@ function getMaxMessagesPerPrompt(): number {
* sees the prior context it missed. Host's countDueMessages gates waking on
* trigger=1 separately (see src/db/session-db.ts).
*/
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
const inbound = openInboundDb();
export function getPendingMessages(): MessageInRow[] {
const inbound = getInboundDb();
const outbound = getOutboundDb();
try {
const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : '';
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
${onWakeFilter}
ORDER BY seq DESC
LIMIT ?2`,
)
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
ORDER BY seq DESC
LIMIT ?`,
)
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
if (pending.length === 0) return [];
if (pending.length === 0) return [];
// Filter out messages already acknowledged in outbound.db
const ackedIds = new Set(
(outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
(r) => r.message_id,
),
);
// Filter out messages already acknowledged in outbound.db
const ackedIds = new Set(
(outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
(r) => r.message_id,
),
);
// Reverse: we fetched DESC to take the most recent N, but the agent
// should see them in chronological order (oldest first).
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
} finally {
inbound.close();
}
// Reverse: we fetched DESC to take the most recent N, but the agent
// should see them in chronological order (oldest first).
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
}
/** Mark messages as processing — writes to processing_ack in outbound.db. */
@@ -131,12 +112,7 @@ export function markFailed(id: string): void {
/** Get a message by ID (read from inbound.db). */
export function getMessageIn(id: string): MessageInRow | undefined {
const inbound = openInboundDb();
try {
return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
} finally {
inbound.close();
}
return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
}
/**
@@ -144,23 +120,19 @@ export function getMessageIn(id: string): MessageInRow | undefined {
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
*/
export function findQuestionResponse(questionId: string): MessageInRow | undefined {
const inbound = openInboundDb();
const inbound = getInboundDb();
const outbound = getOutboundDb();
try {
const response = inbound
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
const response = inbound
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
if (!response) return undefined;
if (!response) return undefined;
// Check it hasn't been acked already
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
if (acked) return undefined;
// Check it hasn't been acked already
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
if (acked) return undefined;
return response;
} finally {
inbound.close();
}
return response;
}
@@ -1,63 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js';
import { buildSystemPromptAddendum } from './destinations.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES (?, ?, 'channel', ?, ?, NULL)`,
)
.run(name, displayName, channelType, platformId);
}
describe('buildSystemPromptAddendum — multi-destination routing guidance', () => {
it('includes default-routing nudge when there are >1 destinations', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('from="name"');
expect(prompt).toContain('`casa`');
expect(prompt).toContain('`whatsapp-mg-17780`');
});
it('describes message wrapping for a single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('`casa`');
});
it('handles the no-destination case without crashing', () => {
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('no configured destinations');
expect(prompt).not.toContain('default to addressing');
});
it('includes default-routing and wrapping instructions for single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('`casa`');
});
});
+21 -16
View File
@@ -102,29 +102,34 @@ function buildDestinationsSection(): string {
].join('\n');
}
const lines = ['## Sending messages', ''];
// Single-destination shortcut: the agent just writes its response normally.
if (all.length === 1) {
const d = all[0];
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`Your destination is \`${d.name}\`${label}.`);
} else {
lines.push('You can send messages to the following destinations:', '');
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
}
return [
'## Sending messages',
'',
`Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`,
'',
'To mark something as scratchpad (logged but not sent), wrap it in `<internal>...</internal>`.',
'',
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.',
].join('\n');
}
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
}
lines.push('');
lines.push(
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
);
lines.push('To send a message, wrap it in a `<message to="name">...</message>` block.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
lines.push('');
lines.push(
'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `<message>` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").',
);
lines.push('');
lines.push(
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
);
return lines.join('\n');
}
+16 -37
View File
@@ -66,18 +66,6 @@ export function isClearCommand(msg: MessageInRow): boolean {
return text.toLowerCase().startsWith('/clear');
}
/**
* True for any chat that needs the outer loop's command path: /clear plus
* admin/passthrough slash commands the SDK can only dispatch when they are
* a query's first input. Used by the follow-up poller to bail out and let
* the outer loop reopen the query.
*/
export function isRunnerCommand(msg: MessageInRow): boolean {
if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') return false;
const cat = categorizeMessage(msg).category;
return cat === 'admin' || cat === 'passthrough';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractSenderId(msg: MessageInRow, content: any): string | null {
const raw: string | null = content?.senderId || content?.author?.userId || null;
@@ -177,49 +165,40 @@ function formatSingleChat(msg: MessageInRow): string {
const replyPrefix = formatReplyContext(content.replyTo);
const attachmentsSuffix = formatAttachments(content.attachments);
const fromAttr = originAttr(msg);
// Look up the destination name for the origin (reverse map lookup).
// If not found, fall back to a raw channel:platform_id marker so nothing
// gets silently dropped — this should only happen if the destination was
// removed between when the message was received and when it's being processed.
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
const fromAttr = fromDest
? ` from="${escapeXml(fromDest.name)}"`
: msg.channel_type || msg.platform_id
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
: '';
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
}
/**
* Build a ` from="destination_name"` attribute string from a message's routing
* fields. Shared by all formatters so the agent always knows where a message
* originated critical for explicit addressing.
*/
function originAttr(msg: MessageInRow): string {
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
if (fromDest) return ` from="${escapeXml(fromDest.name)}"`;
if (msg.channel_type || msg.platform_id) {
return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`;
}
return '';
}
function formatTaskMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const from = originAttr(msg);
const time = formatLocalTime(msg.timestamp, TIMEZONE);
const parts: string[] = [];
const parts = ['[SCHEDULED TASK]'];
if (content.scriptOutput) {
parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), '');
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
}
parts.push('Instructions:', content.prompt || '');
return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
parts.push('', 'Instructions:', content.prompt || '');
return parts.join('\n');
}
function formatWebhookMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const source = content.source || 'unknown';
const event = content.event || 'unknown';
const from = originAttr(msg);
return `<webhook${from} source="${escapeXml(source)}" event="${escapeXml(event)}">${JSON.stringify(content.payload || content, null, 2)}</webhook>`;
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
}
function formatSystemMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const from = originAttr(msg);
return `<system_response${from} action="${escapeXml(content.action || 'unknown')}" status="${escapeXml(content.status || 'unknown')}">${JSON.stringify(content.result || null)}</system_response>`;
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
}
/**
-2
View File
@@ -91,8 +91,6 @@ async function main(): Promise<void> {
mcpServers,
env: { ...process.env },
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
});
await runPollLoop({
@@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { getPendingMessages } from './db/messages-in.js';
import { getContinuation, setContinuation } from './db/session-state.js';
import { MockProvider } from './providers/mock.js';
import { runPollLoop } from './poll-loop.js';
@@ -75,163 +74,6 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('should resolve thread_id per-destination, not from global routing', async () => {
// Seed a second destination
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
)
.run();
// Insert messages from each destination with distinct thread IDs
insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' });
insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' });
// Agent replies to both destinations
const provider = new MockProvider({}, () =>
'<message to="discord-test">reply-d</message><message to="slack-test">reply-s</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
controller.abort();
const out = getUndeliveredMessages();
const discordOut = out.find((m) => m.platform_id === 'chan-1');
const slackOut = out.find((m) => m.platform_id === 'chan-2');
expect(discordOut).toBeDefined();
expect(discordOut!.thread_id).toBe('discord-thread-1');
expect(discordOut!.in_reply_to).toBe('m-discord');
expect(slackOut).toBeDefined();
expect(slackOut!.thread_id).toBe('slack-thread-99');
expect(slackOut!.in_reply_to).toBe('m-slack');
await loopPromise.catch(() => {});
});
it('bare text produces no outbound messages (scratchpad only)', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' });
// Agent responds with bare text — no <message to="..."> wrapping
const provider = new MockProvider({}, () => 'I am thinking about this...');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
// Wait long enough for the poll loop to process
await sleep(1000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(0);
await loopPromise.catch(() => {});
});
it('unknown destination is dropped, valid destination is sent', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
// Only the valid destination should produce output
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('delivered');
expect(out[0].platform_id).toBe('chan-1');
await loopPromise.catch(() => {});
});
it('multiple <message> blocks each produce an outbound message', async () => {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
)
.run();
insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(2);
const discord = out.find((m) => m.platform_id === 'chan-1');
const slack = out.find((m) => m.platform_id === 'chan-2');
expect(discord).toBeDefined();
expect(JSON.parse(discord!.content).text).toBe('for discord');
expect(slack).toBeDefined();
expect(JSON.parse(slack!.content).text).toBe('for slack');
await loopPromise.catch(() => {});
});
it('sends null thread_id when no prior inbound from destination', async () => {
// Seed a second destination that has NO inbound messages
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`,
)
.run();
// Only insert a message from discord — slack-new has never sent anything
insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' });
const provider = new MockProvider({}, () => '<message to="slack-new">hello slack</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('chan-new');
expect(out[0].thread_id).toBeNull();
await loopPromise.catch(() => {});
});
it('resolves most recent thread_id when destination has multiple inbound messages', async () => {
// Two messages from same destination, different threads
insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' });
insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' });
const provider = new MockProvider({}, () => '<message to="discord-test">reply</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].thread_id).toBe('thread-new');
expect(out[0].in_reply_to).toBe('m-new');
await loopPromise.catch(() => {});
});
it('should process messages arriving after loop starts', async () => {
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
const controller = new AbortController();
@@ -249,52 +91,6 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('internal tags between message blocks are stripped from scratchpad', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('answer');
await loopPromise.catch(() => {});
});
it('handles mixed task + chat batch with correct origin metadata', async () => {
// Seed destination for routing lookup
insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
// Task with same routing — simulates a scheduled task in a channel session
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ prompt: 'daily check' }));
const provider = new MockProvider({}, () => '<message to="discord-test">done</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('chan-1');
await loopPromise.catch(() => {});
});
});
// Helper: run poll loop until aborted or timeout
@@ -323,142 +119,3 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('poll loop — provider error recovery', () => {
it('writes error to outbound and continues loop on provider throw', async () => {
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new ThrowingProvider('API rate limit exceeded');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded');
// Input message should be marked completed despite the error
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
describe('poll loop — stale session recovery', () => {
it('clears continuation when provider reports session invalid', async () => {
// Pre-seed a continuation so the local variable in runPollLoop is set.
// Without this, the `if (continuation && isSessionInvalid)` check skips.
setContinuation('mock', 'pre-existing-session');
insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new InvalidSessionProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
// Error was written to outbound
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
// Continuation was cleared (isSessionInvalid returned true)
expect(getContinuation('mock')).toBeUndefined();
await loopPromise.catch(() => {});
});
});
describe('poll loop — /clear command', () => {
it('clears session, writes confirmation, skips query', async () => {
// Seed a continuation so we can verify it gets cleared
setContinuation('mock', 'existing-session-id');
expect(getContinuation('mock')).toBe('existing-session-id');
// Insert a /clear command
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ text: '/clear' }));
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('Session cleared.');
// Continuation was cleared
expect(getContinuation('mock')).toBeUndefined();
// Command message was completed
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that throws on every query, simulating API failures.
*/
class ThrowingProvider {
readonly supportsNativeSlashCommands = false;
private errorMessage: string;
constructor(errorMessage: string) {
this.errorMessage = errorMessage;
}
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const errorMessage = this.errorMessage;
return {
push() {},
end() {},
abort() {},
events: (async function* () {
throw new Error(errorMessage);
})(),
};
}
}
/**
* Provider that throws with an error that triggers isSessionInvalid.
* First emits an init event (setting continuation), then throws.
*/
class InvalidSessionProvider {
readonly supportsNativeSlashCommands = false;
isSessionInvalid(): boolean {
return true;
}
query(_input: { prompt: string; cwd: string }) {
return {
push() {},
end() {},
abort() {},
events: (async function* () {
yield { type: 'init' as const, continuation: 'doomed-session' };
throw new Error('session not found');
})(),
};
}
}
@@ -1,83 +0,0 @@
## Admin CLI (`ncl`)
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
### Usage
```
ncl <resource> <verb> [--flags]
ncl <resource> help
ncl help
```
### Scope
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
### Resources
Run `ncl help` for the full list. Common resources:
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
### When to use
- **Looking up your own config**`ncl groups get` or `ncl groups config get` to see your container config.
- **Restarting your container**`ncl groups restart` (with optional `--rebuild` and `--message`).
- **Checking who's in your group**`ncl members list`.
- **Seeing your destinations**`ncl destinations list`.
- **Answering questions about the system** — query `ncl` rather than guessing.
### Access rules
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
### Approval flow
Write commands require admin approval. Here's what happens:
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
4. Once the admin responds:
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
- **Rejected:** you get a system message saying the request was rejected.
You don't need to poll or retry — the result arrives automatically.
### Examples
```bash
# Read commands (no approval needed)
ncl groups get
ncl groups config get
ncl sessions list
ncl destinations list
ncl members list
# Write commands (approval required)
ncl groups restart
ncl groups restart --rebuild --message "Config updated."
ncl groups config update --model claude-sonnet-4-5-20250514
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
ncl groups config add-package --npm some-package
ncl members add --user telegram:jane
```
### Important
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
### Tips
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.
@@ -1,6 +1,6 @@
## Sending messages
**Every response** must be wrapped in `<message to="name">...</message>` blocks — even if you only have one destination. Bare text outside of `<message>` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names.
Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `<message to="name">...</message>` blocks). See that section for the current destination list.
### Mid-turn updates (`send_message`)
@@ -1,50 +0,0 @@
/**
* Tests for the core MCP tools' interaction with the per-batch routing
* context. The agent-runner sets a current `inReplyTo` at the top of each
* batch in poll-loop, and outbound writes from MCP tools (send_message,
* send_file) must pick it up so a2a return-path routing on the host can
* correlate replies back to the originating session.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
import { getUndeliveredMessages } from '../db/messages-out.js';
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
import { sendMessage } from './core.js';
beforeEach(() => {
initTestSessionDb();
// Seed a peer agent destination
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`,
)
.run();
});
afterEach(() => {
clearCurrentInReplyTo();
closeSessionDb();
});
describe('send_message MCP tool — in_reply_to plumbing', () => {
it('stamps current batch in_reply_to on outbound rows', async () => {
setCurrentInReplyTo('inbound-msg-1');
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBe('inbound-msg-1');
});
it('writes null when no batch is active', async () => {
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBeNull();
});
});
+9 -10
View File
@@ -9,7 +9,6 @@
import fs from 'fs';
import path from 'path';
import { getCurrentInReplyTo } from '../current-batch.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
@@ -51,7 +50,9 @@ function destinationList(): string {
*/
function resolveRouting(
to: string | undefined,
): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } {
):
| { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string }
| { error: string } {
if (!to) {
// Default: reply to whatever thread/channel this session is bound to.
const session = getSessionRouting();
@@ -81,7 +82,9 @@ function resolveRouting(
// preserve the thread_id so replies land in the correct thread.
const session = getSessionRouting();
const threadId =
session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null;
session.channel_type === dest.channelType && session.platform_id === dest.platformId
? session.thread_id
: null;
return {
channel_type: dest.channelType!,
platform_id: dest.platformId!,
@@ -95,14 +98,12 @@ function resolveRouting(
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
description:
'Send a message to a named destination. If you have only one destination, you can omit `to`.',
inputSchema: {
type: 'object' as const,
properties: {
to: {
type: 'string',
description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.',
},
to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' },
text: { type: 'string', description: 'Message content' },
},
required: ['text'],
@@ -118,7 +119,6 @@ export const sendMessage: McpToolDefinition = {
const id = generateId();
const seq = writeMessageOut({
id,
in_reply_to: getCurrentInReplyTo(),
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
@@ -165,7 +165,6 @@ export const sendFile: McpToolDefinition = {
writeMessageOut({
id,
in_reply_to: getCurrentInReplyTo(),
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
@@ -89,9 +89,6 @@ export const scheduleTask: McpToolDefinition = {
script,
processAfter,
recurrence,
platformId: r.platform_id,
channelType: r.channel_type,
threadId: r.thread_id,
}),
});
@@ -22,4 +22,4 @@ Use **`add_mcp_server`** to add an MCP server to your configuration. Browse avai
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
```
Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery).
Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential.
+9 -138
View File
@@ -14,18 +14,13 @@ afterEach(() => {
closeSessionDb();
});
function insertMessage(
id: string,
kind: string,
content: object,
opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 },
) {
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`,
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
)
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content));
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
}
describe('formatter', () => {
@@ -52,7 +47,7 @@ describe('formatter', () => {
insertMessage('m1', 'task', { prompt: 'Review open PRs' });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('<task');
expect(prompt).toContain('[SCHEDULED TASK]');
expect(prompt).toContain('Review open PRs');
});
@@ -60,17 +55,15 @@ describe('formatter', () => {
insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('<webhook');
expect(prompt).toContain('source="github"');
expect(prompt).toContain('event="push"');
expect(prompt).toContain('[WEBHOOK: github/push]');
});
it('should format system messages', () => {
insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('<system_response');
expect(prompt).toContain('action="register_group"');
expect(prompt).toContain('[SYSTEM RESPONSE]');
expect(prompt).toContain('register_group');
});
it('should handle mixed kinds', () => {
@@ -79,7 +72,7 @@ describe('formatter', () => {
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('sender="John"');
expect(prompt).toContain('<system_response');
expect(prompt).toContain('[SYSTEM RESPONSE]');
});
it('should escape XML in content', () => {
@@ -136,58 +129,6 @@ describe('accumulate gate (trigger column)', () => {
});
});
describe('on_wake filtering', () => {
it('first poll returns on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('subsequent polls skip on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(0);
});
it('normal messages returned regardless of isFirstPoll', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
expect(getPendingMessages(true)).toHaveLength(1);
// Reset: mark completed so we can re-test with a fresh message
markCompleted(['m1']);
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
expect(getPendingMessages(false)).toHaveLength(1);
});
it('mixed batch: first poll returns both normal and on_wake messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(2);
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
});
it('mixed batch: subsequent poll returns only normal messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('on_wake defaults to 0 for inserts without explicit value', () => {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
)
.run();
// Should be returned even on non-first poll (on_wake=0)
expect(getPendingMessages(false)).toHaveLength(1);
});
});
describe('routing', () => {
it('should extract routing from messages', () => {
getInboundDb()
@@ -206,76 +147,6 @@ describe('routing', () => {
});
});
describe('origin metadata (from= attribute)', () => {
function seedDestination(name: string, channelType: string, platformId: string): void {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES (?, ?, 'channel', ?, ?, NULL)`,
)
.run(name, name, channelType, platformId);
}
function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
)
.run(id, kind, platformId, channelType, JSON.stringify(content));
}
it('chat message includes from= when destination matches', () => {
seedDestination('discord-main', 'discord', 'chan-1');
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('from="discord-main"');
});
it('chat message falls back to raw routing when no destination matches', () => {
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('from="unknown:telegram:chat-999"');
});
it('chat message omits from= when routing is null', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' });
const prompt = formatMessages(getPendingMessages());
expect(prompt).not.toContain('from=');
});
it('task message includes from= when destination matches', () => {
seedDestination('slack-ops', 'slack', 'C-OPS');
insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<task');
expect(prompt).toContain('from="slack-ops"');
});
it('task message omits from= when routing is null', () => {
insertMessage('t1', 'task', { prompt: 'check status' });
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<task');
expect(prompt).not.toContain('from=');
});
it('webhook message includes from= when destination matches', () => {
seedDestination('github-ch', 'github', 'repo-1');
insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<webhook');
expect(prompt).toContain('from="github-ch"');
});
it('system message includes from= when destination matches', () => {
seedDestination('discord-main', 'discord', 'chan-1');
insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<system_response');
expect(prompt).toContain('from="discord-main"');
});
});
describe('mock provider', () => {
it('should produce init + result events', async () => {
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
+70 -151
View File
@@ -1,18 +1,13 @@
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import {
formatMessages,
extractRouting,
categorizeMessage,
isClearCommand,
isRunnerCommand,
stripInternalTags,
type RoutingContext,
} from './formatter.js';
clearContinuation,
migrateLegacyContinuation,
setContinuation,
} from './db/session-state.js';
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
@@ -67,11 +62,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
clearStaleProcessingAcks();
let pollCount = 0;
let isFirstPoll = true;
while (true) {
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
isFirstPoll = false;
const messages = getPendingMessages().filter((m) => m.kind !== 'system');
pollCount++;
// Periodic heartbeat so we know the loop is alive
@@ -177,9 +170,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Process the query while concurrently polling for new messages
const skippedSet = new Set(skipped);
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
// can stamp it on outbound rows — needed for a2a return-path routing.
setCurrentInReplyTo(routing.inReplyTo);
try {
const result = await processQuery(query, routing, processingIds, config.providerName);
if (result.continuation && result.continuation !== continuation) {
@@ -208,8 +198,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
thread_id: routing.threadId,
content: JSON.stringify({ text: `Error: ${errMsg}` }),
});
} finally {
clearCurrentInReplyTo();
}
// Ensure completed even if processQuery ended without a result event
@@ -265,94 +253,38 @@ async function processQuery(
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
let unwrappedNudged = false;
// Concurrent polling: push follow-ups into the active query as they arrive.
// We do NOT force-end the stream on silence — keeping the query open avoids
// re-spawning the SDK subprocess (~few seconds) and re-loading the .jsonl
// transcript on every turn. 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.
// We do NOT force-end the stream on silence — keeping the query open is
// strictly cheaper than close+reopen (no cold prompt cache, no reconnect).
// Stream liveness is decided host-side via the heartbeat file + processing
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
// will kill the container and messages get reset to pending.
let pollInFlight = false;
let endedForCommand = false;
const pollHandle = setInterval(() => {
if (done || pollInFlight || endedForCommand) return;
pollInFlight = true;
if (done) return;
void (async () => {
try {
const pending = getPendingMessages();
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
// Thread routing is the router's concern — if a message landed in this
// session, the agent should see it. Per-thread sessions already isolate
// threads into separate containers; shared sessions intentionally merge
// everything. Filtering on thread_id here caused deadlocks when the
// initial batch and follow-ups had mismatched thread_ids (e.g. a
// host-generated welcome trigger with null thread vs a Discord DM reply).
const newMessages = getPendingMessages().filter((m) => {
if (m.kind === 'system') return false;
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
return true;
});
if (newMessages.length > 0) {
const newIds = newMessages.map((m) => m.id);
markProcessing(newIds);
// Slash commands need a fresh query: /clear resets the SDK's
// resume id (fixed at sdkQuery() time); admin/passthrough commands
// (/compact, /cost, …) only dispatch when they're the first input
// of a query — pushed mid-stream they arrive as plain text and
// the SDK never runs them. End the stream and leave the rows
// pending; the outer loop handles them on next iteration via the
// canonical command path + formatMessagesWithCommands.
if (pending.some((m) => isRunnerCommand(m))) {
log('Pending slash command — ending stream so outer loop can process');
endedForCommand = true;
query.end();
return;
}
const prompt = formatMessages(newMessages);
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
query.push(prompt);
// Skip system messages (MCP tool responses).
// Thread routing is the router's concern — if a message landed in this
// session, the agent should see it. Per-thread sessions already isolate
// threads into separate containers; shared sessions intentionally merge
// everything. Filtering on thread_id here caused deadlocks when the
// initial batch and follow-ups had mismatched thread_ids (e.g. a
// host-generated welcome trigger with null thread vs a Discord DM reply).
const newMessages = pending.filter((m) => m.kind !== 'system');
if (newMessages.length === 0) return;
const newIds = newMessages.map((m) => m.id);
markProcessing(newIds);
// Run pre-task scripts on follow-ups too — without this, a task that
// arrives during an active query (e.g. a */10 monitoring cron) bypasses
// its script gate and always wakes the agent, defeating the gate.
// Mirrors the initial-batch hook above.
let keep = newMessages;
let skipped: string[] = [];
// MODULE-HOOK:scheduling-pre-task-followup:start
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
const preTask = await applyPreTaskScripts(newMessages);
keep = preTask.keep;
skipped = preTask.skipped;
if (skipped.length > 0) {
markCompleted(skipped);
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
}
// MODULE-HOOK:scheduling-pre-task-followup:end
if (keep.length === 0) return;
// Re-check done — the outer query may have finished while the script
// was awaited. Pushing into a closed stream is wasted work; the
// claimed messages get released by the host's processing-claim sweep.
if (done) return;
const keptIds = keep.map((m) => m.id);
const prompt = formatMessages(keep);
log(`Pushing ${keep.length} follow-up message(s) into active query`);
unwrappedNudged = false;
query.push(prompt);
markCompleted(keptIds);
} catch (err) {
// Without this catch the rejection escapes the void IIFE and Node
// terminates the container on unhandled-rejection. The initial-batch
// path is wrapped by processQuery's outer try/catch; the follow-up
// path is not, so it needs its own.
const errMsg = err instanceof Error ? err.message : String(err);
log(`Follow-up poll error: ${errMsg}`);
} finally {
pollInFlight = false;
}
})();
markCompleted(newIds);
}
}, ACTIVE_POLL_INTERVAL_MS);
try {
@@ -378,18 +310,7 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
if (hasUnwrapped && !unwrappedNudged) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
dispatchResultText(event.text, routing);
}
}
}
@@ -410,9 +331,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
break;
case 'error':
log(
`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`,
);
log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`);
break;
case 'progress':
log(`Progress: ${event.message}`);
@@ -423,12 +342,16 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
/**
* Parse the agent's final text for <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
* (including <internal>...</internal>) is scratchpad logged but not sent.
* (including <internal>...</internal>) is normally scratchpad logged but
* not sent.
*
* The agent must always wrap output in <message to="name">...</message>
* blocks, even with a single destination. Bare text is scratchpad only.
* Single-destination shortcut: if the agent has exactly one configured
* destination AND the output contains zero <message> blocks, the entire
* cleaned text (with <internal> tags stripped) is sent to that destination.
* This preserves the simple case of one user on one channel the agent
* doesn't need to know about wrapping syntax at all.
*/
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
function dispatchResultText(text: string, routing: RoutingContext): void {
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
let match: RegExpExecArray | null;
@@ -459,60 +382,56 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
const scratchpad = stripInternalTags(scratchpadParts.join(''));
// Single-destination shortcut: the agent wrote plain text — send to
// the session's originating channel (from session_routing) if available,
// otherwise fall back to the single destination.
if (sent === 0 && scratchpad) {
if (routing.channelType && routing.platformId) {
// Reply to the channel/thread the message came from
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: scratchpad }),
});
return;
}
const all = getAllDestinations();
if (all.length === 1) {
sendToDestination(all[0], scratchpad, routing);
return;
}
}
if (scratchpad) {
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
}
const hasUnwrapped = sent === 0 && !!scratchpad;
if (hasUnwrapped) {
if (sent === 0 && text.trim()) {
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
}
return { sent, hasUnwrapped };
}
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
// Resolve thread_id per-destination from the most recent inbound message
// that came from this same channel+platform. In agent-shared sessions,
// different destinations have different thread contexts — using a single
// routing.threadId would stamp one channel's thread onto another.
const destRouting = resolveDestinationThread(channelType, platformId);
// Inherit thread_id from the inbound routing context so replies land in the
// same thread the conversation is in. For non-threaded adapters the router
// strips thread_id at ingest, so this will already be null.
writeMessageOut({
id: generateId(),
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
thread_id: destRouting?.threadId ?? null,
thread_id: routing.threadId,
content: JSON.stringify({ text: body }),
});
}
/**
* Find the thread_id and message id from the most recent inbound message
* matching the given channel+platform. Returns null if no match found.
*/
function resolveDestinationThread(
channelType: string,
platformId: string,
): { threadId: string | null; inReplyTo: string | null } | null {
try {
const db = getInboundDb();
const row = db
.prepare(
`SELECT thread_id, id FROM messages_in
WHERE channel_type = ? AND platform_id = ?
ORDER BY seq DESC LIMIT 1`,
)
.get(channelType, platformId) as { thread_id: string | null; id: string } | undefined;
if (row) return { threadId: row.thread_id, inReplyTo: row.id };
} catch (err) {
log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`);
}
return null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+4 -28
View File
@@ -34,11 +34,7 @@ const SDK_DISALLOWED_TOOLS = [
'ExitWorktree',
];
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
// at the call site from the registered `mcpServers` map so that any server
// added via `add_mcp_server` (or wired in container.json directly) is
// reachable to the agent — without this, the SDK's allowedTools filter
// silently drops every MCP namespace not listed here.
// Tool allowlist for NanoClaw agent containers
const TOOL_ALLOWLIST = [
'Bash',
'Read',
@@ -58,15 +54,9 @@ const TOOL_ALLOWLIST = [
'ToolSearch',
'Skill',
'NotebookEdit',
'mcp__nanoclaw__*',
];
// MCP server names are sanitized by the SDK when forming tool prefixes:
// any character outside [A-Za-z0-9_-] becomes '_'. Mirror that here so our
// allowlist patterns match what the SDK actually exposes.
function mcpAllowPattern(serverName: string): string {
return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
interface SDKUserMessage {
type: 'user';
message: { role: 'user'; content: string };
@@ -236,12 +226,8 @@ function createPreCompactHook(assistantName?: string): HookCallback {
/**
* Claude Code auto-compacts context at this window (tokens). Kept here so
* the generic bootstrap doesn't need to know about Claude-specific env vars.
*
* Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to
* raise or lower the threshold without editing source useful when running
* with a 1M-context model variant or when emergency-tuning a deployment.
*/
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000';
/**
* Stale-session detection. Matches Claude Code's error text when a
@@ -257,15 +243,11 @@ export class ClaudeProvider implements AgentProvider {
private mcpServers: Record<string, McpServerConfig>;
private env: Record<string, string | undefined>;
private additionalDirectories?: string[];
private model?: string;
private effort?: string;
constructor(options: ProviderOptions = {}) {
this.assistantName = options.assistantName;
this.mcpServers = options.mcpServers ?? {};
this.additionalDirectories = options.additionalDirectories;
this.model = options.model;
this.effort = options.effort;
this.env = {
...(options.env ?? {}),
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
@@ -291,15 +273,9 @@ export class ClaudeProvider implements AgentProvider {
resume: input.continuation,
pathToClaudeCodeExecutable: '/pnpm/claude',
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
allowedTools: [
...TOOL_ALLOWLIST,
...Object.keys(this.mcpServers).map(mcpAllowPattern),
],
allowedTools: TOOL_ALLOWLIST,
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
effort: this.effort as any,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
@@ -25,16 +25,6 @@ export interface ProviderOptions {
mcpServers?: Record<string, McpServerConfig>;
env?: Record<string, string | undefined>;
additionalDirectories?: string[];
/**
* Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
* to the underlying SDK. If omitted, the SDK default is used.
*/
model?: string;
/**
* Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
* through to the underlying SDK. If omitted, the SDK default is used.
*/
effort?: string;
}
export interface QueryInput {
-85
View File
@@ -1,85 +0,0 @@
---
name: onecli-gateway
description: >-
OneCLI Gateway: transparent HTTPS proxy that injects stored credentials
into outbound calls. You MUST use this skill when the user asks you to
read emails, check calendar, access GitHub repos, create issues, check
Stripe payments, or interact with ANY external service or API. Do NOT
use browser extensions or OAuth CLI tools. Make HTTP requests directly;
the gateway injects credentials automatically.
compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`)
metadata:
author: onecli
version: "0.5.0"
---
# OneCLI Gateway
Your outbound HTTPS traffic is transparently proxied through the OneCLI
gateway, which injects stored credentials at the proxy boundary. You never
see or handle credential values directly.
## How to Access External Services
You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub,
Google Calendar, Google Drive, etc.) and API key services are all available
through the gateway. Just make the request directly; the gateway injects
credentials if the app is connected. If not, it returns an error with a
connect URL you can present to the user.
## Making Requests
Call the real API URL. The gateway intercepts the request and injects
credentials automatically.
```bash
curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5"
curl -s "https://api.github.com/user/repos?per_page=10"
curl -s "https://api.stripe.com/v1/charges?limit=5"
```
Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all
honor the `HTTPS_PROXY` environment variable automatically. You do not need
to set any auth headers.
## Credential Stubs for MCP Servers
Some MCP servers need local credential files to start. Stubs for connected
apps are pre-written automatically. Files containing `"onecli-managed"`
values are managed by OneCLI — do NOT modify or delete them.
If an MCP server won't start due to missing credentials, create stubs
**before** starting it. Use `"onecli-managed"` as the placeholder for all
secret values, with file permissions `0600`. See the guide at:
https://www.onecli.sh/docs/guides/credential-stubs/general-app
## When a Request Fails
If you get a 401, 403, or a gateway error (e.g., `app_not_connected`):
**Step 1 — Show the user a connect link.** Use the `connect_url` from the
error response:
> To connect [service], open this link:
> [connect_url from the error response]
If there is no `connect_url` in the error, tell the user to open the
OneCLI dashboard and connect the service there.
**Step 2 — Retry after the user connects.** Let the user know you will
retry once they have connected. When they confirm, retry the original
request. If the retry still fails, ask if they need help with the setup.
## Rules
- **Never** say "I don't have access to X" without first making the HTTP
request through the proxy.
- **Never** use browser extensions, gcloud, or manual auth flows. The
gateway handles credentials for you.
- **Never** ask the user for API keys or tokens directly. Direct them to
connect the service in the OneCLI dashboard.
- **Never** suggest the user open Gmail/Calendar/GitHub in their browser
when they ask you to read or interact with those services. You have API
access. Use it.
- If the gateway returns a policy error (403 with a JSON body), respect
the block. Do not retry or circumvent it.
@@ -1,7 +0,0 @@
# Credentials & External Services
Your HTTP requests go through the OneCLI proxy, which injects real credentials automatically. Just call any API directly (Gmail, GitHub, Slack, etc.) — the proxy adds auth before it reaches the service.
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
If you get a `401`/`403`/`app_not_connected`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.
+1 -1
View File
@@ -9,7 +9,7 @@ You've just been connected to a new user. This your time to shine and make a str
## What to do
1. Send a short, warm greeting
1. Send a short, warm greeting using `send_message`
2. State your name (from your system prompt / CLAUDE.md)
3. Signal that you're capable of a lot — but don't list everything upfront. Be intriguing, not encyclopedic
4. Ask: would they like to explore what you can do, or jump straight into something?
+1 -1
View File
@@ -2,7 +2,7 @@
## Structure
**`nanocoai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**Channel forks** (`nanoclaw-whatsapp`, `nanoclaw-telegram`, `nanoclaw-slack`, etc.) — each fork = upstream + one channel's code applied. Users clone upstream, then merge a fork into their clone to add a channel.
+1 -29
View File
@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
### 1.1 `agent_groups`
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB.
```sql
CREATE TABLE agent_groups (
@@ -294,32 +294,6 @@ CREATE TABLE schema_version (
);
```
### 1.15 `container_configs`
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.
```sql
CREATE TABLE container_configs (
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
provider TEXT,
model TEXT,
effort TEXT,
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
updated_at TEXT NOT NULL
);
```
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
---
## 2. Migration system
@@ -339,8 +313,6 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
+13 -16
View File
@@ -33,22 +33,19 @@ Every message landing in the session: user chat, scheduled task, recurring task,
```sql
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL, -- JSON; shape depends on kind
source_session_id TEXT, -- agent-to-agent return path
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL -- JSON; shape depends on kind
);
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
```
+2 -2
View File
@@ -77,7 +77,7 @@ NanoClaw must live inside the workspace directory — Docker-in-Docker can only
```bash
# Clone to home first (virtiofs can corrupt git pack files during clone)
cd ~
git clone https://github.com/nanocoai/nanoclaw.git
git clone https://github.com/qwibitai/nanoclaw.git
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
WORKSPACE=/Users/you/nanoclaw-workspace
@@ -347,7 +347,7 @@ docker sandbox network proxy <sandbox-name> \
### Git clone fails with "inflate: data stream error"
Clone to a non-workspace path first, then move:
```bash
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
```
### WhatsApp QR code doesn't display
-139
View File
@@ -1,139 +0,0 @@
# v1 → v2 Migration — Development Guide
How to test, develop, and debug the migration flow.
## Quick start
```bash
# Full cycle: reset → migrate → Claude finishes
bash migrate-v2-reset.sh && bash migrate-v2.sh
```
## Architecture
Two-part migration:
1. **`migrate-v2.sh`** — deterministic bash script. Handles prerequisites, DB seeding, file copies, channel install, container build, service switchover. Writes `logs/setup-migration/handoff.json` then `exec`s into Claude.
2. **`/migrate-from-v1` skill** — Claude-driven. Reads the handoff, seeds owner/roles, cleans up CLAUDE.local.md, validates container configs, ports fork customizations.
## File layout
```
migrate-v2.sh # Entry point
migrate-v2-reset.sh # Wipe v2 state for re-testing
setup/migrate-v2/
env.ts # Phase 1a: merge .env
db.ts # Phase 1b: seed v2 DB
groups.ts # Phase 1c: copy group folders + container.json
sessions.ts # Phase 1d: copy sessions + set continuation
tasks.ts # Phase 1e: port scheduled tasks
channel-auth.ts # Phase 2b: copy channel auth state
select-channels.ts # Phase 2a: clack multiselect
switchover-prompt.ts # Service switch prompts
setup/migrate-v2/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.)
.claude/skills/migrate-from-v1/ # The Claude skill
logs/setup-migration/handoff.json # Written by migrate-v2.sh, read by skill
logs/migrate-steps/*.log # Per-step raw output
```
## Development loop
```bash
# Reset v2 to clean state (keeps node_modules)
bash migrate-v2-reset.sh
# Run migration with non-interactive channel selection
NANOCLAW_CHANNELS="telegram" bash migrate-v2.sh
# Or run interactively (clack multiselect)
bash migrate-v2.sh
```
`migrate-v2-reset.sh` wipes: `data/`, `logs/`, `.env`, `groups/` (restores git-tracked), `container/skills/` (restores git-tracked), `src/channels/` (restores git-tracked).
It does NOT wipe `node_modules/` (expensive to reinstall).
## Testing individual steps
Each step is a standalone TypeScript file:
```bash
# Run a single step (after pnpm install)
pnpm exec tsx setup/migrate-v2/env.ts /path/to/v1
pnpm exec tsx setup/migrate-v2/db.ts /path/to/v1
pnpm exec tsx setup/migrate-v2/groups.ts /path/to/v1
pnpm exec tsx setup/migrate-v2/sessions.ts /path/to/v1
pnpm exec tsx setup/migrate-v2/tasks.ts /path/to/v1
pnpm exec tsx setup/migrate-v2/channel-auth.ts /path/to/v1 telegram discord
```
Each prints `OK:<details>`, `SKIPPED:<reason>`, or errors to stdout. Exit 0 on success/skip, non-zero on failure.
## Debugging
### Check what was migrated
```bash
# Agent groups
sqlite3 data/v2.db "SELECT * FROM agent_groups"
# Messaging groups + wiring
sqlite3 data/v2.db "SELECT mg.id, mg.channel_type, mg.platform_id, mg.unknown_sender_policy, mga.engage_mode, mga.engage_pattern FROM messaging_groups mg JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id"
# Sessions
sqlite3 data/v2.db "SELECT * FROM sessions"
# Users and roles
sqlite3 data/v2.db "SELECT * FROM users"
sqlite3 data/v2.db "SELECT * FROM user_roles"
# Session continuation (which Claude Code session will be resumed)
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups LIMIT 1")
SESS_ID=$(sqlite3 data/v2.db "SELECT id FROM sessions LIMIT 1")
sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/outbound.db "SELECT * FROM session_state"
# Scheduled tasks
sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/inbound.db "SELECT id, kind, recurrence, status FROM messages_in WHERE kind='task'"
```
### Check handoff
```bash
python3 -m json.tool logs/setup-migration/handoff.json
```
### Common issues
**Bot doesn't respond after switchover:**
1. Check both services aren't running: `systemctl --user list-units 'nanoclaw*'`
2. Check error log: `tail logs/nanoclaw.error.log`
3. Check sender policy: `sqlite3 data/v2.db "SELECT unknown_sender_policy FROM messaging_groups"` — must be `public` before owner is seeded
4. Check engage pattern: `sqlite3 data/v2.db "SELECT engage_mode, engage_pattern FROM messaging_group_agents"` — should be `pattern` / `.` for respond-to-everything
**Session not continuing from v1:**
1. Check continuation is set: see "Session continuation" query above
2. Check JSONL exists at the right path: `ls data/v2-sessions/<ag_id>/.claude-shared/projects/-workspace-agent/`
3. The v1 session JSONL should be copied from `-workspace-group/` to `-workspace-agent/` (v2 container CWD is `/workspace/agent`)
**Service switchover revert didn't work:**
1. The v2 service name is `nanoclaw-v2-<hash>` — find it: `systemctl --user list-units 'nanoclaw*'`
2. Manually stop: `systemctl --user stop <unit> && systemctl --user disable <unit>`
3. Restart v1: `systemctl --user start nanoclaw`
### Step logs
Each step writes raw output to `logs/migrate-steps/<step>.log`. Read these when a step fails:
```bash
cat logs/migrate-steps/1b-db.log
cat logs/migrate-steps/1d-sessions.log
```
## Key decisions
- `unknown_sender_policy` is set to `public` during migration so the bot responds immediately. The `/migrate-from-v1` skill tightens it after seeding the owner.
- `requires_trigger=0` in v1 takes priority over a non-empty `trigger_pattern` — it means "respond to everything."
- v1 `container_config.additionalMounts` is written directly to v2 `container.json` (same shape).
- v1 Claude Code sessions are copied from `-workspace-group/` to `-workspace-agent/` and the session ID is written to `outbound.db` as `continuation:claude` so the agent-runner resumes the same conversation.
- `exec claude "/migrate-from-v1"` at the end replaces the bash process — `write_handoff` is called explicitly before `exec` since EXIT traps don't fire on `exec`.
+22 -22
View File
@@ -23,7 +23,7 @@ This replaces the previous `skills-engine/` system (three-way file merging, `.na
### Repository structure
The upstream repo (`nanocoai/nanoclaw`) maintains:
The upstream repo (`qwibitai/nanoclaw`) maintains:
- `main` — core NanoClaw (no skill code)
- `skill/discord` — main + Discord integration
@@ -46,7 +46,7 @@ Skills are split into two categories:
**Feature skills** (in marketplace, installed on demand):
- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc.
- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code
- Live in the marketplace repo (`nanocoai/nanoclaw-skills`)
- Live in the marketplace repo (`qwibitai/nanoclaw-skills`)
Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently:
@@ -78,7 +78,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
"repo": "qwibitai/nanoclaw-skills"
}
}
}
@@ -88,7 +88,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
The marketplace repo uses Claude Code's plugin structure:
```
nanocoai/nanoclaw-skills/
qwibitai/nanoclaw-skills/
.claude-plugin/
marketplace.json # Plugin catalog
plugins/
@@ -213,7 +213,7 @@ A GitHub Action runs on every push to `main`:
### New users (recommended)
1. Fork `nanocoai/nanoclaw` on GitHub (click the Fork button)
1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button)
2. Clone your fork:
```bash
git clone https://github.com/<you>/nanoclaw.git
@@ -229,9 +229,9 @@ Forking is recommended because it gives users a remote to push their customizati
### Existing users migrating from clone
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations:
1. Fork `nanocoai/nanoclaw` on GitHub
1. Fork `qwibitai/nanoclaw` on GitHub
2. Reroute remotes:
```bash
git remote rename origin upstream
@@ -239,7 +239,7 @@ Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` an
git push --force origin main
```
The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose.
3. From this point, `origin` = their fork, `upstream` = nanocoai/nanoclaw
3. From this point, `origin` = their fork, `upstream` = qwibitai/nanoclaw
### Existing users migrating from the old skills engine
@@ -316,7 +316,7 @@ git fetch upstream main
git checkout -b my-fix upstream/main
# Make changes
git push origin my-fix
# Create PR from my-fix to nanocoai/nanoclaw:main
# Create PR from my-fix to qwibitai/nanoclaw:main
```
Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR.
@@ -327,7 +327,7 @@ The flow below is for **feature skills** (branch-based). For utility skills (sel
### Contributor flow (feature skills)
1. Fork `nanocoai/nanoclaw`
1. Fork `qwibitai/nanoclaw`
2. Branch from `main`
3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.)
4. Open a PR to `main`
@@ -345,7 +345,7 @@ When a skill PR is reviewed and approved:
```
2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes)
3. Merge the slimmed PR into `main` (just the contributor addition)
4. Add the skill's SKILL.md to the marketplace repo (`nanocoai/nanoclaw-skills`)
4. Add the skill's SKILL.md to the marketplace repo (`qwibitai/nanoclaw-skills`)
This way:
- The contributor gets merge credit (their PR is merged)
@@ -388,7 +388,7 @@ If the community contributor is trusted, they can open a PR to add their marketp
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
"repo": "qwibitai/nanoclaw-skills"
}
},
"alice-nanoclaw-skills": {
@@ -434,7 +434,7 @@ A flavor is a curated fork of NanoClaw — a combination of skills, custom chang
### Creating a flavor
1. Fork `nanocoai/nanoclaw`
1. Fork `qwibitai/nanoclaw`
2. Merge in the skills you want
3. Make custom changes (trigger word, prompts, integrations, etc.)
4. Your fork's `main` IS the flavor
@@ -462,7 +462,7 @@ Then setup continues normally (dependencies, auth, container, service).
After installation, the user's fork has three remotes:
- `origin` — their fork (push customizations here)
- `upstream``nanocoai/nanoclaw` (core updates)
- `upstream``qwibitai/nanoclaw` (core updates)
- `<flavor-name>` — the flavor fork (flavor updates)
### Updating a flavor
@@ -538,14 +538,14 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
Before:
```bash
git clone https://github.com/nanocoai/NanoClaw.git
git clone https://github.com/qwibitai/NanoClaw.git
cd NanoClaw
claude
```
After:
```
1. Fork nanocoai/nanoclaw on GitHub
1. Fork qwibitai/nanoclaw on GitHub
2. git clone https://github.com/<you>/nanoclaw.git
3. cd nanoclaw
4. claude
@@ -556,8 +556,8 @@ After:
Updates to the setup flow:
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/nanocoai/nanoclaw.git`
- Check if `origin` points to the user's fork (not nanocoai). If it points to nanocoai, guide them through the fork migration.
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git`
- Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration.
- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart)
- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels
- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp)
@@ -573,7 +573,7 @@ Marketplace configuration so the official marketplace is auto-registered:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
"repo": "qwibitai/nanoclaw-skills"
}
}
}
@@ -601,7 +601,7 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
### New infrastructure
- **Marketplace repo** (`nanocoai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution
- **`/update-skills` skill** — checks for and applies skill branch updates using git history
- **`CONTRIBUTORS.md`** — tracks skill contributors
@@ -650,7 +650,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to.
>
> **If you currently have a clone with local changes**, migrate to a fork:
> 1. Fork `nanocoai/nanoclaw` on GitHub
> 1. Fork `qwibitai/nanoclaw` on GitHub
> 2. Run:
> ```
> git remote rename origin upstream
@@ -668,7 +668,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **Contributing skills**
>
> To contribute a skill:
> 1. Fork `nanocoai/nanoclaw`
> 1. Fork `qwibitai/nanoclaw`
> 2. Branch from `main` and make your code changes
> 3. Open a regular PR
>
-172
View File
@@ -1,172 +0,0 @@
# NanoClaw v1 → v2 — what changed
Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash migrate-v2.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here.
Read this before touching the migration code or porting customizations forward.
---
## One-line summary
v1 was one Node process with one SQLite file and native channel adapters. v2 is a host that spawns per-session Docker containers, splits state across a central DB + per-session DB pair, routes through an explicit entity model, and installs channels as skills from a sibling branch.
---
## Entity model — the biggest shift
**v1:** one flat table `registered_groups(jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name)`. A group folder is the unit of agent identity. A chat (JID) is wired to exactly one folder, and `trigger_pattern` is an opaque regex the router applies to every incoming message.
**v2:** three tables, with a deliberate many-to-many in the middle:
```
agent_groups ─┐
├─ messaging_group_agents ─┬─ messaging_groups
│ (engage_mode, │ (channel_type,
│ engage_pattern, │ platform_id,
│ sender_scope, │ unknown_sender_policy)
│ ignored_message_policy,
│ session_mode, priority)
```
Consequences:
- **One agent can answer on many chats, and one chat can fan out to many agents.** v1 couldn't do either.
- **No `is_main` flag.** Privilege is now explicit via `user_roles` (owner/admin, global or scoped). See below.
- **No `trigger_pattern` regex.** Replaced with four orthogonal columns. Mapping rule used by the automated migration and by the `/migrate-from-v1` skill:
- v1 `trigger_pattern` non-empty → v2 `engage_mode='pattern'`, `engage_pattern = <the regex>`
- v1 `requires_trigger=0` or pattern was `.`/`.*` → v2 `engage_mode='pattern'`, `engage_pattern='.'` (the "always" flavor)
- no pattern and requires a trigger → v2 `engage_mode='mention'`
- `sender_scope` and `ignored_message_policy` are new; defaults `all` / `drop`
- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc``discord`, `tg``telegram`, `wa``whatsapp`) are in `setup/migrate-v2/shared.ts`.
- **`channel_name` was unreliable in v1.** Many rows had it empty; the actual channel had to be guessed from the JID prefix. v2's `channel_type` is always explicit.
---
## Central DB vs session DBs
**v1:** one SQLite file at `store/messages.db`. Every chat, message, registered group, scheduled task, and session lived there. Host and any agent processes all opened the same file.
**v2:** three DB shapes.
1. `data/v2.db`**central**. Everything that isn't per-session: users, roles, agent groups, messaging groups, wirings, pending approvals, user DMs, schema migrations.
2. `data/v2-sessions/<session_id>/inbound.db`**host writes, container reads**. `messages_in`, routing, destinations, pending questions, processing_ack. This is where scheduled tasks live (see "Scheduling" below).
3. `data/v2-sessions/<session_id>/outbound.db`**container writes, host reads**. `messages_out`, session_state.
Exactly one writer per file. No cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd.
Message history (v1 `messages` table, v1 `chats` table) is **not migrated**. The migration copies operationally important state forward (agents, channels, wirings, scheduled tasks, group folders) and leaves chat logs behind.
---
## Scheduling
**v1:** dedicated `scheduled_tasks` table in `store/messages.db` with its own columns (`schedule_type`, `schedule_value`, `next_run`, `last_run`, `context_mode`, `script`, `status`). A separate cron-ish scheduler process read from it.
**v2:** scheduled tasks are **`messages_in` rows with `kind='task'`** in a session's `inbound.db`. Relevant columns:
- `process_after` (ISO8601) — host sweep wakes the container when `datetime(process_after) <= datetime('now')`
- `recurrence` — cron string; `NULL` = one-shot
- `series_id` — groups recurring occurrences; set to the task id on first insert
- `status``pending` | `processing` | `completed` | `failed` | `paused`
The public API is `insertTask()` in `src/modules/scheduling/db.ts`. Recurrence is computed in the user's TZ via `cron-parser` (see `src/modules/scheduling/recurrence.ts`). The migration maps v1's `schedule_type`+`schedule_value` pair into a single cron string before calling `insertTask()`.
Tasks can exist before a session is awake — the host sweep creates/wakes the container on the first due tick.
---
## Credentials
**v1:** `.env` — plain environment variables. `DISCORD_BOT_TOKEN`, `ANTHROPIC_API_KEY`, etc. The host read them directly and passed them in to any code that needed them.
**v2:** OneCLI Agent Vault. A separate local service at `http://127.0.0.1:10254` holds secrets. Agents are *scoped* to specific secrets and the vault injects them into approved API requests as they leave the container. The container never sees the raw secret value.
Gotcha: auto-created agents default to `selective` secret mode — no secrets attached, even if matching secrets exist in the vault. See the "auto-created agents start in selective secret mode" section of the root CLAUDE.md for the fix (`onecli agents set-secret-mode --mode all`).
**What the automated migration does:** copies every v1 `.env` key verbatim into v2 `.env`, never overwriting existing v2 keys. The OneCLI vault migration is a separate step owned by the `/init-onecli` skill, which knows how to pull from `.env`.
---
## Channel adapters
**v1:** native adapters (e.g. `discord.js` used directly) imported in `src/channels/`. Installing a channel meant editing code, adding a dependency, and setting env vars.
**v2:** channel adapters live on a sibling `channels` branch. Each `/add-<channel>` skill:
1. `git fetch origin channels`
2. `git show channels:src/channels/<name>.ts > src/channels/<name>.ts`
3. Appends `import './<name>.js';` to `src/channels/index.ts`
4. `pnpm install @chat-adapter/<name>@<pinned>`
5. `pnpm run build`
Idempotent — re-running is a no-op. Pinned versions keep the supply chain honest. The automated migration detects which channels were wired in v1 (via distinct `channel_name` / JID prefix) and runs the matching `setup/install-<channel>.sh` for each. Channels in v1 that don't have a v2 skill (rare now, more common as v2 catches up) are recorded in the handoff file for the `/migrate-from-v1` skill to raise with the user.
**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` step has a per-channel registry (`setup/migrate-v2/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys.
---
## Privilege — from implicit to explicit
**v1:** `registered_groups.is_main = 1` flagged one group as the privileged one. No `users` table. Permissions were conventions, not enforced.
**v2:** explicit tables.
- `users(id = "<channel_type>:<handle>", kind, display_name)` — one row per messaging-platform identifier
- `user_roles(user_id, role ∈ {owner, admin}, agent_group_id nullable, granted_by, granted_at)` — owner is always global; admin can be global or scoped
- `agent_group_members(user_id, agent_group_id, ...)` — "known" membership for the `sender_scope='known'` gate
Owner gets seeded during the `/migrate-from-v1` skill's interview phase ("Which handle is you?"). The automated migration doesn't guess — v1 has no source of truth for it.
**Default access — "anyone can talk to the bot" vs "only known users".** v1 stored this implicitly (via trigger regex + `is_main`). v2 exposes it as `messaging_groups.unknown_sender_policy ∈ {'strict', 'request_approval', 'public'}`. The skill asks the user which mode v1 ran in and flips the migrated messaging groups accordingly.
---
## Group folders on disk
**v1:** `groups/<folder>/CLAUDE.md` and optional `logs/`. `CLAUDE.md` was a plain instruction file, group-specific.
**v2:** each group still lives at `groups/<folder>/`, but the shape is richer:
- `CLAUDE.md`**composed at container spawn** from `.claude-shared.md` (symlink to global) + `.claude-fragments/*.md` (module fragments) + `CLAUDE.local.md`. **Don't edit `CLAUDE.md` directly.**
- `CLAUDE.local.md` — per-group content. The migration writes v1's old `CLAUDE.md` here.
- `container.json` — optional per-group container config (apt deps, env, mounts). v1's `registered_groups.container_config` JSON is close but not identical — the migration stores the v1 payload at `groups/<folder>/.v1-container-config.json` for the skill to reconcile, rather than silently mapping it.
- `.claude-fragments/` and `.claude-shared.md` are installed by `initGroupFilesystem()` the first time the host touches the group, so the migration only has to write `CLAUDE.local.md` and leave the scaffolding to the host.
---
## Host process vs containers
**v1:** single Node process. The "agent" was the same process as the router.
**v2:** Node host at top, Bun-runtime Docker container per session. They communicate only via the two session DBs. No shared modules, no IPC, no stdin piping. If you wrote custom code that reached from the agent into host internals (or vice versa), that surface no longer exists — porting it is a `/migrate-from-v1` skill topic, not a mechanical copy.
Lockfiles: host uses `pnpm-lock.yaml`, agent-runner uses `bun.lock`. `minimumReleaseAge: 4320` on the host side (3-day supply-chain wait); agent-runner has no release-age gate.
---
## Self-modification and MCP tools
**v1:** if you added MCP servers or self-modification plumbing, it was usually direct edits to the long-running process.
**v2:**
- MCP servers register through `container/agent-runner/src/mcp-tools/*.ts` and load per-session. There's also `install_packages` and `add_mcp_server` self-mod tools that go through an admin-approval flow (`src/modules/self-mod/apply.ts`) before rebuilding the container image.
- Custom MCP tools you wrote in v1 map cleanly to the v2 tool registry, but the import paths, runtime (Bun vs Node), and SQL helper differences (`bun:sqlite` uses `$name`-prefixed params) may need adjustment. The skill walks through this.
---
## Things that are gone or don't map
- **`scheduled_tasks` as a separate table** — moved into session `inbound.db` under `kind='task'`. Migration ports active rows; inactive/completed are exported to `logs/setup-migration/inactive-tasks.json` for reference.
- **`messages` / `chats` tables (chat history)** — not migrated. Stay in the v1 checkout if you need them.
- **`router_state` (key/value)** — not migrated. v2 state lives in the explicit tables above.
- **`sessions` (v1 group→session_id)** — v1 sessions don't map; v2 sessions are keyed by `(agent_group_id, messaging_group_id, thread_id)` and are created on demand.
- **Raw access to the old `store/messages.db`** — the v1 DB is left in place and untouched. If migration goes wrong you can re-run it (the migration sub-steps are idempotent for agents/channels/wirings; folders use rsync semantics).
---
## Migration surface — where the code lives
- `migrate-v2.sh` — entry point: `bash migrate-v2.sh` from the v2 checkout.
- `setup/migrate-v2/*.ts` — individual migration steps (env, db, groups, sessions, tasks, channel-auth, select-channels, switchover-prompt).
- `setup/migrate-v2/shared.ts` — JID parsing, trigger mapping, channel auth registry.
- `logs/setup-migration/handoff.json` — written by `migrate-v2.sh`, read by the `/migrate-from-v1` skill.
- `logs/migrate-steps/*.log` — raw per-step stdout.
- `.claude/skills/migrate-from-v1/SKILL.md` — Claude skill for owner seeding, CLAUDE.md cleanup, container config validation, fork porting.
- `migrate-v2-reset.sh` — development helper to wipe v2 state for re-testing.
- See [docs/migration-dev.md](migration-dev.md) for the full development guide.
+504
View File
@@ -0,0 +1,504 @@
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
;; Author: NanoClaw
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: ai, assistant, chat
;;
;; Vanilla Emacs (init.el):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
;;
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
;;
;; Doom Emacs (config.el):
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
;; (map! :leader
;; :prefix ("N" . "NanoClaw")
;; :desc "Chat buffer" "c" #'nanoclaw-chat
;; :desc "Send org" "o" #'nanoclaw-org-send)
;; ;; Evil users: teach evil about the C-c C-c send binding
;; (after! evil
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
;;; Code:
(require 'cl-lib)
(require 'url)
(require 'json)
(require 'org)
;; ---------------------------------------------------------------------------
;; Customization
(defgroup nanoclaw nil
"NanoClaw AI assistant interface."
:group 'tools
:prefix "nanoclaw-")
(defcustom nanoclaw-host "localhost"
"Hostname where NanoClaw is running."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-port 8766
"Port for the NanoClaw Emacs channel HTTP server."
:type 'integer
:group 'nanoclaw)
(defcustom nanoclaw-auth-token nil
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
Leave nil if EMACS_AUTH_TOKEN is not set."
:type '(choice (const nil) string)
:group 'nanoclaw)
(defcustom nanoclaw-poll-interval 1.5
"Seconds between response polls when waiting for a reply."
:type 'number
:group 'nanoclaw)
(defcustom nanoclaw-agent-name "Andy"
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-convert-to-org t
"When non-nil, convert agent responses to org-mode format.
Uses pandoc when available; falls back to regex substitutions."
:type 'boolean
:group 'nanoclaw)
(defcustom nanoclaw-timestamp-format "%H:%M"
"Format string for timestamps shown next to agent replies in the chat buffer.
Passed to `format-time-string'. Set to nil to suppress timestamps."
:type '(choice (const nil) string)
:group 'nanoclaw)
;; ---------------------------------------------------------------------------
;; Formatting helpers
(defun nanoclaw--to-org (text)
"Convert TEXT (markdown or plain) to org-mode markup.
Tries pandoc -f gfm -t org when available; falls back to regex."
(if (not nanoclaw-convert-to-org)
text
(if (executable-find "pandoc")
(with-temp-buffer
(insert text)
(let* ((coding-system-for-read 'utf-8)
(coding-system-for-write 'utf-8)
(exit (call-process-region
(point-min) (point-max)
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
(if (zerop exit)
(string-trim (buffer-string))
text)))
(nanoclaw--md-to-org-regex text))))
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
;; Agents responding on this channel must output markdown, not org-mode syntax.
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
;; re-converted to /bold/ by the italic rule.
(defun nanoclaw--md-to-org-regex (text)
"Lightweight markdown → org conversion using regexp substitutions."
(let ((s text))
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
;; (must run before inline-code to avoid mangling backticks)
(setq s (replace-regexp-in-string
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
(lambda (m)
(let ((lang (match-string 1 m))
(body (match-string 2 m)))
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
"\n" body "#+end_src")))
s t))
;; Bold **text** → *text*, italic *text* → /text/
;; Two-pass to prevent the italic regex from re-matching the bold result:
;; 1. Mark bold spans with a placeholder (control char \x01)
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
;; 2. Convert remaining single-star spans to italic
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
;; 3. Resolve bold placeholders to org bold markers
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
;; Strikethrough ~~text~~ → +text+
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
;; Underline __text__ → _text_
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
;; Inline code `code` → ~code~
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
;; ATX headings ## … → ** …
(setq s (replace-regexp-in-string
"^\\(#+\\) "
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
s))
;; Links [text](url) → [[url][text]]
(setq s (replace-regexp-in-string
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
s))
(defun nanoclaw--format-timestamp ()
"Return a formatted timestamp string, or nil if disabled."
(when nanoclaw-timestamp-format
(format-time-string nanoclaw-timestamp-format)))
;; ---------------------------------------------------------------------------
;; Internal state
(defvar nanoclaw--poll-timer nil
"Timer used to poll for responses in the chat buffer.")
(defvar nanoclaw--last-timestamp 0
"Epoch ms of the most recently received message.")
(defvar nanoclaw--pending nil
"Non-nil while waiting for a response.")
(defvar-local nanoclaw--thinking-dot-count 0
"Dot cycle counter for the animated thinking indicator.")
(defvar-local nanoclaw--input-beg nil
"Marker for the start of the current user input area.")
;; ---------------------------------------------------------------------------
;; HTTP helpers
(defun nanoclaw--url (path)
"Return the full URL for PATH on the NanoClaw server."
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
(defun nanoclaw--headers ()
"Return alist of HTTP headers for NanoClaw requests."
(let ((hdrs '(("Content-Type" . "application/json"))))
(when nanoclaw-auth-token
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
hdrs))
(defun nanoclaw--post (text callback)
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
(let* ((url-request-method "POST")
(url-request-extra-headers (nanoclaw--headers))
(url-request-data (encode-coding-string
(json-encode `((text . ,text)))
'utf-8)))
(url-retrieve
(nanoclaw--url "/api/message")
(lambda (status)
(if (plist-get status :error)
(message "NanoClaw: POST error %s" (plist-get status :error))
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let ((data (ignore-errors (json-read))))
(funcall callback data))))
nil t t)))
(defun nanoclaw--poll (since callback)
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
(let* ((url-request-method "GET")
(url-request-extra-headers (nanoclaw--headers)))
(url-retrieve
(nanoclaw--url (format "/api/messages?since=%d" since))
(lambda (status)
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
(body (decode-coding-string raw 'utf-8))
(data (ignore-errors (json-read-from-string body)))
(msgs (cdr (assq 'messages data))))
(when msgs (funcall callback (append msgs nil))))))
nil t t)))
;; ---------------------------------------------------------------------------
;; Chat buffer
(defvar nanoclaw-chat-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'newline)
(define-key map (kbd "<return>") #'newline)
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
map)
"Keymap for `nanoclaw-chat-mode'.")
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
"Major mode for the NanoClaw chat buffer.
Derives from org-mode so that org markup (headings, bold, code blocks,
etc.) is fontified automatically. RET and <return> insert plain newlines
for multi-line input; send with C-c C-c."
(setq-local word-wrap t)
(visual-line-mode 1)
;; Disable org features that conflict with a linear chat buffer
(setq-local org-return-follows-link nil)
(setq-local org-cycle-emulate-tab nil)
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
(defun nanoclaw--advance-input-beg ()
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
(setq nanoclaw--input-beg (copy-marker (point-max)))))
(defun nanoclaw--chat-buffer ()
"Return the NanoClaw chat buffer, creating it if necessary."
(or (get-buffer "*NanoClaw*")
(with-current-buffer (get-buffer-create "*NanoClaw*")
(nanoclaw-chat-mode)
(set-buffer-file-coding-system 'utf-8)
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
(nanoclaw--insert-header)
(setq nanoclaw--input-beg (copy-marker (point-max)))
(current-buffer))))
(defun nanoclaw--insert-header ()
"Insert the welcome header into the chat buffer."
(let ((inhibit-read-only t))
(insert (propertize
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
nanoclaw-agent-name)
'face 'font-lock-comment-face))))
(defun nanoclaw--chat-insert (speaker text)
"Append SPEAKER: TEXT to the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let* ((inhibit-read-only t)
(is-agent (not (string= speaker "You")))
(display-text (if is-agent (nanoclaw--to-org text) text))
(ts (nanoclaw--format-timestamp))
(label (if ts (format "%s [%s]" speaker ts) speaker))
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
(goto-char (point-max))
(insert (propertize (concat label ": ") 'face face))
(insert display-text "\n\n")
(goto-char (point-max))
(when is-agent
(nanoclaw--advance-input-beg)))))
;;;###autoload
(defun nanoclaw-chat ()
"Open the NanoClaw chat buffer."
(interactive)
(pop-to-buffer (nanoclaw--chat-buffer))
(goto-char (point-max)))
(defun nanoclaw-chat-send ()
"Send the accumulated input area as a message to NanoClaw.
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
(interactive)
(when nanoclaw--pending
(message "NanoClaw: waiting for previous response...")
(cl-return-from nanoclaw-chat-send))
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
(marker-position nanoclaw--input-beg)
(line-beginning-position)))
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
(when (string-empty-p text)
(user-error "Nothing to send"))
(let ((inhibit-read-only t))
(delete-region beg (point-max)))
(nanoclaw--chat-insert "You" text)
(nanoclaw--advance-input-beg)
(setq nanoclaw--pending t)
(nanoclaw--post text
(lambda (data)
(when data
(setq nanoclaw--last-timestamp
(or (cdr (assq 'timestamp data))
nanoclaw--last-timestamp))
(nanoclaw--start-thinking)
(nanoclaw--start-poll))))))
(defun nanoclaw--start-poll ()
"Start polling for new messages."
(nanoclaw--stop-poll)
(setq nanoclaw--poll-timer
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
#'nanoclaw--poll-tick)))
(defun nanoclaw--stop-poll ()
"Stop the polling timer."
(when nanoclaw--poll-timer
(cancel-timer nanoclaw--poll-timer)
(setq nanoclaw--poll-timer nil)))
(defun nanoclaw--start-thinking ()
"Insert an animated thinking indicator at the end of the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let ((inhibit-read-only t))
(goto-char (point-max))
(setq nanoclaw--thinking-dot-count 1)
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
'nanoclaw-thinking t
'face 'font-lock-string-face)))))
(defun nanoclaw--tick-thinking ()
"Advance the dot animation in the thinking indicator."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(when nanoclaw--pending
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(let* ((end (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
(setq nanoclaw--thinking-dot-count n)
(delete-region pos end)
(save-excursion
(goto-char pos)
(insert (propertize
(format "%s: %s\n\n" nanoclaw-agent-name
(make-string n ?.))
'nanoclaw-thinking t
'face 'font-lock-string-face)))))))))))
(defun nanoclaw--clear-thinking ()
"Remove the thinking indicator from the chat buffer."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(delete-region pos (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))))))))
(defun nanoclaw--poll-tick ()
"Poll for new messages and insert them into the chat buffer."
(nanoclaw--tick-thinking)
(nanoclaw--poll
nanoclaw--last-timestamp
(lambda (msgs)
(dolist (msg msgs)
(let ((text (cdr (assq 'text msg)))
(ts (cdr (assq 'timestamp msg))))
(when (and text (> ts nanoclaw--last-timestamp))
(setq nanoclaw--last-timestamp ts)
(nanoclaw--clear-thinking)
(nanoclaw--chat-insert nanoclaw-agent-name text))))
(when msgs
(setq nanoclaw--pending nil)
(nanoclaw--stop-poll)))))
;; ---------------------------------------------------------------------------
;; Org integration
;;;###autoload
(defun nanoclaw-org-send ()
"Send the current org subtree to NanoClaw and insert the response as a child.
If a region is active, send the region text instead."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Not in an org-mode buffer"))
(let ((text (if (use-region-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(nanoclaw--org-subtree-text))))
(when (string-empty-p (string-trim text))
(user-error "Nothing to send"))
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
(let ((marker (point-marker))
(buf (current-buffer)))
(nanoclaw--post
text
(lambda (data)
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
(level (with-current-buffer buf
(save-excursion (goto-char marker) (org-outline-level))))
(ph (with-current-buffer buf
(save-excursion
(goto-char marker)
(nanoclaw--org-insert-placeholder level)))))
(nanoclaw--poll-until-response
ts
(lambda (response)
(with-current-buffer buf
(save-excursion
(when (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil))
(goto-char marker)
(nanoclaw--org-insert-response response))))
(lambda ()
(message "NanoClaw: timed out waiting for response")
(when (marker-buffer ph)
(with-current-buffer (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil)))))))))))
(defun nanoclaw--org-insert-placeholder (level)
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
(org-back-to-heading t)
(org-end-of-subtree t t)
(let ((beg (point)))
(insert "\n" (make-string (1+ level) ?*) " "
nanoclaw-agent-name " [processing...]\n\n")
(copy-marker beg)))
(defun nanoclaw--org-subtree-text ()
"Return the text of the org subtree at point (heading + body)."
(org-with-wide-buffer
(org-back-to-heading t)
(let ((start (point))
(end (progn (org-end-of-subtree t t) (point))))
(buffer-substring-no-properties start end))))
(defun nanoclaw--org-insert-response (text)
"Insert TEXT as a child org heading under the current subtree."
(org-back-to-heading t)
(let* ((level (org-outline-level))
(child-stars (make-string (1+ level) ?*))
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
(body (nanoclaw--to-org text)))
(org-end-of-subtree t t)
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
body "\n")))
(defun nanoclaw--now-ms ()
"Return current time as milliseconds since epoch."
(let ((time (current-time)))
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
(/ (caddr time) 1000))))
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
"Poll until a message newer than SINCE arrives, then call CALLBACK.
Calls TIMEOUT-FN after 60 attempts (~90s)."
(let ((n (or attempts 0)))
(if (>= n 60)
(funcall timeout-fn)
(nanoclaw--poll
since
(lambda (msgs)
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
msgs)))
(if fresh
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
fresh "\n")))
(funcall callback text))
(run-with-timer nanoclaw-poll-interval nil
#'nanoclaw--poll-until-response
since callback timeout-fn (1+ n)))))))))
;; ---------------------------------------------------------------------------
(provide 'nanoclaw)
;;; nanoclaw.el ends here
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env bash
#
# migrate-v2-reset.sh — Wipe v2 migration state back to clean.
#
# For development iteration:
# bash migrate-v2-reset.sh && bash migrate-v2.sh
#
# What it removes:
# - data/ (v2 DBs, session state)
# - logs/ (migration + setup logs)
# - .env (merged env keys)
# - groups/*/ (non-git group folders copied from v1)
# - container/skills/*/ (untracked skill dirs copied from v1)
# - src/channels/*.ts (untracked adapters copied from channels branch)
# - setup/groups.ts (untracked, copied by channel install scripts)
#
# What it restores from git:
# - groups/ (CLAUDE.md files etc.)
# - container/skills/ (tracked container skills)
# - src/channels/ (tracked bridge / registry code)
# - setup/whatsapp-auth.ts (channel installs may overwrite)
# - setup/pair-telegram.ts (channel installs may overwrite)
# - setup/index.ts (channel installs append entries)
# - package.json + pnpm-lock.yaml (channel installs add deps)
#
# What it does NOT touch:
# - node_modules/ (expensive to reinstall, kept on purpose)
# - setup/migrate-v2/* (the migration scripts themselves, plus user WIP)
# - The v1 install (read-only, never modified)
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; }
dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; }
green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; }
clean() {
local target=$1 label=$2
if [ -e "$target" ]; then
rm -rf "$target"
printf '%s Removed %s\n' "$(green '✓')" "$label"
fi
}
echo
printf '%s\n\n' "$(dim 'Resetting v2 migration state…')"
clean "data" "data/"
clean "logs" "logs/"
clean ".env" ".env"
# Remove all group folders, then restore the two git-tracked ones
if [ -d "groups" ]; then
rm -rf groups
printf '%s Removed %s\n' "$(green '✓')" "groups/"
fi
git checkout -- groups/ 2>/dev/null || true
printf '%s Restored %s\n' "$(green '✓')" "groups/ from git"
# Restore container/skills/ to git state (remove v1-copied skills)
git checkout -- container/skills/ 2>/dev/null || true
# Remove any untracked skill dirs that were copied from v1
for d in container/skills/*/; do
[ -d "$d" ] || continue
if ! git ls-files --error-unmatch "$d" >/dev/null 2>&1; then
rm -rf "$d"
fi
done
printf '%s Restored %s\n' "$(green '✓')" "container/skills/ from git"
# Restore channel code (src/channels/) to git state
git checkout -- src/channels/ 2>/dev/null || true
# Remove any untracked channel adapters copied in by install-*.sh
for f in src/channels/*.ts; do
[ -f "$f" ] || continue
if ! git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
rm -f "$f"
fi
done
printf '%s Restored %s\n' "$(green '✓')" "src/channels/ from git"
# Restore tracked setup helpers that channel installs overwrite, and
# remove the untracked ones they create. Don't blanket-clean setup/
# because user WIP (setup/migrate-v2/*) lives there too.
git checkout -- setup/whatsapp-auth.ts setup/pair-telegram.ts setup/index.ts 2>/dev/null || true
rm -f setup/groups.ts
printf '%s Restored %s\n' "$(green '✓')" "setup/ install helpers"
# Restore package.json + lockfile (channel installs add deps like
# @whiskeysockets/baileys). node_modules/ is intentionally kept.
git checkout -- package.json pnpm-lock.yaml 2>/dev/null || true
printf '%s Restored %s\n' "$(green '✓')" "package.json + pnpm-lock.yaml"
echo
printf '%s\n\n' "$(dim 'Clean. Run: bash migrate-v2.sh')"
-742
View File
@@ -1,742 +0,0 @@
#!/usr/bin/env bash
#
# migrate-v2.sh — Migrate a NanoClaw v1 install into this v2 checkout.
#
# Run from the v2 directory:
# bash migrate-v2.sh
#
# If you're in Claude Code, exit first or open a separate terminal.
#
# Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH).
# Installs prerequisites (Node, pnpm, deps) via the existing setup.sh
# bootstrap, then runs the migration steps.
#
# Idempotent — safe to re-run. Use migrate-v2-reset.sh to wipe v2 state
# back to clean for development iteration.
set -uo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
# This script has interactive prompts (channel selection, service switchover)
# and streams progress output — it must run in a real terminal, not inside
# a tool subprocess (e.g. Claude Code's Bash tool, which collapses output).
if ! [ -t 0 ] || ! [ -t 1 ]; then
echo "This script requires an interactive terminal."
echo ""
echo "If you're in Claude Code, exit first or open a separate terminal,"
echo "then run:"
echo " bash migrate-v2.sh"
echo ""
exit 1
fi
LOGS_DIR="$PROJECT_ROOT/logs"
STEPS_DIR="$LOGS_DIR/migrate-steps"
MIGRATE_LOG="$LOGS_DIR/migrate-v2.log"
# Defaults for variables that may not be set if we exit early
V1_PATH=""
V1_VERSION="unknown"
ONECLI_OK=false
SERVICE_SWITCHED=false
SELECTED_CHANNELS=()
ABORTED_AT=""
# Per-step status tracking. Parallel indexed arrays so this works on
# bash 3.2 (macOS default) which has no associative arrays.
STEP_NAMES=()
STEP_STATUSES=()
record_step() {
STEP_NAMES+=("$1")
STEP_STATUSES+=("$2")
}
# Write handoff.json on any exit so the skill can always read it
write_handoff() {
local handoff_dir="$LOGS_DIR/setup-migration"
mkdir -p "$handoff_dir"
local has_failures=false
local i
for ((i=0; i<${#STEP_NAMES[@]}; i++)); do
[ "${STEP_STATUSES[$i]}" = "failed" ] && has_failures=true
done
local overall="success"
$has_failures && overall="partial"
[ -n "$ABORTED_AT" ] && overall="failed"
local steps_json="{"
for ((i=0; i<${#STEP_NAMES[@]}; i++)); do
local n="${STEP_NAMES[$i]}"
local s="${STEP_STATUSES[$i]}"
steps_json="${steps_json}\"${n}\": {\"status\": \"${s}\", \"log\": \"logs/migrate-steps/${n}.log\"},"
done
steps_json="${steps_json%,}}"
cat > "$handoff_dir/handoff.json" <<HANDOFF_EOF
{
"version": 1,
"started_at": "$(ts_utc)",
"v1_path": "$V1_PATH",
"v1_version": "$V1_VERSION",
"overall_status": "$overall",
"aborted_at": "$ABORTED_AT",
"source": "migrate-v2.sh",
"channels_installed": [$(printf '"%s",' "${SELECTED_CHANNELS[@]}" 2>/dev/null | sed 's/,$//')],
"onecli_healthy": $ONECLI_OK,
"service_switched": $SERVICE_SWITCHED,
"steps": $steps_json,
"step_logs_dir": "logs/migrate-steps",
"followups": [
"Seed owner user and access policy",
"Review CLAUDE.local.md files for v1-specific patterns",
"Verify container.json mount paths are valid"
]
}
HANDOFF_EOF
}
trap write_handoff EXIT
abort() {
ABORTED_AT="$1"
log "ABORTED at $1"
exit 1
}
# ─── output helpers ──────────────────────────────────────────────────────
use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; }
dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; }
green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; }
red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; }
bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; }
clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; }
step_ok() { printf '%s %s\n' "$(green '✓')" "$1"; }
step_fail() { printf '%s %s\n' "$(red '✗')" "$1"; }
step_skip() { printf '%s %s\n' "$(dim '')" "$1"; }
step_info() { printf '%s %s\n' "$(dim '·')" "$1"; }
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$MIGRATE_LOG"
}
# ─── init logs ───────────────────────────────────────────────────────────
mkdir -p "$STEPS_DIR"
{
echo "## $(ts_utc) · migrate-v2.sh started"
echo " cwd: $PROJECT_ROOT"
echo ""
} > "$MIGRATE_LOG"
echo
bold "NanoClaw v1 → v2 migration"
echo
echo
# ─── phase 0a: bootstrap prerequisites ──────────────────────────────────
step_info "Installing prerequisites (Node, pnpm, dependencies)…"
BOOTSTRAP_RAW="$STEPS_DIR/01-bootstrap.log"
export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW"
if bash "$PROJECT_ROOT/setup.sh" > "$BOOTSTRAP_RAW" 2>&1; then
# Parse the status block from setup.sh output
STATUS=$(grep '^STATUS:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^STATUS: *//')
NODE_VERSION=$(grep '^NODE_VERSION:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^NODE_VERSION: *//')
if [ "$STATUS" = "success" ]; then
step_ok "Prerequisites ready $(dim "(node $NODE_VERSION)")"
log "Bootstrap succeeded: node=$NODE_VERSION"
else
step_fail "Bootstrap reported: $STATUS"
echo
dim " See: $BOOTSTRAP_RAW"
echo
abort "bootstrap"
fi
else
step_fail "Bootstrap failed"
echo
echo "$(dim '── last 20 lines ──')"
tail -20 "$BOOTSTRAP_RAW" 2>/dev/null || true
echo
dim " Full log: $BOOTSTRAP_RAW"
echo
abort "bootstrap"
fi
# setup.sh may have installed pnpm to a prefix not on our PATH — replay
# the same lookup nanoclaw.sh does.
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
NPM_PREFIX="$(npm config get prefix 2>/dev/null)"
if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then
export PATH="$NPM_PREFIX/bin:$PATH"
fi
fi
if ! command -v pnpm >/dev/null 2>&1; then
step_fail "pnpm not found after bootstrap"
abort "pnpm-missing"
fi
# ─── phase 0b: find v1 install ──────────────────────────────────────────
find_v1() {
# Explicit override
if [ -n "${NANOCLAW_V1_PATH:-}" ]; then
if [ -f "$NANOCLAW_V1_PATH/store/messages.db" ]; then
echo "$NANOCLAW_V1_PATH"
return 0
fi
step_fail "NANOCLAW_V1_PATH=$NANOCLAW_V1_PATH does not contain store/messages.db"
return 1
fi
# Scan sibling directories for anything claw-ish with a v1 DB
local parent
parent="$(dirname "$PROJECT_ROOT")"
for entry in "$parent"/*/; do
[ -d "$entry" ] || continue
# Skip ourselves
[ "$(cd "$entry" && pwd)" = "$PROJECT_ROOT" ] && continue
# Must have the v1 DB
[ -f "$entry/store/messages.db" ] || continue
# Must not be v2 (check package.json version)
if [ -f "$entry/package.json" ]; then
local ver
ver=$(grep '"version"' "$entry/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([0-9]+)\..*/\1/')
[ "$ver" = "2" ] && continue
fi
echo "$(cd "$entry" && pwd)"
return 0
done
return 1
}
V1_PATH=""
if V1_PATH=$(find_v1); then
V1_VERSION=$(grep '"version"' "$V1_PATH/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || echo "unknown")
step_ok "Found v1 at $(dim "$V1_PATH") $(dim "(v$V1_VERSION)")"
log "v1 found: $V1_PATH (v$V1_VERSION)"
else
step_fail "No v1 install found"
echo
echo " $(dim 'Set NANOCLAW_V1_PATH to point at your v1 checkout:')"
echo " $(dim 'NANOCLAW_V1_PATH=~/nanoclaw bash migrate-v2.sh')"
echo
abort "v1-not-found"
fi
# ─── phase 0c: validate v1 DB ───────────────────────────────────────────
V1_DB="$V1_PATH/store/messages.db"
# Quick schema check — make sure the tables we need exist.
# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via
# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI,
# and #2191 documented how a missing CLI here used to surface as a
# misleading "registered_groups missing" abort.
TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 2>/dev/null || true)
if echo "$TABLES" | grep -q "registered_groups"; then
step_ok "v1 database has registered_groups"
else
step_fail "v1 database missing registered_groups table"
abort "v1-db-invalid"
fi
# Show what we found
GROUP_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0)
TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0)
ENV_KEYS=0
if [ -f "$V1_PATH/.env" ]; then
ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0)
fi
step_info "v1 state: $(bold "$GROUP_COUNT") groups, $(bold "$TASK_COUNT") active tasks, $(bold "$ENV_KEYS") env keys"
echo
step_ok "Phase 0 complete — ready to migrate"
echo
log "Phase 0 complete: groups=$GROUP_COUNT tasks=$TASK_COUNT env_keys=$ENV_KEYS"
export NANOCLAW_V1_PATH="$V1_PATH"
export NANOCLAW_V2_PATH="$PROJECT_ROOT"
# ─── run_step helper ─────────────────────────────────────────────────────
# Runs a TypeScript migration step, captures output, reports success/failure.
# Step outcomes are tracked via record_step() into STEP_NAMES/STEP_STATUSES
# (defined above, near write_handoff).
run_step() {
local name=$1 label=$2 script=$3
shift 3
local raw="$STEPS_DIR/${name}.log"
if pnpm exec tsx "$script" "$@" > "$raw" 2>&1; then
local result
result=$(grep '^OK:' "$raw" | head -1 || true)
step_ok "$label $(dim "$result")"
log "$name: $result"
record_step "$name" "success"
# Surface partial errors (rows skipped due to parse/lookup failures)
# even when the step exited successfully — they're easy to miss in the
# raw log and have caused silent migrations before.
if grep -q '^ERROR:' "$raw" 2>/dev/null; then
local err_count
err_count=$(grep -c '^ERROR:' "$raw")
echo " $(dim "${err_count} error(s) reported — see $raw")"
grep '^ERROR:' "$raw" | head -3 | while IFS= read -r line; do
echo " $(dim "$line")"
done
log "$name: ${err_count} non-fatal errors"
fi
elif grep -q '^SKIPPED:' "$raw" 2>/dev/null; then
local reason
reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://')
step_skip "$label $(dim "($reason)")"
log "$name: skipped ($reason)"
record_step "$name" "skipped"
else
step_fail "$label"
echo
tail -10 "$raw" 2>/dev/null | while IFS= read -r line; do
echo " $(dim "$line")"
done
echo
log "$name: FAILED (see $raw)"
record_step "$name" "failed"
fi
}
# ─── phase 1: core state ────────────────────────────────────────────────
echo "$(bold 'Phase 1: Core state')"
echo
run_step "1a-env" \
"Merge .env" \
"setup/migrate-v2/env.ts" "$V1_PATH"
run_step "1b-db" \
"Seed v2 database" \
"setup/migrate-v2/db.ts" "$V1_PATH"
run_step "1c-groups" \
"Copy group folders" \
"setup/migrate-v2/groups.ts" "$V1_PATH"
run_step "1d-sessions" \
"Copy session data" \
"setup/migrate-v2/sessions.ts" "$V1_PATH"
run_step "1e-tasks" \
"Port scheduled tasks" \
"setup/migrate-v2/tasks.ts" "$V1_PATH"
echo
step_ok "Phase 1 complete"
echo
# ─── phase 2: channels (interactive) ────────────────────────────────────
echo "$(bold 'Phase 2: Channels')"
echo
# Channel selection — clack multiselect (interactive) or NANOCLAW_CHANNELS env var.
# NANOCLAW_CHANNELS accepts comma-separated channel names: "telegram,discord"
SELECTED_CHANNELS=()
CHANNEL_SELECT_OUT="$STEPS_DIR/2a-channels-selected.txt"
pnpm exec tsx setup/migrate-v2/select-channels.ts "$CHANNEL_SELECT_OUT" || true
if [ -f "$CHANNEL_SELECT_OUT" ]; then
while IFS= read -r ch; do
[ -n "$ch" ] && SELECTED_CHANNELS+=("$ch")
done < "$CHANNEL_SELECT_OUT"
fi
if [ ${#SELECTED_CHANNELS[@]} -eq 0 ]; then
echo
step_skip "No channels selected"
else
echo
step_info "Selected: ${SELECTED_CHANNELS[*]}"
echo
# 2b. Copy channel auth state
run_step "2b-channel-auth" \
"Copy channel credentials" \
"setup/migrate-v2/channel-auth.ts" "$V1_PATH" "${SELECTED_CHANNELS[@]}"
# 2c. Install channel code
for ch in "${SELECTED_CHANNELS[@]}"; do
INSTALL_SCRIPT="setup/install-${ch}.sh"
STEP_NAME="2c-install-${ch}"
if [ -f "$INSTALL_SCRIPT" ]; then
STEP_LOG="$STEPS_DIR/${STEP_NAME}.log"
if bash "$INSTALL_SCRIPT" > "$STEP_LOG" 2>&1; then
STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//')
if [ "$STATUS_LINE" = "already-installed" ]; then
step_skip "Install $ch $(dim "(already installed)")"
record_step "$STEP_NAME" "skipped"
else
step_ok "Install $ch"
record_step "$STEP_NAME" "success"
fi
log "install-$ch: $STATUS_LINE"
else
step_fail "Install $ch"
tail -5 "$STEP_LOG" 2>/dev/null | while IFS= read -r line; do
echo " $(dim "$line")"
done
log "install-$ch: FAILED (see $STEP_LOG)"
record_step "$STEP_NAME" "failed"
fi
else
step_skip "Install $ch $(dim "(no install script)")"
log "install-$ch: no install script"
record_step "$STEP_NAME" "failed"
fi
done
# 2d. (Removed) WhatsApp LID resolution was previously needed because the
# v6 adapter couldn't reliably translate LID→phone JIDs, so the migration
# pre-created dual messaging_groups rows. With Baileys v7, the adapter
# resolves LIDs via extractAddressingContext + signalRepository.lidMapping
# on every inbound message, so dual rows are unnecessary and were causing
# split sessions.
fi
echo
step_ok "Phase 2 complete"
echo
# ─── phase 3: infrastructure ────────────────────────────────────────────
echo "$(bold 'Phase 3: Infrastructure')"
echo
# 3a. Docker — install if missing (OneCLI needs it)
if command -v docker >/dev/null 2>&1; then
DOCKER_V=$(docker --version 2>/dev/null | head -1)
step_ok "Docker available $(dim "($DOCKER_V)")"
log "Docker: $DOCKER_V"
else
step_info "Installing Docker…"
DOCKER_LOG="$STEPS_DIR/3a-docker.log"
if bash setup/install-docker.sh > "$DOCKER_LOG" 2>&1; then
hash -r 2>/dev/null || true
step_ok "Docker installed"
record_step "3a-docker" "success"
log "Docker: installed"
else
step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")"
record_step "3a-docker" "failed"
log "Docker: FAILED"
fi
fi
# 3b. OneCLI — detect or install via setup step (requires Docker)
ONECLI_OK=false
ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//')
ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}"
if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then
step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")"
ONECLI_OK=true
log "OneCLI: running at $ONECLI_URL_CHECK"
elif command -v docker >/dev/null 2>&1; then
step_info "Setting up OneCLI…"
ONECLI_LOG="$STEPS_DIR/3b-onecli.log"
ONECLI_ERR="$STEPS_DIR/3b-onecli.err"
if pnpm exec tsx setup/index.ts --step onecli > "$ONECLI_LOG" 2>"$ONECLI_ERR"; then
step_ok "OneCLI ready"
ONECLI_OK=true
record_step "3b-onecli" "success"
log "OneCLI: installed/configured"
else
step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")"
record_step "3b-onecli" "failed"
log "OneCLI: FAILED"
fi
else
step_fail "OneCLI needs Docker $(dim "(install Docker first)")"
record_step "3b-onecli" "failed"
log "OneCLI: skipped (no Docker)"
fi
# 3c. Anthropic credential — run the auth setup step if no credential found
if grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then
step_ok "Anthropic credential found in .env"
log "Anthropic credential: found in .env"
elif [ "$ONECLI_OK" = "true" ]; then
step_info "Registering Anthropic credential…"
AUTH_LOG="$STEPS_DIR/3c-auth.log"
AUTH_ERR="$STEPS_DIR/3c-auth.err"
if pnpm exec tsx setup/index.ts --step auth > "$AUTH_LOG" 2>"$AUTH_ERR"; then
step_ok "Anthropic credential registered"
record_step "3c-auth" "success"
log "Anthropic credential: registered via auth step"
else
step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")"
record_step "3c-auth" "failed"
log "Anthropic credential: FAILED"
fi
else
step_info "No Anthropic credential $(dim "(OneCLI not available — add manually to .env)")"
log "Anthropic credential: skipped (no OneCLI)"
fi
# 3d. Copy container skills from v1 that v2 doesn't have
V1_SKILLS_DIR="$V1_PATH/container/skills"
V2_SKILLS_DIR="$PROJECT_ROOT/container/skills"
if [ -d "$V1_SKILLS_DIR" ]; then
SKILLS_COPIED=0
SKILLS_SKIPPED=0
for skill_dir in "$V1_SKILLS_DIR"/*/; do
[ -d "$skill_dir" ] || continue
skill_name=$(basename "$skill_dir")
if [ -d "$V2_SKILLS_DIR/$skill_name" ]; then
SKILLS_SKIPPED=$((SKILLS_SKIPPED + 1))
else
cp -r "$skill_dir" "$V2_SKILLS_DIR/$skill_name"
SKILLS_COPIED=$((SKILLS_COPIED + 1))
fi
done
if [ $SKILLS_COPIED -gt 0 ]; then
step_ok "Copied $SKILLS_COPIED container skills $(dim "(skipped $SKILLS_SKIPPED already in v2)")"
else
step_skip "All v1 container skills already in v2 $(dim "($SKILLS_SKIPPED)")"
fi
log "Container skills: copied=$SKILLS_COPIED skipped=$SKILLS_SKIPPED"
else
step_skip "No v1 container skills"
fi
# 3e. Build agent container image
if command -v docker >/dev/null 2>&1; then
step_info "Building agent container image…"
BUILD_LOG="$STEPS_DIR/3e-container-build.log"
if bash container/build.sh > "$BUILD_LOG" 2>&1; then
step_ok "Container image built"
record_step "3e-build" "success"
log "Container build: success"
else
step_fail "Container build failed"
record_step "3e-build" "failed"
tail -10 "$BUILD_LOG" 2>/dev/null | while IFS= read -r line; do
echo " $(dim "$line")"
done
log "Container build: FAILED (see $BUILD_LOG)"
fi
else
step_fail "Docker not available — cannot build container"
record_step "3e-build" "failed"
log "Container build: skipped (no Docker)"
fi
echo
step_ok "Phase 3 complete"
echo
# ─── service switchover ─────────────────────────────────────────────────
echo "$(bold 'Service switchover')"
echo
# Disable the v1 service so it doesn't auto-start, but leave the unit file
# on disk so the user can rollback with: systemctl --user start nanoclaw
# Idempotent — safe to call multiple times.
disable_v1_service() {
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
local v1_file="$HOME/.config/systemd/user/${V1_SERVICE}.service"
if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then
systemctl --user stop "$V1_SERVICE" 2>/dev/null || true
systemctl --user disable "$V1_SERVICE" 2>/dev/null || true
step_ok "Disabled $V1_SERVICE (unit file kept for rollback)"
fi
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist"
if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then
launchctl unload "$v1_plist" 2>/dev/null || true
step_ok "Unloaded $V1_SERVICE (plist kept for rollback)"
fi
fi
}
# Detect platform and service names
V1_SERVICE=""
V2_SERVICE=""
PLATFORM_SERVICE=""
if [ "$(uname -s)" = "Darwin" ]; then
PLATFORM_SERVICE="launchd"
V1_SERVICE="com.nanoclaw"
# v2 uses install-slug for unique service names
V2_SERVICE=$(pnpm exec tsx -e "import{getLaunchdLabel}from'./src/install-slug.js';console.log(getLaunchdLabel())" 2>/dev/null || echo "")
elif [ "$(uname -s)" = "Linux" ]; then
PLATFORM_SERVICE="systemd"
V1_SERVICE="nanoclaw"
V2_SERVICE=$(pnpm exec tsx -e "import{getSystemdUnit}from'./src/install-slug.js';console.log(getSystemdUnit())" 2>/dev/null || echo "")
fi
# Check if v1 service is running
V1_RUNNING=false
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
systemctl --user is-active "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
launchctl list "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true
fi
SERVICE_SWITCHED=false
if [ "$V1_RUNNING" = "true" ]; then
step_info "v1 service is running $(dim "($V1_SERVICE)")"
# Ask user if they want to switch
SWITCH_ANSWER_FILE=$(mktemp)
pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch "$SWITCH_ANSWER_FILE" || true
SWITCH_ANSWER=$(cat "$SWITCH_ANSWER_FILE" 2>/dev/null || echo "skip")
rm -f "$SWITCH_ANSWER_FILE"
if [ "$SWITCH_ANSWER" = "switch" ]; then
# Stop v1
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
systemctl --user stop "$V1_SERVICE" 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1"
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
launchctl unload ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1"
fi
# Install and start v2 service
V2_SERVICE_LOG="$STEPS_DIR/service-install.log"
V2_SERVICE_ERR="$STEPS_DIR/service-install.err"
if pnpm exec tsx setup/index.ts --step service > "$V2_SERVICE_LOG" 2>"$V2_SERVICE_ERR"; then
# Parse the actual unit name from the service step stdout (clean, no ANSI)
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
V2_SERVICE=$(grep '^SERVICE_UNIT:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_UNIT: *//')
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
V2_SERVICE=$(grep '^SERVICE_LABEL:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_LABEL: *//')
fi
step_ok "v2 service installed and started $(dim "($V2_SERVICE)")"
else
step_fail "Could not start v2 service $(dim "(see $V2_SERVICE_LOG)")"
fi
SERVICE_SWITCHED=true
echo
step_info "v2 is running — send a test message to your bot"
echo
# Ask: keep or revert?
KEEP_ANSWER_FILE=$(mktemp)
pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --keep-or-revert "$KEEP_ANSWER_FILE" || true
KEEP_ANSWER=$(cat "$KEEP_ANSWER_FILE" 2>/dev/null || echo "keep")
rm -f "$KEEP_ANSWER_FILE"
if [ "$KEEP_ANSWER" = "revert" ]; then
# Stop v2
if [ "$PLATFORM_SERVICE" = "systemd" ] && [ -n "$V2_SERVICE" ]; then
systemctl --user stop "$V2_SERVICE" 2>/dev/null || true
systemctl --user disable "$V2_SERVICE" 2>/dev/null || true
elif [ "$PLATFORM_SERVICE" = "launchd" ] && [ -n "$V2_SERVICE" ]; then
launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist 2>/dev/null || true
fi
# Restart v1
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
systemctl --user start "$V1_SERVICE" 2>/dev/null || true
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null || true
fi
step_ok "Reverted to v1 service"
SERVICE_SWITCHED=false
else
step_ok "Keeping v2 service"
disable_v1_service
fi
else
step_skip "Service switchover skipped"
fi
else
step_skip "v1 service not running — nothing to switch"
disable_v1_service
fi
echo
# ─── phase 4: handoff ───────────────────────────────────────────────────
# handoff.json is written by the EXIT trap (write_handoff) — always, even on
# abort. Here we just print the summary.
echo "$(bold 'Phase 4: Handoff')"
echo
step_ok "Wrote handoff summary"
# Summary
echo
echo "$(bold '── Migration complete ──')"
echo
echo " $(dim 'v1:') $V1_PATH"
echo " $(dim 'v2:') $PROJECT_ROOT"
echo
echo " $(bold 'What was done:')"
echo " $(green '✓') .env keys merged"
echo " $(green '✓') Database seeded (agent groups, messaging groups, wiring)"
echo " $(green '✓') Group folders copied (CLAUDE.md → CLAUDE.local.md)"
echo " $(green '✓') Session data copied"
echo " $(green '✓') Scheduled tasks ported"
if [ ${#SELECTED_CHANNELS[@]} -gt 0 ]; then
echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}"
fi
echo " $(green '✓') Container skills copied"
echo " $(green '✓') Container image built"
if [ "$SERVICE_SWITCHED" = "true" ] && [ -n "$V2_SERVICE" ]; then
echo " $(green '✓') Service switched to v2 $(dim "($V2_SERVICE)")"
echo
echo " $(bold 'Rollback to v1:')"
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
echo " $(dim '$') systemctl --user stop $V2_SERVICE && systemctl --user start $V1_SERVICE"
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
echo " $(dim '$') launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist && launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist"
fi
fi
echo
echo " $(bold 'What still needs a human:')"
if [ "$ONECLI_OK" = "false" ]; then
echo " $(dim '·') Set up OneCLI: pnpm exec tsx setup/index.ts --step onecli"
fi
if ! grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then
echo " $(dim '·') Add Anthropic credential to .env or OneCLI vault"
fi
echo " $(dim '·') Run $(bold '/migrate-from-v1') in Claude to finish:"
echo " $(dim '- Seed your owner account')"
echo " $(dim '- Set access policies')"
echo " $(dim '- Port any custom v1 code')"
echo
echo " $(dim "Handoff: $LOGS_DIR/setup-migration/handoff.json")"
echo " $(dim "Full log: $MIGRATE_LOG")"
echo " $(dim "Step logs: $STEPS_DIR/")"
echo
# ─── hand off to Claude ─────────────────────────────────────────────────
if command -v claude >/dev/null 2>&1; then
write_handoff
trap - EXIT
exec claude "/migrate-from-v1"
fi
+7 -117
View File
@@ -129,123 +129,10 @@ rm -f "$PROGRESS_LOG"
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
write_header
# NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
# with the figlet wordmark and taglines below. Pre-rendered into
# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
# figlet); the bash script just streams the literal frame. clack's intro
# then carries the "let's get you set up" framing — setup:auto sees
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
cat "$PROJECT_ROOT/assets/setup-splash.txt"
# ─── pre-flight: minimum hardware specs ────────────────────────────────
# NanoClaw runs an agent container per session. Below this threshold the
# host + container + agent will struggle (OOM under load). Soft warn — the
# user can override.
# RAM floor is set below 4 GB because "4 GB" VMs typically report 37003900 MB
# after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800).
MIN_MEM_MB=3700
detect_mem_mb() {
case "$(uname -s)" in
Linux)
awk '/^MemTotal:/ {printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null
;;
Darwin)
local bytes
bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
echo $(( bytes / 1024 / 1024 ))
;;
esac
}
MEM_MB=$(detect_mem_mb)
: "${MEM_MB:=0}"
LOW_MEM=false
[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true
if [ "$LOW_MEM" = true ]; then
printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')"
printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ RAM machine. Below this, the host + agent')"
printf ' %s\n' "$(dim 'container will run out of memory under most workloads. A stronger')"
printf ' %s\n' "$(dim 'machine is strongly recommended.')"
printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")"
printf '\n'
read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS </dev/tty
case "${SPECS_ANS:-N}" in
[Yy]*)
ph_event setup_low_specs_continued mem_mb="$MEM_MB" low_mem="$LOW_MEM"
printf '\n'
;;
*)
ph_event setup_low_specs_aborted mem_mb="$MEM_MB" low_mem="$LOW_MEM"
printf '\n %s\n\n' "$(dim 'Aborted. Re-run after upgrading the host.')"
exit 1
;;
esac
fi
# ─── pre-flight: Google Cloud VM warning (Linux) ──────────────────────
# NanoClaw is known to not run reliably on Google Compute Engine instances.
# Warn early — before the root check or bootstrap spinner — so users can
# switch providers before sinking time into setup. Detection uses DMI
# (no network round-trip), which on GCE reports "Google" / "Google
# Compute Engine".
if [ "$(uname -s)" = "Linux" ] \
&& { grep -qi 'Google' /sys/class/dmi/id/product_name 2>/dev/null \
|| grep -qi 'Google' /sys/class/dmi/id/sys_vendor 2>/dev/null; }; then
printf ' %s\n' "$(red 'Warning: Google Cloud VM detected.')"
printf ' %s\n' "$(dim 'Google blocks sudo commands, so NanoClaw is unlikely to run successfully on this VM.')"
printf ' %s\n\n' "$(dim 'If you want to run NanoClaw successfully, switch to a different provider (Hetzner, Hostinger, exe.dev and others..).')"
read -r -p " $(bold 'Try anyway?') [y/N] " GCE_ANS </dev/tty
case "${GCE_ANS:-N}" in
[Yy]*)
ph_event setup_gce_continued
printf '\n'
;;
*)
ph_event setup_gce_aborted
printf '\n %s\n\n' "$(dim 'Aborted. Re-run on a non-GCE host to continue.')"
exit 1
;;
esac
fi
# ─── pre-flight: root user warning (Linux) ────────────────────────────
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
printf ' %s\n' \
"$(red 'Warning: you are running as root.')"
printf ' %s\n' \
"$(dim "Running NanoClaw as root is not recommended. It can cause permission")"
printf ' %s\n\n' \
"$(dim "issues with containers, services, and file ownership.")"
printf ' %s\n' "$(bold '1)') $(dim 'Show me instructions for creating a new Linux user')"
printf ' %s\n\n' "$(bold '2)') $(dim 'Continue setting up NanoClaw as root user (not recommended)')"
read -r -p " $(bold 'Choose [1/2]: ')" ROOT_ANS </dev/tty
case "${ROOT_ANS:-1}" in
2)
ph_event setup_root_continued
printf '\n'
;;
*)
ph_event setup_root_aborted
printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')"
printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')"
printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')"
printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')"
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
printf ' %s\n' "$(dim '4. Log out: exit')"
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/nanocoai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
exit 1
;;
esac
fi
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
# skips re-printing the wordmark, keeping the flow visually continuous.
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
@@ -301,6 +188,9 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
BOOTSTRAP_LABEL="Installing the basics"
BOOTSTRAP_START=$(date +%s)
# One-line "why" that teaches a differentiator while the user waits.
printf '%s %s\n' "$(gray '│')" \
"$(dim "Small. Runs on your machine. Yours to modify.")"
spinner_start "$BOOTSTRAP_LABEL"
# Run in the background so we can tick elapsed time. Capture exit code via
+21 -7
View File
@@ -1,13 +1,10 @@
{
"name": "nanoclaw",
"version": "2.0.61",
"version": "2.0.14",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
"main": "dist/index.js",
"bin": {
"ncl": "bin/ncl"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
@@ -19,7 +16,6 @@
"prepare": "husky",
"setup": "tsx setup/index.ts",
"setup:auto": "tsx setup/auto.ts",
"ncl": "tsx src/cli/client.ts",
"chat": "tsx scripts/chat.ts",
"auth": "tsx src/whatsapp-auth.ts",
"lint": "eslint src/",
@@ -28,13 +24,31 @@
"test:watch": "vitest"
},
"dependencies": {
"@beeper/chat-adapter-matrix": "^0.2.0",
"@bitbasti/chat-adapter-webex": "^0.1.0",
"@chat-adapter/discord": "^4.24.0",
"@chat-adapter/gchat": "^4.24.0",
"@chat-adapter/github": "^4.24.0",
"@chat-adapter/linear": "^4.26.0",
"@chat-adapter/slack": "^4.24.0",
"@chat-adapter/state-memory": "^4.24.0",
"@chat-adapter/teams": "^4.24.0",
"@chat-adapter/telegram": "4.26.0",
"@chat-adapter/whatsapp": "^4.24.0",
"@clack/core": "^1.2.0",
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.5.0",
"@onecli-sh/sdk": "^0.3.1",
"@resend/chat-sdk-adapter": "^0.1.1",
"@types/qrcode": "^1.5.6",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"chat-adapter-imessage": "^0.1.1",
"cron-parser": "5.5.0",
"kleur": "^4.1.5"
"kleur": "^4.1.5",
"pino": "^9.6.0",
"qrcode": "^1.5.4",
"wechat-ilink-client": "^0.1.0"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
+3875 -7
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -12,7 +12,7 @@ A GitHub Action that calculates the size of your codebase in terms of tokens and
## Usage
```yaml
- uses: nanocoai/nanoclaw/repo-tokens@v1
- uses: qwibitai/nanoclaw/repo-tokens@v1
with:
include: 'src/**/*.ts'
exclude: 'src/**/*.test.ts'
@@ -34,7 +34,7 @@ Repos using repo-tokens:
| Repo | Badge |
|------|-------|
| [NanoClaw](https://github.com/nanocoai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/nanocoai/NanoClaw/main/repo-tokens/badge.svg) |
| [NanoClaw](https://github.com/qwibitai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/qwibitai/NanoClaw/main/repo-tokens/badge.svg) |
### Full workflow example
@@ -59,7 +59,7 @@ jobs:
with:
python-version: '3.12'
- uses: nanocoai/nanoclaw/repo-tokens@v1
- uses: qwibitai/nanoclaw/repo-tokens@v1
id: tokens
with:
include: 'src/**/*.ts'
+2 -2
View File
@@ -114,7 +114,7 @@ runs:
with open(readme_path, "r", encoding="utf-8") as f:
content = f.read()
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
linked_badge = f'<a href="{repo_tokens_url}">{badge}</a>'
new_content = marker_re.sub(rf"\1{linked_badge}\2", content)
@@ -148,7 +148,7 @@ runs:
lx = label_w // 2
vx = label_w + value_w // 2
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total_w}" height="20" role="img" aria-label="{full_desc}">
<title>{full_desc}</title>
+6 -6
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="174k tokens, 87% of context window">
<title>174k tokens, 87% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="133k tokens, 66% of context window">
<title>133k tokens, 66% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -7,16 +7,16 @@
<clipPath id="r">
<rect width="90" height="20" rx="3" fill="#fff"/>
</clipPath>
<a xlink:href="https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens">
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#e05d44"/>
<rect x="52" width="38" height="20" fill="#dfb317"/>
<rect width="90" height="20" fill="url(#s)"/>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">174k</text>
<text x="71" y="14">174k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">133k</text>
<text x="71" y="14">133k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -2
View File
@@ -1,5 +1,5 @@
/**
* ncl chat with your NanoClaw agent from the terminal.
* nc chat with your NanoClaw agent from the terminal.
*
* Usage:
* pnpm run chat <message...>
@@ -36,7 +36,7 @@ function main(): void {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
console.error(`NanoClaw daemon not reachable at ${socketPath()}.`);
console.error('Start the service (launchctl/systemd) before running ncl.');
console.error('Start the service (launchctl/systemd) before running nc.');
} else {
console.error('CLI socket error:', err);
}
-75
View File
@@ -1,75 +0,0 @@
/**
* Delete the scratch CLI agent created during setup's ping-pong test.
*
* Dynamically finds and removes all rows referencing the agent group
* (any table with an agent_group_id column), deletes the agent group
* itself, and removes the groups/<folder>/ directory. Leaves the CLI
* messaging group intact so it can be reused for a new agent.
*
* Usage:
* pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from '../src/config.js';
import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
interface Args {
folder: string;
}
function parseArgs(): Args {
const argv = process.argv.slice(2);
let folder = '';
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i];
}
if (!folder) {
console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>');
process.exit(1);
}
return { folder };
}
const args = parseArgs();
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
const ag = getAgentGroupByFolder(args.folder);
if (!ag) {
console.log(`No agent group with folder "${args.folder}" — nothing to delete.`);
process.exit(0);
}
const cleanup = db.transaction(() => {
const tables = db
.prepare(
`SELECT DISTINCT m.name FROM sqlite_master m
JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id'
WHERE m.type = 'table' AND m.name != 'agent_groups'`,
)
.all() as { name: string }[];
for (const { name } of tables) {
db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id);
}
deleteAgentGroup(ag.id);
});
cleanup();
// Remove the groups/<folder>/ directory.
const groupDir = path.join(process.cwd(), 'groups', args.folder);
if (fs.existsSync(groupDir)) {
fs.rmSync(groupDir, { recursive: true });
}
// Remove session data on disk.
const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id);
if (fs.existsSync(sessionsDir)) {
fs.rmSync(sessionsDir, { recursive: true });
}
console.log(`Deleted agent group ${ag.id} (${args.folder}).`);
+2 -8
View File
@@ -1,5 +1,5 @@
/**
* Initialize the scratch CLI agent used during `/new-setup`.
* Initialize the scratch CLI agent used during `/setup`.
*
* Creates the synthetic `cli:local` user, grants owner role if no owner
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
@@ -41,13 +41,11 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
interface Args {
displayName: string;
agentName: string;
folder?: string;
}
function parseArgs(argv: string[]): Args {
let displayName: string | undefined;
let agentName: string | undefined;
let folder: string | undefined;
for (let i = 0; i < argv.length; i++) {
const key = argv[i];
const val = argv[i + 1];
@@ -57,9 +55,6 @@ function parseArgs(argv: string[]): Args {
} else if (key === '--agent-name') {
agentName = val;
i++;
} else if (key === '--folder') {
folder = val;
i++;
}
}
@@ -72,7 +67,6 @@ function parseArgs(argv: string[]): Args {
return {
displayName,
agentName: agentName?.trim() || displayName,
folder,
};
}
@@ -101,7 +95,7 @@ async function main(): Promise<void> {
const promotedToOwner = false;
// 2. Agent group + filesystem.
const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
const folder = `cli-with-${normalizeName(args.displayName)}`;
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) {
const agId = generateId('ag');
-3
View File
@@ -47,7 +47,6 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
import { upsertUser } from '../src/modules/permissions/db/users.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
@@ -232,8 +231,6 @@ async function main(): Promise<void> {
granted_at: now,
});
}
// Owner's agent group gets global CLI access
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
} else if (args.role === 'admin') {
const alreadyAdmin = existingRoles.some(
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
-106
View File
@@ -1,106 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import Database from 'better-sqlite3';
/**
* Smoke tests for the q.ts sqlite-CLI replacement wrapper.
*
* Verifies the two modes (SELECT prints rows in sqlite3 default "list"
* format; mutation runs via db.exec) and a few edge cases that real
* skill invocations rely on.
*/
const Q = path.resolve(__dirname, 'q.ts');
describe('scripts/q.ts', () => {
let tempDir: string;
let dbPath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-'));
dbPath = path.join(tempDir, 'test.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE t (id INTEGER, name TEXT, note TEXT);
INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL);
`);
db.close();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
function run(sql: string): { stdout: string; stderr: string; status: number } {
const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], {
encoding: 'utf-8',
cwd: path.resolve(__dirname, '..'),
});
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
}
it('SELECT prints pipe-separated rows in default order', () => {
const r = run('SELECT id, name FROM t ORDER BY id');
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe('1|alice\n2|bob');
});
it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => {
const r = run('SELECT id, note FROM t ORDER BY id');
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe('1|hi\n2|');
});
it('SELECT with no rows prints nothing', () => {
const r = run("SELECT id FROM t WHERE name = 'nobody'");
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
});
it('INSERT runs via db.exec and persists', () => {
const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')");
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
const db = new Database(dbPath, { readonly: true });
const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string };
db.close();
expect(row.name).toBe('carol');
});
it('compound mutation statements execute together', () => {
const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');");
expect(r.status).toBe(0);
const db = new Database(dbPath, { readonly: true });
const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map(
(r) => r.id,
);
db.close();
expect(ids).toEqual([2, 9]);
});
it('WITH...DELETE is treated as a mutation, not a query', () => {
const r = run("WITH stale AS (SELECT id FROM t WHERE name = 'alice') DELETE FROM t WHERE id IN (SELECT id FROM stale)");
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare('SELECT name FROM t').all() as { name: string }[];
db.close();
expect(rows).toEqual([{ name: 'bob' }]);
});
it('exits 2 with usage when args are missing', () => {
const r = spawnSync('pnpm', ['exec', 'tsx', Q], {
encoding: 'utf-8',
cwd: path.resolve(__dirname, '..'),
});
expect(r.status).toBe(2);
expect(r.stderr).toMatch(/Usage/);
});
});
-58
View File
@@ -1,58 +0,0 @@
/**
* scripts/q.ts sqlite3 CLI replacement for skill SQL invocations.
*
* Usage:
* pnpm exec tsx scripts/q.ts <db-path> "<sql>"
*
* Uses better-sqlite3's stmt.reader property to distinguish queries
* (SELECT / WITH...SELECT) from mutations. Queries print rows in
* sqlite3 CLI default ("list") format pipe-separated, no header
* so existing skill text reads identically. Mutations run via
* stmt.run() (single statement) or db.exec() (compound).
*
* Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids
* depending on the sqlite3 CLI binary; setup never installs or probes
* for it. Skills that shell out to `sqlite3` therefore fail on hosts
* where it isn't preinstalled (common on fresh Ubuntu see #2191).
* This wrapper preserves the skill-text shape (path then SQL string)
* while routing through the better-sqlite3 dep that setup already
* installs and verifies.
*/
import Database from 'better-sqlite3';
const [, , dbPath, sql] = process.argv;
if (!dbPath || sql === undefined) {
console.error('Usage: pnpm exec tsx scripts/q.ts <db-path> "<sql>"');
process.exit(2);
}
const db = new Database(dbPath);
try {
try {
const stmt = db.prepare(sql);
if (stmt.reader) {
const rows = stmt.all() as Record<string, unknown>[];
for (const row of rows) {
console.log(
Object.values(row)
.map((v) => (v === null ? '' : String(v)))
.join('|'),
);
}
} else {
stmt.run();
}
} catch (e: unknown) {
// better-sqlite3 throws on compound statements ("contains more than
// one statement"). Compound SQL in skills is always mutations
// (e.g. "DELETE ...; INSERT ...;"), so fall back to db.exec().
if (e instanceof Error && /more than one statement/i.test(e.message)) {
db.exec(sql);
} else {
throw e;
}
}
} finally {
db.close();
}
Regular → Executable
View File
+2 -2
View File
@@ -7,7 +7,7 @@
* already exists unless --force is passed.
*
* The actual user-facing prompt (subscription vs API key, paste the token)
* stays in the /new-setup SKILL.md. This step is just the machine side:
* stays in the /setup SKILL.md. This step is just the machine side:
* it calls `onecli secrets list` / `onecli secrets create` and emits a
* structured status block. The token value is never logged.
*/
@@ -124,7 +124,7 @@ export async function run(args: string[]): Promise<void> {
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'onecli_list_failed',
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
HINT: 'Is OneCLI running? Run `/setup` from the onecli step.',
LOG: 'logs/setup.log',
});
process.exit(1);
+97 -257
View File
@@ -14,8 +14,8 @@
* "Terminal Agent".
* NANOCLAW_SKIP comma-separated step names to skip
* (environment|container|onecli|auth|mounts|
* service|cli-agent|timezone|channel|
* verify|first-chat)
* service|cli-agent|timezone|channel|verify|
* first-chat)
*
* Timezone is auto-detected after the CLI agent step. UTC resolves are
* confirmed with the user, and free-text replies fall through to a
@@ -23,13 +23,11 @@
*/
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import * as os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
@@ -39,7 +37,7 @@ import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
import { offerClaudeAssist } from './lib/claude-assist.js';
import {
applyToEnv,
parseFlags,
@@ -48,29 +46,21 @@ import {
} from './lib/setup-config-parse.js';
import { runAdvancedScreen } from './lib/setup-config-screen.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
import { pollHealth } from './onecli.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js';
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now();
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'other' | 'skip';
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
async function main(): Promise<void> {
// Make sure ~/.local/bin is on PATH for every child process we spawn.
// Installers we run mid-setup (OneCLI, claude) drop binaries there and
// append a PATH line to the user's shell rc, but rc updates don't reach
// an already-running Node process — so without this patch a freshly
// installed `onecli` is invisible to a subsequent `runInheritScript`.
ensureLocalBinOnPath();
// Parse CLI flags first — `--help` short-circuits before we render anything,
// and flag values get folded into process.env so existing step code reading
// NANOCLAW_* sees them unchanged.
@@ -94,21 +84,17 @@ async function main(): Promise<void> {
// Welcome menu — default path or open advanced overrides before any setup
// work begins. Default lands on standard so Enter is the happy path.
// On sg re-exec, the user already chose — skip straight to standard.
let startChoice: 'default' | 'advanced' = 'default';
if (process.env.NANOCLAW_REEXEC_SG !== '1') {
startChoice = ensureAnswer(
await brightSelect<'default' | 'advanced'>({
message: 'How would you like to begin?',
options: [
{ value: 'default', label: 'Standard setup' },
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
],
initialValue: 'default',
}),
) as 'default' | 'advanced';
setupLog.userInput('start_choice', startChoice);
}
const startChoice = ensureAnswer(
await brightSelect<'default' | 'advanced'>({
message: 'How would you like to begin?',
options: [
{ value: 'default', label: 'Standard setup' },
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
],
initialValue: 'default',
}),
) as 'default' | 'advanced';
setupLog.userInput('start_choice', startChoice);
if (startChoice === 'advanced') {
configValues = await runAdvancedScreen(configValues);
applyToEnv(configValues);
@@ -136,13 +122,11 @@ async function main(): Promise<void> {
}
if (!skip.has('container')) {
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
p.log.message(
brandBody(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
),
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
),
);
const res = await runWindowedStep('container', {
@@ -177,11 +161,9 @@ async function main(): Promise<void> {
if (!skip.has('onecli')) {
p.log.message(
brandBody(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
),
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
),
);
@@ -305,39 +287,29 @@ async function main(): Promise<void> {
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
}
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
p.log.message(
brandBody(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
),
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
);
}
}
let displayName: string | undefined;
async function resolveDisplayName(): Promise<string> {
if (displayName) return displayName;
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
const existing = detectExistingDisplayName(process.cwd());
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
if (needsDisplayName) {
const fallback = process.env.USER?.trim() || 'Operator';
displayName = preset || existing || (await askDisplayName(fallback));
return displayName;
}
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
skip.add('cli-agent');
skip.add('first-chat');
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
displayName = preset || (await askDisplayName(fallback));
}
if (!skip.has('cli-agent')) {
await resolveDisplayName();
const res = await runQuietStep(
'cli-agent',
{
running: 'Bringing your assistant online…',
done: 'Assistant wired up.',
},
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'],
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
);
if (!res.ok) {
await fail(
@@ -348,39 +320,16 @@ async function main(): Promise<void> {
}
if (!skip.has('first-chat')) {
p.log.message(
brandBody(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
),
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
),
);
const ping = await confirmAssistantResponds();
if (ping === 'ok') {
phEmit('first_chat_ready');
const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent');
const cleanupStart = Date.now();
const cleanup = await spawnQuiet(
'pnpm',
['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'],
cleanupRawLog,
);
setupLog.step(
'cleanup-cli-agent',
cleanup.ok ? 'success' : 'failed',
Date.now() - cleanupStart,
{ exit_code: cleanup.exitCode },
cleanupRawLog,
);
if (!cleanup.ok) {
p.log.warn(
brandBody(
`Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`,
),
);
}
const next = ensureAnswer(
await brightSelect<'continue' | 'chat'>({
await p.select({
message: 'What next?',
options: [
{
@@ -396,27 +345,11 @@ async function main(): Promise<void> {
}),
) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next);
if (next === 'chat') {
const terminalAgentName = `${displayName!}'s Terminal`;
const createRes = await runQuietChild(
'create-terminal-agent',
'pnpm',
['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
{ running: `Creating ${terminalAgentName}`, done: `${terminalAgentName} is ready.` },
);
if (!createRes.ok) {
await fail(
'create-terminal-agent',
`Couldn't create ${terminalAgentName}.`,
'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
);
}
await runFirstChat();
}
if (next === 'chat') await runFirstChat();
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
await offerClaudeOnFailure({
await offerClaudeAssist({
stepName: 'cli-agent',
msg:
ping === 'socket_error'
@@ -435,51 +368,30 @@ async function main(): Promise<void> {
await runTimezoneStep();
}
// v1 → v2 migration is handled by `bash migrate-v2.sh`, not the setup flow.
// Users migrating from v1 run that script before (or instead of) setup.
let channelChoice: ChannelChoice = 'skip';
if (!skip.has('channel')) {
// Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on
// its first prompt and bounce the user back to the chooser without
// restarting setup. Channels not yet wired with the back option just
// return void and the loop exits after one pass.
let backed = true;
while (backed) {
backed = false;
channelChoice = await askChannelChoice();
if (channelChoice !== 'skip' && channelChoice !== 'other') {
await resolveDisplayName();
}
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
if (channelChoice === 'telegram') {
result = await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
result = await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
result = await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
result = await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
result = await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
result = await runSlackChannel(displayName!);
} else if (channelChoice === 'imessage') {
result = await runIMessageChannel(displayName!);
} else if (channelChoice === 'other') {
result = await askOtherChannelName();
} else {
p.log.info(
brandBody(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
),
);
}
if (result === BACK_TO_CHANNEL_SELECTION) backed = true;
channelChoice = await askChannelChoice();
if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
await runSlackChannel(displayName!);
} else if (channelChoice === 'imessage') {
await runIMessageChannel(displayName!);
} else {
p.log.info(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
);
}
}
@@ -508,6 +420,14 @@ async function main(): Promise<void> {
6,
),
);
} else {
const agentPing = res.terminal?.fields.AGENT_PING;
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
notes.push(
"• Your assistant didn't reply to a test message. " +
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
);
}
}
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
notes.push(
@@ -515,7 +435,7 @@ async function main(): Promise<void> {
);
}
if (notes.length > 0) {
note(notes.join('\n'), "What's left");
p.note(notes.join('\n'), "What's left");
}
// "What's left" is a soft failure — we don't abort like fail(), but the
// user is still stuck and a fix is exactly what claude-assist is for.
@@ -527,8 +447,9 @@ async function main(): Promise<void> {
unresolved_count: notes.length,
service_running: res.terminal?.fields.SERVICE === 'running',
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
agent_responds: res.terminal?.fields.AGENT_PING === 'ok',
});
await offerClaudeOnFailure({
await offerClaudeAssist({
stepName: 'verify',
msg: summary || 'Verification completed with unresolved issues.',
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
@@ -546,11 +467,11 @@ async function main(): Promise<void> {
];
const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
note(nextSteps, 'Try these');
p.note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the
// caveat doesn't land after the user's already looked away at their phone.
note(
p.note(
wrapForGutter(
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
6,
@@ -567,7 +488,7 @@ async function main(): Promise<void> {
// that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro.
note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
p.outro(k.green("You're set."));
} else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
@@ -589,7 +510,10 @@ function channelDmLabel(choice: ChannelChoice): string | null {
case 'imessage':
return 'iMessage';
case 'slack':
return 'Slack DMs';
// Slack install doesn't wire an agent or send a welcome DM — the
// driver prints its own "finish in your Slack app" note. Falling
// through to null avoids a misleading "check your Slack DMs" banner.
return null;
default:
return null;
}
@@ -608,16 +532,18 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const s = p.spinner();
const start = Date.now();
const label = 'Waking your assistant…';
s.start(fitToWidth(label, ' (99m 59s)'));
s.start(fitToWidth(label, ' (999s)'));
const tick = setInterval(() => {
const suffix = ` (${fmtDuration(Date.now() - start)})`;
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await pingCliAgent();
clearInterval(tick);
const suffix = ` (${fmtDuration(Date.now() - start)})`;
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else {
@@ -644,7 +570,7 @@ function renderPingFailureNote(result: PingResult): void {
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
6,
);
note(body, 'Skipping the first chat');
p.note(body, 'Skipping the first chat');
}
/**
@@ -659,7 +585,7 @@ function renderPingFailureNote(result: PingResult): void {
* clearly optional.
*/
async function runFirstChat(): Promise<void> {
note(
p.note(
wrapForGutter(
[
'Your assistant runs in a sandbox on this machine.',
@@ -706,7 +632,7 @@ function sendChatMessage(message: string): Promise<void> {
async function runAuthStep(): Promise<void> {
if (anthropicSecretExists()) {
p.log.success(brandBody('Your Claude account is already connected.'));
p.log.success('Your Claude account is already connected.');
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
return;
}
@@ -740,38 +666,12 @@ async function runAuthStep(): Promise<void> {
label: 'Paste an Anthropic API key',
hint: 'pay-per-use via console.anthropic.com',
},
{
value: 'skip',
label: "Skip — I'll connect later",
hint: 'not recommended — Claude helps debug setup issues',
},
],
}),
) as 'subscription' | 'oauth' | 'api' | 'skip';
) as 'subscription' | 'oauth' | 'api';
setupLog.userInput('auth_method', method);
phEmit('auth_method_chosen', { method });
if (method === 'skip') {
const confirmed = ensureAnswer(
await p.confirm({
message:
"Skip Claude sign-in? The agent won't be able to run until you connect, and we won't be able to help debug setup errors.",
initialValue: false,
}),
);
if (!confirmed) {
// Loop back to the auth picker so they can choose a real method.
return runAuthStep();
}
setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped' });
p.log.warn(
brandBody(
'Claude sign-in skipped. Re-run setup or run `bash nanoclaw.sh` to finish later.',
),
);
return;
}
if (method === 'subscription') {
await runSubscriptionAuth();
} else {
@@ -780,7 +680,7 @@ async function runAuthStep(): Promise<void> {
}
async function runSubscriptionAuth(): Promise<void> {
p.log.step(brandBody('Opening the Claude sign-in flow…'));
p.log.step('Opening the Claude sign-in flow…');
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
console.log();
const start = Date.now();
@@ -799,7 +699,7 @@ async function runSubscriptionAuth(): Promise<void> {
);
}
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
p.log.success(brandBody('Claude account connected.'));
p.log.success('Claude account connected.');
}
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
@@ -809,27 +709,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
const answer = ensureAnswer(
await p.password({
message: `Paste your ${label}`,
clearOnError: true,
validate: (v) => {
// Strip any internal whitespace so a line-wrapped paste that did
// survive into clack can still validate. The mid-token-newline
// case where clack only sees the first line is caught by the
// shape check below.
const cleaned = (v ?? '').replace(/\s+/g, '');
if (!cleaned) return 'Required';
if (!cleaned.startsWith(prefix)) {
if (!v || !v.trim()) return 'Required';
if (!v.trim().startsWith(prefix)) {
return `Should start with ${prefix}`;
}
if (method === 'oauth' && !/^sk-ant-oat[A-Za-z0-9_-]{80,500}AA$/.test(cleaned)) {
return cleaned.length < 90
? 'Token looks truncated — line breaks in the paste can cut it off. Widen your terminal so the token fits on one line, then paste again.'
: "Token shape doesn't look right (expected sk-ant-oat…AA).";
}
return undefined;
},
}),
);
const token = (answer as string).replace(/\s+/g, '');
const token = (answer as string).trim();
const res = await runQuietChild(
'auth',
@@ -1033,11 +922,9 @@ async function runTimezoneStep(): Promise<void> {
tz = await resolveTimezoneViaClaude(raw);
} else {
p.log.warn(
brandBody(
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
),
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
),
);
}
@@ -1080,7 +967,7 @@ async function runTimezoneStep(): Promise<void> {
async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer(
await p.text({
message: `What should your assistant call ${accentGreen('you')}?`,
message: 'What should your assistant call you?',
placeholder: fallback,
defaultValue: fallback,
}),
@@ -1115,7 +1002,6 @@ async function askChannelChoice(): Promise<ChannelChoice> {
hint: 'needs public URL',
},
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
{ value: 'other', label: 'Other…', hint: 'install via /add-<name> after setup' },
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
],
}),
@@ -1125,52 +1011,8 @@ async function askChannelChoice(): Promise<ChannelChoice> {
return choice;
}
async function askOtherChannelName(): Promise<void | typeof BACK_TO_CHANNEL_SELECTION> {
const action = ensureAnswer(
await brightSelect<'type' | 'back'>({
message: 'Which channel would you like to install?',
options: [
{
value: 'type',
label: 'Type the channel name',
hint: 'e.g. matrix, github, linear, webex',
},
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'type',
}),
);
if (action === 'back') return BACK_TO_CHANNEL_SELECTION;
const answer = ensureAnswer(
await p.text({
message: 'Channel name',
placeholder: 'e.g. matrix, github, linear, webex',
}),
);
const name = (answer as string).trim().toLowerCase().replace(/^\/?(add-)?/, '');
setupLog.userInput('other_channel', name);
phEmit('channel_other_named', { channel: name });
p.log.info(
brandBody(
wrapForGutter(
`No bash installer for ${k.bold(name)} — open Claude Code after setup and run ${k.bold(`/add-${name}`)} to install it.`,
4,
),
),
);
}
// ─── interactive / env helpers ─────────────────────────────────────────
function ensureLocalBinOnPath(): void {
const localBin = path.join(os.homedir(), '.local', 'bin');
const current = process.env.PATH ?? '';
const segments = current.split(path.delimiter).filter(Boolean);
if (segments.includes(localBin)) return;
process.env.PATH = current ? `${localBin}${path.delimiter}${current}` : localBin;
}
function anthropicSecretExists(): boolean {
try {
const res = spawnSync('onecli', ['secrets', 'list'], {
@@ -1247,12 +1089,10 @@ function maybeReexecUnderSg(): void {
if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) },
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
});
process.exit(res.status ?? 1);
}
+33 -43
View File
@@ -27,13 +27,10 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { confirmThenOpen } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
@@ -49,10 +46,8 @@ interface AppInfo {
owner: { id: string; username: string } | null;
}
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
const choice = await askHasBotToken();
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
const hasBot = choice === 'yes';
export async function runDiscordChannel(displayName: string): Promise<void> {
const hasBot = await askHasBotToken();
if (!hasBot) {
await walkThroughBotCreation();
}
@@ -145,23 +140,22 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
}
}
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
async function askHasBotToken(): Promise<boolean> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
{ value: 'no', label: "No, walk me through creating one" },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
return answer as 'yes' | 'no' | 'back';
return answer === 'yes';
}
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
note(
p.note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
@@ -169,8 +163,9 @@ async function walkThroughBotCreation(): Promise<void> {
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'',
k.dim(url),
].join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
@@ -189,7 +184,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void {
// to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one.
if (hasExistingBot) {
note(
p.note(
[
"Where to find your bot token:",
'',
@@ -221,15 +216,16 @@ async function walkThroughServerCreation(): Promise<void> {
// the web client and rely on the + button being visible. The steps below
// are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me';
note(
p.note(
[
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
'',
' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'',
k.dim(url),
].join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
@@ -243,22 +239,9 @@ async function walkThroughServerCreation(): Promise<void> {
}
async function collectDiscordToken(): Promise<string> {
const existing = readEnvKey('DISCORD_BOT_TOKEN');
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('discord_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
@@ -292,8 +275,9 @@ async function validateDiscordToken(token: string): Promise<string> {
username?: string;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
@@ -311,7 +295,8 @@ async function validateDiscordToken(token: string): Promise<string> {
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -339,6 +324,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
team?: unknown;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
@@ -351,7 +337,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
@@ -369,7 +355,8 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
owner,
};
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
@@ -398,14 +385,14 @@ async function resolveOwnerUserId(
}
} else {
p.log.info(
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
note(
p.note(
[
"To get your Discord user ID:",
'',
@@ -443,14 +430,15 @@ async function promptInviteBot(
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
note(
p.note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'',
k.dim(url),
].join('\n'),
'Add bot to a server',
);
await confirmThenOpen(url, 'Press Enter to open the invite page');
@@ -477,6 +465,7 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
@@ -489,13 +478,14 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
@@ -516,7 +506,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+30 -52
View File
@@ -33,12 +33,10 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { wrapForGutter } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -49,11 +47,10 @@ interface RemoteCreds {
apiKey: string;
}
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
export async function runIMessageChannel(displayName: string): Promise<void> {
const isMac = os.platform() === 'darwin';
const mode = await askMode(isMac);
if (mode === 'back') return BACK_TO_CHANNEL_SELECTION;
let remoteCreds: RemoteCreds | null = null;
if (mode === 'local') {
@@ -141,38 +138,34 @@ export async function runIMessageChannel(displayName: string): Promise<ChannelFl
}
}
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
const baseOptions = isMac
? [
{
value: 'local' as const,
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
];
async function askMode(isMac: boolean): Promise<Mode> {
const choice = ensureAnswer(
await brightSelect<Mode | 'back'>({
await brightSelect<Mode>({
message: 'How should iMessage run?',
initialValue: isMac ? 'local' : 'remote',
options: [
...baseOptions,
{ value: 'back', label: '← Back to channel selection' },
],
options: isMac
? [
{
value: 'local',
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
],
}),
);
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
setupLog.userInput('imessage_mode', String(choice));
return choice;
}
@@ -196,7 +189,7 @@ async function walkThroughFullDiskAccess(): Promise<void> {
}
const nodeDir = path.dirname(nodePath);
note(
p.note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
@@ -229,20 +222,7 @@ async function walkThroughFullDiskAccess(): Promise<void> {
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
const existingKey = readEnvKey('IMESSAGE_API_KEY');
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('imessage_remote_creds', 'reused-existing');
return { serverUrl: existingUrl, apiKey: existingKey };
}
}
note(
p.note(
[
"Photon is a separate service that owns an iMessage account and",
"exposes it over HTTP. NanoClaw will talk to it via its API.",
@@ -270,7 +250,6 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
clearOnError: true,
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
@@ -285,13 +264,12 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
}
async function askOperatorHandle(): Promise<string> {
note(
p.note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
'',
k.dim(' • Phone: start with + and your country code, no spaces or dashes'),
k.dim(' Example: +14155551234 (country code 1, then 4155551234)'),
k.dim(' • Phone: full E.164, e.g. +15551234567'),
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
].join('\n'),
'Your iMessage handle',
@@ -325,7 +303,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+21 -81
View File
@@ -33,8 +33,6 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import {
type Block,
type StepResult,
@@ -46,37 +44,10 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runSignalChannel(displayName: string): Promise<ChannelFlowResult> {
note(
[
"NanoClaw links to Signal as a *secondary* device on your existing",
"phone — no new number needed. Your assistant will send and receive",
"messages as the number on that phone.",
'',
"Here's what's about to happen — no input needed for any of it:",
'',
' 1. Set up signal-cli (auto-installs if missing)',
' 2. Install the Signal adapter',
' 3. Show a QR code — scan it from Signal → Settings → Linked Devices',
' 4. Wire your assistant and send a welcome message',
].join('\n'),
'Set up Signal',
);
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
message: 'Ready to set up Signal?',
options: [
{ value: 'continue', label: 'Continue' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'continue',
}));
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
export async function runSignalChannel(displayName: string): Promise<void> {
await ensureSignalCli();
const install = await runQuietChild(
@@ -162,74 +133,42 @@ export async function runSignalChannel(displayName: string): Promise<ChannelFlow
async function ensureSignalCli(): Promise<void> {
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
const probeFor = (): boolean => {
const r = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
return !r.error && r.status === 0;
};
if (probeFor()) return;
const probe = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!probe.error && probe.status === 0) return;
note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
"We'll install it for you now — about 30 seconds, one-time only.",
'',
process.platform === 'darwin'
? "On this Mac we'll use Homebrew (no admin password needed)."
: "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.",
].join('\n'),
'Setting up signal-cli',
);
const install = await runQuietChild(
'install-signal-cli',
'bash',
['setup/install-signal-cli.sh'],
{
running: 'Installing signal-cli…',
done: 'signal-cli installed.',
},
);
if (install.ok && probeFor()) return;
const reason = install.terminal?.fields.ERROR;
if (process.platform === 'darwin') {
note(
p.note(
[
"We couldn't install signal-cli automatically.",
reason === 'homebrew_not_installed'
? ' Reason: Homebrew is not installed.'
: ` Reason: ${reason ?? 'unknown'}.`,
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'You can install it manually:',
'The quickest way on macOS is Homebrew:',
'',
k.cyan(' brew install signal-cli'),
'',
'Then re-run setup.',
"Install it in another terminal, then re-run setup.",
].join('\n'),
"Couldn't install signal-cli",
'signal-cli not found',
);
} else {
note(
p.note(
[
"We couldn't install signal-cli automatically.",
` Reason: ${reason ?? 'unknown'}.`,
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'You can install it manually from GitHub:',
'Grab the latest release from GitHub:',
'',
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
'',
'Then re-run setup.',
"Install it, make sure `signal-cli --version` works, then re-run setup.",
].join('\n'),
"Couldn't install signal-cli",
'signal-cli not found',
);
}
await fail(
'install-signal-cli',
'signal-cli is required but the auto-install failed.',
'Install it manually and re-run setup.',
'signal-install',
'signal-cli is required but not installed.',
'Install it and re-run setup.',
);
}
@@ -384,7 +323,8 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
@@ -406,7 +346,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+38 -237
View File
@@ -1,23 +1,24 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
* `runSlackChannel(displayName)` walks the operator from a bare Slack
* workspace through a running bot, then stops before wiring an agent:
*
* 1. Walk through creating a Slack app (api.slack.com/apps) scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
* 5. Print the post-install checklist: set the public webhook URL in
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
* then `/manage-channels` to wire an agent.
*
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
* greeting in Slack immediately; inbound replies require webhooks, so the
* post-install note covers that.
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
* Slack needs a public Event Subscriptions URL for inbound events, and
* opening an unsolicited DM would need `im:write` scope we don't force
* the SKILL.md to require. Shipping a honest "here's what's left" note
* is better than a welcome DM the user won't receive until they
* configure the webhook anyway.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
@@ -25,18 +26,12 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { openUrl } from '../lib/browser.js';
import { isHeadless } from '../platform.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { confirmThenOpen } from '../lib/browser.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
import { wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo {
teamName: string;
@@ -45,9 +40,11 @@ interface WorkspaceInfo {
botUserId: string;
}
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
const intro = await walkThroughAppCreation();
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
// displayName is reserved for when we start wiring the first agent here.
// Kept to match the `run<X>Channel(displayName)` signature every other
// channel driver uses, so auto.ts can dispatch without a branch.
export async function runSlackChannel(_displayName: string): Promise<void> {
await walkThroughAppCreation();
const token = await collectBotToken();
const signingSecret = await collectSigningSecret();
@@ -81,93 +78,29 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
);
}
const ownerUserId = await collectSlackUserId();
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `slack:${dmChannelId}`;
const role = await askOperatorRole('Slack');
setupLog.userInput('slack_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'slack',
'--user-id', `slack:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Wiring ${agentName} to your Slack DMs…`,
done: 'Agent wired.',
},
{
extraFields: {
CHANNEL: 'slack',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/init-first-agent` in Claude Code.',
);
}
showPostInstallChecklist(info);
}
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
const linkBlock = isHeadless()
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
: [];
note(
async function walkThroughAppCreation(): Promise<void> {
p.note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
...linkBlock,
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' • im:write, im:history',
' channels:read, channels:history',
' • groups:read, groups:history',
' • chat:write',
' • users:read',
' • reactions:write',
' • files:read, files:write',
' chat:write, channels:history, groups:history, im:history,',
' channels:read, groups:read, users:read, reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
'Create a Slack app',
);
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
// Slack app settings" so users can bail out of Slack before we open the
// browser or ask for tokens.
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
message: 'Open Slack app settings in your browser?',
options: [
{ value: 'open', label: 'Open Slack app settings' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'open',
}));
if (choice === 'back') return 'back';
if (!isHeadless()) openUrl(SLACK_APPS_URL);
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
ensureAnswer(
await p.confirm({
@@ -175,26 +108,12 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
initialValue: true,
}),
);
return 'continue';
}
async function collectBotToken(): Promise<string> {
const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_bot_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
@@ -213,22 +132,9 @@ async function collectBotToken(): Promise<string> {
}
async function collectSigningSecret(): Promise<string> {
const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_signing_secret', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
@@ -269,9 +175,10 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
user_id?: string;
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
@@ -300,7 +207,8 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -313,133 +221,26 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
}
}
async function collectSlackUserId(): Promise<string> {
note(
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (bottom left)',
' 2. Click "Profile"',
' 3. Click the three dots (⋮) → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Slack member ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Member ID is required';
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
return "That doesn't look like a Slack member ID (starts with U)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('slack_user_id', id);
return id;
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${SLACK_API}/conversations.open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const data = (await res.json()) as {
ok?: boolean;
channel?: { id?: string };
error?: string;
};
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
return data.channel.id;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
if (reason === 'missing_scope') {
await fail(
'slack-open-dm',
"Your Slack app is missing the im:write scope.",
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
);
}
await fail(
'slack-open-dm',
"Couldn't open a DM channel with you.",
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-open-dm',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
function showPostInstallChecklist(info: WorkspaceInfo): void {
note(
p.note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
'',
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
' 1. A public URL so Slack can deliver events.',
' NanoClaw serves a webhook on port 3000 by default — expose it',
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • Save Changes',
' • Save, then reinstall the app when Slack prompts',
'',
' 3. In your Slack app → Interactivity & Shortcuts:',
' • Toggle "Interactivity" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
' messaging group. Then run `/manage-channels` in `claude` to',
' wire an agent to it.',
].join('\n'),
6,
),
+40 -120
View File
@@ -30,7 +30,6 @@ import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import {
@@ -41,9 +40,7 @@ import {
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
@@ -58,62 +55,20 @@ interface Collected {
agentName?: string;
}
export async function runTeamsChannel(_displayName: string): Promise<ChannelFlowResult> {
export async function runTeamsChannel(_displayName: string): Promise<void> {
const collected: Collected = {};
const completed: string[] = [];
const existingAppId = readEnvKey('TEAMS_APP_ID');
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
if (existingAppId && existingPassword) {
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
options: [
{ value: 'yes', label: 'Yes, use the existing credentials' },
{ value: 'no', label: "No, set up new ones" },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'yes',
}));
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
if (choice === 'yes') {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);
completed.push('Adapter installed and service restarted (reused existing credentials).');
await finishWithHandoff(collected, completed);
return;
}
}
printIntro();
const prereqsResult = await confirmPrereqs({ collected, completed });
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
await confirmPrereqs({ collected, completed });
await stepPublicUrl({ collected, completed });
if (await stepAppRegistration({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepClientSecret({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepAzureBot({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
await stepAppRegistration({ collected, completed });
await stepClientSecret({ collected, completed });
await stepAzureBot({ collected, completed });
await stepEnableTeamsChannel({ collected, completed });
const manifestResult = await stepGenerateManifest({ collected, completed });
if (
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
=== 'back'
) {
return BACK_TO_CHANNEL_SELECTION;
}
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
await installAdapter(collected);
completed.push('Adapter installed and service restarted.');
@@ -124,7 +79,7 @@ export async function runTeamsChannel(_displayName: string): Promise<ChannelFlow
// ─── step: intro / prereqs ──────────────────────────────────────────────
function printIntro(): void {
note(
p.note(
[
'Setting up Teams is more involved than the other channels — about',
'7 steps across the Azure portal and Teams admin.',
@@ -137,8 +92,8 @@ function printIntro(): void {
);
}
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> {
note(
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
p.note(
[
'Before we start, confirm you have:',
'',
@@ -152,42 +107,19 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
'Prereqs',
);
// Back-aware variant of stepGate — Back is only offered on the very first
// step of the Teams flow so users can bail out before any state is taken.
while (true) {
const choice = ensureAnswer(
await brightSelect<'done' | 'help' | 'reshow' | 'back'>({
message: 'How did that go?',
options: [
{ value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice === 'back') return 'back';
if (choice === 'done') break;
if (choice === 'help') {
await offerHandoff({
step: 'teams-prereqs',
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
args,
});
continue;
}
if (choice === 'reshow') {
return confirmPrereqs(args);
}
}
await stepGate({
stepName: 'teams-prereqs',
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
reshow: () => confirmPrereqs(args),
args,
});
args.completed.push('Prereqs confirmed.');
return 'continue';
}
// ─── step: public URL ──────────────────────────────────────────────────
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
note(
p.note(
[
"Azure Bot Service delivers messages to an HTTPS endpoint you",
"control. The endpoint needs to reach this machine's webhook",
@@ -242,8 +174,8 @@ async function stepPublicUrl(args: { collected: Collected; completed: string[] }
async function stepAppRegistration(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
}): Promise<void> {
p.note(
[
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
'2. Name it (e.g. "NanoClaw")',
@@ -275,17 +207,15 @@ async function stepAppRegistration(args: {
);
}
const gate = await stepGate({
await stepGate({
stepName: 'teams-app-registration',
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
reshow: () => stepAppRegistration(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push(
`App registered: ${args.collected.appId} (${args.collected.appType})`,
);
return 'continue';
}
async function askAppType(args: {
@@ -328,8 +258,8 @@ async function askAppType(args: {
async function stepClientSecret(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
}): Promise<void> {
p.note(
[
`1. In your app registration, open "Certificates & secrets"`,
'2. Click "New client secret"',
@@ -346,7 +276,6 @@ async function stepClientSecret(args: {
const answer = ensureAnswer(
await p.password({
message: 'Paste the client secret Value',
clearOnError: true,
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
@@ -371,15 +300,13 @@ async function stepClientSecret(args: {
break;
}
const gate = await stepGate({
await stepGate({
stepName: 'teams-client-secret',
stepDescription: 'creating and copying the client secret',
reshow: () => stepClientSecret(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Client secret captured.');
return 'continue';
}
// ─── step: Azure Bot resource ──────────────────────────────────────────
@@ -387,7 +314,7 @@ async function stepClientSecret(args: {
async function stepAzureBot(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
}): Promise<void> {
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
const tenantFlag =
args.collected.appType === 'SingleTenant'
@@ -401,7 +328,7 @@ async function stepAzureBot(args: {
` --appid ${args.collected.appId} \\\n` +
` ${tenantFlag}--endpoint "${endpoint}"`;
note(
p.note(
[
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
'',
@@ -422,16 +349,14 @@ async function stepAzureBot(args: {
'Step 3 of 6 — Create Azure Bot resource',
);
const gate = await stepGate({
await stepGate({
stepName: 'teams-azure-bot',
stepDescription:
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
reshow: () => stepAzureBot(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Azure Bot created; messaging endpoint configured.');
return 'continue';
}
// ─── step: enable Teams channel ────────────────────────────────────────
@@ -439,8 +364,8 @@ async function stepAzureBot(args: {
async function stepEnableTeamsChannel(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
}): Promise<void> {
p.note(
[
'1. Open your Azure Bot resource → Channels',
'2. Click Microsoft Teams → Accept terms → Apply',
@@ -450,15 +375,13 @@ async function stepEnableTeamsChannel(args: {
].join('\n'),
'Step 4 of 6 — Enable Teams channel on the bot',
);
const gate = await stepGate({
await stepGate({
stepName: 'teams-enable-channel',
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
reshow: () => stepEnableTeamsChannel(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Teams channel enabled on the bot.');
return 'continue';
}
// ─── step: manifest zip ────────────────────────────────────────────────
@@ -511,8 +434,8 @@ async function stepSideload(args: {
collected: Collected;
completed: string[];
zipPath: string;
}): Promise<'continue' | 'back'> {
note(
}): Promise<void> {
p.note(
[
'1. Open Microsoft Teams',
'2. Go to Apps → Manage your apps → Upload an app',
@@ -526,15 +449,13 @@ async function stepSideload(args: {
].join('\n'),
'Step 5 of 6 — Sideload the app into Teams',
);
const gate = await stepGate({
await stepGate({
stepName: 'teams-sideload',
stepDescription: 'uploading the generated zip into Teams as a custom app',
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
reshow: () => stepSideload(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('App sideloaded into Teams.');
return 'continue';
}
// ─── step: install adapter ─────────────────────────────────────────────
@@ -580,7 +501,7 @@ async function finishWithHandoff(
collected: Collected,
completed: string[],
): Promise<void> {
note(
p.note(
[
'The Teams adapter is live and the service is running.',
'',
@@ -609,7 +530,7 @@ async function finishWithHandoff(
);
if (choice === 'self') {
note(
p.note(
[
' 1. Find your bot in Teams (search by name, or via the sideloaded',
' app) and send it a message ("hi" is fine)',
@@ -646,9 +567,9 @@ async function finishWithHandoff(
async function stepGate(args: {
stepName: string;
stepDescription: string;
reshow: () => Promise<'continue' | 'back'>;
reshow: () => Promise<void> | Promise<unknown>;
args: { collected: Collected; completed: string[] };
}): Promise<'continue' | 'back'> {
}): Promise<void> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
@@ -657,12 +578,10 @@ async function stepGate(args: {
{ value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice === 'done') return 'continue';
if (choice === 'back') return 'back';
if (choice === 'done') return;
if (choice === 'help') {
await offerHandoff({
step: args.stepName,
@@ -672,7 +591,8 @@ async function stepGate(args: {
continue;
}
if (choice === 'reshow') {
return args.reshow();
await args.reshow();
return;
}
}
}

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